Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ BRAIN_ANSWER_OUT=_brain_answer.txt
#SGM_NO_STAR_PROMPT=1
# Folder for that once-per-machine marker (default: $XDG_CONFIG_HOME or ~/.config).
#SGM_CONFIG_DIR=~/.config/sqlite-graph-memory

# PageRank lane in the ordering (ppr.py): RRF with the reranker, top 5 protected. Off by default.
# SGM_PPR=1
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ recorded after the fact rather than backdated to look contemporaneous.

## Unreleased

**The PageRank lane the README credits is now in the package** (#51). `ppr.py` runs
personalized PageRank over the `[[wikilink]]` graph, seeded by the dense hits, and fuses it with
the reranker by RRF while the reranker's top 5 stay where they are. Off by default (`SGM_PPR=1`
or `sgm-ask --ppr`); `eval/run_eval.py --ppr` scores it as its own mode. Any failure returns the
reranker's order. `tests/test_ppr.py` builds a hub note that pushes the reranker's rank-1 hit to
position 11 when the guard is removed, and goes red if it is.

**`sgm-stats <notes_dir>`: four health numbers for the link graph.** Orphan rate, links per
page, share of pages in the main connected component, and share of concept pages whose
`updated` date is older than 90 days, each with an ok/warn/fail verdict and a `--threshold-*`
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,17 @@ of guessing — 97% of the correct `bridge` answers were *already in the candida
cross-encoder was dropping them, because the relation is structural, not textual. Widening
retrieval, the obvious fix, would have done nothing at all.

That lane ships in the package as `src/sqlite_graph_memory/ppr.py`, **off by default**: turn it
on with `SGM_PPR=1` or `sgm-ask --ppr`, and score it with `python eval/run_eval.py --gold ...
--ppr`, which adds a third mode next to `vector` and `graph`. The numbers above were measured on
the graph pool, so the matching command is `sgm-ask --graph --ppr`; `--ppr` alone reorders the
vector pool, and `--ab` leaves the lane out so its A/B log stays vector against graph. Any failure in it falls back to
the reranker's order, like the graph hop. Be clear about what that reproduces: the bridge
numbers above came from the maintainer's home vault and home pipeline on 2026-09-16, and they
have not been re-run from the published package. The code is the same algorithm with the same
Comment on lines +334 to +335

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the required retrieval regression evaluation

Do not ship this retrieval-ordering change based only on the earlier home-pipeline result: this paragraph explicitly says the published implementation was not evaluated, so there is no evidence that it stays within the repository's 0.01 nDCG@12 regression limit. Run the same frozen gold set before and after enabling this implementation, and run the required eval mutant check before merging.

AGENTS.md reference: AGENTS.md:L45-L57

Useful? React with 👍 / 👎.

constants (alpha 0.85, 10 PPR files, top 5 protected), but your vault will give your numbers,
and `bench/` does not score this lane yet.

Seven other changes were rejected by the same ruler, under one rule fixed in advance:
**a regression worse than 0.01 nDCG@12 on any class reverts the change.**

Expand All @@ -334,10 +345,10 @@ Seven other changes were rejected by the same ruler, under one rule fixed in adv
| Qwen3-Embedding-0.6B | body Recall 0.767 -> 0.683; reindex 2863 s vs 333 s -> **rejected** |
| bge-reranker-v2-m3 | −0.011…−0.018 nDCG on every class except English (+0.041) -> **rejected** |
| Qwen3-Reranker-0.6B | title −0.111 nDCG at 12x the cost -> **rejected** |
| BM25 + RRF hybrid | temporal −0.035 -> **merged switched off** |
| BM25 + RRF hybrid | temporal −0.035 -> **merged switched off** (in the home pipeline, not in this package) |
| heading-path prefix in every chunk | body −0.023, temporal −0.035 -> **reverted** |
| `sqlite-vec` (vec0) | full scan 11.99 s vs 0.14 s for a plain SQLite table, and its ANN missed 1.2 hits of 60 -> vectors live in SQLite, **the extension does not** |
| bi-temporal demotion of superseded notes | temporal +0.130 nDCG on a 41-question temporal set, but title −0.012 -> **merged behind a flag, default off** |
| bi-temporal demotion of superseded notes | temporal +0.130 nDCG on a 41-question temporal set, but title −0.012 -> **merged behind a flag, default off** (in the home pipeline, not in this package) |

None of those verdicts is a claim about the models. They are claims about *this corpus with
this ruler*, which is the only kind of claim a swap decision actually needs — and the reason
Expand Down
38 changes: 31 additions & 7 deletions eval/run_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@

vector dense retrieve -> rerank (what you get without the graph)
graph dense -> 1-hop [[wikilinks]] -> rerank
ppr graph, then personalized PageRank fused with the reranker by RRF, top 5
protected (only with --ppr; see src/sqlite_graph_memory/ppr.py)

The difference between those two columns is the only honest answer to "does the graph
actually help?" — and on our own vault the answer differed by class (see README).

USAGE
python eval/run_eval.py --gold eval/gold-2026-09-16.jsonl
python eval/run_eval.py --gold ... --limit 20 # smoke run
python eval/run_eval.py --gold ... --ppr # add the PageRank lane as a 3rd mode
python eval/run_eval.py --selftest # metrics only, no model, no index

Config (env): BRAIN_INDEX_DIR (index to score), TURNSTATE_DB (where --sqlite appends a
Expand Down Expand Up @@ -119,8 +122,8 @@ def chk(name, got, want):


# ------------------------------------------------------------------ runner
def score_gold(gold, progress=True):
"""Run every question through both modes. -> (rows, config dict)."""
def score_gold(gold, progress=True, ppr=False):
"""Run every question through both modes (three with ppr=True). -> (rows, config dict)."""
from sqlite_graph_memory import brain_ask as ba

dev = ba.pick_device()
Expand All @@ -135,15 +138,26 @@ def score_gold(gold, progress=True):
by_base = ba.index_by_basename(meta)
stem_of = lambda i: Path(meta[i]['path']).stem.lower()

rows, t0 = [], time.time()
rows, t0, ppr_ran = [], time.time(), False
for k, g in enumerate(gold):
tq = time.time()
sims = ba.query_sims(enc, emb, g['query'])
base = ba.retrieve_candidates(sims, meta)
added = ba.expand_1hop(sims, meta, base, by_base)
lists = {'vector': ba.rerank_candidates(ce, g['query'], base, meta)}
if ppr:
# One rerank of the graph pool serves both modes: `graph` is its top-12, exactly
# what rerank_candidates(topn=12) returns, and `ppr` fuses the FULL list.
full = ba.rerank_candidates(ce, g['query'], base + added, meta, topn=None)
lists['graph'] = full[:TOPN]
lane = ba.ppr_lane(meta, sims, base)
ppr_ran = ppr_ran or bool(lane)
lists['ppr'] = ba.fuse_ppr(full, meta, lane, TOPN)
else:
lists['graph'] = ba.rerank_candidates(ce, g['query'], base + added, meta)
res = {}
for mode, cand in (('vector', base), ('graph', base + added)):
ranked = [stem_of(i) for i, _ in ba.rerank_candidates(ce, g['query'], cand, meta)]
for mode, scored in lists.items():
ranked = [stem_of(i) for i, _ in scored]
res[mode] = {'ranked': ranked,
'recall': recall_at(ranked, g['gold']),
'mrr': mrr_at(ranked, g['gold']),
Expand All @@ -163,6 +177,11 @@ def score_gold(gold, progress=True):
sys.stderr.write('!! the graph added no neighbours to ANY question — link resolution or '
'the index is broken, not the graph. The graph rows below are the '
'vector rows.\n')
# Same silence, PPR lane: an empty lane falls back to the reranker order, so the ppr rows
# would equal the graph rows and read as "PPR adds nothing" when it never ran.
if ppr and rows and not ppr_ran:
sys.stderr.write('!! the PPR lane returned no files for ANY question (no edges, or it '
'failed). The ppr rows below are the graph rows.\n')
return rows, cfg


Expand All @@ -173,7 +192,7 @@ def summarize(rows):
groups += [('lang:' + lg, [r for r in rows if r['lang'] == lg]) for lg in ('ru', 'en')]
for name, rs in groups:
if not rs: continue
for mode in ('vector', 'graph'):
for mode in [m for m in ('vector', 'graph', 'ppr') if m in rs[0]['res']]:
out[(name, mode)] = {'n': len(rs),
'recall': sum(r['res'][mode]['recall'] for r in rs) / len(rs),
'mrr': sum(r['res'][mode]['mrr'] for r in rs) / len(rs),
Expand All @@ -197,6 +216,9 @@ def print_table(summary, cfg, gold_file):
if (name, 'graph') in summary:
d = summary[(name, 'graph')]['ndcg'] - summary[(name, 'vector')]['ndcg']
print('graph effect on %-9s nDCG@12 %+.3f' % (name, d))
if (name, 'ppr') in summary:
d = summary[(name, 'ppr')]['ndcg'] - summary[(name, 'graph')]['ndcg']
print('ppr effect on %-9s nDCG@12 %+.3f (vs graph)' % (name, d))


def write_sqlite(summary, cfg, gold_file, scores_file):
Expand Down Expand Up @@ -232,6 +254,8 @@ def main():
ap.add_argument('--out', help='scores .jsonl (default: eval/scores-<timestamp>.jsonl)')
ap.add_argument('--sqlite', action='store_true', help='also append to gold_eval in TURNSTATE_DB')
ap.add_argument('--no-persist', action='store_true', help='print the table, write nothing')
ap.add_argument('--ppr', action='store_true',
help='also score the PageRank lane (graph + PPR/RRF, reranker top 5 protected)')
ap.add_argument('--selftest', action='store_true', help='check the metrics and exit')
a = ap.parse_args()

Expand All @@ -256,7 +280,7 @@ def main():
if a.limit: gold = gold[:a.limit]
if not gold: sys.exit('gold file %s is empty' % gold_path)

rows, cfg = score_gold(gold)
rows, cfg = score_gold(gold, ppr=a.ppr)
summary = summarize(rows)
print_table(summary, cfg, a.gold)
if os.environ.get('EVAL_MUTANT') == '1':
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ python eval/run_eval.py --gold eval/gold-<date>.jsonl --sqlite

## Configuration (env only)

BRAIN_INDEX_DIR (index folder, default ./index), TURNSTATE_DB (default ./turnstate.db), BRAIN_ANSWER_OUT, BRAIN_AB_JSON (structured A/B output), AB_SOURCE, SGM_NO_STAR_PROMPT=1 (silence the one-time star line after --ab), SGM_CONFIG_DIR. Full list: .env.example.
BRAIN_INDEX_DIR (index folder, default ./index), TURNSTATE_DB (default ./turnstate.db), BRAIN_ANSWER_OUT, BRAIN_AB_JSON (structured A/B output), AB_SOURCE, SGM_NO_STAR_PROMPT=1 (silence the one-time star line after --ab), SGM_CONFIG_DIR, SGM_PPR=1 (PageRank lane in the ordering, off by default). Full list: .env.example.

## Links

Expand Down
59 changes: 55 additions & 4 deletions src/sqlite_graph_memory/brain_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
python brain_ask.py --graph "question" # vector + graph expansion (Associative)
python brain_ask.py --ab "question" # run BOTH, diff, log to SQLite (ab_recall)
python brain_ask.py --ask "question" # emit a context bundle for an LLM agent
python brain_ask.py --ppr --graph "question" # + PageRank lane in the ORDERING (see ppr.py)

Config (env, all optional):
BRAIN_INDEX_DIR dir holding the embedding index (default: ./index)
TURNSTATE_DB SQLite db for A/B telemetry (default: ./turnstate.db)
BRAIN_ANSWER_OUT file to mirror the answer into (default: ./_brain_answer.txt)
BRAIN_AB_JSON if set, --ab also writes a structured JSON diff to this path
SGM_PPR=1 same as --ppr: fuse personalized PageRank over [[wikilinks]] with the
reranker by RRF, reranker top 5 protected (off by default)
SGM_NO_STAR_PROMPT=1 never print the one-time GitHub star line after --ab
SGM_CONFIG_DIR where that line's once-per-machine marker lives
(default: $XDG_CONFIG_HOME or ~/.config, /sqlite-graph-memory)
Expand Down Expand Up @@ -190,12 +193,47 @@ def expand_1hop(sims, meta, base_order, by_base=None, ghops=GHOPS, gmax=GMAX):


def rerank_candidates(ce, query, cand, meta, topn=TOPN):
"""Cross-encoder rerank of candidate chunks -> [(chunk index, score)], best first."""
"""Cross-encoder rerank of candidate chunks -> [(chunk index, score)], best first.
topn=None returns the whole reranked list (the PPR fusion needs the tail)."""
pairs = [(query, meta[i]['title'] + '. ' + meta[i]['snippet']) for i in cand]
sc = ce.predict(pairs) if len(pairs) else []
return sorted(zip(cand, sc), key=lambda x: -x[1])[:topn]


def _ppr():
try:
from sqlite_graph_memory import ppr
except ImportError: # run as a loose script next to ppr.py
import ppr
return ppr


def ppr_enabled(env=None):
"""True when SGM_PPR asks for the PageRank lane. Never raises."""
try:
return _ppr().enabled(env)
except Exception:
return False


def ppr_lane(meta, sims, base_order):
"""PPR file order for this query, or [] (lane off) on ANY failure, including import."""
try:
return _ppr().ppr_file_order(meta, sims, base_order)
except Exception as e:
sys.stderr.write('PPR lane fell back to reranker order (%s)\n' % str(e)[:140])
return []


def fuse_ppr(ranked_full, meta, ppr_stems, topn=TOPN):
"""Reranker list fused with the PPR order, top `topn`. Any failure: reranker top `topn`."""
try:
return _ppr().fuse_after_rerank(ranked_full, meta, ppr_stems, topn)
except Exception as e:
sys.stderr.write('PPR fusion fell back to reranker order (%s)\n' % str(e)[:140])
return list(ranked_full)[:topn]


STAR_URL = 'https://github.com/tonydzi/sqlite-graph-memory'
STAR_MARKER = 'star_prompt_shown'

Expand Down Expand Up @@ -257,7 +295,7 @@ def main():
args = sys.argv[1:]
ask = '--ask' in args
graph = '--graph' in args; ab = '--ab' in args
flags = {'--ask', '--graph', '--ab'}
flags = {'--ask', '--graph', '--ab', '--ppr'}
query = ' '.join(a for a in args if a not in flags)
if not query or not EMB.exists():
sys.stderr.write('need an embedding index (%s) and a query — run index_notes.py first\n' % EMB)
Expand All @@ -284,10 +322,23 @@ def main():
added = expand_1hop(sims, meta, base_order)
graph_order = base_order + added; graph_set = set(added)

# PageRank lane (opt-in): a second ORDERING, not a candidate source. Crash-safe like the
# hop: any failure leaves ppr_stems empty and every list below keeps the reranker's order.
# Not in --ab: the A/B answers "does the 1-hop graph help?", and its rows in ab_recall
# carry no PPR column, so a lane on both arms would be logged as plain vector vs graph.
ppr_stems = []
if ab and ('--ppr' in args or ppr_enabled()):
sys.stderr.write('PPR lane is not applied in --ab (score it with eval/run_eval.py --ppr)\n')
elif order and ('--ppr' in args or ppr_enabled()):
ppr_stems = ppr_lane(meta, sims, base_order)

# rerank on the matched chunk (precise)
ce = load_reranker(dev)
def rerank(cand):
return rerank_candidates(ce, query, cand, meta)
if not ppr_stems:
return rerank_candidates(ce, query, cand, meta)
full = rerank_candidates(ce, query, cand, meta, topn=None) # fusion needs the tail
return fuse_ppr(full, meta, ppr_stems, TOPN)

# --ab: run BOTH paths in ONE process, diff, log. Answers "does graph REALLY help?"
if ab:
Expand Down Expand Up @@ -349,7 +400,7 @@ def _ser(lst):
sys.stderr.write("wiki-graph gated OFF (entity-like query) -> Direct only\n")
order = graph_order if (graph and not gated) else base_order
reranked = rerank(order)
scope = 'all' + (' /graph+%d' % len(graph_set) if graph else '')
scope = 'all' + (' /graph+%d' % len(graph_set) if graph else '') + (' /ppr' if ppr_stems else '')
lines = [f'QUERY: {query}', f'(scope={scope}; e5 {len(order)} files -> rerank; {len(meta)} chunks)', '']
if ask:
lines.append('=== CONTEXT BUNDLE (paste to your agent) ===\n')
Expand Down
Loading
Loading