feat: brain get/stats/eval call internal/brain, not Python (#18)
Tests / Test (push) Failing after 5s
Tests / Release (semver) (push) Skipped

Read path is cgo like search. Control questions live in rank so CI can
test them without ladybug. Python bin/kb/{get,stats,eval} stays the
runner fallback.
This commit is contained in:
2026-08-13 21:16:48 +01:00
committed by GitHub
co-authored by GitHub
parent a6dbe2f6da
commit 54201d8c22
14 changed files with 394 additions and 19 deletions
+3 -1
View File
@@ -82,7 +82,9 @@ bin/facts/crm [--dry-run] # proof person↔company/compa
bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go
bin/brain/search.go "query" [--root facts|info] # deduction search → YAML bin/brain/search.go "query" [--root facts|info] # deduction search → YAML
bin/brain/search.go "query" --no-web # local graph only bin/brain/search.go "query" --no-web # local graph only
bin/brain/get.go <id> [--body] bin/brain/get.go <id> [--body] [--json] # Go read; Python bin/kb/get CI fallback
bin/brain/stats.go [--json]
bin/brain/eval.go [--json] # recall@5; questions in internal/brain/rank
bin/markdown/import.go [dir] # mistune leaves → YAML bin/markdown/import.go [dir] # mistune leaves → YAML
bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs
bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
+6 -3
View File
@@ -29,7 +29,7 @@ detective method: **a fact needs ≥2 independent sources or it is
| D3 | web search | Go client `bin/web/search.go` (`internal/websearch`). SearXNG URL is config (`BRAIN_SEARCH_URL`). Optional Compose profile `searxng` (sanitized settings). Do not run a second copy on a host that already has one. Empty/`throttled` ≠ “nothing exists”. | | D3 | web search | Go client `bin/web/search.go` (`internal/websearch`). SearXNG URL is config (`BRAIN_SEARCH_URL`). Optional Compose profile `searxng` (sanitized settings). Do not run a second copy on a host that already has one. Empty/`throttled` ≠ “nothing exists”. |
| D4 | embeddings | **model2vec** `minishlab/potion-multilingual-128M` instead of embeddinggemma. | | D4 | embeddings | **model2vec** `minishlab/potion-multilingual-128M` instead of embeddinggemma. |
| D5 | parser | **mistune** for MD → leaf extraction (duckdb-md documented as future optional SQL/export layer, not v1). | | D5 | parser | **mistune** for MD → leaf extraction (duckdb-md documented as future optional SQL/export layer, not v1). |
| D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`); Python remains for index/write until the Go write path is safe. | | D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`). Read path (`get.go` / `stats.go` / `eval.go`) is Go + cgo. Python `bin/kb/{get,stats,eval}` is the CI fallback (GitHub runners have no ladybug cgo). Index/write stays Python until the Go write path is safe. |
| D7 | db access | `db-yaml`/`psql-yq`-style, read-only, YAML out. OnlyOffice Postgres via SSH tunnel (`127.0.0.1:5433`). | | D7 | db access | `db-yaml`/`psql-yq`-style, read-only, YAML out. OnlyOffice Postgres via SSH tunnel (`127.0.0.1:5433`). |
| D8 | evidence | detective method: ≥2 independent sources or `(not confirmed)`. Auto-pair docker ps × compose × ssh-config × docs. | | D8 | evidence | detective method: ≥2 independent sources or `(not confirmed)`. Auto-pair docker ps × compose × ssh-config × docs. |
| D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. | | D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. |
@@ -56,7 +56,8 @@ detective method: **a fact needs ≥2 independent sources or it is
facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate
kb/index Python write path (called by bin/brain/index.go) kb/index Python write path (called by bin/brain/index.go)
brain/index.go rebuild FTS + HNSW (incl. --with-mail) brain/index.go rebuild FTS + HNSW (incl. --with-mail)
brain/get.go stats.go eval.go watch.go brain/get.go stats.go eval.go # Go read (cgo); Python bin/kb/* CI fallback
brain/watch.go
brain/search.go deduction: facts → info → web-search brain/search.go deduction: facts → info → web-search
brain/serve.go HTTP API in-process (internal/httpapi + internal/brain) brain/serve.go HTTP API in-process (internal/httpapi + internal/brain)
mail/import.go JSON → markdown (no brain write) mail/import.go JSON → markdown (no brain write)
@@ -133,7 +134,9 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser) 2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
3. python -m unittest discover -s bin/tools (includes published-docs SoT) 3. python -m unittest discover -s bin/tools (includes published-docs SoT)
4. bin/facts/audit self (lexicon internal consistency) 4. bin/facts/audit self (lexicon internal consistency)
5. bin/brain/eval.go (recall@5 ≥ 0.95, gates index regressions) 5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go`; CI uses
the Python twin until the runner has ladybug cgo. Questions live in
`internal/brain/rank`.
6. md-docs build/lint if docs tooling arrives. 6. md-docs build/lint if docs tooling arrives.
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
+2 -1
View File
@@ -126,7 +126,8 @@ bin/brain/search.go "invoice from last week" # same s
- **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25 - **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25
in one engine, embedded (no server), ACID, read-only-safe for concurrent in one engine, embedded (no server), ACID, read-only-safe for concurrent
readers. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves readers. Read tools (`get` / `stats` / `eval`) are Go + cgo; Python
`bin/kb/{get,stats,eval}` is the CI fallback. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves
ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while
`SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` + `SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` +
`bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts. `bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts.
+6 -4
View File
@@ -1,20 +1,22 @@
//usr/bin/env go run -tags=brain_eval "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_eval "$0" "$@"; exit
//go:build brain_eval //go:build cgo && system_ladybug && brain_eval
// //
// bin/brain/eval.go - recall@5 gate. // bin/brain/eval.go - recall@5 gate.
// //
// ./bin/brain/eval.go // ./bin/brain/eval.go
// ./bin/brain/eval.go --json // ./bin/brain/eval.go --json
// //
// Needs CGO + libladybug. Python bin/kb/eval is the CI fallback (no cgo).
// Control questions live in internal/brain/rank (cgo-free).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/eval", os.Args[1:])) os.Exit(brain.MainEval(os.Args[1:]))
} }
+6 -4
View File
@@ -1,20 +1,22 @@
//usr/bin/env go run -tags=brain_get "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_get "$0" "$@"; exit
//go:build brain_get //go:build cgo && system_ladybug && brain_get
// //
// bin/brain/get.go - read one leaf by id. // bin/brain/get.go - read one leaf by id.
// //
// ./bin/brain/get.go <id> // ./bin/brain/get.go <id>
// ./bin/brain/get.go <id> --body // ./bin/brain/get.go <id> --body
// ./bin/brain/get.go <id> --json
// //
// Needs CGO + libladybug. Python bin/kb/get is the CI fallback (no cgo).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/get", os.Args[1:])) os.Exit(brain.MainGet(os.Args[1:]))
} }
+5 -4
View File
@@ -1,20 +1,21 @@
//usr/bin/env go run -tags=brain_stats "$0" "$@"; exit //usr/bin/env go run -tags=system_ladybug,brain_stats "$0" "$@"; exit
//go:build brain_stats //go:build cgo && system_ladybug && brain_stats
// //
// bin/brain/stats.go - index health. // bin/brain/stats.go - index health.
// //
// ./bin/brain/stats.go // ./bin/brain/stats.go
// ./bin/brain/stats.go --json // ./bin/brain/stats.go --json
// //
// Needs CGO + libladybug. Python bin/kb/stats is the CI fallback (no cgo).
// NOTE: never run `gofmt -w` on this file — it breaks the shebang. // NOTE: never run `gofmt -w` on this file — it breaks the shebang.
package main package main
import ( import (
"os" "os"
"github.com/eSlider/2dph/internal/cmdbin" "github.com/eSlider/2dph/internal/brain"
) )
func main() { func main() {
os.Exit(cmdbin.ExecFile("bin/kb/stats", os.Args[1:])) os.Exit(brain.MainStats(os.Args[1:]))
} }
+31
View File
@@ -77,6 +77,37 @@ class BinLayoutTest(unittest.TestCase):
for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"): for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"):
self._assert_shebang(f"bin/brain/{method}") self._assert_shebang(f"bin/brain/{method}")
def test_brain_get_stats_eval_are_not_python_exec(self) -> None:
for method in ("get.go", "stats.go", "eval.go"):
text = (ROOT / "bin" / "brain" / method).read_text()
self.assertNotIn(
"ExecFile",
text,
f"bin/brain/{method} must call internal/brain, not ExecFile Python",
)
self.assertNotIn(
"cmdbin",
text,
f"bin/brain/{method} must not import internal/cmdbin",
)
self.assertIn(
"system_ladybug",
text.splitlines()[0],
f"bin/brain/{method} shebang must pass -tags=system_ladybug",
)
self.assertIn(
"github.com/eSlider/2dph/internal/brain",
text,
)
def test_eval_control_questions_live_in_rank(self) -> None:
rank = (ROOT / "internal" / "brain" / "rank" / "evalq.go").read_text()
py = (ROOT / "bin" / "kb" / "eval").read_text()
for frag in ("BM25", "DevOps", "LadybugDB"):
self.assertIn(frag, rank)
self.assertIn(frag, py)
self.assertIn("0.95", rank)
def test_mail_import_is_shebang_not_brain_write(self) -> None: def test_mail_import_is_shebang_not_brain_write(self) -> None:
self._assert_shebang("bin/mail/import.go") self._assert_shebang("bin/mail/import.go")
index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text() index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text()
+8
View File
@@ -59,6 +59,14 @@ class PublishedDocsTest(unittest.TestCase):
self.assertNotIn("password", settings.lower()) self.assertNotIn("password", settings.lower())
self.assertIn("json", settings) self.assertIn("json", settings)
def test_readme_read_path_is_go(self) -> None:
plan = (ROOT / "PLAN.md").read_text()
self.assertIn("get.go", plan)
self.assertIn("CI fallback", plan)
design = (ROOT / "docs" / "design.md").read_text()
self.assertIn("internal/brain/rank", design)
self.assertIn("They do not exec Python", design)
def test_readme_search_escalates_web(self) -> None: def test_readme_search_escalates_web(self) -> None:
text = (ROOT / "README.md").read_text() text = (ROOT / "README.md").read_text()
self.assertIn("--no-web", text) self.assertIn("--no-web", text)
+9 -1
View File
@@ -61,4 +61,12 @@ corpus HEAD.
- C: narrative — READMEs, AGENTS.md, docs - C: narrative — READMEs, AGENTS.md, docs
Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`. Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`.
Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution). Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution).
## Read path
`bin/brain/get.go`, `stats.go`, and `eval.go` call `internal/brain` with cgo
(`system_ladybug`). They do not exec Python. Control questions for recall@5
live in `internal/brain/rank` so CI can test the table without libladybug.
Python `bin/kb/{get,stats,eval}` remain for GitHub Actions until the runner
has ladybug cgo. Index/write is still `bin/kb/index`.
+3
View File
@@ -0,0 +1,3 @@
package brain
const ModelID = "minishlab/potion-multilingual-128M"
+16
View File
@@ -0,0 +1,16 @@
package rank
// Eval control questions (recall@5). Kept here so CI can test the gate
// table without ladybug cgo. The runner lives in internal/brain (cgo).
const EvalRecallThreshold = 0.95
type EvalQuestion struct {
Query string
Fragment string
}
var EvalQuestions = []EvalQuestion{
{"hybrid search fts and vector", "BM25"},
{"eslider devops engineer", "DevOps"},
{"ladybugdb graph engine storage", "LadybugDB"},
}
+17
View File
@@ -0,0 +1,17 @@
package rank
import "testing"
func TestEvalQuestionsAreThreeAndThreshold(t *testing.T) {
if EvalRecallThreshold != 0.95 {
t.Fatalf("threshold = %v", EvalRecallThreshold)
}
if len(EvalQuestions) != 3 {
t.Fatalf("questions = %d, want 3", len(EvalQuestions))
}
for _, q := range EvalQuestions {
if q.Query == "" || q.Fragment == "" {
t.Fatalf("empty control: %+v", q)
}
}
}
+281
View File
@@ -0,0 +1,281 @@
//go:build cgo && system_ladybug
package brain
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"unicode/utf8"
"github.com/eSlider/2dph/internal/brain/rank"
)
func MainGet(args []string) int {
id, body, jsonOut := "", false, false
for _, a := range args {
switch {
case a == "--body":
body = true
case a == "--json":
jsonOut = true
case a == "-h" || a == "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/get.go <id> [--body] [--json]`)
return 0
case strings.HasPrefix(a, "-"):
fmt.Fprintf(os.Stderr, "brain/get: unknown flag %s\n", a)
return 2
default:
id = a
}
}
if id == "" {
fmt.Fprintln(os.Stderr, "brain/get: id required")
return 2
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
meta, text, err := lookupLeaf(id)
if err != nil {
fmt.Fprintf(os.Stderr, "brain/get: %v\n", err)
return 1
}
out := Dict{
{"id", meta["id"]},
{"root", meta["root"]},
{"confidence", meta["confidence"]},
{"source", meta["source"]},
{"type", meta["type"]},
}
if body {
out = append(out, KV{"text", text})
} else {
out = append(out, KV{"snippet", clip(text, 280)})
}
if jsonOut {
m := map[string]any{}
for _, kv := range out {
m[kv.K] = kv.V
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return b2i(enc.Encode(m))
}
fmt.Print(toYAML(out, 0))
return 0
}
func MainStats(args []string) int {
jsonOut := false
for _, a := range args {
switch a {
case "--json":
jsonOut = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/stats.go [--json]`)
return 0
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(os.Stderr, "brain/stats: unknown flag %s\n", a)
return 2
}
}
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
s, err := leafStats()
if err != nil {
fmt.Fprintf(os.Stderr, "brain/stats: %v\n", err)
return 1
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return b2i(enc.Encode(s))
}
by := s["by_root"].(map[string]int)
keys := make([]string, 0, len(by))
for k := range by {
keys = append(keys, k)
}
sort.Strings(keys)
byRoot := make(Dict, 0, len(keys))
for _, k := range keys {
byRoot = append(byRoot, KV{k, by[k]})
}
out := Dict{
{"total", s["total"]},
{"by_root", byRoot},
{"db", s["db"]},
{"model", s["model"]},
}
fmt.Print(toYAML(out, 0))
return 0
}
func MainEval(args []string) int {
jsonOut := false
for _, a := range args {
switch a {
case "--json":
jsonOut = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, `usage: bin/brain/eval.go [--json]`)
return 0
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(os.Stderr, "brain/eval: unknown flag %s\n", a)
return 2
}
}
}
if err := openBrain(); err != nil {
fmt.Fprintf(os.Stderr, "open brain: %v\n", err)
return 1
}
defer closeBrain()
recalled := 0
details := make([]any, 0, len(rank.EvalQuestions))
jsDetails := make([]map[string]any, 0, len(rank.EvalQuestions))
for _, q := range rank.EvalQuestions {
hits, err := queryFTS(q.Query, 5)
ok := false
if err == nil {
frag := strings.ToLower(q.Fragment)
for _, h := range hits {
if strings.Contains(strings.ToLower(h.Text), frag) {
ok = true
break
}
}
}
if ok {
recalled++
}
details = append(details, Dict{
{"q", q.Query},
{"fragment", q.Fragment},
{"in_top5", ok},
})
jsDetails = append(jsDetails, map[string]any{
"q": q.Query, "fragment": q.Fragment, "in_top5": ok,
})
}
n := len(rank.EvalQuestions)
recall := 0.0
if n > 0 {
recall = float64(recalled) / float64(n)
}
passed := recall >= rank.EvalRecallThreshold
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
_ = enc.Encode(map[string]any{
"recall@5": round3(recall),
"passed": passed,
"gate": n,
"details": jsDetails,
})
} else {
out := Dict{
{"recall@5", round3(recall)},
{"passed", passed},
{"gate", n},
{"details", details},
}
fmt.Print(toYAML(out, 0))
}
if !passed {
return 2
}
return 0
}
func lookupLeaf(id string) (map[string]string, string, error) {
if conn == nil {
return nil, "", fmt.Errorf("brain not open")
}
stmt, err := conn.Prepare(
"MATCH (l:Leaf {id:$id}) RETURN l.id, l.text, l.root, l.confidence, l.source, l.type",
)
if err != nil {
return nil, "", err
}
defer stmt.Close()
res, err := conn.Execute(stmt, map[string]any{"id": id})
if err != nil {
return nil, "", err
}
if !res.HasNext() {
return nil, "", fmt.Errorf("no leaf %s", id)
}
row, err := res.Next()
if err != nil {
return nil, "", err
}
vals, err := row.GetAsSlice()
if err != nil || len(vals) < 6 {
return nil, "", fmt.Errorf("leaf row")
}
meta := map[string]string{
"id": fmt.Sprint(vals[0]),
"root": fmt.Sprint(vals[2]),
"confidence": fmt.Sprint(vals[3]),
"source": fmt.Sprint(vals[4]),
"type": fmt.Sprint(vals[5]),
}
return meta, fmt.Sprint(vals[1]), nil
}
func leafStats() (map[string]any, error) {
if conn == nil {
return nil, fmt.Errorf("brain not open")
}
res, err := conn.Query("MATCH (l:Leaf) RETURN l.root, count(*)")
if err != nil {
return nil, err
}
byRoot := map[string]int{}
total := 0
for res.HasNext() {
row, err := res.Next()
if err != nil {
return nil, err
}
vals, err := row.GetAsSlice()
if err != nil || len(vals) < 2 {
continue
}
n := int(asInt(vals[1]))
byRoot[fmt.Sprint(vals[0])] = n
total += n
}
return map[string]any{
"total": total,
"by_root": byRoot,
"db": dbPath(),
"model": ModelID,
}, nil
}
func clip(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
func round3(f float64) float64 {
return float64(int(f*1000+0.5)) / 1000
}
+1 -1
View File
@@ -28,7 +28,7 @@ bin/brain/search.go "onlyoffice postgres" --root facts # restrict to confirmed
bin/brain/search.go "where is cs-lexicon" --json | yq '.[].ref' bin/brain/search.go "where is cs-lexicon" --json | yq '.[].ref'
bin/brain/get.go <id> --body # full chunk only when needed bin/brain/get.go <id> --body # full chunk only when needed
bin/brain/stats.go # index health bin/brain/stats.go # index health
bin/brain/eval.go # recall@5 >= 0.95 gate bin/brain/eval.go # recall@5 >= 0.95 gate (Go; Python bin/kb/eval is CI fallback)
``` ```
`bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges `bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges