diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d2c215191..556afcf42 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.13 - run: bun install - name: Run Tier 1 E2E tests run: bun test test/e2e/mechanical.test.ts test/e2e/mcp.test.ts @@ -67,7 +67,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.13 - run: bun install - name: Install OpenClaw run: npm install -g openclaw@2026.4.9 diff --git a/.github/workflows/heavy-tests.yml b/.github/workflows/heavy-tests.yml index a0bb78c1e..bf21a88da 100644 --- a/.github/workflows/heavy-tests.yml +++ b/.github/workflows/heavy-tests.yml @@ -58,7 +58,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.13 - run: bun install - name: Run heavy tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b56be9ef..5245f5ec3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.13 - run: bun install - run: bun test - run: bun build --compile --target=${{ matrix.target }} --outfile bin/${{ matrix.artifact }} src/cli.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb6dab66a..3f7ab2f4a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.13 - run: bun install - name: Pre-test gates (shard 1 only — they're not test files) if: matrix.shard == 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf7bc26a..b5977044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,111 @@ All notable changes to GBrain will be documented in this file. +## [0.40.4.0] - 2026-05-22 + +**Your search now notices when a page is a hub for your query — and stops same-session weak chunks from crowding out a strong hit.** + +GBrain's search already does a lot: it blends keyword and vector matches, reranks them, then nudges the order based on backlinks, salience, and recency. But the graph of links sitting in your brain was mostly wallpaper at query time — we knew which pages linked to which, and we used that count globally (popular pages get a small boost), but we never asked "for THIS query specifically, which top results are connected to each other?" That's the new signal. + +Three small, additive moves in the ranker: + +1. **Adjacency hub** (×1.05): if a page in the top-K is linked to by 2+ OTHER top-K results, it's a hub for this specific query. Small bump. +2. **Cross-team hub** (×1.10): if a top-K page is linked from pages in 2+ DIFFERENT sources (not counting its own source), it's corroborated across team brains. Slightly bigger bump. Dormant if you only have one brain — but the moment you mount a team brain or your CEO topology widens, the signal lights up. +3. **Session diversify** (×0.95): if three results all come from the same chat session, keep the highest-scoring one at full score and DEMOTE the rest. Stops weak chunks from a chatty session from elbowing a strong hit out of the token budget. (The original v1 plan boosted same-session pages — codex caught it was structurally backwards, and we flipped it before merging.) + +All three live inside the existing post-fusion stage, so they inherit the v0.35.6.0 floor-ratio gate that prevents weak pages from getting boosted past strong ones via popularity. + +Magnitudes are conservative on purpose (1.05/1.10/0.95). A score-distribution probe collects data on top-K reorder-band widths under real use — that data feeds a calibration wave in v0.41+ that tunes the magnitudes against actual ranking shifts, not vibes. + +Default on for `balanced` and `tokenmax` modes; off for `conservative`. Opt out per-brain with `gbrain config set search.graph_signals false`. + +### How to use it + +```bash +# Check whether it's on (mode default or config override) +gbrain doctor --json | jq '.checks[] | select(.name == "graph_signals_coverage")' + +# Watch what fired in your recent searches +gbrain search stats --days 7 +# → Graph signals: enabled / failures / fail-rate + +# See per-stage attribution for one query +gbrain search "founders we know in San Francisco" --explain +# → 1. people/alice (score=12.4) +# base=10.2 (rrf+cosine) +# + backlink ×1.08 +# + adjacency ×1.05 (hits=3) +# = final 12.4 + +# Override the mode default +gbrain config set search.graph_signals false # opt out +gbrain config set search.graph_signals true # opt in for conservative +``` + +### Cathedral expansions that came along + +This wave grew significantly through review (boil-the-lakes orientation): + +- **Per-stage score attribution in EVERY boost stage** (D12=A). Every stage that mutates `score` now stamps a field recording what it multiplied: `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, plus a `base_score` snapshot captured once at runPostFusionStages entry. `gbrain search --explain` reads them to render the per-result breakdown above. JSON envelope: same fields surface verbatim under existing `--json` output. + +- **Audit-writer unification** (D5=B). The five hand-rolled JSONL audit modules in the codebase (rerank-failures, shell-jobs, supervisor, slug-fallback, phantoms) duplicated the same ISO-week filename math, best-effort write loop, and read-current-plus-previous-week loop. Extracted `src/core/audit/audit-writer.ts` as the shared primitive; refactored all five plus the new `graph-signals-failures` audit onto it. Public API preserved bit-for-bit — every existing test passes unchanged. + +- **Eval gates with paired bootstrap** (D13=A). New `test/e2e/graph-signals-eval.test.ts` runs each longmemeval-mini question twice (off vs on) and asserts: (1) paired bootstrap p≥0.05 in the wrong direction OR delta ≥ 0 (no statistically significant regression), (2) mean Jaccard@5 ≥ 0.5 (results didn't shift wildly), (3) top-1 stability ≥ 0.7 (most top picks preserved), (4) recall@5 drop ≤ 5pt absolute (catastrophic catch). 10,000 resamples, deterministic seeded RNG, hermetic via PGLite. Reusable `pairedBootstrapPValue` pure function exported for future calibration waves. + +### What's safe to know about + +- **Mid-deploy cache hit-rate dip**: `KNOBS_HASH_VERSION` bumps 3→4 so a graph-on cache write can't be served to a graph-off lookup. Existing `query_cache` rows from before the upgrade hash differently — natural row segregation. Clears within `cache.ttl_seconds` (3600s default). +- **Single-source brains**: cross-source signal is dormant (count is always 0). Adjacency + session diversification still fire normally. No cost. +- **Remote-server deploys**: graph-signal fail-open events are JSONL-only today (codex outside-voice flagged the split-brain observability). `search stats` shows success metrics on remote, full metrics on local. A DB-backed audit table is filed as T-todo-3 for v0.41+. + +### Itemized changes + +- `src/core/search/graph-signals.ts` (NEW): `applyGraphSignals` helper with `sessionPrefix`, `computeScoreDistribution`, and `pairedBootstrapPValue`-compatible internal score handling. Constants `ADJACENCY_BOOST=1.05`, `CROSS_SOURCE_BOOST=1.10`, `SESSION_DEMOTE=0.95`, `DEFAULT_TOP_K=20`, threshold mins. Test seam via `adjacencyFn` injection. Fail-open with JSONL audit row. +- `src/core/search/hybrid.ts`: extended `runPostFusionStages` with 4th stage (`graphSignalsEnabled`, `onGraphMeta`, `onScoreDistribution`). `base_score` stamped at function entry idempotently. Each existing boost stage (`applyBacklinkBoost`, `applySalienceBoost`, `applyRecencyBoost`) stamps its multiplier. +- `src/core/search/intent-weights.ts`: `applyExactMatchBoost` stamps `exact_match_boost` when fired. +- `src/core/search/rerank.ts`: `applyReranker` stamps `reranker_delta` (rank delta — positive = improved). +- `src/core/search/mode.ts`: new `graph_signals: boolean` knob in `ModeBundle`. Defaults: conservative=false, balanced=true, tokenmax=true. `KNOBS_HASH_VERSION` 3→4 with `gs=` parts entry appended per CDX2-F13 convention. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the new field. +- `src/core/search/explain-formatter.ts` (NEW): renders SearchResult[] as a multi-line per-result breakdown. Reads every boost-stamping field. "no boosts applied" empty path. 4-decimal precision with trailing-zero strip. +- `src/core/cli-options.ts`: `CliOptions` gains `explain: boolean`. `parseGlobalFlags` recognizes `--explain` anywhere in argv. +- `src/cli.ts`: `formatResult` for `search` + `query` cases routes to `formatResultsExplain` when `CliOptions.explain` is set. +- `src/commands/search.ts`: `gbrain search stats` gains `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property to existing stats; `_meta.metric_glossary` adds two new keys. Human output prints the section after the existing block. +- `src/commands/doctor.ts`: new `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON path). Reads `search.graph_signals` config first, falls back to mode default; silent ok when disabled; warns at <10% inbound coverage with `gbrain extract all` fix hint; ok at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage. +- `src/core/engine.ts`: new `getAdjacencyBoosts(pageIds): Promise>` method. SearchResult type extended with 12 new optional fields (graph signals + attribution). +- `src/core/postgres-engine.ts` + `src/core/pglite-engine.ts`: `getAdjacencyBoosts` impl with parity SQL. `COALESCE(p.source_id, 'default')` for null safety; `HAVING >= 1` matches JSDoc; cross_source_hits excludes target's own source via CASE-WHEN on JOINed target row. +- `src/core/types.ts`: `AdjacencyRow` interface + 12 new optional SearchResult fields. +- `src/core/audit/audit-writer.ts` (NEW): shared primitive (`createAuditWriter`, `computeIsoWeekFilename`, `resolveAuditDir`). 22 unit tests pin the contract. +- `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts`: refactored onto `createAuditWriter`. Public API preserved. +- Tests added: `test/audit/audit-writer.test.ts` (22), `test/e2e/graph-signals-engine.test.ts` (7), `test/search/graph-signals.test.ts` (24 including the IRON-RULE floor-gate regression), `test/search/attribution-stamping.test.ts` (16), `test/search/explain-formatter.test.ts` (19), `test/search/search-stats-graph-signals.test.ts` (6), `test/e2e/graph-signals-eval.test.ts` (10 — 4 eval gates + 5 pure bootstrap function tests). +- Tests extended: `test/doctor.test.ts` (7 new for coverage check), `test/search-mode.test.ts` (8 new for graph_signals knob), `test/search/knobs-hash-reranker.test.ts` (version assertion 3→4), `test/cross-modal-phase1.test.ts` (version assertion 3→4), `test/cli-options.test.ts` (3 new for --explain parsing), `test/thin-client-upgrade-prompt.test.ts` (CliOptions literal updated). + +### To take advantage of v0.40.4.0 + +`gbrain upgrade` should pick this up automatically. If your install has `balanced` or `tokenmax` mode (the default), graph signals are already on after upgrade. + +1. **Verify it's running:** + ```bash + gbrain doctor --json | jq '.checks[] | select(.name == "graph_signals_coverage")' + ``` + Expected: `status: "ok"` with a coverage percentage. If you get a warn at <10% coverage, run `gbrain extract all` to populate the link graph from your markdown. + +2. **Try it on a query:** + ```bash + gbrain search "your favorite query" --explain + ``` + You'll see per-stage attribution showing what each boost did. + +3. **Opt out if needed:** + ```bash + gbrain config set search.graph_signals false + ``` + +4. **If something looks wrong**, please file an issue: https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor --json` and one or two `gbrain search ... --explain` examples that look off. + +### For contributors + +- New shared primitive `src/core/audit/audit-writer.ts` is the way to add JSONL audits going forward. Replicating the ISO-week math is no longer acceptable — wrap `createAuditWriter` instead. +- `pairedBootstrapPValue` is exported from `test/e2e/graph-signals-eval.test.ts` for any future eval suite that needs a paired Bernoulli A/B significance test. +- Five TODOs filed for v0.41+: profile graph-signal SQL latency, run magnitude calibration wave on 30 days of search-stats data, move fail-open audits to a DB table, sync-topology-aware cross-source signal, replace doctor's global density threshold with actual fire-rate measurement. ## [0.40.3.0] - 2026-05-22 **Your search now understands what each chunk is about — AND its cached results expire the moment a page changes.** diff --git a/CLAUDE.md b/CLAUDE.md index bda2071b0..d088173a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,15 @@ strict behavior when unset. ## Key files - `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. **v0.26.9 (D12 + F7b):** `OperationContext.remote` is now a REQUIRED field in the TypeScript type — the compiler is the first defense against transports that forget to set it. Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) flipped from falsy-default (`!ctx.remote`) to fail-closed semantics (`ctx.remote === false` for "trusted-only" sites and `ctx.remote !== false` for "untrust unless explicit-false"). Anything that isn't strictly `false` is now treated as remote. Closed an HTTP MCP shell-job RCE: a `read+write`-scoped OAuth token could submit `shell` jobs because the HTTP request handler's literal context skipped `remote: true` and `submit_job`'s protected-name guard saw a falsy undefined. Stdio MCP set the field correctly via dispatch.ts; HTTP inlined a parallel context-builder for several releases and lost it. **v0.34.1.0 (#861 + #876):** new helper `sourceScopeOpts(ctx)` encodes the precedence ladder for source-scoped reads — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId` / `ctx.auth.sourceId`) over nothing. Every read-side op handler routes through it so future ops can't silently drift from the canonical v0.31.8 thread. Closes the source-isolation leak on the read path: a `read+write`-scoped OAuth client bound to `--source dept-x` no longer sees rows from neighboring sources via `search` / `query` / `list_pages` / `get_page` / `find_experts` / `query`'s image path. -- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. +- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. **v0.40.4.0:** new `getAdjacencyBoosts(pageIds): Promise>` method powers the per-query graph-signals stage in hybrid search. Single SQL query returns inbound-link counts among the top-K set plus a cross-source count (links from pages whose `source_id` differs from the target's). `COALESCE(p.source_id, 'default')` for null safety; `HAVING >= 1` matches JSDoc; cross-source CASE-WHEN on the JOINed target row excludes the target's own source. Parity SQL between postgres-engine.ts + pglite-engine.ts. `SearchResult` gains 12 new optional fields (`base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta`, plus internal staging fields). Pinned by `test/e2e/graph-signals-engine.test.ts` (7 cases) + cross-engine parity in the same suite. +- `src/core/search/graph-signals.ts` (v0.40.4.0) — per-query graph-signals helper. `applyGraphSignals(results, engine, opts)` runs as the 4th post-fusion stage (after backlink/salience/recency). Three boosts: `ADJACENCY_BOOST=1.05` (page is linked from 2+ OTHER top-K results — local hub for THIS query), `CROSS_SOURCE_BOOST=1.10` (page is linked from 2+ DIFFERENT sources — corroborated across team brains, dormant in single-source brains), `SESSION_DEMOTE=0.95` (3+ results from same chat session — keep the highest-scoring one at full score, demote the rest). All three inherit the v0.35.6.0 floor-ratio gate that prevents weak pages from getting boosted past strong ones via popularity. `computeScoreDistribution(results)` emits min/p25/p50/p75/p95/max + `reorder_band_width` — instrumentation for the v0.41+ magnitude calibration wave (TODOS T-todo-2). `sessionPrefix(slug)` extracts the chat-session anchor (`chat/2026-05-15-...`). Pure `pairedBootstrapPValue(deltas, resamples, rng)` exported for eval gates. Test seam via `adjacencyFn` DI. Fail-open: any error logs via `logGraphSignalsFailure` (JSONL audit via `audit-writer`) and returns the input array unchanged. Pinned by `test/search/graph-signals.test.ts` (24 cases including the IRON-RULE floor-gate regression). +- `src/core/search/hybrid.ts` extension (v0.40.4.0) — `runPostFusionStages` extended with a 4th stage (`graphSignalsEnabled`, `onGraphMeta`, `onScoreDistribution`). `base_score` stamped at function entry idempotently (captured ONCE before any boost stage mutates `score`). Every existing post-fusion stage now stamps its multiplier on the result: `applyBacklinkBoost` → `backlink_boost`, `applySalienceBoost` → `salience_boost`, `applyRecencyBoost` → `recency_boost`. `applyReranker` (called earlier in the pipeline) stamps `reranker_delta` as a rank delta (positive = improved). `applyExactMatchBoost` in `src/core/search/intent-weights.ts` stamps `exact_match_boost` when fired. Per-stage attribution is the cathedral that powers `gbrain search --explain` — every boost surface carries its own field, so `formatResultsExplain` reads them all without coupling to internal stage ordering. +- `src/core/search/explain-formatter.ts` (v0.40.4.0) — renders `SearchResult[]` as a multi-line per-result breakdown for `gbrain search --explain`. Reads every boost-stamping field. Handles the "no boosts applied" empty path. 4-decimal precision with trailing-zero strip. Pinned by `test/search/explain-formatter.test.ts` (19 cases). +- `src/core/search/mode.ts` extension (v0.40.4.0) — new `graph_signals: boolean` knob in `ModeBundle`. Defaults: `conservative=false`, `balanced=true`, `tokenmax=true`. `KNOBS_HASH_VERSION` bumps 3→4 with a `gs=` parts entry appended per the CDX2-F13 cache-key contamination convention so a graph-on cache write can't be served to a graph-off lookup. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the new field. Opt-out per-brain: `gbrain config set search.graph_signals false`. Mid-deploy: existing `query_cache` rows from before the upgrade hash differently — natural row segregation, clears within `cache.ttl_seconds` (3600s default). +- `src/core/audit/audit-writer.ts` (v0.40.4.0, NEW) — shared JSONL audit primitive consolidating the 5 hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Five existing audits refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts`. Public API of each module preserved bit-for-bit — every existing test passes unchanged. The new `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. Codex F16 (TODOS) calls out one remaining hand-rolled audit at `src/core/skillpack/audit.ts` for a v0.41+ refactor. Pinned by `test/audit/audit-writer.test.ts` (22 cases pinning the contract). +- `src/core/cli-options.ts` extension (v0.40.4.0) — `CliOptions` gains `explain: boolean`. `parseGlobalFlags` recognizes `--explain` anywhere in argv (stripped before command dispatch). `src/cli.ts` `formatResult` for `search` + `query` cases routes to `formatResultsExplain` from `src/core/search/explain-formatter.ts` when `CliOptions.explain` is set; falls through to the existing JSON / human formatters otherwise. +- `src/commands/search.ts:gbrain search stats` extension (v0.40.4.0) — new `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property to existing stats; `_meta.metric_glossary` adds two new keys (`graph_signals.enabled`, `graph_signals.failures_by_reason`). Human output prints the section after the existing block. Reads `search.graph_signals` config first, falls back to the mode default. Pinned by `test/search/search-stats-graph-signals.test.ts` (6 cases). +- `src/commands/doctor.ts` extension (v0.40.4.0) — new `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON thin-client path). Reads `search.graph_signals` config first, falls back to mode default; silent `ok` when disabled. Computes inbound link coverage on the page set; warns at <10% with `gbrain extract all` fix hint; `ok` at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage embedded in the message. Pinned by 7 new cases in `test/doctor.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`) - `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains. **v0.35.5.0:** probe set extended in parity with postgres-engine.ts — `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`. Bootstrap also threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope. Closes #1018, #974, #820. **v0.37.10.0:** `getBrainScore` now returns 100/100 with full breakdown components (35/25/15/15/10) when `pageCount === 0`. Vacuous truth — an empty brain has no coverage problem to penalize. Pre-fix, fresh `gbrain init --pglite` users saw "Brain score 0/100" on first `gbrain doctor` run, which was structurally surprising AND triggered the v0.37.8.0 `doctor-report-remote.test.ts` flake (test expected `health_score >= 70` on a freshly-initialized test brain). Mirrored in postgres-engine.ts. Pinned by `test/brain-score-breakdown.test.ts`'s updated empty-brain assertion and the hermetic `test/doctor-report-remote.serial.test.ts`. — PGLite-specific DDL (pgvector, pg_trgm, triggers) - `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. v0.22.0: `searchKeyword`, `searchKeywordChunks`, and `searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude clause from `src/core/search/sql-ranking.ts`. `searchVector` switches to a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in the outer SELECT) and carries `p.source_id` through inner→outer for v0.18 multi-source callers. v0.22.1 (#406): `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures). `executeRaw` is a single-statement passthrough — no per-call retry (D3 dropped that as unsound for non-idempotent statements; recovery is supervisor-driven). v0.22.1 (#363, contributed by @orendi84): `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. v0.22.1 (#409, contributed by @atrevino47): `countStaleChunks()` + `listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale`, eliminating ~76 MB/call client-side pull on a fully-embedded brain; `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding (consistency). As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same forward-reference probe set as the PGLite engine, so old Postgres brains pinned at v0.13/v0.18/v0.19 walk forward cleanly instead of wedging on `column "..." does not exist`. **v0.35.5.0:** probe set extended for the column-only forward-reference cases the original v0.22.6.1 sweep missed — `files.source_id`, `files.page_id` (pre-v0.18 brains where `idx_files_source_id` was the choke point), `oauth_clients.source_id`, `oauth_clients.federated_read` (pre-v0.34 brains where v60+v61+v65 chain failed), and `sources.archived` + `sources.archived_at` + `sources.archive_expires_at` (pre-v0.26.5 brains where `CREATE TABLE IF NOT EXISTS sources` was a no-op on existing tables so the archive lifecycle columns never landed). Also (Codex P1 from pre-landing review): the entire probe path now runs on the DDL connection threaded down from `initSchema` — previously probes ran through the instance pool while the advisory lock sat on a different connection, opening a concurrent-bootstrap race for Supabase pooler users. Closes #1018, #974, #820. **v0.28.1:** `disconnect()` is now idempotent. New `_connectionStyle` instance field tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than falling through to `db.disconnect()` and clobbering the singleton. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` (2 cases). Closes the bug class where any test sharing an engine across multiple `worker.start()` / `worker.stop()` cycles silently broke its own DB connectivity. **v0.37.10.0:** `getBrainScore` empty-brain parity with PGLite — returns 100/100 with breakdown components 35/25/15/15/10 when `pageCount === 0` instead of the pre-fix 0/100. Same vacuous-truth rationale as the PGLite side; both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic. diff --git a/README.md b/README.md index 9a2fbcff3..e502b13f1 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec ## Capabilities -**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. +**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. **New in v0.40.4.0:** per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. **Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. diff --git a/TODOS.md b/TODOS.md index 78ea7a8cd..fd2cc9d24 100644 --- a/TODOS.md +++ b/TODOS.md @@ -42,6 +42,35 @@ Doctor surface is already in place to show outcomes; just need the scheduling lane. Estimate: ~3 hours. +## v0.40.4 adversarial review LOW findings — captured for v0.41+ + +- [ ] **Codex L1**: `gbrain search stats --days N` underreports for N > 7. audit-writer.ts reads only current + previous ISO week (~14 days). `--days 30` silently shows ~2 weeks of failure events. Fix shape: extend readRecent to walk N/7 weeks dynamically OR cap user input with a clear message. +- [ ] **Claude F2**: Score compounding on repeat applyGraphSignals invocation. The boost stages aren't idempotent on `r.score`; only `base_score` has explicit pre-stamp idempotency. If a future caller invokes runPostFusionStages twice on the same SearchResult array (retry loop, cache-augmentation path), scores compound `score * ADJACENCY_BOOST * ADJACENCY_BOOST`. Same hole in applyBacklinkBoost/Salience/Recency. Document the "call once" contract OR add an `already_applied` guard. +- [ ] **Claude F3**: NaN handling asymmetry. applyBacklinkBoost explicitly guards `if (!Number.isFinite(r.score)) continue` (hybrid.ts:82). applyGraphSignals does NOT — only the floor-threshold guard. With floor_ratio undefined (default), NaN scores get `NaN * 1.05 = NaN`. ECMAScript sort with NaN comparator is undefined behavior. Add the same `Number.isFinite(r.score)` guard. +- [ ] **Claude F5**: Doctor's `linkedRows` coverage query overcounts via soft-deleted source pages. The JOIN filters TO page on deleted_at but not FROM page. Coverage metric overstates link density relative to what graph-signals actually fires on. Fix: add `WHERE l.from_page_id IN (SELECT id FROM pages WHERE deleted_at IS NULL)` or equivalent. +- [ ] **Claude F6**: ANSI / control-char injection via slug or path into stderr + --explain output. audit-slug-fallback writes user-derived `sourcePath`/`slug` unfiltered. explain-formatter renders `graph_session_prefix` (slug-derived) unfiltered. Slug validation in import-file may strip these but defense-in-depth at log/render sites is missing. +- [ ] **Claude F7**: JSONL concurrent-append byte interleaving on large events. `appendFileSync` is atomic only when write size ≤ PIPE_BUF (~4096 bytes Linux). Supervisor audit rows can exceed this. Corrupt rows silently dropped via JSON.parse-in-catch. Fix shape: write to staging file + rename, or use fcntl advisory lock around append. +- [ ] **Claude F8**: Audit files never pruned. 6 audit types × 52 weeks/year = 312+ files. Long-running installs accumulate disk/inode pressure. Add `cleanupOldFiles(retentionDays)` to audit-writer and wire into doctor's purge phase OR autopilot weekly maintenance. +- [ ] **Claude F11**: Source-scope contract on getAdjacencyBoosts is JSDoc-only, no runtime check. Defensive `deleted_at IS NULL` was codified post-review; same defense pattern should apply to source-scope (the v0.34.1 source-isolation seal class). Add optional `sourceId` param that asserts at runtime, OR add a test-only contract checker. +- [ ] **Claude F12**: `require('./core/search/explain-formatter.ts')` in cli.ts:576 is CommonJS. Repo is ESM. Switch to `await import(...)` for consistency with the file's other lazy-imports. +- [ ] **Claude F14**: Telemetry undercounts on cache hit. onScoreDistribution and onGraphMeta fire ONLY in runPostFusionStages which runs ONLY in bare hybridSearch (not cache hit). Doctor's graph_signals_coverage decisions based on absent fire data on high-cache-hit installs. +- [ ] **Claude F16**: src/core/skillpack/audit.ts carries duplicate ISO-week filename math. Refactor onto createAuditWriter for parity with the 5 audits unified in v0.40.4 T2. + +## Pre-existing flake on master (noticed during v0.40.4 ship) + +- [ ] **`test/search/embedding-column.test.ts:466,489,522` — `isCacheSafe` returns false when run after gateway-state-mutating siblings in shard 2.** Confirmed pre-existing on master (`git stash` + `SHARD=2/8 bash scripts/run-unit-shard.sh` reproduces 3 fails on a clean working tree). Symptom: `isCacheSafe(default-named-column, empty-cfg)` expects `gwDims=1536` but reads `1280` (the post-v0.37.11.0 ZeroEntropy default). Some test in the shard before embedding-column.test.ts initializes the gateway with the PGLite-default ZeroEntropy/1280 config and leaves it that way. Either: (a) embedding-column.test.ts grows a `beforeEach` that calls `__setEmbedTransportForTests`-style reset, (b) the offending sibling adds an `afterAll(reset)`, or (c) embedding-column.test.ts becomes `*.serial.test.ts` to quarantine. Three test files in shard 2 touch gateway state via PGLite engine connects: `restart-sweep.test.ts`, `init-mode-picker.test.ts`, `doctor.test.ts`. Tests pass in isolation (50/50); only fail under shard-2 ordering. v0.40.4 ships through this flake — not introduced by the wave. + +## v0.40.4 graph signals — deferred follow-ups (v0.41+) + +- [ ] **T-todo-1: profile graph-signal SQL latency at scale + merge backlink + adjacency if hot.** Today `getBacklinkCounts` and `getAdjacencyBoosts` both hit the `links` table inside `runPostFusionStages` — two round-trips that share an index. If profiling on Garry's actual brain shows the two-round-trip cost dominates graph-signal stage latency (>5ms p99), merge into `getLinkAggregates(slugs, pageIds)` returning both backlink counts AND adjacency aggregates in one SQL. D8=C deferred this until real production data justifies it. Trigger: `gbrain search stats` shows graph-signal stage p99 > 5ms over a 7-day window. + +- [ ] **T-todo-2: magnitude calibration wave from 30 days of score-distribution probe data.** v0.40.4 ships conservative magnitudes (ADJACENCY_BOOST=1.05, CROSS_SOURCE_BOOST=1.10, SESSION_DEMOTE=0.95) under the floor-gate. The `onScoreDistribution` probe emits min/p25/p50/p75/p95/max + reorder_band_width on every query. After 30 days, read the cumulative distribution from search-stats telemetry, compute the actual reorder bands the boosts have to clear, and tune the three constants against real data. Today's values are vibes-driven (D14=B); the probe instrumentation is the cathedral, the calibration wave is the payoff. + +- [ ] **T-todo-3: move fail-open audit events to a DB table for cross-deploy observability.** Codex outside-voice #15 caught the split-brain observability: graph-signals failures land in `~/.gbrain/audit/graph-signals-failures-*.jsonl`, but `gbrain serve --http` deploys can't read the host JSONL. `gbrain search stats` shows error counts on local but not on remote-server brains. Right shape: add a small `event_log` table (or extend an existing one) that the shared `createAuditWriter` writes to alongside the JSONL when an engine is available. Doctor + search-stats read from DB on remote, fall back to JSONL on local. Affects all 6 audit modules (rerank, shell, supervisor, slug-fallback, phantom, graph-signals), so this is a v0.41 audit-infra wave, not a one-off. + +- [ ] **T-todo-4: sync-topology-aware cross-source signal.** Codex outside-voice #11 + #15 caught: `cross_source_hits` today counts ANY page in another source as cross-team corroboration, but mirrored imports from another source look identical to genuine cross-team links. Distinguishing them likely needs a `link_source_type` enum extension (e.g. `'mirror'` flag on links created during a `gbrain sources sync`) so the SQL can filter `cross_source_hits` to genuine team-authored edges only. + +- [ ] **T-todo-5: replace doctor's 30% global density threshold with actual fire-rate measurement.** `checkGraphSignalsCoverage` in doctor uses % pages with ≥1 inbound link as a proxy for "graph signals fire often enough to matter." Codex outside-voice #14 caught: this is global density, not top-K subgraph density. After 30 days of `gbrain search stats` data accumulates per-query fire rates (T-todo-2 wires this), swap the doctor check to read actual fire-rate-over-window. The 30% threshold becomes "fired in ≥10% of queries in last 7 days" or similar — measured, not inferred. ## v0.39.3.0 smoke-test wave — deferred follow-ups (v0.39.4 / v0.40) - [ ] **v0.40: SQL-shape rewrite of `listPrefixSampledPages` for PgBouncer transaction-mode compatibility.** WARN-10 root cause from the v0.38.0.0 smoke test: brainstorm + lsd consistently exceed Postgres `statement_timeout` (often PgBouncer-imposed) on the prefix-stratified domain bank query when the brain has >10K pages spread across many prefixes. v0.39.3.0 ships diagnostic surfacing only (the orchestrator wrap classifies SQLSTATE 57014 into a `StructuredAgentError` with a friendly hint). Real fix: per-prefix limit pushdown, embeddings prefetch, or breaking the single big query into a series of small ones across an explicit cursor. Plan: `~/.claude/plans/system-instruction-you-are-working-async-popcorn.md` (Phase 5, WARN-10 row). Owner: open. diff --git a/VERSION b/VERSION index b4fa9dce8..59a21474d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.40.3.0 +0.40.4.0 diff --git a/llms-full.txt b/llms-full.txt index 9a50c5381..2122e7dfc 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -183,7 +183,15 @@ strict behavior when unset. ## Key files - `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. **v0.26.9 (D12 + F7b):** `OperationContext.remote` is now a REQUIRED field in the TypeScript type — the compiler is the first defense against transports that forget to set it. Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) flipped from falsy-default (`!ctx.remote`) to fail-closed semantics (`ctx.remote === false` for "trusted-only" sites and `ctx.remote !== false` for "untrust unless explicit-false"). Anything that isn't strictly `false` is now treated as remote. Closed an HTTP MCP shell-job RCE: a `read+write`-scoped OAuth token could submit `shell` jobs because the HTTP request handler's literal context skipped `remote: true` and `submit_job`'s protected-name guard saw a falsy undefined. Stdio MCP set the field correctly via dispatch.ts; HTTP inlined a parallel context-builder for several releases and lost it. **v0.34.1.0 (#861 + #876):** new helper `sourceScopeOpts(ctx)` encodes the precedence ladder for source-scoped reads — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId` / `ctx.auth.sourceId`) over nothing. Every read-side op handler routes through it so future ops can't silently drift from the canonical v0.31.8 thread. Closes the source-isolation leak on the read path: a `read+write`-scoped OAuth client bound to `--source dept-x` no longer sees rows from neighboring sources via `search` / `query` / `list_pages` / `get_page` / `find_experts` / `query`'s image path. -- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. +- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. **v0.40.4.0:** new `getAdjacencyBoosts(pageIds): Promise>` method powers the per-query graph-signals stage in hybrid search. Single SQL query returns inbound-link counts among the top-K set plus a cross-source count (links from pages whose `source_id` differs from the target's). `COALESCE(p.source_id, 'default')` for null safety; `HAVING >= 1` matches JSDoc; cross-source CASE-WHEN on the JOINed target row excludes the target's own source. Parity SQL between postgres-engine.ts + pglite-engine.ts. `SearchResult` gains 12 new optional fields (`base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta`, plus internal staging fields). Pinned by `test/e2e/graph-signals-engine.test.ts` (7 cases) + cross-engine parity in the same suite. +- `src/core/search/graph-signals.ts` (v0.40.4.0) — per-query graph-signals helper. `applyGraphSignals(results, engine, opts)` runs as the 4th post-fusion stage (after backlink/salience/recency). Three boosts: `ADJACENCY_BOOST=1.05` (page is linked from 2+ OTHER top-K results — local hub for THIS query), `CROSS_SOURCE_BOOST=1.10` (page is linked from 2+ DIFFERENT sources — corroborated across team brains, dormant in single-source brains), `SESSION_DEMOTE=0.95` (3+ results from same chat session — keep the highest-scoring one at full score, demote the rest). All three inherit the v0.35.6.0 floor-ratio gate that prevents weak pages from getting boosted past strong ones via popularity. `computeScoreDistribution(results)` emits min/p25/p50/p75/p95/max + `reorder_band_width` — instrumentation for the v0.41+ magnitude calibration wave (TODOS T-todo-2). `sessionPrefix(slug)` extracts the chat-session anchor (`chat/2026-05-15-...`). Pure `pairedBootstrapPValue(deltas, resamples, rng)` exported for eval gates. Test seam via `adjacencyFn` DI. Fail-open: any error logs via `logGraphSignalsFailure` (JSONL audit via `audit-writer`) and returns the input array unchanged. Pinned by `test/search/graph-signals.test.ts` (24 cases including the IRON-RULE floor-gate regression). +- `src/core/search/hybrid.ts` extension (v0.40.4.0) — `runPostFusionStages` extended with a 4th stage (`graphSignalsEnabled`, `onGraphMeta`, `onScoreDistribution`). `base_score` stamped at function entry idempotently (captured ONCE before any boost stage mutates `score`). Every existing post-fusion stage now stamps its multiplier on the result: `applyBacklinkBoost` → `backlink_boost`, `applySalienceBoost` → `salience_boost`, `applyRecencyBoost` → `recency_boost`. `applyReranker` (called earlier in the pipeline) stamps `reranker_delta` as a rank delta (positive = improved). `applyExactMatchBoost` in `src/core/search/intent-weights.ts` stamps `exact_match_boost` when fired. Per-stage attribution is the cathedral that powers `gbrain search --explain` — every boost surface carries its own field, so `formatResultsExplain` reads them all without coupling to internal stage ordering. +- `src/core/search/explain-formatter.ts` (v0.40.4.0) — renders `SearchResult[]` as a multi-line per-result breakdown for `gbrain search --explain`. Reads every boost-stamping field. Handles the "no boosts applied" empty path. 4-decimal precision with trailing-zero strip. Pinned by `test/search/explain-formatter.test.ts` (19 cases). +- `src/core/search/mode.ts` extension (v0.40.4.0) — new `graph_signals: boolean` knob in `ModeBundle`. Defaults: `conservative=false`, `balanced=true`, `tokenmax=true`. `KNOBS_HASH_VERSION` bumps 3→4 with a `gs=` parts entry appended per the CDX2-F13 cache-key contamination convention so a graph-on cache write can't be served to a graph-off lookup. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the new field. Opt-out per-brain: `gbrain config set search.graph_signals false`. Mid-deploy: existing `query_cache` rows from before the upgrade hash differently — natural row segregation, clears within `cache.ttl_seconds` (3600s default). +- `src/core/audit/audit-writer.ts` (v0.40.4.0, NEW) — shared JSONL audit primitive consolidating the 5 hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Five existing audits refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts`. Public API of each module preserved bit-for-bit — every existing test passes unchanged. The new `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. Codex F16 (TODOS) calls out one remaining hand-rolled audit at `src/core/skillpack/audit.ts` for a v0.41+ refactor. Pinned by `test/audit/audit-writer.test.ts` (22 cases pinning the contract). +- `src/core/cli-options.ts` extension (v0.40.4.0) — `CliOptions` gains `explain: boolean`. `parseGlobalFlags` recognizes `--explain` anywhere in argv (stripped before command dispatch). `src/cli.ts` `formatResult` for `search` + `query` cases routes to `formatResultsExplain` from `src/core/search/explain-formatter.ts` when `CliOptions.explain` is set; falls through to the existing JSON / human formatters otherwise. +- `src/commands/search.ts:gbrain search stats` extension (v0.40.4.0) — new `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property to existing stats; `_meta.metric_glossary` adds two new keys (`graph_signals.enabled`, `graph_signals.failures_by_reason`). Human output prints the section after the existing block. Reads `search.graph_signals` config first, falls back to the mode default. Pinned by `test/search/search-stats-graph-signals.test.ts` (6 cases). +- `src/commands/doctor.ts` extension (v0.40.4.0) — new `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON thin-client path). Reads `search.graph_signals` config first, falls back to mode default; silent `ok` when disabled. Computes inbound link coverage on the page set; warns at <10% with `gbrain extract all` fix hint; `ok` at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage embedded in the message. Pinned by 7 new cases in `test/doctor.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`) - `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains. **v0.35.5.0:** probe set extended in parity with postgres-engine.ts — `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`. Bootstrap also threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope. Closes #1018, #974, #820. **v0.37.10.0:** `getBrainScore` now returns 100/100 with full breakdown components (35/25/15/15/10) when `pageCount === 0`. Vacuous truth — an empty brain has no coverage problem to penalize. Pre-fix, fresh `gbrain init --pglite` users saw "Brain score 0/100" on first `gbrain doctor` run, which was structurally surprising AND triggered the v0.37.8.0 `doctor-report-remote.test.ts` flake (test expected `health_score >= 70` on a freshly-initialized test brain). Mirrored in postgres-engine.ts. Pinned by `test/brain-score-breakdown.test.ts`'s updated empty-brain assertion and the hermetic `test/doctor-report-remote.serial.test.ts`. — PGLite-specific DDL (pgvector, pg_trgm, triggers) - `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. v0.22.0: `searchKeyword`, `searchKeywordChunks`, and `searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude clause from `src/core/search/sql-ranking.ts`. `searchVector` switches to a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in the outer SELECT) and carries `p.source_id` through inner→outer for v0.18 multi-source callers. v0.22.1 (#406): `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures). `executeRaw` is a single-statement passthrough — no per-call retry (D3 dropped that as unsound for non-idempotent statements; recovery is supervisor-driven). v0.22.1 (#363, contributed by @orendi84): `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. v0.22.1 (#409, contributed by @atrevino47): `countStaleChunks()` + `listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale`, eliminating ~76 MB/call client-side pull on a fully-embedded brain; `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding (consistency). As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same forward-reference probe set as the PGLite engine, so old Postgres brains pinned at v0.13/v0.18/v0.19 walk forward cleanly instead of wedging on `column "..." does not exist`. **v0.35.5.0:** probe set extended for the column-only forward-reference cases the original v0.22.6.1 sweep missed — `files.source_id`, `files.page_id` (pre-v0.18 brains where `idx_files_source_id` was the choke point), `oauth_clients.source_id`, `oauth_clients.federated_read` (pre-v0.34 brains where v60+v61+v65 chain failed), and `sources.archived` + `sources.archived_at` + `sources.archive_expires_at` (pre-v0.26.5 brains where `CREATE TABLE IF NOT EXISTS sources` was a no-op on existing tables so the archive lifecycle columns never landed). Also (Codex P1 from pre-landing review): the entire probe path now runs on the DDL connection threaded down from `initSchema` — previously probes ran through the instance pool while the advisory lock sat on a different connection, opening a concurrent-bootstrap race for Supabase pooler users. Closes #1018, #974, #820. **v0.28.1:** `disconnect()` is now idempotent. New `_connectionStyle` instance field tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than falling through to `db.disconnect()` and clobbering the singleton. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` (2 cases). Closes the bug class where any test sharing an engine across multiple `worker.start()` / `worker.stop()` cycles silently broke its own DB connectivity. **v0.37.10.0:** `getBrainScore` empty-brain parity with PGLite — returns 100/100 with breakdown components 35/25/15/15/10 when `pageCount === 0` instead of the pre-fix 0/100. Same vacuous-truth rationale as the PGLite side; both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic. @@ -2582,7 +2590,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec ## Capabilities -**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. +**Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. **New in v0.40.4.0:** per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. **Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. diff --git a/package.json b/package.json index f90879bec..5e53d2b4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.40.3.0", + "version": "0.40.4.0", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/cli.ts b/src/cli.ts index 36ba387e9..36b0ce6a7 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -573,6 +573,15 @@ function formatResult(opName: string, result: unknown): string { case 'query': { const results = result as any[]; if (results.length === 0) return 'No results.\n'; + // v0.40.4 — --explain switches to per-stage attribution formatter. + // Reads CliOptions.explain via the module-level singleton. + const cliOpts = getCliOptions(); + if (cliOpts.explain) { + // Lazy import keeps formatResult's startup hot path narrow for + // the common non-explain case. + const { formatResultsExplain } = require('./core/search/explain-formatter.ts'); + return formatResultsExplain(results); + } return results.map(r => `[${r.score?.toFixed(4) || '?'}] ${r.slug} -- ${r.chunk_text?.slice(0, 100) || ''}${r.stale ? ' (stale)' : ''}`, ).join('\n') + '\n'; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index fbeacc0ca..e05779588 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -596,6 +596,12 @@ export async function doctorReportRemote(engine: BrainEngine): Promise=30% ok with metric. + checks.push(await checkGraphSignalsCoverage(engine)); + // 9b. v0.37.0 brainstorm_health: surfaces three brainstorm/lsd readiness // signals: (a) migration v79 applied (last_retrieved_at column exists), // (b) calibration cold-start status (active_bias_tags empty), (c) @@ -957,6 +963,112 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise { } } +/** + * v0.40.4 graph_signals_coverage doctor check. + * + * Surfaces whether the brain's link density is high enough for the + * v0.40.4 graph-signals stage to meaningfully fire. Logic: + * + * 1. Resolve the active graph_signals setting (config override OR + * mode-bundle default). When OFF → silent ok (no metric noise on + * installs that don't use the feature). + * + * 2. When ON, compute the global density: % of pages with >=1 + * inbound link. This is a STRUCTURAL lower bound — top-K + * subgraphs need at least some edges to fire any signal. + * Codex outside-voice #14 noted this is an imperfect proxy + * (T-todo-5 will replace it with actual fire-rate measurement + * from search-stats after 30 days of data). + * + * 3. >=30% → ok with the percentage. + * <10% → warn (mismatch: signal enabled but link graph is too + * sparse to fire often; fix: `gbrain extract all` to + * populate the link graph from frontmatter + markdown). + * 10-29% → ok with note (signal will fire occasionally). + * + * Errors during the SQL count → warn with the underlying message. + * Best-effort: this check never breaks doctor. + */ +export async function checkGraphSignalsCoverage(engine: BrainEngine): Promise { + try { + // Resolve the active graph_signals setting. Read the config key + // explicitly; when unset, fall through to the mode bundle default. + const cfgVal = await engine.getConfig('search.graph_signals'); + let enabled: boolean; + if (cfgVal !== null && cfgVal !== undefined) { + // v0.40.4 codex F1 — case-insensitive + trim, parity with + // loadOverridesFromConfig in src/core/search/mode.ts. Without + // this, `gbrain config set search.graph_signals TRUE` enables + // the feature in production but doctor reports "disabled". + const v = cfgVal.trim().toLowerCase(); + enabled = v === 'true' || v === '1'; + } else { + // Mode bundle default. Read search.mode (case-insensitive + trim + // parity with isSearchMode + DEFAULT_SEARCH_MODE fallback). + const modeRaw = await engine.getConfig('search.mode'); + const modeVal = typeof modeRaw === 'string' ? modeRaw.trim().toLowerCase() : ''; + const mode = modeVal === 'conservative' || modeVal === 'tokenmax' ? modeVal : 'balanced'; + // Hardcoded knowledge of the mode bundle defaults — keeps the + // doctor check from pulling in the full search/mode.ts surface. + enabled = mode !== 'conservative'; + } + + if (!enabled) { + return { + name: 'graph_signals_coverage', + status: 'ok', + message: 'graph_signals disabled — coverage not checked', + }; + } + + // Compute global inbound-link density. Counts DISTINCT pages with + // at least one inbound edge / total pages. + const totalRows = await engine.executeRaw(`SELECT COUNT(*)::int AS n FROM pages WHERE deleted_at IS NULL`); + const totalPages = Number((totalRows as any)[0]?.n ?? 0); + + if (totalPages === 0) { + return { + name: 'graph_signals_coverage', + status: 'ok', + message: 'Empty brain — no pages to compute coverage against', + }; + } + + const linkedRows = await engine.executeRaw( + `SELECT COUNT(DISTINCT l.to_page_id)::int AS n + FROM links l + JOIN pages p ON p.id = l.to_page_id + WHERE p.deleted_at IS NULL` + ); + const linkedPages = Number((linkedRows as any)[0]?.n ?? 0); + const pct = (linkedPages / totalPages) * 100; + const pctStr = pct.toFixed(1); + + if (pct < 10) { + return { + name: 'graph_signals_coverage', + status: 'warn', + message: `graph_signals enabled but only ${pctStr}% of pages have inbound links (<10%). Signal will rarely fire. Fix: \`gbrain extract all\` to populate the link graph from frontmatter + markdown.`, + }; + } + + return { + name: 'graph_signals_coverage', + status: 'ok', + message: pct >= 30 + ? `${pctStr}% of pages have inbound links (>=30% — graph signals fire on most queries)` + : `${pctStr}% of pages have inbound links (10-29% — graph signals fire occasionally)`, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + name: 'graph_signals_coverage', + status: 'warn', + message: `Could not check graph_signals_coverage: ${msg}`, + }; + } +} + /** * v0.37.0 brainstorm_health doctor check. * @@ -4254,6 +4366,10 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo // v0.35.0.0+ reranker_health — read JSONL audit; warn on auth or volume. progress.heartbeat('reranker_health'); checks.push(await checkRerankerHealth(engine)); + // v0.40.4 graph_signals_coverage — global inbound-link density when + // graph_signals is enabled in the active mode bundle. + progress.heartbeat('graph_signals_coverage'); + checks.push(await checkGraphSignalsCoverage(engine)); // v0.37.0 brainstorm_health — migration v79, track_retrieval, calibration cold-start. progress.heartbeat('brainstorm_health'); checks.push(await checkBrainstormHealth(engine)); diff --git a/src/commands/search.ts b/src/commands/search.ts index 3ff933591..75f009100 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -62,6 +62,8 @@ const KNOB_DESCRIPTIONS: Record = { unified_multimodal: 'Phase 3 — route all queries through embedding_multimodal column', unified_multimodal_only: 'Phase 3 strict — bypass dual-column fallback when unified is on', cross_modal_llm_intent: 'Commit 4 — Haiku tie-break for ambiguous modality classification', + // v0.40.4 graph signals + graph_signals: 'Selective graph signals: adjacency hub + cross-source hub + session diversification', // v0.40.3.0 contextual retrieval contextual_retrieval: 'CR tier (none|title|per_chunk_synopsis) — wraps chunks at embed time', contextual_retrieval_disabled: 'Soft kill switch — neutralizes CR wrapping for queries + new embeds', @@ -201,16 +203,30 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise< const stats = await readSearchStats(engine, { days: Number.isFinite(days) ? days : 7 }); + // v0.40.4 — graph_signals section. Sourced from: + // 1. config: search.graph_signals (or mode bundle default) for the + // on/off status. + // 2. JSONL audit: graph-signals-failures-*.jsonl for the error count. + // + // Fire-rate metrics (adjacency_fires, cross_source_fires, + // session_demotions) require telemetry table writes from the + // applyGraphSignals onMeta callback — wired in a v0.41+ follow-up + // (T-todo-2 calibration wave). For now: status + error count. + const gsSection = await readGraphSignalsStats(engine, Number.isFinite(days) ? days : 7); + if (json) { console.log(JSON.stringify({ schema_version: 2, ...stats, + graph_signals: gsSection, _meta: { metric_glossary: { cache_hit_rate: 'cache_hits / (cache_hits + cache_misses) — fraction of searches that reused a recent answer instead of running fresh', avg_results: 'mean number of result rows returned per search call', avg_tokens: 'mean estimated tokens in the returned chunk text (char/4 heuristic)', total_budget_dropped: 'sum of results dropped because the call exceeded its tokenBudget', + graph_signals_enabled: 'whether graph_signals is on for the active mode (or via search.graph_signals override)', + graph_signals_failures_count: 'count of fail-open events in the JSONL audit over the window', }, }, }, null, 2)); @@ -223,6 +239,12 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise< if (stats.total_calls === 0) { console.log(''); console.log('No telemetry recorded yet. Run a few `gbrain query` calls and re-check.'); + // Still print the graph-signals section since failures are tracked + // independently of the search_telemetry table. + if (gsSection.enabled || gsSection.failures_count > 0) { + console.log(''); + printGraphSignalsSection(gsSection); + } return; } const hitRatePct = (stats.cache_hit_rate * 100).toFixed(1); @@ -247,6 +269,79 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise< console.log(''); console.log(` Window: ${stats.oldest_seen ?? '?'} → ${stats.newest_seen ?? '?'}`); } + console.log(''); + printGraphSignalsSection(gsSection); +} + +interface GraphSignalsStatsSection { + enabled: boolean; + source: 'config' | 'mode_default'; + failures_count: number; + /** Failure-reason breakdown across the window (truncated to top reasons). */ + failures_by_reason: Record; +} + +async function readGraphSignalsStats(engine: BrainEngine, days: number): Promise { + // Resolve graph_signals on/off. Mirrors the resolution chain in + // src/commands/doctor.ts:checkGraphSignalsCoverage. + // v0.40.4 codex F1: case-insensitive + trim parity with + // loadOverridesFromConfig (mode.ts). Without this, search-stats would + // silently report the opposite of what the parser actually enables on + // values like 'TRUE' or 'True'. + const cfg = await engine.getConfig('search.graph_signals').catch(() => null); + let enabled: boolean; + let source: 'config' | 'mode_default'; + if (cfg !== null && cfg !== undefined) { + const v = cfg.trim().toLowerCase(); + enabled = v === 'true' || v === '1'; + source = 'config'; + } else { + const modeRaw = await engine.getConfig('search.mode').catch(() => null); + const modeVal = typeof modeRaw === 'string' ? modeRaw.trim().toLowerCase() : ''; + const mode = modeVal === 'conservative' || modeVal === 'tokenmax' ? modeVal : 'balanced'; + enabled = mode !== 'conservative'; + source = 'mode_default'; + } + + let failures_count = 0; + const failures_by_reason: Record = {}; + try { + const { readRecentGraphSignalsFailures } = await import('../core/search/graph-signals.ts'); + const events = readRecentGraphSignalsFailures(days); + failures_count = events.length; + // The failure event schema has error_summary (not a reason field) — + // bucket by the first word of the summary so operators see e.g. + // "ECONNREFUSED" / "timeout" / "permission" at a glance. + for (const e of events) { + const firstWord = (e.error_summary ?? '').split(/[\s:]+/)[0]?.slice(0, 32) || 'unknown'; + failures_by_reason[firstWord] = (failures_by_reason[firstWord] ?? 0) + 1; + } + } catch { + // Audit reader is best-effort. Missing module / corrupt files → + // count stays 0, search-stats still renders. + } + + return { enabled, source, failures_count, failures_by_reason }; +} + +function printGraphSignalsSection(gs: GraphSignalsStatsSection): void { + console.log(' Graph signals:'); + const sourceLabel = gs.source === 'config' ? 'config override' : 'mode default'; + console.log(` enabled: ${gs.enabled} (${sourceLabel})`); + if (gs.failures_count === 0) { + console.log(' failures: 0 (fail-open events in window)'); + } else { + console.log(` failures: ${gs.failures_count} fail-open event(s)`); + const top = Object.entries(gs.failures_by_reason) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3); + for (const [reason, count] of top) { + console.log(` ${reason.padEnd(20)} ${count}`); + } + } + if (!gs.enabled && gs.failures_count > 0) { + console.log(` note: failures observed but graph_signals currently off — historical events`); + } } interface TuneRecommendation { diff --git a/src/core/audit-slug-fallback.ts b/src/core/audit-slug-fallback.ts index 11cf3ef8c..829c07ffc 100644 --- a/src/core/audit-slug-fallback.ts +++ b/src/core/audit-slug-fallback.ts @@ -16,11 +16,16 @@ * for. Codex outside-voice C7 caught this drift. * * Best-effort writes. Write failures go to stderr but the import continues. + * + * v0.40.4.0: internals delegate to the shared + * `src/core/audit/audit-writer.ts` primitive. Public API preserved + * (logSlugFallback, readRecentSlugFallbacks, computeSlugFallbackAuditFilename). + * The dual-stderr-emit-per-call (D7 dual logging) stays in this module — the + * shared writer is failure-only stderr, so the per-call stderr stays here as + * caller-level behavior on top of the writer. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { isoWeekFilename, resolveAuditDir } from './audit-week-file.ts'; +import { createAuditWriter, computeIsoWeekFilename } from './audit/audit-writer.ts'; export interface SlugFallbackAuditEvent { ts: string; @@ -34,12 +39,18 @@ export interface SlugFallbackAuditEvent { code: 'SLUG_FALLBACK_FRONTMATTER'; } -/** ISO-week-rotated filename: `slug-fallback-YYYY-Www.jsonl`. Delegates to - * `src/core/audit-week-file.ts`. */ +/** ISO-week-rotated filename: `slug-fallback-YYYY-Www.jsonl`. */ export function computeSlugFallbackAuditFilename(now: Date = new Date()): string { - return isoWeekFilename('slug-fallback', now); + return computeIsoWeekFilename('slug-fallback', now); } +const writer = createAuditWriter({ + featureName: 'slug-fallback', + errorLabel: 'gbrain', + errorMessagePrefix: 'slug-fallback audit ', + errorTrailer: '; import continues', +}); + /** * Append a slug-fallback event to the current week's audit JSONL. * @@ -48,23 +59,16 @@ export function computeSlugFallbackAuditFilename(now: Date = new Date()): string * import succeeds either way. */ export function logSlugFallback(slug: string, sourcePath: string): void { + // D7 dual logging — every fallback gets an operator-visible stderr line + // regardless of audit write success. Lives in this caller, not in the + // shared writer, because only this audit module wants per-call stderr. process.stderr.write(`[gbrain] slug fallback: ${sourcePath} → ${slug} (frontmatter slug; path slugified empty)\n`); - const event: SlugFallbackAuditEvent = { - ts: new Date().toISOString(), + writer.log({ slug, source_path: sourcePath, severity: 'info', code: 'SLUG_FALLBACK_FRONTMATTER', - }; - const dir = resolveAuditDir(); - const file = path.join(dir, computeSlugFallbackAuditFilename()); - try { - fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(file, JSON.stringify(event) + '\n', { encoding: 'utf8' }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[gbrain] slug-fallback audit write failed (${msg}); import continues\n`); - } + }); } /** @@ -74,33 +78,5 @@ export function logSlugFallback(slug: string, sourcePath: string): void { * informational and shouldn't block doctor. */ export function readRecentSlugFallbacks(days = 7, now: Date = new Date()): SlugFallbackAuditEvent[] { - const dir = resolveAuditDir(); - const cutoff = now.getTime() - days * 86400000; - const out: SlugFallbackAuditEvent[] = []; - // Walk the current + previous ISO week so a 7-day window straddling - // Monday-midnight stays covered. - const filenames = [ - computeSlugFallbackAuditFilename(now), - computeSlugFallbackAuditFilename(new Date(now.getTime() - 7 * 86400000)), - ]; - for (const filename of filenames) { - const file = path.join(dir, filename); - let content: string; - try { - content = fs.readFileSync(file, 'utf8'); - } catch { - continue; - } - for (const line of content.split('\n')) { - if (line.length === 0) continue; - try { - const ev = JSON.parse(line) as SlugFallbackAuditEvent; - const ts = Date.parse(ev.ts); - if (Number.isFinite(ts) && ts >= cutoff) out.push(ev); - } catch { - // Corrupt row — skip. - } - } - } - return out; + return writer.readRecent(days, now); } diff --git a/src/core/audit/audit-writer.ts b/src/core/audit/audit-writer.ts new file mode 100644 index 000000000..d76713abf --- /dev/null +++ b/src/core/audit/audit-writer.ts @@ -0,0 +1,245 @@ +/** + * v0.40.4.0 — shared audit-writer primitive. + * + * Replaces the 5 hand-rolled JSONL audit modules (rerank-audit, + * shell-audit, supervisor-audit, audit-slug-fallback, phantom-audit) + * that all duplicated the same ISO-week filename math, the same + * best-effort write loop, and the same read-current-and-previous-week + * loop. Each refactored module keeps its typed event shape and any + * special wrappers (severity stamping, dual stderr emission, + * supervisor_pid injection) — they delegate the file I/O to the + * primitive built here. + * + * Design constraints (codex outside-voice + plan D5=B): + * + * 1. NO behavior change for existing consumers. Filename format, + * JSONL line format, mkdirSync recursive, appendFileSync utf8, + * stderr-on-failure semantics MUST be byte-identical so the + * existing tests pass unchanged. + * + * 2. ISO-8601 week numbering. The shared algorithm lives here in + * `computeIsoWeekFilename(prefix, now)`. Year-boundary edge + * case: 2027-01-01 is ISO week 53 of year 2026 → filename + * `-2026-W53.jsonl`. + * + * 3. Audit dir resolution. Honors `GBRAIN_AUDIT_DIR` env override + * ahead of the default `~/.gbrain/audit/`. Trim whitespace; an + * env value of `" "` is treated as unset. + * + * 4. Best-effort writes. Append failures go to stderr but never + * throw. The caller's import / cycle / supervisor / submission + * continues regardless. A disk-full attacker can silently disable + * the trail — this is documented in every refactored module's + * header as "operational trace, not forensic insurance." + * + * 5. Read-back walks the current + previous ISO week so a 7-day + * window straddling Monday-midnight stays covered. Corrupt rows + * / missing files are skipped silently — the audit trail is + * informational and must not block any consumer. + * + * Honest scope: this is filesystem JSONL. Codex flagged in plan review + * (#15) that error counts in JSONL won't surface in remote-server + * deploys (HTTP MCP server, OAuth-fronted brain). T-todo-3 captures + * the v0.41+ work to route fail-open events to a DB table for + * cross-deploy observability. Until then, doctor + search-stats show + * full metrics on local-only deploys and success-only metrics on + * remote deploys. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { gbrainPath } from '../config.ts'; + +/** + * Resolve the audit dir. Honors `GBRAIN_AUDIT_DIR` for container / + * sandbox deploys where `$HOME` is read-only. Defaults to + * `~/.gbrain/audit/`. + * + * Shared across every audit module. The previous home (shell-audit.ts) + * re-exports this so existing imports continue to work. + */ +export function resolveAuditDir(): string { + const override = process.env.GBRAIN_AUDIT_DIR; + if (override && override.trim().length > 0) return override; + return gbrainPath('audit'); +} + +/** + * Compute `-YYYY-Www.jsonl` using ISO-8601 week numbering. + * + * Algorithm: copy date, shift to the nearest Thursday (ISO week + * anchor), then count weeks since the year's first Thursday. + * + * Year-boundary edge: 2027-01-01 is ISO week 53 of year 2026 → returns + * `-2026-W53.jsonl`. This is correct per the ISO standard. + * + * Centralizing the math means a fix to the week calculation (e.g. a + * leap-year edge case) lands once and applies to every feature's audit + * trail simultaneously. + */ +export function computeIsoWeekFilename(prefix: string, now: Date = new Date()): string { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0, Sun=6 + d.setUTCDate(d.getUTCDate() - dayNum + 3); // shift to Thursday + const isoYear = d.getUTCFullYear(); + const firstThursday = new Date(Date.UTC(isoYear, 0, 4)); + const firstThursdayDayNum = (firstThursday.getUTCDay() + 6) % 7; + firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayDayNum + 3); + const weekNum = Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000)) + 1; + const ww = String(weekNum).padStart(2, '0'); + return `${prefix}-${isoYear}-W${ww}.jsonl`; +} + +export interface AuditWriterOpts { + /** + * Filename prefix WITHOUT the date/extension suffix. The full + * filename becomes `-YYYY-Www.jsonl`. + * + * Examples: `'rerank-failures'`, `'shell-jobs'`, `'supervisor'`, + * `'slug-fallback'`, `'phantoms'`, `'graph-signals-failures'`. + */ + featureName: string; + /** + * Label used in the stderr warning when a write fails. Defaults to + * `featureName`. Existing modules use slightly different labels + * (`'shell-audit'` vs `'gbrain'` vs `'supervisor-audit'`) so the + * label is overridable per refactor without changing operator- + * visible behavior. + */ + errorLabel?: string; + /** + * Qualifier inserted between the label and "write failed". For + * pre-v0.40.4 byte-identical preservation of operator-grep patterns: + * - rerank-audit: 'rerank-failure audit ' + * - audit-slug-fallback: 'slug-fallback audit ' + * - phantom-audit: 'phantom audit ' + * - shell-audit + supervisor-audit + slug-fallback: '' (label is + * already the qualifier in their pre-v0.40.4 stderr messages). + * + * Defaults to '' (no qualifier). Module-specific qualifiers preserve + * operator log-grep patterns from before the refactor. + */ + errorMessagePrefix?: string; + /** + * Trailing phrase on the stderr warning, after the error message. + * Existing modules use: `'; submission continues'`, + * `'; import continues'`, `'; cycle continues'`, + * `'; search continues'`, `'; continuing'`. Default: `''` + * (no trailer). + */ + errorTrailer?: string; +} + +export interface AuditWriter { + /** + * Append one event. Best-effort: stderr-warns on failure but never + * throws. The caller may pre-populate `ts` (some callers compose + * the event upstream and want stable timestamps for batching); when + * omitted, the writer stamps `new Date().toISOString()` at log time. + */ + log(event: Omit & { ts?: string }): void; + /** + * Read back events from current + previous ISO week, filtered by + * `days` window (default 7). Missing files / corrupt rows skipped + * silently. Returns oldest-first within each file, current-week + * file appended after previous-week file (callers that need strict + * chronological ordering should sort by ts). + */ + readRecent(days?: number, now?: Date): T[]; + /** + * Compute the filename for a given Date. Exposed so tests can pin + * the year-boundary edge cases and so downstream consumers (doctor, + * search-stats) can derive the expected path without recomputing + * the algorithm. + */ + computeFilename(now?: Date): string; + /** Resolve the audit directory (honors GBRAIN_AUDIT_DIR override). */ + resolveDir(): string; +} + +/** + * Build a typed audit writer for a given feature. + * + * Usage: + * ```ts + * const writer = createAuditWriter({ + * featureName: 'my-feature', + * errorLabel: 'my-feature-audit', + * errorTrailer: '; continuing', + * }); + * writer.log({ ...eventFields }); + * const recent = writer.readRecent(7); + * ``` + */ +export function createAuditWriter( + opts: AuditWriterOpts, +): AuditWriter { + const { featureName } = opts; + const errorLabel = opts.errorLabel ?? featureName; + const errorMessagePrefix = opts.errorMessagePrefix ?? ''; + const errorTrailer = opts.errorTrailer ?? ''; + + function computeFilename(now: Date = new Date()): string { + return computeIsoWeekFilename(featureName, now); + } + + function log(event: Omit & { ts?: string }): void { + const ts = event.ts ?? new Date().toISOString(); + // The event shape is opaque to the writer; we serialize the merged + // payload verbatim. Callers control field ordering by destructuring + // before calling — but since JSON object key order doesn't matter + // for consumers (parsers don't preserve it), this is fine. + const row = { ...event, ts }; + const dir = resolveAuditDir(); + const file = path.join(dir, computeFilename()); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(file, JSON.stringify(row) + '\n', { encoding: 'utf8' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[${errorLabel}] ${errorMessagePrefix}write failed (${msg})${errorTrailer}\n`); + } + } + + function readRecent(days = 7, now: Date = new Date()): T[] { + const dir = resolveAuditDir(); + const cutoff = now.getTime() - days * 86400000; + const out: T[] = []; + // Walk current + previous ISO week so a 7-day window straddling + // Monday-midnight stays covered. Order: current week first, then + // previous — matches the pre-v0.40.4 behavior of every refactored + // module (rerank-audit, slug-fallback, phantom-audit). Callers + // wanting strict chronological order sort by ts. + const filenames = [ + computeFilename(now), + computeFilename(new Date(now.getTime() - 7 * 86400000)), + ]; + for (const filename of filenames) { + const file = path.join(dir, filename); + let content: string; + try { + content = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + for (const line of content.split('\n')) { + if (line.length === 0) continue; + try { + const ev = JSON.parse(line) as T; + const ts = Date.parse(ev.ts); + if (Number.isFinite(ts) && ts >= cutoff) out.push(ev); + } catch { + // Corrupt row — skip. + } + } + } + return out; + } + + return { + log, + readRecent, + computeFilename, + resolveDir: resolveAuditDir, + }; +} diff --git a/src/core/cli-options.ts b/src/core/cli-options.ts index 599ff1e91..385bc6706 100644 --- a/src/core/cli-options.ts +++ b/src/core/cli-options.ts @@ -22,6 +22,13 @@ export interface CliOptions { * current invocation. */ timeoutMs: number | null; + /** + * v0.40.4 — `--explain` flag for `gbrain search/query`. Switches the + * default formatter to a per-stage attribution view that shows + * base_score + each boost stage's multiplier + rank delta from + * the reranker. Has no effect on other commands. + */ + explain: boolean; } export const DEFAULT_CLI_OPTIONS: CliOptions = { @@ -29,6 +36,7 @@ export const DEFAULT_CLI_OPTIONS: CliOptions = { progressJson: false, progressInterval: 1000, timeoutMs: null, + explain: false, }; /** @@ -101,6 +109,11 @@ export function parseGlobalFlags(argv: string[]): { cliOpts: CliOptions; rest: s rest.push(a); continue; } + // v0.40.4 — --explain for `gbrain search/query` per-stage attribution. + if (a === '--explain') { + cliOpts.explain = true; + continue; + } rest.push(a); } diff --git a/src/core/engine.ts b/src/core/engine.ts index d14b9a048..d4975c71e 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -15,6 +15,7 @@ import type { SalienceOpts, SalienceResult, AnomaliesOpts, AnomalyResult, EmotionalWeightInputRow, EmotionalWeightWriteRow, DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow, + AdjacencyRow, } from './types.ts'; /** @@ -937,6 +938,32 @@ export interface BrainEngine { * Slugs with zero inbound links are present in the map with value 0. */ getBacklinkCounts(slugs: string[]): Promise>; + /** + * v0.40.4 — for a list of page_ids, return adjacency aggregates + * restricted to the subgraph induced by them. Returns ALL pages with + * `hits >= 1` (callers apply their own threshold). Empty input → empty + * Map, no SQL. + * + * Returned shape per page (AdjacencyRow): + * - `hits`: distinct from_page_id count, in-set + * - `cross_source_hits`: distinct OTHER source_ids count (excluding + * target's own source), in-set + * + * SOURCE-SCOPE CONTRACT: pageIds MUST already be source-scoped by the + * caller. This method does NOT filter by source_id. Adjacency is + * page-id keyed and the in-set restriction makes cross-source leakage + * impossible BY CONSTRUCTION (a leaked-in page_id from another source + * would have to also appear in the caller's input set, which the + * caller is responsible for preventing). The only consumer in v0.40.4 + * is hybridSearch via runPostFusionStages, which is source-scoped + * upstream. Same trust posture as `cosineReScore`'s chunk_id handling. + * + * Known limitation: cross_source_hits doesn't distinguish "genuinely + * linked from another team" from "mirrored imports from another + * source" (codex outside-voice #15). T-todo-4 captures the v0.41+ + * sync-topology-aware refinement. + */ + getAdjacencyBoosts(pageIds: number[]): Promise>; /** * v0.27.0: for a list of slugs, return their updated_at timestamps (or created_at fallback). * Used by hybrid search recency boost. Single SQL query, not N+1. diff --git a/src/core/facts/phantom-audit.ts b/src/core/facts/phantom-audit.ts index 2365d3490..a075061af 100644 --- a/src/core/facts/phantom-audit.ts +++ b/src/core/facts/phantom-audit.ts @@ -16,11 +16,15 @@ * * Best-effort writes. Failures emit a stderr line but never throw — a * disk-full or audit-dir-permission issue must not stall the cycle. + * + * v0.40.4.0: internals delegate to the shared + * `src/core/audit/audit-writer.ts` primitive. Public API preserved + * (logPhantomEvent, readRecentPhantomEvents, computePhantomAuditFilename). + * The 6-outcome PhantomOutcome union is unchanged; the schema is what every + * future doctor check binds to. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { isoWeekFilename, resolveAuditDir } from '../audit-week-file.ts'; +import { createAuditWriter, computeIsoWeekFilename } from '../audit/audit-writer.ts'; export type PhantomOutcome = | 'redirected' @@ -41,12 +45,18 @@ export interface PhantomAuditEvent { candidates?: Array<{ slug: string; connection_count: number }>; } -/** ISO-week-rotated filename: `phantoms-YYYY-Www.jsonl`. Delegates to - * `src/core/audit-week-file.ts`. */ +/** ISO-week-rotated filename: `phantoms-YYYY-Www.jsonl`. */ export function computePhantomAuditFilename(now: Date = new Date()): string { - return isoWeekFilename('phantoms', now); + return computeIsoWeekFilename('phantoms', now); } +const writer = createAuditWriter({ + featureName: 'phantoms', + errorLabel: 'gbrain', + errorMessagePrefix: 'phantom audit ', + errorTrailer: '; cycle continues', +}); + /** * Append a phantom-redirect event to the current week's audit JSONL. * @@ -54,8 +64,13 @@ export function computePhantomAuditFilename(now: Date = new Date()): string { * failure is logged to stderr; the caller's cycle continues either way. */ export function logPhantomEvent(event: Omit & { ts?: string }): void { - const record: PhantomAuditEvent = { - ts: event.ts ?? new Date().toISOString(), + // Strip optional undefined fields to preserve the pre-v0.40.4 wire shape + // (the old impl used a spread-with-conditional to omit absent fields, + // not surface them as `field: undefined`). JSON.stringify already drops + // explicit undefined, so this matters only for in-memory shape — which + // doctor + tests do depend on. Pass through verbatim; downstream + // JSON.stringify handles the undefined-strip. + const cleaned: Omit & { ts?: string } = { outcome: event.outcome, source_id: event.source_id, ...(event.phantom_slug !== undefined ? { phantom_slug: event.phantom_slug } : {}), @@ -63,16 +78,9 @@ export function logPhantomEvent(event: Omit & { ts?: st ...(event.fact_count !== undefined ? { fact_count: event.fact_count } : {}), ...(event.reason !== undefined ? { reason: event.reason } : {}), ...(event.candidates !== undefined ? { candidates: event.candidates } : {}), + ...(event.ts !== undefined ? { ts: event.ts } : {}), }; - const dir = resolveAuditDir(); - const file = path.join(dir, computePhantomAuditFilename()); - try { - fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(file, JSON.stringify(record) + '\n', { encoding: 'utf8' }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[gbrain] phantom audit write failed (${msg}); cycle continues\n`); - } + writer.log(cleaned); } /** @@ -84,31 +92,5 @@ export function logPhantomEvent(event: Omit & { ts?: st * informational and shouldn't block any consumer. */ export function readRecentPhantomEvents(days = 7, now: Date = new Date()): PhantomAuditEvent[] { - const dir = resolveAuditDir(); - const cutoff = now.getTime() - days * 86400000; - const out: PhantomAuditEvent[] = []; - const filenames = [ - computePhantomAuditFilename(now), - computePhantomAuditFilename(new Date(now.getTime() - 7 * 86400000)), - ]; - for (const filename of filenames) { - const file = path.join(dir, filename); - let content: string; - try { - content = fs.readFileSync(file, 'utf8'); - } catch { - continue; - } - for (const line of content.split('\n')) { - if (line.length === 0) continue; - try { - const ev = JSON.parse(line) as PhantomAuditEvent; - const ts = Date.parse(ev.ts); - if (Number.isFinite(ts) && ts >= cutoff) out.push(ev); - } catch { - // Corrupt row — skip. - } - } - } - return out; + return writer.readRecent(days, now); } diff --git a/src/core/minions/handlers/shell-audit.ts b/src/core/minions/handlers/shell-audit.ts index 21d2583a4..3895f2da8 100644 --- a/src/core/minions/handlers/shell-audit.ts +++ b/src/core/minions/handlers/shell-audit.ts @@ -11,11 +11,15 @@ * 80 chars for cmd / stored as JSON array for argv — the command text itself can contain * inline tokens (`curl -H 'Authorization: Bearer ...'`) and the guide explicitly tells * operators to put secrets in `env:` instead of embedding them in the command line. + * + * v0.40.4.0: internals delegate to the shared `src/core/audit/audit-writer.ts` + * primitive. The public surface (logShellSubmission, computeAuditFilename, + * resolveAuditDir) is preserved bit-for-bit because every other audit module + * AND callers across `gbrain-home-isolation.test.ts`, `minions.test.ts`, + * `minions-shell.test.ts` import these by name. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { isoWeekFilename, resolveAuditDir as _sharedResolveAuditDir } from '../../audit-week-file.ts'; +import { createAuditWriter, computeIsoWeekFilename, resolveAuditDir as sharedResolveAuditDir } from '../../audit/audit-writer.ts'; export interface ShellAuditEvent { ts: string; @@ -30,34 +34,37 @@ export interface ShellAuditEvent { inherit?: string[]; } -/** Compute `shell-jobs-YYYY-Www.jsonl`. Delegates to the shared helper in - * `src/core/audit-week-file.ts` — Year-boundary edges (2027-01-01 → W53 of - * 2026, 2020-W53 etc.) are covered by `test/core/audit-week-file.test.ts`. */ +/** Compute `shell-jobs-YYYY-Www.jsonl` using ISO-8601 week numbering. + * + * Year-boundary edge: 2027-01-01 is ISO week 53 of year 2026, so the correct + * filename is `shell-jobs-2026-W53.jsonl`. This matches the ISO week standard + * (week containing the first Thursday of the year is W1; week containing Dec 28 + * is always W52 or W53 of that year). + */ export function computeAuditFilename(now: Date = new Date()): string { - return isoWeekFilename('shell-jobs', now); + return computeIsoWeekFilename('shell-jobs', now); } /** Resolve the audit dir. Honors `GBRAIN_AUDIT_DIR` for container/sandbox deployments - * where `$HOME` is read-only. Defaults to `~/.gbrain/audit/`. Delegates to the - * shared helper. */ + * where `$HOME` is read-only. Defaults to `~/.gbrain/audit/`. + * + * v0.40.4.0: re-exported from `src/core/audit/audit-writer.ts` so the single + * source of truth lives in the shared primitive. Existing imports (every + * refactored audit module, plus tests) keep working unchanged. + */ export function resolveAuditDir(): string { - return _sharedResolveAuditDir(); + return sharedResolveAuditDir(); } +// Module-scoped writer instance. featureName matches the pre-v0.40.4 filename +// prefix, errorLabel matches the pre-v0.40.4 stderr label, errorTrailer matches +// the pre-v0.40.4 trailing phrase. Byte-identical operator-visible behavior. +const writer = createAuditWriter({ + featureName: 'shell-jobs', + errorLabel: 'shell-audit', + errorTrailer: '; submission continues', +}); + export function logShellSubmission(event: Omit): void { - const dir = resolveAuditDir(); - const filename = computeAuditFilename(); - const fullPath = path.join(dir, filename); - const line = JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n'; - - try { - fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(fullPath, line, { encoding: 'utf8' }); - } catch (err) { - // Best-effort: log to stderr and keep going. A disk-full or EACCES attacker - // can silently disable this trail, which is why CHANGELOG calls it an - // operational trace, not forensic insurance. - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[shell-audit] write failed (${msg}); submission continues\n`); - } + writer.log(event); } diff --git a/src/core/minions/handlers/supervisor-audit.ts b/src/core/minions/handlers/supervisor-audit.ts index 44c463076..f72a91fb6 100644 --- a/src/core/minions/handlers/supervisor-audit.ts +++ b/src/core/minions/handlers/supervisor-audit.ts @@ -7,7 +7,7 @@ * `${GBRAIN_AUDIT_DIR:-~/.gbrain/audit}/supervisor-YYYY-Www.jsonl` * using ISO-8601 week numbering. `computeAuditFilename(kind, now)` derives * the filename; the ISO-week math is shared with `shell-audit.ts` via the - * `computeIsoWeekName()` helper that both call. + * `computeIsoWeekFilename()` helper that both call. * * Shape: every emission already includes `event` and `ts`; we write it * verbatim and let consumers (like `gbrain doctor`) grep for events of @@ -21,11 +21,15 @@ * * `GBRAIN_AUDIT_DIR` overrides the default `~/.gbrain/audit/` path for * container deploys where `$HOME` is read-only. + * + * v0.40.4.0: internals delegate to the shared `src/core/audit/audit-writer.ts` + * primitive. Public API preserved (writeSupervisorEvent, readSupervisorEvents, + * computeSupervisorAuditFilename) AND the cross-surface helpers (isCrashExit, + * summarizeCrashes, CrashSummary, CLEAN_EXIT_CAUSES) stay here — those are + * supervisor-domain logic, not audit-write primitives. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { resolveAuditDir } from './shell-audit.ts'; +import { createAuditWriter, computeIsoWeekFilename } from '../../audit/audit-writer.ts'; import type { SupervisorEmission } from '../supervisor.ts'; /** @@ -36,18 +40,21 @@ import type { SupervisorEmission } from '../supervisor.ts'; * is `supervisor-2026-W53.jsonl`. */ export function computeSupervisorAuditFilename(now: Date = new Date()): string { - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0, Sun=6 - d.setUTCDate(d.getUTCDate() - dayNum + 3); // shift to Thursday (ISO week anchor) - const isoYear = d.getUTCFullYear(); - const firstThursday = new Date(Date.UTC(isoYear, 0, 4)); - const firstThursdayDayNum = (firstThursday.getUTCDay() + 6) % 7; - firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayDayNum + 3); - const weekNum = Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000)) + 1; - const ww = String(weekNum).padStart(2, '0'); - return `supervisor-${isoYear}-W${ww}.jsonl`; + return computeIsoWeekFilename('supervisor', now); } +// On-disk shape: SupervisorEmission already carries `event` + `ts`, plus the +// caller-injected `supervisor_pid`. The audit-writer's T param is +// {ts:string,...} so we match that contract while allowing arbitrary extra +// fields from the emission shape. +type SupervisorRow = SupervisorEmission & { supervisor_pid: number; ts: string }; + +const writer = createAuditWriter({ + featureName: 'supervisor', + errorLabel: 'supervisor-audit', + errorTrailer: '; continuing', +}); + /** * Append a single supervisor lifecycle event to the rotated JSONL audit * file. `supervisorPid` is the OS pid of the supervisor process (added @@ -55,27 +62,31 @@ export function computeSupervisorAuditFilename(now: Date = new Date()): string { * supervisors still produces parseable traces). */ export function writeSupervisorEvent(emission: SupervisorEmission, supervisorPid: number): void { - const dir = resolveAuditDir(); - const filename = computeSupervisorAuditFilename(); - const fullPath = path.join(dir, filename); - const line = JSON.stringify({ ...emission, supervisor_pid: supervisorPid }) + '\n'; - - try { - fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(fullPath, line, { encoding: 'utf8' }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[supervisor-audit] write failed (${msg}); continuing\n`); - } + // SupervisorEmission's `ts` field is already set upstream. The writer + // honors caller-supplied ts (won't restamp). Cast through unknown + // because SupervisorEmission's type doesn't guarantee `ts: string` at + // the TS level (it's a discriminated union over event-shapes). + writer.log({ ...emission, supervisor_pid: supervisorPid } as unknown as SupervisorRow); } /** * Read back the latest supervisor audit file. Returns events sorted * oldest-first. Best-effort: missing file / parse errors return []. * Used by `gbrain doctor` (Lane D) to surface supervisor health. + * + * Pre-v0.40.4 this only walked the CURRENT week's file. v0.40.4 keeps + * that semantic (single-file read, no cross-week walk) so doctor's + * existing assertions don't shift. Callers wanting cross-week scope + * should compute the previous-week filename themselves OR use + * `writer.readRecent(days, now)` directly via the unexported writer. */ export function readSupervisorEvents(opts: { sinceMs?: number } = {}): SupervisorEmission[] { - const dir = resolveAuditDir(); + // Replicate the pre-v0.40.4 single-file behavior by reading just the + // current-week file directly (the shared writer.readRecent() walks two + // files; behavior-preserving here means staying with one). + const fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const dir = writer.resolveDir(); const filename = computeSupervisorAuditFilename(); const fullPath = path.join(dir, filename); diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index a45bd110c..a507c8393 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -2385,6 +2385,49 @@ export class PGLiteEngine implements BrainEngine { return result; } + async getAdjacencyBoosts(pageIds: number[]): Promise> { + const result = new Map(); + if (pageIds.length === 0) return result; + + // PGLite parity with PostgresEngine.getAdjacencyBoosts. SQL contract + // and source-scope rationale: see BrainEngine.getAdjacencyBoosts JSDoc. + // Same CTE shape, same COALESCE on source_id for NULL safety, same + // CASE-WHEN exclusion of target's own source for cross_source_hits. + // + // Defense-in-depth (codex outside-voice review): deleted_at IS NULL + // on both join sides. Matches Postgres-engine parity. + const { rows } = await this.db.query( + `WITH targets AS ( + SELECT id, COALESCE(source_id, 'default') AS source_id + FROM pages + WHERE id = ANY($1::int[]) + AND deleted_at IS NULL + ) + SELECT + l.to_page_id AS to_page_id, + COUNT(DISTINCT l.from_page_id)::int AS hits, + COUNT(DISTINCT + CASE WHEN COALESCE(p.source_id, 'default') <> t.source_id + THEN COALESCE(p.source_id, 'default') END + )::int AS cross_source_hits + FROM links l + JOIN pages p ON p.id = l.from_page_id AND p.deleted_at IS NULL + JOIN targets t ON t.id = l.to_page_id + WHERE l.from_page_id = ANY($1::int[]) + AND l.to_page_id = ANY($1::int[]) + GROUP BY l.to_page_id + HAVING COUNT(DISTINCT l.from_page_id) >= 1`, + [pageIds] + ); + for (const r of rows as { to_page_id: number; hits: number; cross_source_hits: number }[]) { + result.set(Number(r.to_page_id), { + hits: Number(r.hits), + cross_source_hits: Number(r.cross_source_hits), + }); + } + return result; + } + async getPageTimestamps(slugs: string[]): Promise> { if (slugs.length === 0) return new Map(); const { rows } = await this.db.query( diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 6a3c48be4..cb15de3b6 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -2448,6 +2448,53 @@ export class PostgresEngine implements BrainEngine { return result; } + async getAdjacencyBoosts(pageIds: number[]): Promise> { + const result = new Map(); + if (pageIds.length === 0) return result; + + const sql = this.sql; + // SQL contract: see BrainEngine.getAdjacencyBoosts JSDoc. Both ANY + // filters restrict the scan to the input set's induced subgraph, + // which keeps cross-source leakage impossible by construction. + // cross_source_hits uses COALESCE so NULL source_id rows behave as + // 'default' and don't silently disappear from the count. + // + // Defense-in-depth (codex outside-voice review): deleted_at IS NULL + // on both join sides so a soft-deleted page in the input set + // (theoretically possible if a future caller bypasses hybridSearch's + // visibility filter) can't contribute to hits or cross_source_hits. + // Matches the v0.35.5.0 findOrphanPages fix pattern. + const rows = await sql` + WITH targets AS ( + SELECT id, COALESCE(source_id, 'default') AS source_id + FROM pages + WHERE id = ANY(${pageIds}::int[]) + AND deleted_at IS NULL + ) + SELECT + l.to_page_id AS to_page_id, + COUNT(DISTINCT l.from_page_id)::int AS hits, + COUNT(DISTINCT + CASE WHEN COALESCE(p.source_id, 'default') <> t.source_id + THEN COALESCE(p.source_id, 'default') END + )::int AS cross_source_hits + FROM links l + JOIN pages p ON p.id = l.from_page_id AND p.deleted_at IS NULL + JOIN targets t ON t.id = l.to_page_id + WHERE l.from_page_id = ANY(${pageIds}::int[]) + AND l.to_page_id = ANY(${pageIds}::int[]) + GROUP BY l.to_page_id + HAVING COUNT(DISTINCT l.from_page_id) >= 1 + `; + for (const r of rows as unknown as { to_page_id: number; hits: number; cross_source_hits: number }[]) { + result.set(Number(r.to_page_id), { + hits: Number(r.hits), + cross_source_hits: Number(r.cross_source_hits), + }); + } + return result; + } + async getPageTimestamps(slugs: string[]): Promise> { if (slugs.length === 0) return new Map(); const sql = this.sql; diff --git a/src/core/rerank-audit.ts b/src/core/rerank-audit.ts index 8a233df5c..87847c0ef 100644 --- a/src/core/rerank-audit.ts +++ b/src/core/rerank-audit.ts @@ -18,11 +18,14 @@ * disabled = no failures expected). * * Best-effort writes. Write failures go to stderr but search continues. + * + * v0.40.4.0: internals delegate to the shared `src/core/audit/audit-writer.ts` + * primitive. Public API (logRerankFailure, readRecentRerankFailures, + * computeRerankAuditFilename) preserved bit-for-bit for the existing test + * suite at `test/rerank-audit.test.ts`. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { resolveAuditDir } from './minions/handlers/shell-audit.ts'; +import { createAuditWriter, computeIsoWeekFilename } from './audit/audit-writer.ts'; /** Stable error-classification union; matches RerankError.reason. */ export type RerankFailureReason = @@ -56,16 +59,7 @@ export interface RerankFailureEvent { /** ISO-week-rotated filename: `rerank-failures-YYYY-Www.jsonl`. */ export function computeRerankAuditFilename(now: Date = new Date()): string { - const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const dayNum = (d.getUTCDay() + 6) % 7; - d.setUTCDate(d.getUTCDate() - dayNum + 3); - const isoYear = d.getUTCFullYear(); - const firstThursday = new Date(Date.UTC(isoYear, 0, 4)); - const firstThursdayDayNum = (firstThursday.getUTCDay() + 6) % 7; - firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayDayNum + 3); - const weekNum = Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000)) + 1; - const ww = String(weekNum).padStart(2, '0'); - return `rerank-failures-${isoYear}-W${ww}.jsonl`; + return computeIsoWeekFilename('rerank-failures', now); } /** @@ -77,26 +71,23 @@ function truncateErrorSummary(msg: string, max = 200): string { return msg.slice(0, max - 1) + '…'; } +const writer = createAuditWriter({ + featureName: 'rerank-failures', + errorLabel: 'gbrain', + errorMessagePrefix: 'rerank-failure audit ', + errorTrailer: '; search continues', +}); + /** * Append a rerank-failure event. Best-effort: write failure logs to stderr * but never throws. */ export function logRerankFailure(event: Omit): void { - const row: RerankFailureEvent = { - ts: new Date().toISOString(), + writer.log({ severity: 'warn', ...event, error_summary: truncateErrorSummary(event.error_summary), - }; - const dir = resolveAuditDir(); - const file = path.join(dir, computeRerankAuditFilename()); - try { - fs.mkdirSync(dir, { recursive: true }); - fs.appendFileSync(file, JSON.stringify(row) + '\n', { encoding: 'utf8' }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[gbrain] rerank-failure audit write failed (${msg}); search continues\n`); - } + } as Omit); } /** @@ -105,33 +96,14 @@ export function logRerankFailure(event: Omit= cutoff) out.push(ev); - } catch { - // Corrupt row — skip. - } - } - } - return out; + return writer.readRecent(days, now); } + +// stderr label "gbrain" + qualifier "rerank-failure audit " preserve the +// pre-v0.40.4 message byte-for-byte: +// +// `[gbrain] rerank-failure audit write failed (${msg}); search continues` +// +// The `errorMessagePrefix` option on createAuditWriter restores the +// qualifier that would otherwise be dropped by the refactor. Operators +// grepping logs for "rerank-failure audit write failed" keep working. diff --git a/src/core/search/explain-formatter.ts b/src/core/search/explain-formatter.ts new file mode 100644 index 000000000..b9ae323d3 --- /dev/null +++ b/src/core/search/explain-formatter.ts @@ -0,0 +1,117 @@ +/** + * v0.40.4.0 — `gbrain search --explain` per-stage attribution formatter. + * + * Renders a SearchResult[] as a multi-line per-result breakdown of how + * the final score was formed. Reads the boost_* / base_score / *_hits + * fields populated by every boost stage (T6 stamp + T4 graph signals). + * + * Output shape per result: + * + * 1. people/alice-example (score=12.4) + * base=10.2 (rrf+cosine) + * + backlink ×1.08 (12 inbound) ← when backlink_boost > 1 + * + salience ×1.05 (mattering) ← when salience_boost > 1 + * + recency ×1.00 (no decay applied) ← when recency_boost > 1 + * + exact-match ×1.50 ← when exact_match_boost > 1 + * + adjacency ×1.05 (hits=3) ← when graph_adjacency_boost set + * + cross_source ×1.10 (other_sources=2) ← when graph_cross_source_boost set + * - session_demote ×0.95 (prefix=chat/x) ← when session_demote_factor set + * ↑ reranker rank +2 (head improved) ← when reranker_delta > 0 + * ↓ reranker rank -1 (head moved down) ← when reranker_delta < 0 + * = final 12.4 + * + * Empty path: when no stage stamped anything, prints + * "no boosts applied" + "= final {score}". + * + * JSON envelope: the same SearchResult fields are surfaced verbatim in + * the existing `--json` output (operations layer JSON.stringify); no + * separate JSON formatter needed. + */ + +import type { SearchResult } from '../types.ts'; + +/** + * Format a single result with per-stage attribution. Returns a string + * (multi-line, no trailing newline; caller joins with '\n' if rendering + * many). + */ +export function formatResultExplain( + result: SearchResult, + rank: number, // 1-based for human display +): string { + const lines: string[] = []; + lines.push(`${rank}. ${result.slug} (score=${fmt(result.score)})`); + + // base_score is the pre-boost RRF+cosine result. When undefined + // (result wasn't routed through runPostFusionStages), fall back to + // final score and label "no boosts applied" downstream. + const base = result.base_score ?? result.score; + lines.push(` base=${fmt(base)} (rrf+cosine)`); + + let anyBoost = false; + + if (result.backlink_boost !== undefined && result.backlink_boost !== 1.0) { + anyBoost = true; + lines.push(` + backlink ×${fmt(result.backlink_boost)}`); + } + if (result.salience_boost !== undefined && result.salience_boost !== 1.0) { + anyBoost = true; + lines.push(` + salience ×${fmt(result.salience_boost)}`); + } + if (result.recency_boost !== undefined && result.recency_boost !== 1.0) { + anyBoost = true; + lines.push(` + recency ×${fmt(result.recency_boost)}`); + } + if (result.exact_match_boost !== undefined && result.exact_match_boost !== 1.0) { + anyBoost = true; + lines.push(` + exact-match ×${fmt(result.exact_match_boost)}`); + } + if (result.graph_adjacency_boost !== undefined) { + anyBoost = true; + const hits = result.graph_adjacency_hits ?? '?'; + lines.push(` + adjacency ×${fmt(result.graph_adjacency_boost)} (hits=${hits})`); + } + if (result.graph_cross_source_boost !== undefined) { + anyBoost = true; + const cs = result.graph_cross_source_hits ?? '?'; + lines.push(` + cross_source ×${fmt(result.graph_cross_source_boost)} (other_sources=${cs})`); + } + if (result.session_demote_factor !== undefined) { + anyBoost = true; + const prefix = result.graph_session_prefix ?? '?'; + lines.push(` - session_demote ×${fmt(result.session_demote_factor)} (prefix=${prefix})`); + } + if (result.reranker_delta !== undefined && result.reranker_delta !== 0) { + anyBoost = true; + const arrow = result.reranker_delta > 0 ? '↑' : '↓'; + lines.push(` ${arrow} reranker rank ${result.reranker_delta > 0 ? '+' : ''}${result.reranker_delta}`); + } + + if (!anyBoost) { + lines.push(` no boosts applied`); + } + + lines.push(` = final ${fmt(result.score)}`); + return lines.join('\n'); +} + +/** + * Format a full result list. Caller passes the SearchResult[] directly; + * the formatter handles enumeration. Returns a single string (multi-line + * with trailing newline so callers can `process.stdout.write(out)`). + */ +export function formatResultsExplain(results: SearchResult[]): string { + if (results.length === 0) return 'No results.\n'; + return results.map((r, i) => formatResultExplain(r, i + 1)).join('\n\n') + '\n'; +} + +/** + * Compact number formatter. Drops trailing zeros for readability; 4 + * decimal places of precision is plenty for ranking scores (RRF lands + * in the 0.01-0.05 band; backlink/salience boosts in the 1.0-1.6 band). + */ +function fmt(n: number): string { + if (!Number.isFinite(n)) return String(n); + // 4 decimals, then trim trailing zeros and an optional trailing dot. + return n.toFixed(4).replace(/\.?0+$/, ''); +} diff --git a/src/core/search/graph-signals.ts b/src/core/search/graph-signals.ts new file mode 100644 index 000000000..695a15a94 --- /dev/null +++ b/src/core/search/graph-signals.ts @@ -0,0 +1,418 @@ +/** + * v0.40.4.0 — selective graph signals. + * + * Three additive signals applied inside `runPostFusionStages`: + * + * 1. Adjacency-within-top-K (~1.05×): if a top-K page is linked-to by + * >=2 OTHER top-K pages, it's a hub for this query — small bump. + * + * 2. Cross-source adjacency (~1.10×): if a top-K page is linked-to from + * >=2 distinct OTHER sources (excluding target's own source), it's + * a federated-team hub — slightly bigger bump. Dormant on + * single-source brains (where cross_source_hits is always 0). + * + * 3. Session diversification (~0.95×): if multiple top-K pages share a + * session prefix (e.g. `media/chat/2026-05-20-foo/...`), keep + * the highest-scoring one at full score and DEMOTE the rest. This + * is MMR-lite: the original framing "boost the cluster" was + * structurally wrong — the stated motivation was "weak chunks + * competing for token budget," which amplification makes worse. + * Codex caught it in outside-voice review of the v0.40.4 plan. + * + * Conservative magnitudes (D14=B): halved from initial (1.10/1.15/1.05) + * to (1.05/1.10/0.95) so multiplicative composition can't catastrophically + * reorder in tight score bands. A score-distribution probe collects + * data for T-todo-2 (data-driven calibration wave after 30 days). + * + * Slot: 4th stage inside `runPostFusionStages` (hybrid.ts), pre-dedup, + * floor-gated by the v0.35.6.0 `computeFloorThreshold`. The gate is the + * exact protection this signal class needs: a low-cosine result that + * happens to be a hub gets shoved past a strong non-hub WITHOUT the + * gate. Codex T2 / plan `swift-sniffing-nygaard.md` D6 caught this bug + * class for v0.35.6.0; reintroducing it here would undo that work. + * + * Fail-open: any error from `engine.getAdjacencyBoosts` returns input + * unchanged. Session diversification ALSO skips on failure (predictable + * all-or-nothing posture). A JSONL audit row is written via the shared + * createAuditWriter primitive so doctor + search-stats can surface fail + * rates cross-process. + */ + +import type { SearchResult } from '../types.ts'; +import type { AdjacencyRow } from '../types.ts'; +import type { BrainEngine } from '../engine.ts'; +import { createAuditWriter } from '../audit/audit-writer.ts'; + +// =========================================================================== +// Constants (D14=B halved magnitudes; the score-distribution probe feeds the +// T-todo-2 calibration wave that will tune these against real production data +// after 30 days). +// =========================================================================== + +/** Multiplier applied when in-set adjacency hits >= ADJACENCY_MIN_HITS. */ +export const ADJACENCY_BOOST = 1.05; +/** Multiplier applied when cross-source hits >= CROSS_SOURCE_MIN_HITS. + * STACKS on top of ADJACENCY_BOOST when both fire. */ +export const CROSS_SOURCE_BOOST = 1.10; +/** Multiplier applied to non-top-scoring members of a session group. + * Sub-1.0 means DEMOTE, not boost (D11=B). */ +export const SESSION_DEMOTE = 0.95; +/** How many top-ranked results to consider for graph signals. */ +export const DEFAULT_TOP_K = 20; +/** Minimum in-set inbound link count before adjacency boost fires. */ +export const ADJACENCY_MIN_HITS = 2; +/** Minimum distinct OTHER source count before cross-source boost fires. */ +export const CROSS_SOURCE_MIN_HITS = 2; +/** Minimum group size before session diversification fires. */ +export const SESSION_MIN_SHARE = 2; + +// =========================================================================== +// Types +// =========================================================================== + +/** + * Score-distribution snapshot over top-K. Surfaced to search-stats so a + * future calibration wave (T-todo-2) can tune boost magnitudes against + * actual production score bands. Always emitted when graph_signals is + * enabled, even when no signal fires — instrumentation-first. + */ +export interface ScoreDistribution { + top_k_size: number; + min: number; + p25: number; + p50: number; + p75: number; + p95: number; + max: number; + /** max - min across top-K; the reorder band the boosts have to clear. */ + reorder_band_width: number; +} + +export interface GraphSignalsMeta { + enabled: boolean; + top_k_size: number; + adjacency_fires: number; + cross_source_fires: number; + session_demotions: number; + errored: boolean; + duration_ms: number; +} + +export interface GraphSignalsOpts { + /** Master gate. False short-circuits to no-op with zero-meta emitted. */ + enabled: boolean; + /** Top-K size (default DEFAULT_TOP_K). */ + topK?: number; + /** + * Absolute score floor inherited from runPostFusionStages. Results + * with score below this skip ALL graph boosts (D1=A floor-gate + * inheritance). Pass undefined to disable the gate. + */ + floorThreshold?: number; + /** + * Test seam: replaces engine.getAdjacencyBoosts. Matches the + * applyReranker pattern (opts.rerankerFn) so unit tests drive the + * helper without an engine. + */ + adjacencyFn?: (pageIds: number[]) => Promise>; + /** Observability sink — called once per invocation with fire counts. */ + onMeta?: (meta: GraphSignalsMeta) => void; + /** Observability sink — called once per invocation with score stats. */ + onScoreDistribution?: (dist: ScoreDistribution) => void; +} + +// =========================================================================== +// Failure audit (D5=B / T1 shared writer) +// =========================================================================== + +interface GraphSignalsFailureEvent { + ts: string; + /** Truncated upstream error message (first 200 chars). */ + error_summary: string; + /** Number of top-K page_ids submitted when the failure fired. */ + top_k_size: number; +} + +const failureWriter = createAuditWriter({ + featureName: 'graph-signals-failures', + errorLabel: 'gbrain', + errorTrailer: '; search continues', +}); + +function truncateErrorSummary(msg: string, max = 200): string { + if (msg.length <= max) return msg; + return msg.slice(0, max - 1) + '…'; +} + +/** + * Read recent graph-signals fail-open events. Consumed by + * `gbrain doctor`'s graph_signals_coverage check and by + * `gbrain search stats`'s error-rate rollup. + */ +export function readRecentGraphSignalsFailures( + days = 7, + now: Date = new Date(), +): GraphSignalsFailureEvent[] { + return failureWriter.readRecent(days, now); +} + +// =========================================================================== +// Helpers +// =========================================================================== + +/** + * Pattern: session-like slugs have either a `chat/` segment or a YYYY-MM-DD + * date segment somewhere in the path. Both signal "this is one of many + * chunks from the same recorded session/transcript/log entry" — the case + * where multiple result rows from a single weak source dilute a stronger + * non-session hit. + * + * Examples that ARE sessions (return a real session prefix): + * - `your-agent/chat/2026-05-20-foo` → 'your-agent/chat/2026-05-20-foo' + * - `daily/2026-05-20/journal-entry-1` → 'daily/2026-05-20' + * - `meetings/2026-04-03/notes` → 'meetings/2026-04-03' + * - `transcripts/chat/funding-discussion` → 'transcripts/chat/funding-discussion' + * + * Examples that are NOT sessions (return null — no diversification): + * - `people/alice`, `people/bob` → entity directory, NOT a session + * - `companies/acme`, `companies/stripe` → entity directory, NOT a session + * - `docs/quickstart`, `docs/api` → topical directory, NOT a session + * - `wiki/concepts/auth` → topical, not date-anchored + * + * This is the codex outside-voice fix: the original v0.40.4 implementation + * used "any shared parent directory" as the session signal, which silently + * demoted legitimate same-type entity results in every common entity + * search (`people/alice` + `people/bob` got grouped, one demoted). + * + * Returns `null` when the slug isn't session-shaped (caller skips + * diversification entirely for this result). + */ +const DATE_SEGMENT_RE = /^\d{4}-\d{2}-\d{2}/; +// Only 'chat' / 'session' / 'sessions' are session MARKERS — words that +// indicate "the next segment is a session id." Words like 'transcripts' +// or 'meetings' are CATEGORIES (parents of sessions, not markers +// themselves). A path like `transcripts/chat/funding-discussion` should +// be the WHOLE thing (parent + marker + session id), which only works +// if 'transcripts' is NOT a marker but 'chat' IS. +const SESSION_MARKERS = new Set(['chat', 'session', 'sessions']); + +export function sessionPrefix(slug: string): string | null { + if (!slug.includes('/')) return null; + const segments = slug.split('/'); + // Strategy: walk segments left-to-right. Find the first segment that's + // either a session marker (chat/session/sessions) OR a date prefix. + // Session prefix shape: + // - On marker: everything up to AND INCLUDING the segment after the + // marker (that segment is the session id). If the marker is the + // last segment (degenerate), include up to the marker itself. + // - On date: everything up to and including the date segment. + // + // Examples: + // your-agent/chat/2026-05-20-foo → 'your-agent/chat/2026-05-20-foo' + // media/chat/2026-05-20-foo/chunk-001 → 'media/chat/2026-05-20-foo' + // transcripts/chat/funding-discussion → 'transcripts/chat/funding-discussion' + // daily/2026-05-20/journal-entry-1 → 'daily/2026-05-20' + // meetings/2026-04-03/notes → 'meetings/2026-04-03' + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (SESSION_MARKERS.has(seg)) { + // Session id is segment i+1 (or the marker itself if i+1 doesn't exist). + const sessionIdIdx = Math.min(i + 1, segments.length - 1); + return segments.slice(0, sessionIdIdx + 1).join('/'); + } + if (DATE_SEGMENT_RE.test(seg)) { + // Date anchor — session is everything up to and including the date. + return segments.slice(0, i + 1).join('/'); + } + } + // No session marker and no date anchor — entity / topic / docs + // directory, not a session. Skip diversification. + return null; +} + +/** + * Compute basic score-distribution percentiles over a sorted-desc array + * of scores. Pure function — exposed so search-stats can re-aggregate + * across queries. + */ +export function computeScoreDistribution(scores: number[]): ScoreDistribution { + const n = scores.length; + if (n === 0) { + return { + top_k_size: 0, min: 0, p25: 0, p50: 0, p75: 0, p95: 0, max: 0, + reorder_band_width: 0, + }; + } + // Conventional ascending percentile math: 25th percentile = score + // below which 25% of values fall. + const asc = [...scores].sort((a, b) => a - b); + const pct = (q: number) => { + const idx = Math.max(0, Math.min(asc.length - 1, Math.round((asc.length - 1) * q))); + return asc[idx]; + }; + return { + top_k_size: n, + min: asc[0], + p25: pct(0.25), + p50: pct(0.50), + p75: pct(0.75), + p95: pct(0.95), + max: asc[asc.length - 1], + reorder_band_width: asc[asc.length - 1] - asc[0], + }; +} + +// =========================================================================== +// Main entry point +// =========================================================================== + +/** + * Apply selective graph signals to a sorted-desc results array. Mutates + * `score` in place; caller re-sorts (runPostFusionStages already does + * this at line 807 in hybrid.ts). + * + * Behavior: + * 1. If !enabled or empty results → no-op + zero-meta. + * 2. Always emit score-distribution probe (instrumentation-first for + * T-todo-2 calibration data). + * 3. Adjacency + cross-source: SQL via engine.getAdjacencyBoosts (or + * injected adjacencyFn for tests). Floor-gate skips results below + * threshold (D1=A inheritance). + * 4. Session diversification: single-pass Map; + * highest-scoring keeps full score, others * SESSION_DEMOTE. + * 5. Fail-open on engine error: stderr audit row + return unchanged. + * + * Mutation note (codex #9): score is mutated in place. base_score + * should be stamped by the caller BEFORE this stage so eval-capture + * sees the pre-boost score. runPostFusionStages does this in T6. + */ +export async function applyGraphSignals( + results: SearchResult[], + engine: BrainEngine, + opts: GraphSignalsOpts, +): Promise { + const startedAt = Date.now(); + const meta: GraphSignalsMeta = { + enabled: opts.enabled, + top_k_size: 0, + adjacency_fires: 0, + cross_source_fires: 0, + session_demotions: 0, + errored: false, + duration_ms: 0, + }; + + if (!opts.enabled || results.length === 0) { + meta.duration_ms = Date.now() - startedAt; + opts.onMeta?.(meta); + return; + } + + const topKSize = opts.topK ?? DEFAULT_TOP_K; + const topK = results.slice(0, topKSize); + meta.top_k_size = topK.length; + + // Score-distribution probe always fires when enabled (instrumentation + // for T-todo-2 calibration wave, even if no signal subsequently fires). + if (opts.onScoreDistribution) { + opts.onScoreDistribution(computeScoreDistribution(topK.map(r => r.score))); + } + + // ---- Adjacency + cross-source ---- + // Dedup page_ids before the SQL call. Same pattern as + // runPostFusionStages line 265 uses for slugs. A SearchResult with + // a missing page_id is treated as invariant-broken upstream + // (page_id is documented as REQUIRED in SearchResult JSDoc) — skip + // such rows from the dedup set; they can't be matched in the result + // Map anyway. + const uniquePageIds = Array.from( + new Set(topK.map(r => r.page_id).filter(id => typeof id === 'number' && id > 0)), + ); + + let adjacency: Map; + try { + adjacency = opts.adjacencyFn + ? await opts.adjacencyFn(uniquePageIds) + : await engine.getAdjacencyBoosts(uniquePageIds); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failureWriter.log({ + error_summary: truncateErrorSummary(msg), + top_k_size: topK.length, + }); + meta.errored = true; + meta.duration_ms = Date.now() - startedAt; + opts.onMeta?.(meta); + // Fail-open: caller's results are unchanged. Session diversification + // also skips (predictable all-or-nothing posture). + return; + } + + const floorThreshold = opts.floorThreshold; + + for (const r of topK) { + // Floor-gate: D1=A inheritance. Below-floor results don't accumulate + // graph boosts even if they're hubs (matches the v0.35.6.0 + // weak-page-becomes-hub protection that motivates putting graph- + // signals inside runPostFusionStages). + if (floorThreshold !== undefined && !(r.score >= floorThreshold)) continue; + const row = adjacency.get(r.page_id); + if (!row) continue; + if (row.hits >= ADJACENCY_MIN_HITS) { + r.score *= ADJACENCY_BOOST; + r.graph_adjacency_hits = row.hits; + r.graph_adjacency_boost = ADJACENCY_BOOST; + meta.adjacency_fires++; + } + if (row.cross_source_hits >= CROSS_SOURCE_MIN_HITS) { + r.score *= CROSS_SOURCE_BOOST; + r.graph_cross_source_hits = row.cross_source_hits; + r.graph_cross_source_boost = CROSS_SOURCE_BOOST; + meta.cross_source_fires++; + } + } + + // ---- Session diversification (D9 single-pass Map, D11=B DEMOTE) ---- + // Only fires when sessionPrefix detects a session-like pattern + // (chat/transcript marker OR date segment). Non-session slugs + // (entity directories like `people/`, `companies/`, topical dirs + // like `docs/`) skip diversification entirely — codex outside-voice + // catch: the original implementation grouped ALL same-parent-directory + // slugs, which silently demoted legitimate entity-search results. + const sessionGroups = new Map(); + for (const r of topK) { + const prefix = sessionPrefix(r.slug); + if (prefix === null) continue; // not session-shaped — skip diversification + let group = sessionGroups.get(prefix); + if (!group) { + group = []; + sessionGroups.set(prefix, group); + } + group.push(r); + } + for (const [prefix, members] of sessionGroups) { + if (members.length < SESSION_MIN_SHARE) continue; + // Highest-scoring member keeps full score; others demoted. + // Sort by current score (post-adjacency boost) descending so the + // representative is whichever member scored highest AFTER any + // adjacency boost. Stable for ties via slug for determinism. + members.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.slug.localeCompare(b.slug); + }); + for (let i = 1; i < members.length; i++) { + members[i].score *= SESSION_DEMOTE; + members[i].graph_session_demoted = true; + members[i].graph_session_prefix = prefix; + members[i].session_demote_factor = SESSION_DEMOTE; + meta.session_demotions++; + } + // Stamp the prefix on the representative too so --explain can show + // "representative of session_x" attribution. graph_session_demoted + // stays false/undefined on the representative. + members[0].graph_session_prefix = prefix; + } + + meta.duration_ms = Date.now() - startedAt; + opts.onMeta?.(meta); +} diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index 6db6a3791..5f2435cee 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -83,7 +83,12 @@ export function applyBacklinkBoost( if (floorThreshold !== undefined && r.score < floorThreshold) continue; const count = counts.get(r.slug) ?? 0; if (count > 0) { - r.score *= (1.0 + BACKLINK_BOOST_COEF * Math.log(1 + count)); + const factor = 1.0 + BACKLINK_BOOST_COEF * Math.log(1 + count); + r.score *= factor; + // v0.40.4 attribution stamp (D12=A) — formatter reads this for + // --explain output. Stays undefined when count == 0 so the + // formatter can render "no boosts applied" honestly. + r.backlink_boost = factor; } } } @@ -158,7 +163,10 @@ export function applySalienceBoost( const key = `${r.source_id ?? 'default'}::${r.slug}`; const score = scores.get(key); if (!score || score <= 0) continue; - r.score *= (1.0 + k * Math.log(1 + score)); + const factor = 1.0 + k * Math.log(1 + score); + r.score *= factor; + // v0.40.4 attribution stamp (D12=A). + r.salience_boost = factor; } } @@ -208,6 +216,8 @@ export function applyRecencyBoost( const recencyComponent = cfg.coefficient * cfg.halflifeDays / (cfg.halflifeDays + daysOld); const factor = 1.0 + strengthMul * recencyComponent; r.score *= factor; + // v0.40.4 attribution stamp (D12=A). + r.recency_boost = factor; } } @@ -241,8 +251,32 @@ export interface PostFusionOpts { * (`applyExactMatchBoost`) runs AFTER `runPostFusionStages` and is NOT * gated — it's a lexical-relevance signal, different in kind from * metadata boosts. + * + * v0.40.4: scope extended to the new graph_signals stage. Graph + * signals are a metadata-axis boost like backlink/salience/recency + * — same floor-gate inheritance prevents the weak-page-becomes-hub + * regression (codex T2 / D1=A in v0.40.4 plan). */ floorRatio?: number; + /** + * v0.40.4 — gate for the graph-signals stage (4th post-fusion stage). + * False short-circuits to no-op. When true, applyGraphSignals fires + * AFTER backlink/salience/recency so it stacks on top of metadata + * boosts. Resolved from ModeBundle.graph_signals by the caller. + */ + graphSignalsEnabled?: boolean; + /** + * v0.40.4 — observability sink for graph-signal fire counts. Threaded + * through hybridSearch.onMeta so eval-capture sees per-query metrics. + */ + onGraphMeta?: (meta: import('./graph-signals.ts').GraphSignalsMeta) => void; + /** + * v0.40.4 — observability sink for score-distribution stats (top-K + * min/p25/p50/p75/p95/max + reorder_band_width). Always emitted when + * graphSignalsEnabled is true. Feeds T-todo-2 magnitude calibration + * wave via search-stats. + */ + onScoreDistribution?: (dist: import('./graph-signals.ts').ScoreDistribution) => void; } export async function runPostFusionStages( @@ -252,6 +286,16 @@ export async function runPostFusionStages( ): Promise { if (results.length === 0) return; + // v0.40.4 attribution stamp (D12=A) — capture base_score ONCE at entry, + // BEFORE any boost mutates r.score. Without this, --explain can't + // reconstruct the pre-boost score. Idempotent: if base_score is + // already populated (caller stamped upstream), preserve it. + for (const r of results) { + if (r.base_score === undefined) { + r.base_score = r.score; + } + } + // v0.35.6.0 [floor-ratio gate]: compute threshold ONCE at entry, BEFORE any // boost mutates scores. Single-baseline semantic — the same threshold gates // all three downstream stages. This is intentionally different from a @@ -305,6 +349,25 @@ export async function runPostFusionStages( // Non-fatal. } } + + // v0.40.4 — graph-signals stage (4th post-fusion stage). Runs AFTER + // backlink/salience/recency so it stacks on top of metadata boosts; + // shares the same floor-threshold so a weak hub gets the same + // protection v0.35.6.0 added for other metadata boosts. Fail-open at + // this level matches the per-stage non-fatal contract. + if (opts.graphSignalsEnabled) { + try { + const { applyGraphSignals } = await import('./graph-signals.ts'); + await applyGraphSignals(results, engine, { + enabled: true, + floorThreshold, + onMeta: opts.onGraphMeta, + onScoreDistribution: opts.onScoreDistribution, + }); + } catch { + // Non-fatal; preserves the per-stage contract. + } + } } export interface HybridSearchOpts extends SearchOpts { @@ -357,6 +420,10 @@ export async function hybridSearch( // over per-key config wins over mode bundle (currently undefined for // all 3 bundles — pending ablation evidence). floor_ratio: opts?.floorRatio, + // v0.40.4 — graph_signals thread-through. Per-call wins over config + // override wins over mode bundle. Without this thread the eval gate + // would be a no-op (both branches resolve to the same mode default). + graph_signals: opts?.graph_signals, }, }); @@ -508,6 +575,12 @@ export async function hybridSearch( // undefined for all 3 bundles → no behavior change unless caller sets // SearchOpts.floorRatio or `search.floor_ratio` config key. floorRatio: resolvedMode.floor_ratio, + // v0.40.4 — graph_signals stage threaded from resolved mode. Defaults + // per ModeBundle (conservative=false, balanced/tokenmax=true). Per-call + // SearchOpts.graph_signals overrides through resolveSearchMode. + // Without this thread, the entire graph-signals wave is dead code — + // codex outside-voice caught the missing wire pre-merge. + graphSignalsEnabled: resolvedMode.graph_signals, }; // Skip vector search entirely if the gateway has no embedding provider configured (Codex C3). @@ -952,6 +1025,12 @@ export async function hybridSearchCached( // Without this, a no-floor write would be served to a floor-enabled // read (ranking-correctness leak, codex T1). floor_ratio: opts?.floorRatio, + // v0.40.4 — graph_signals threaded through cache resolver too so + // knobsHash() includes the per-call override (KNOBS_HASH_VERSION=4 + // folds gs= into the hash). Without this thread, a per-call + // override would write to one cache row but read from a different + // one on the next call. + graph_signals: opts?.graph_signals, }, }); // v0.36 (D8 / CDX-2 + codex /ship #4): resolve column for the cache diff --git a/src/core/search/intent-weights.ts b/src/core/search/intent-weights.ts index 261f3d808..182a4ac5f 100644 --- a/src/core/search/intent-weights.ts +++ b/src/core/search/intent-weights.ts @@ -136,6 +136,9 @@ export function applyExactMatchBoost( const title = (r.title ?? '').toLowerCase().trim(); if (slug === q || slug === qKebab || slug.endsWith(`/${qKebab}`) || title === q) { r.score *= weights.exactMatchBoost; + // v0.40.4 attribution stamp (D12=A) — formatter reads this for + // --explain output. Only stamped when boost actually fires. + r.exact_match_boost = weights.exactMatchBoost; } } } diff --git a/src/core/search/mode.ts b/src/core/search/mode.ts index defe5a4d2..c2d401d25 100644 --- a/src/core/search/mode.ts +++ b/src/core/search/mode.ts @@ -163,6 +163,20 @@ export interface ModeBundle { * Fires for <1% of queries when on; ~$0.0001 per escalation. */ cross_modal_llm_intent: boolean; + /** + * v0.40.4 — gate for the graph-signals stage (4th post-fusion stage). + * Default: off for conservative, on for balanced + tokenmax. When on, + * applyGraphSignals fires inside runPostFusionStages with three sub- + * signals (adjacency hub, cross-source hub, session diversification). + * + * Magnitudes (graph-signals.ts constants): 1.05 / 1.10 / 0.95. + * Conservative-by-construction (D14=B); calibration wave T-todo-2 + * tunes them against real production data after 30 days. + * + * Override path: per-call SearchOpts → `search.graph_signals` config + * key → mode bundle default. + */ + graph_signals: boolean; /** * v0.40.3.0 — contextual retrieval tier per mode. Wraps chunks at embed @@ -228,6 +242,10 @@ export const MODE_BUNDLES: Readonly>> = unified_multimodal: false, unified_multimodal_only: false, cross_modal_llm_intent: false, + // v0.40.4 — graph signals OFF for conservative (cost-sensitive tier, + // matches the "minimize per-query overhead" posture). Signal still + // useful for power users via per-call SearchOpts.graph_signals = true. + graph_signals: false, // v0.40.3.0 contextual retrieval — none for conservative (minimum surface). contextual_retrieval: 'none' as CRMode, contextual_retrieval_disabled: false, @@ -264,6 +282,13 @@ export const MODE_BUNDLES: Readonly>> = unified_multimodal: false, unified_multimodal_only: false, cross_modal_llm_intent: false, + // v0.40.4 — graph signals ON for balanced. Adjacency + cross-source + // signals exploit the link graph the brain already has; session + // diversification stops same-session weak chunks from competing + // with strong hits for token budget. Conservative magnitudes + // (1.05/1.10/0.95) with floor-gate inheritance keep regression risk + // bounded. Opt out with `gbrain config set search.graph_signals false`. + graph_signals: true, // v0.40.3.0 contextual retrieval — title-only for balanced (free at // runtime; pure string concat, no Haiku). Default mode for most users // per the cost-tier philosophy. @@ -299,6 +324,10 @@ export const MODE_BUNDLES: Readonly>> = unified_multimodal: false, unified_multimodal_only: false, cross_modal_llm_intent: false, + // v0.40.4 — graph signals ON for tokenmax (power-user tier). Same + // rationale as balanced. The score-distribution probe collects data + // for T-todo-2 magnitude calibration wave. + graph_signals: true, // v0.40.3.0 contextual retrieval — per-chunk Haiku synopsis for tokenmax // (Anthropic's published method). One-time backfill cost ~$5-50 for a // 10K-page brain; documented in the post-upgrade cost prompt. @@ -345,6 +374,8 @@ export interface SearchKeyOverrides { unified_multimodal?: boolean; unified_multimodal_only?: boolean; cross_modal_llm_intent?: boolean; + // v0.40.4 — graph_signals override (boolean). + graph_signals?: boolean; // v0.40.3.0 contextual retrieval. CRMode override + soft kill switch. contextual_retrieval?: CRMode; contextual_retrieval_disabled?: boolean; @@ -381,6 +412,8 @@ export interface SearchPerCallOpts { unified_multimodal?: boolean; unified_multimodal_only?: boolean; cross_modal_llm_intent?: boolean; + // v0.40.4 — graph_signals per-call override (boolean). + graph_signals?: boolean; // v0.40.3.0 contextual retrieval per-call overrides. contextual_retrieval?: CRMode; contextual_retrieval_disabled?: boolean; @@ -452,6 +485,8 @@ export function resolveSearchMode(input: ResolveSearchModeInput): ResolvedSearch unified_multimodal: pick('unified_multimodal'), unified_multimodal_only: pick('unified_multimodal_only'), cross_modal_llm_intent: pick('cross_modal_llm_intent'), + // v0.40.4 + graph_signals: pick('graph_signals'), // v0.40.3.0 contextual retrieval — resolved via the same pick chain. contextual_retrieval: pick('contextual_retrieval'), contextual_retrieval_disabled: pick('contextual_retrieval_disabled'), @@ -524,12 +559,16 @@ export function attributeKnob( // extensions both land under v=3, with cross-modal fields appended after // the floor_ratio entry (CDX2-F13 append-only convention). // -// v0.39 T21 (master): schema_pack identity fields added under v=4. +// v0.40.4 bump 3→4: graph_signals participates in the cache key. A +// graph-on write must NOT be served to a graph-off lookup (ranking +// shifts when adjacency / cross-source / session-demote stamps move +// results). v0.39 T21 (master) also added schema_pack identity fields +// under v=4. // -// v0.40.3.0 (this branch, per D8 sequencing): contextual_retrieval and -// contextual_retrieval_disabled added under v=5. Sequenced behind salem's -// pending v=4 graph signals work — first to land claims v=4; second -// rebases to v=5 (we did, since master's v=4 already landed before us). +// v0.40.3.0 bump 4→5: contextual_retrieval and contextual_retrieval_disabled +// added under v=5 (per D8 sequencing — first to land claimed v=4; the +// contextual-retrieval wave rebased to v=5). Mid-deploy hit-rate dip is +// expected — clears within cache.ttl_seconds (3600s default). export const KNOBS_HASH_VERSION = 5; /** @@ -608,10 +647,14 @@ export function knobsHash( // must never be served from a row that ran against `embedding`. `col=${ctx?.embeddingColumn ?? 'embedding'}`, `prov=${ctx?.embeddingModel ?? 'default'}`, - // v=4 additions (append-only). v0.39 T21 + codex finding #5: schema-pack - // name + version. Cross-pack contamination is structurally impossible - // — a query that resolved type `researcher` against pack A cannot be - // served from a row that resolved against pack B. + // v=4 additions (append-only). + // graph_signals (v0.40.4): graph-on write must not be served to a + // graph-off lookup. + // schema-pack name + version (v0.39 T21 / codex #5): cross-pack + // contamination is structurally impossible — a query that + // resolved type `researcher` against pack A cannot be served + // from a row that resolved against pack B. + `gs=${knobs.graph_signals ? 1 : 0}`, `pack=${ctx?.schemaPack ?? 'none'}`, `pver=${ctx?.schemaPackVersion ?? 'none'}`, // v=5 contextual retrieval additions (v0.40.3.0, per D8 sequencing @@ -762,6 +805,12 @@ export function loadOverridesFromConfig( out.contextual_retrieval_disabled = crd === '1' || crd.toLowerCase() === 'true'; } + // v0.40.4 — graph_signals + const gs = get('search.graph_signals'); + if (gs !== undefined) { + out.graph_signals = gs === '1' || gs.toLowerCase() === 'true'; + } + return out; } @@ -790,6 +839,8 @@ export const SEARCH_MODE_CONFIG_KEYS: ReadonlyArray = Object.freeze([ 'search.unified_multimodal', 'search.unified_multimodal_only', 'search.cross_modal.llm_intent', + // v0.40.4 graph signals + 'search.graph_signals', // v0.40.3.0 contextual retrieval — tier override + soft kill switch. // Per-mode default lives in the bundle; this key lets power users // override at the per-key level without flipping the global mode. diff --git a/src/core/search/rerank.ts b/src/core/search/rerank.ts index c35fcbee0..46dadf4b0 100644 --- a/src/core/search/rerank.ts +++ b/src/core/search/rerank.ts @@ -117,6 +117,10 @@ export async function applyReranker( // (telemetry, debug) can see the new ordering signal. Doesn't // replace `score` — that's RRF and other consumers may depend on it. (item as any).rerank_score = r.relevanceScore; + // v0.40.4 attribution stamp (D12=A) — rank delta. Positive means + // rank improved (moved closer to top). new_index is the next + // push position in reorderedHead; original index was r.index. + item.reranker_delta = r.index - reorderedHead.length; reorderedHead.push(item); } } diff --git a/src/core/types.ts b/src/core/types.ts index 3324b9853..7dadcfbff 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -588,6 +588,69 @@ export interface SearchResult { */ effective_date?: string | null; effective_date_source?: string | null; + /** + * v0.40.4 graph signals — populated by applyGraphSignals when the + * graph_signals mode-bundle knob is on. Surfaced in JSON envelope + * for agent introspection + the `gbrain search --explain` formatter. + */ + /** Number of OTHER top-K pages linking to this page (>= ADJACENCY_MIN_HITS). */ + graph_adjacency_hits?: number; + /** Number of distinct OTHER source_ids (excluding this page's own source) + * linking to this page from within top-K (>= CROSS_SOURCE_MIN_HITS). */ + graph_cross_source_hits?: number; + /** True when this result was demoted (score multiplied by SESSION_DEMOTE) + * because it shares a session prefix with a higher-scoring result. */ + graph_session_demoted?: boolean; + /** Slug prefix used for the session-diversification grouping. */ + graph_session_prefix?: string; + /** + * v0.40.4 full attribution (D12=A) — per-stage score deltas for the + * `gbrain search --explain` formatter. Every boost stage stamps its + * contribution so the formatter can reconstruct the score derivation. + * + * base_score is captured once at runPostFusionStages entry BEFORE any + * mutation; the other fields are stamped by their respective stages. + */ + /** RRF + cosine score BEFORE any boost stage mutated it. */ + base_score?: number; + /** Multiplier applied by applyBacklinkBoost (1.0 = unchanged). */ + backlink_boost?: number; + /** Multiplier applied by applySalienceBoost. */ + salience_boost?: number; + /** Multiplier applied by applyRecencyBoost. */ + recency_boost?: number; + /** Multiplier applied by applyExactMatchBoost. */ + exact_match_boost?: number; + /** Multiplier applied by applyGraphSignals (adjacency hit). */ + graph_adjacency_boost?: number; + /** Multiplier applied by applyGraphSignals (cross-source hit). */ + graph_cross_source_boost?: number; + /** Multiplier applied by applyGraphSignals (session demote; <1.0). */ + session_demote_factor?: number; + /** Post-rerank rank delta: original_index - new_index in the reranker's + * topNIn head. Positive means rank improved (moved closer to top). + * Undefined when no reranker fired. The raw reranker relevance score + * is separately stamped as `rerank_score` for back-compat. */ + reranker_delta?: number; +} + +/** + * v0.40.4 — adjacency aggregates for a single page within a + * subgraph induced by an input set. Returned by + * BrainEngine.getAdjacencyBoosts. + */ +export interface AdjacencyRow { + /** Distinct from_page_id count, restricted to the input set. */ + hits: number; + /** + * Distinct OTHER source_id count, restricted to the input set, + * EXCLUDING the target page's own source. A page in source A linked + * from 2 pages in source A reports cross_source_hits = 0. Linked + * from 1 in source B + 1 in source C reports 2. The exclusion of + * self-source matches the "cross-team corroboration" intent + * (D15=A in the v0.40.4 plan). + */ + cross_source_hits: number; } /** @@ -835,6 +898,18 @@ export interface SearchOpts { * sending them produces garbage scores. */ crossModal?: 'text' | 'image' | 'both' | 'auto'; + /** + * v0.40.4 — per-call override for the graph-signals stage. Threads + * through to PostFusionOpts.graphSignalsEnabled. When undefined, + * falls through to the active mode bundle default (conservative=false, + * balanced/tokenmax=true) via the resolveSearchMode chain. + * + * Primary consumer is eval gates: `graph-signals-eval.test.ts` runs + * each fixture question twice (off vs on) and needs explicit per-call + * control, not mode-bundle default. Without this field, both branches + * would resolve to the same mode default and the gate would be a no-op. + */ + graph_signals?: boolean; } /** diff --git a/test/audit/audit-writer.test.ts b/test/audit/audit-writer.test.ts new file mode 100644 index 000000000..fcc919bf9 --- /dev/null +++ b/test/audit/audit-writer.test.ts @@ -0,0 +1,362 @@ +/** + * v0.40.4.0 — shared audit-writer primitive contract. + * + * Pins the byte-for-byte contract that the 5 refactored audit modules + * (rerank-audit, shell-audit, supervisor-audit, audit-slug-fallback, + * phantom-audit) depend on. A regression here is a regression in + * every consumer simultaneously, which is precisely the point of + * unifying them — single test target. + * + * Hermetic via `withEnv` for `GBRAIN_AUDIT_DIR` override; tmpdir per + * test for isolation. No mock.module, no module-load env reads. + */ + +import { describe, it, expect, afterEach } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { withEnv } from '../helpers/with-env.ts'; +import { + createAuditWriter, + computeIsoWeekFilename, + resolveAuditDir, +} from '../../src/core/audit/audit-writer.ts'; + +interface TestEvent { + ts: string; + message: string; + count?: number; +} + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-audit-writer-test-')); +} + +const tmpDirs: string[] = []; +function makeDir(): string { + const d = tmpDir(); + tmpDirs.push(d); + return d; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop(); + if (d) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // best-effort + } + } + } +}); + +describe('computeIsoWeekFilename', () => { + it('formats -YYYY-Www.jsonl', () => { + const name = computeIsoWeekFilename('test-feature', new Date('2026-05-22T12:00:00Z')); + expect(name).toMatch(/^test-feature-\d{4}-W\d{2}\.jsonl$/); + }); + + it('handles year-boundary edge: 2027-01-01 → 2026-W53', () => { + // 2027-01-01 is a Friday; the ISO week starts on Monday 2026-12-28. + const name = computeIsoWeekFilename('phantoms', new Date('2027-01-01T12:00:00Z')); + expect(name).toBe('phantoms-2026-W53.jsonl'); + }); + + it('handles year-boundary edge: 2024-01-01 → 2024-W01', () => { + // 2024-01-01 is a Monday → ISO week 1 of 2024. + const name = computeIsoWeekFilename('rerank-failures', new Date('2024-01-01T12:00:00Z')); + expect(name).toBe('rerank-failures-2024-W01.jsonl'); + }); + + it('week numbers zero-pad to two digits', () => { + const name = computeIsoWeekFilename('shell-jobs', new Date('2026-01-05T12:00:00Z')); + expect(name).toBe('shell-jobs-2026-W02.jsonl'); + }); + + it('different prefixes produce distinct filenames for the same date', () => { + const d = new Date('2026-05-22T12:00:00Z'); + expect(computeIsoWeekFilename('a', d)).not.toBe(computeIsoWeekFilename('b', d)); + }); +}); + +describe('resolveAuditDir', () => { + it('honors GBRAIN_AUDIT_DIR override', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + expect(resolveAuditDir()).toBe(dir); + }); + }); + + it('falls back to gbrainPath("audit") when override is unset', async () => { + await withEnv({ GBRAIN_AUDIT_DIR: undefined }, async () => { + const resolved = resolveAuditDir(); + expect(resolved).toContain('audit'); + }); + }); + + it('treats whitespace-only override as unset', async () => { + await withEnv({ GBRAIN_AUDIT_DIR: ' ' }, async () => { + const resolved = resolveAuditDir(); + // Should fall back to the default path, not literally " " + expect(resolved.trim().length).toBeGreaterThan(3); + expect(resolved).not.toBe(' '); + }); + }); +}); + +describe('createAuditWriter — log()', () => { + it('stamps ts at call time when not provided', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'log-stamps-ts' }); + writer.log({ message: 'hello' }); + const file = path.join(dir, writer.computeFilename()); + const content = fs.readFileSync(file, 'utf8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBe(1); + const row = JSON.parse(lines[0]); + expect(row.message).toBe('hello'); + expect(typeof row.ts).toBe('string'); + expect(Date.parse(row.ts)).toBeGreaterThan(0); + }); + }); + + it('honors caller-supplied ts override', async () => { + const dir = makeDir(); + const fixedTs = '2026-05-22T14:00:00.000Z'; + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'ts-override' }); + writer.log({ ts: fixedTs, message: 'pinned' }); + const file = path.join(dir, writer.computeFilename()); + const content = fs.readFileSync(file, 'utf8'); + const row = JSON.parse(content.trim()); + expect(row.ts).toBe(fixedTs); + expect(row.message).toBe('pinned'); + }); + }); + + it('appends one JSONL line per log() call (no in-place overwrite)', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'append-mode' }); + writer.log({ message: 'first', count: 1 }); + writer.log({ message: 'second', count: 2 }); + writer.log({ message: 'third', count: 3 }); + const file = path.join(dir, writer.computeFilename()); + const content = fs.readFileSync(file, 'utf8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBe(3); + expect(JSON.parse(lines[0]).message).toBe('first'); + expect(JSON.parse(lines[2]).count).toBe(3); + }); + }); + + it('mkdirs the parent directory recursively', async () => { + const root = makeDir(); + const dir = path.join(root, 'nested', 'deeper', 'audit'); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + // dir does NOT exist yet + expect(fs.existsSync(dir)).toBe(false); + const writer = createAuditWriter({ featureName: 'mkdir-recursive' }); + writer.log({ message: 'creates dirs' }); + expect(fs.existsSync(dir)).toBe(true); + const file = path.join(dir, writer.computeFilename()); + expect(fs.existsSync(file)).toBe(true); + }); + }); + + it('best-effort: write failure stderr-warns but does not throw', async () => { + // Force a non-creatable path: use a file-as-dir trick. Create a regular + // file at `${root}/blocker`, then point GBRAIN_AUDIT_DIR at + // `${root}/blocker/sub` — mkdirSync(recursive:true) on a path whose + // parent is a regular file fails with ENOTDIR. The writer must + // swallow this error and write a stderr line. + const root = makeDir(); + const blocker = path.join(root, 'blocker'); + fs.writeFileSync(blocker, 'i am a file, not a dir'); + const badDir = path.join(blocker, 'sub'); + + const stderrWrites: string[] = []; + const origStderrWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: any, ...rest: any[]) => { + stderrWrites.push(String(chunk)); + return true; + }) as any; + + try { + await withEnv({ GBRAIN_AUDIT_DIR: badDir }, async () => { + const writer = createAuditWriter({ + featureName: 'fail-open', + errorLabel: 'test-label', + errorTrailer: '; trailing-phrase', + }); + // MUST NOT throw. + expect(() => writer.log({ message: 'will fail' })).not.toThrow(); + }); + } finally { + process.stderr.write = origStderrWrite; + } + + const errMsg = stderrWrites.join(''); + expect(errMsg).toContain('[test-label]'); + expect(errMsg).toContain('write failed'); + expect(errMsg).toContain('trailing-phrase'); + }); +}); + +describe('createAuditWriter — readRecent()', () => { + it('returns events from current week, filtered by ts cutoff', async () => { + const dir = makeDir(); + const now = new Date('2026-05-22T12:00:00Z'); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'read-current' }); + + // Write 3 events: 1 day ago (in window), 6 days ago (in window), + // 8 days ago (out of window). + const inWin1 = new Date(now.getTime() - 1 * 86400000).toISOString(); + const inWin2 = new Date(now.getTime() - 6 * 86400000).toISOString(); + const outOfWin = new Date(now.getTime() - 8 * 86400000).toISOString(); + + // All written to current-week file for simplicity (the readRecent + // window filter is what we're testing, not the cross-week walk). + writer.log({ ts: inWin1, message: 'in window 1' }); + writer.log({ ts: inWin2, message: 'in window 2' }); + writer.log({ ts: outOfWin, message: 'out of window' }); + + const recent = writer.readRecent(7, now); + expect(recent.length).toBe(2); + expect(recent.map(e => e.message).sort()).toEqual(['in window 1', 'in window 2']); + }); + }); + + it('walks current + previous ISO week (handles Monday-midnight straddle)', async () => { + const dir = makeDir(); + // Pick a Monday so the previous week is reachable through the + // (now - 7 days) computation. + const now = new Date('2026-05-25T12:00:00Z'); // Monday + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'read-cross-week' }); + + // Write an event 5 days ago by directly placing it in the + // previous-week file. (Simulates events from before the week roll.) + const previousTs = new Date(now.getTime() - 5 * 86400000).toISOString(); + const previousFile = path.join( + dir, + writer.computeFilename(new Date(now.getTime() - 7 * 86400000)), + ); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(previousFile, JSON.stringify({ ts: previousTs, message: 'previous' }) + '\n'); + + // Write a current-week event. + const currentTs = new Date(now.getTime() - 1 * 86400000).toISOString(); + writer.log({ ts: currentTs, message: 'current' }); + + const recent = writer.readRecent(7, now); + const messages = recent.map(e => e.message).sort(); + expect(messages).toEqual(['current', 'previous']); + }); + }); + + it('skips corrupt JSON lines silently', async () => { + const dir = makeDir(); + const now = new Date('2026-05-22T12:00:00Z'); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'corrupt-skip' }); + const goodTs = new Date(now.getTime() - 1 * 86400000).toISOString(); + + // Write good + corrupt + good directly to the file. + const file = path.join(dir, writer.computeFilename(now)); + fs.mkdirSync(dir, { recursive: true }); + const content = [ + JSON.stringify({ ts: goodTs, message: 'good-1' }), + '{not-valid-json', + JSON.stringify({ ts: goodTs, message: 'good-2' }), + '', + ].join('\n'); + fs.writeFileSync(file, content); + + const recent = writer.readRecent(7, now); + expect(recent.length).toBe(2); + expect(recent.map(e => e.message).sort()).toEqual(['good-1', 'good-2']); + }); + }); + + it('returns empty array when no audit files exist', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'missing-file' }); + const recent = writer.readRecent(7); + expect(recent).toEqual([]); + }); + }); + + it('skips events with non-finite ts', async () => { + const dir = makeDir(); + const now = new Date('2026-05-22T12:00:00Z'); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'non-finite-ts' }); + const file = path.join(dir, writer.computeFilename(now)); + fs.mkdirSync(dir, { recursive: true }); + const content = [ + JSON.stringify({ ts: 'not-a-date', message: 'bad-ts' }), + JSON.stringify({ ts: '', message: 'empty-ts' }), + JSON.stringify({ ts: new Date(now.getTime() - 1 * 86400000).toISOString(), message: 'good' }), + ].join('\n'); + fs.writeFileSync(file, content); + const recent = writer.readRecent(7, now); + expect(recent.length).toBe(1); + expect(recent[0].message).toBe('good'); + }); + }); +}); + +describe('createAuditWriter — round-trip', () => { + it('log then readRecent recovers every field', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'round-trip' }); + writer.log({ message: 'round-trip-test', count: 42 }); + const recent = writer.readRecent(7); + expect(recent.length).toBe(1); + expect(recent[0].message).toBe('round-trip-test'); + expect(recent[0].count).toBe(42); + expect(typeof recent[0].ts).toBe('string'); + }); + }); + + it('preserves arbitrary nested fields', async () => { + const dir = makeDir(); + interface NestedEvent { + ts: string; + nested: { a: number; b: string[]; c: { deep: boolean } }; + } + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'nested-fields' }); + writer.log({ nested: { a: 1, b: ['x', 'y'], c: { deep: true } } }); + const recent = writer.readRecent(7); + expect(recent[0].nested.a).toBe(1); + expect(recent[0].nested.b).toEqual(['x', 'y']); + expect(recent[0].nested.c.deep).toBe(true); + }); + }); +}); + +describe('createAuditWriter — filename behavior', () => { + it('computeFilename uses featureName as prefix', () => { + const writer = createAuditWriter({ featureName: 'my-feature' }); + const name = writer.computeFilename(new Date('2026-05-22T12:00:00Z')); + expect(name.startsWith('my-feature-')).toBe(true); + expect(name.endsWith('.jsonl')).toBe(true); + }); + + it('resolveDir matches the module-level resolveAuditDir', async () => { + const dir = makeDir(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const writer = createAuditWriter({ featureName: 'resolve-dir-check' }); + expect(writer.resolveDir()).toBe(dir); + expect(writer.resolveDir()).toBe(resolveAuditDir()); + }); + }); +}); diff --git a/test/cli-options.test.ts b/test/cli-options.test.ts index 016063b62..ab01c9851 100644 --- a/test/cli-options.test.ts +++ b/test/cli-options.test.ts @@ -65,9 +65,27 @@ describe('parseGlobalFlags', () => { test('all global flags combined', () => { const r = parseGlobalFlags(['--quiet', '--progress-json', '--progress-interval=250', 'sync']); - expect(r.cliOpts).toEqual({ quiet: true, progressJson: true, progressInterval: 250, timeoutMs: null }); + expect(r.cliOpts).toEqual({ quiet: true, progressJson: true, progressInterval: 250, timeoutMs: null, explain: false }); expect(r.rest).toEqual(['sync']); }); + + // v0.40.4 — --explain flag + test('--explain sets cliOpts.explain', () => { + const r = parseGlobalFlags(['--explain', 'search', 'test query']); + expect(r.cliOpts.explain).toBe(true); + expect(r.rest).toEqual(['search', 'test query']); + }); + + test('--explain absent → false default', () => { + const r = parseGlobalFlags(['search', 'test query']); + expect(r.cliOpts.explain).toBe(false); + }); + + test('--explain works in any argv position', () => { + const r = parseGlobalFlags(['search', '--explain', 'test query']); + expect(r.cliOpts.explain).toBe(true); + expect(r.rest).toEqual(['search', 'test query']); + }); }); describe('getCliOptions / setCliOptions singleton', () => { @@ -78,7 +96,7 @@ describe('getCliOptions / setCliOptions singleton', () => { test('setCliOptions applies + getCliOptions returns a copy', () => { _resetCliOptionsForTest(); - setCliOptions({ quiet: false, progressJson: true, progressInterval: 250, timeoutMs: null }); + setCliOptions({ quiet: false, progressJson: true, progressInterval: 250, timeoutMs: null, explain: false }); expect(getCliOptions().progressJson).toBe(true); expect(getCliOptions().progressInterval).toBe(250); }); @@ -138,12 +156,12 @@ describe('CLI integration: progress streams to the right channel', () => { describe('cliOptsToProgressOptions', () => { test('--quiet → quiet mode', () => { - const opts = cliOptsToProgressOptions({ quiet: true, progressJson: false, progressInterval: 1000, timeoutMs: null }); + const opts = cliOptsToProgressOptions({ quiet: true, progressJson: false, progressInterval: 1000, timeoutMs: null, explain: false }); expect(opts.mode).toBe('quiet'); }); test('--progress-json → json mode with interval', () => { - const opts = cliOptsToProgressOptions({ quiet: false, progressJson: true, progressInterval: 500, timeoutMs: null }); + const opts = cliOptsToProgressOptions({ quiet: false, progressJson: true, progressInterval: 500, timeoutMs: null, explain: false }); expect(opts.mode).toBe('json'); expect(opts.minIntervalMs).toBe(500); }); @@ -155,7 +173,7 @@ describe('cliOptsToProgressOptions', () => { }); test('quiet takes priority over progressJson', () => { - const opts = cliOptsToProgressOptions({ quiet: true, progressJson: true, progressInterval: 1000, timeoutMs: null }); + const opts = cliOptsToProgressOptions({ quiet: true, progressJson: true, progressInterval: 1000, timeoutMs: null, explain: false }); expect(opts.mode).toBe('quiet'); }); }); diff --git a/test/cross-modal-phase1.test.ts b/test/cross-modal-phase1.test.ts index ccb9cac7e..c5bd6a380 100644 --- a/test/cross-modal-phase1.test.ts +++ b/test/cross-modal-phase1.test.ts @@ -136,11 +136,11 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => { return resolveSearchMode({ mode: 'balanced' }); } - test('KNOBS_HASH_VERSION is 5 (master v=4 schema-pack; v0.40.3.0 D8 CR appends)', () => { + test('KNOBS_HASH_VERSION is 5 (v=4 graph_signals + schema-pack; v=5 contextual_retrieval; cross-modal still appended)', () => { // v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3 - // with 7 cross-modal knobs + column/provider context. v0.39 T21 bumps - // to v=4 for schema-pack fields. v0.40.3.0 D8 bumps to v=5 (sequenced - // behind salem's v=4 graph-signals). + // with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) + + // v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields. + // v0.40.3.0 D8 bumps to v=5 (sequenced behind salem's v=4 graph-signals). expect(KNOBS_HASH_VERSION).toBe(5); }); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 6ff218def..088d06f46 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; describe('doctor command', () => { test('doctor module exports runDoctor', async () => { @@ -681,3 +681,108 @@ describe('stub_guard_24h check (v0.34.5)', () => { expect(source).toMatch(/events\.length === 0|Zero hits is the goal/); }); }); + +describe('v0.40.4 — graph_signals_coverage check', () => { + const { PGLiteEngine } = require('../src/core/pglite-engine.ts'); + const { checkGraphSignalsCoverage } = require('../src/commands/doctor.ts'); + + let engine: any; + + beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ engine: 'pglite' }); + await engine.initSchema(); + }); + + afterAll(async () => { + if (engine) await engine.disconnect(); + }); + + beforeEach(async () => { + // Wipe pages + links + config between tests for isolation. + await engine.executeRaw(`DELETE FROM links`); + await engine.executeRaw(`DELETE FROM pages`); + await engine.executeRaw(`DELETE FROM config WHERE key IN ('search.graph_signals', 'search.mode')`); + }); + + test('graph_signals disabled (conservative mode) → silent ok regardless of coverage', async () => { + await engine.setConfig('search.mode', 'conservative'); + // Seed pages without links (would normally warn) but conservative + // disables graph_signals so the check stays ok. + for (let i = 0; i < 5; i++) { + await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' }); + } + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('ok'); + expect(check.message).toContain('disabled'); + }); + + test('graph_signals enabled (balanced default) + zero links → warn at <10%', async () => { + // No config set → balanced default (graph_signals=true). + for (let i = 0; i < 10; i++) { + await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' }); + } + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('warn'); + expect(check.message).toContain('0.0%'); + expect(check.message).toContain('gbrain extract all'); + }); + + test('graph_signals enabled + >=30% coverage → ok with metric', async () => { + for (let i = 0; i < 10; i++) { + await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' }); + } + // Add inbound links to 4/10 pages = 40%. + await engine.addLinksBatch([ + { from_slug: 'page/0', to_slug: 'page/1', link_type: 'mentions' }, + { from_slug: 'page/0', to_slug: 'page/2', link_type: 'mentions' }, + { from_slug: 'page/0', to_slug: 'page/3', link_type: 'mentions' }, + { from_slug: 'page/0', to_slug: 'page/4', link_type: 'mentions' }, + ]); + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('ok'); + expect(check.message).toContain('40.0%'); + expect(check.message).toContain('fire on most queries'); + }); + + test('graph_signals enabled + 10-29% coverage → ok with occasional-fire note', async () => { + for (let i = 0; i < 10; i++) { + await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' }); + } + // Add inbound to 2/10 = 20%. + await engine.addLinksBatch([ + { from_slug: 'page/0', to_slug: 'page/1', link_type: 'mentions' }, + { from_slug: 'page/0', to_slug: 'page/2', link_type: 'mentions' }, + ]); + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('ok'); + expect(check.message).toContain('20.0%'); + expect(check.message).toContain('fire occasionally'); + }); + + test('explicit search.graph_signals=false overrides mode default', async () => { + // Balanced normally enables; explicit override turns it off. + await engine.setConfig('search.graph_signals', 'false'); + // No links → would normally warn, but override means we don't check. + for (let i = 0; i < 5; i++) { + await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' }); + } + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('ok'); + expect(check.message).toContain('disabled'); + }); + + test('empty brain → ok with explanation', async () => { + const check = await checkGraphSignalsCoverage(engine); + expect(check.status).toBe('ok'); + expect(check.message).toContain('Empty brain'); + }); + + test('check is wired into runDoctor (source-grep)', async () => { + const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text(); + // Local engine path. + expect(source).toMatch(/await checkGraphSignalsCoverage\(engine\)/); + // Remote/JSON path heartbeat. + expect(source).toContain("progress.heartbeat('graph_signals_coverage')"); + }); +}); diff --git a/test/e2e/graph-signals-engine.test.ts b/test/e2e/graph-signals-engine.test.ts new file mode 100644 index 000000000..2c6d98ec2 --- /dev/null +++ b/test/e2e/graph-signals-engine.test.ts @@ -0,0 +1,177 @@ +/** + * v0.40.4.0 E2E — getAdjacencyBoosts engine method. + * + * Hermetic PGLite (no DATABASE_URL needed). Pins: + * + * - Empty input → empty Map, no SQL fired + * - Seeded same-source subgraph: hits computed, cross_source_hits = 0 + * - Multi-source subgraph: cross_source_hits EXCLUDES target's own source + * - NULL source_id treated as 'default' via COALESCE (no crash) + * - HAVING >= 1 matches JSDoc contract: rows with hits=1 returned + * (callers apply their own threshold) + * + * Postgres parity is asserted structurally (same SQL shape) and via the + * shared logic test — running this against real PG is a TODO once the + * E2E lifecycle (test/e2e/auth-takes-holders-pglite.test.ts pattern) + * gets a Postgres-mirror sibling. Source-grep guard in T3 keeps the + * two engine impls in lockstep. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; + +let engine: PGLiteEngine; + +// Track page IDs by slug for deterministic adjacency assertions. +const ids: Record = {}; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ engine: 'pglite' } as never); + await engine.initSchema(); + + // Ensure sources 'a' and 'b' exist before inserting pages that + // reference them via FK. + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path) VALUES + ('a', 'source a', '/tmp/a'), + ('b', 'source b', '/tmp/b') + ON CONFLICT (id) DO NOTHING` + ); + + // Seed pages across two sources. + for (const [slug, source] of [ + ['people/alice', 'a'], + ['people/bob', 'a'], + ['companies/acme', 'a'], + ['companies/widget', 'b'], + ] as const) { + const page = await engine.putPage( + slug, + { + type: 'note', + title: slug, + compiled_truth: `body of ${slug}`, + }, + { sourceId: source }, + ); + ids[slug] = page.id; + } + + // Edges: + // alice → bob, alice → acme, bob → acme (same-source hub: acme has hits=2) + // widget → acme (cross-source link: acme's cross_source_hits gains 'b') + await engine.addLinksBatch([ + { from_slug: 'people/alice', to_slug: 'people/bob', link_type: 'mentions', from_source_id: 'a', to_source_id: 'a' }, + { from_slug: 'people/alice', to_slug: 'companies/acme', link_type: 'works_at', from_source_id: 'a', to_source_id: 'a' }, + { from_slug: 'people/bob', to_slug: 'companies/acme', link_type: 'works_at', from_source_id: 'a', to_source_id: 'a' }, + { from_slug: 'companies/widget', to_slug: 'companies/acme', link_type: 'mentions', from_source_id: 'b', to_source_id: 'a' }, + ]); +}); + +afterAll(async () => { + if (engine) await engine.disconnect(); +}); + +describe('getAdjacencyBoosts — contract', () => { + test('empty input → empty Map (no SQL)', async () => { + const out = await engine.getAdjacencyBoosts([]); + expect(out.size).toBe(0); + }); + + test('singleton input → empty Map (no in-set edges possible to/from itself)', async () => { + // alice is in the set alone. Even though alice has outbound links, neither + // endpoint of any link is BOTH in the set, so HAVING >= 1 yields no rows. + const out = await engine.getAdjacencyBoosts([ids['people/alice']]); + expect(out.size).toBe(0); + }); +}); + +describe('getAdjacencyBoosts — same-source hub', () => { + test('acme has hits=2 within {alice, bob, acme}', async () => { + const pageIds = [ + ids['people/alice'], + ids['people/bob'], + ids['companies/acme'], + ]; + const out = await engine.getAdjacencyBoosts(pageIds); + const acme = out.get(ids['companies/acme']); + expect(acme).toBeDefined(); + expect(acme!.hits).toBe(2); + // All three are in source 'a' — cross_source_hits excludes target's own + // source, so acme (in 'a') gets cross_source_hits = 0. + expect(acme!.cross_source_hits).toBe(0); + }); + + test('bob has hits=1 within {alice, bob, acme} (alice → bob)', async () => { + const pageIds = [ + ids['people/alice'], + ids['people/bob'], + ids['companies/acme'], + ]; + const out = await engine.getAdjacencyBoosts(pageIds); + const bob = out.get(ids['people/bob']); + expect(bob).toBeDefined(); + expect(bob!.hits).toBe(1); + expect(bob!.cross_source_hits).toBe(0); + }); +}); + +describe('getAdjacencyBoosts — cross-source', () => { + test('acme gets cross_source_hits=1 when widget (source b) is in set', async () => { + const pageIds = [ + ids['people/alice'], + ids['people/bob'], + ids['companies/acme'], + ids['companies/widget'], + ]; + const out = await engine.getAdjacencyBoosts(pageIds); + const acme = out.get(ids['companies/acme']); + expect(acme).toBeDefined(); + expect(acme!.hits).toBe(3); // alice, bob, widget all link to acme + expect(acme!.cross_source_hits).toBe(1); // only widget is in source 'b' + }); + + test('exclusion of target own source: widget linked only by source-a pages → cross_source_hits should fire (source a != source b)', async () => { + // widget is in source 'b'. The links we seeded don't go INTO widget. Let's + // add edges from alice + bob (source a) → widget and verify widget gets + // cross_source_hits = 1 (source 'a', not its own 'b'). + await engine.addLinksBatch([ + { from_slug: 'people/alice', to_slug: 'companies/widget', link_type: 'mentions', from_source_id: 'a', to_source_id: 'b' }, + { from_slug: 'people/bob', to_slug: 'companies/widget', link_type: 'mentions', from_source_id: 'a', to_source_id: 'b' }, + ]); + const pageIds = [ + ids['people/alice'], + ids['people/bob'], + ids['companies/widget'], + ]; + const out = await engine.getAdjacencyBoosts(pageIds); + const widget = out.get(ids['companies/widget']); + expect(widget).toBeDefined(); + expect(widget!.hits).toBe(2); + // alice + bob are in source 'a', widget is in source 'b'. Both inbound + // links are from a different source → cross_source_hits counts 1 distinct + // OTHER source ('a'). + expect(widget!.cross_source_hits).toBe(1); + }); +}); + +// NULL source_id COALESCE branch: cannot be exercised in PGLite because +// pages.source_id is NOT NULL. COALESCE stays as defense-in-depth for +// schema variants; structural coverage is the source-grep guard against +// regressions in the SQL shape. + +describe('getAdjacencyBoosts — JSDoc contract', () => { + test('HAVING >= 1 returns rows with hits=1 (callers apply >=2 threshold)', async () => { + const pageIds = [ + ids['people/alice'], + ids['people/bob'], + ]; + // alice → bob is the only in-set edge. bob has hits=1, should appear in + // the map (per the JSDoc contract); caller decides whether to boost. + const out = await engine.getAdjacencyBoosts(pageIds); + const bob = out.get(ids['people/bob']); + expect(bob).toBeDefined(); + expect(bob!.hits).toBe(1); + }); +}); diff --git a/test/e2e/graph-signals-eval.test.ts b/test/e2e/graph-signals-eval.test.ts new file mode 100644 index 000000000..c070de2c3 --- /dev/null +++ b/test/e2e/graph-signals-eval.test.ts @@ -0,0 +1,302 @@ +/** + * v0.40.4.0 — Eval gates for graph_signals default-on (T10, D13=A). + * + * Three load-bearing gates against longmemeval-mini fixture: + * + * Gate 1 (QUALITY): paired-bootstrap p>=0.05 in the WRONG direction + * (signals-on significantly worse than off) → fail. Plus a hard + * 5pt absolute floor on recall@5 drop as the sanity catch. + * + * Gate 2 (CHANGE-MAGNITUDE): Jaccard@5 >= 0.5 + top-1 stability + * >= 0.7. These are NOT quality metrics (codex outside-voice #18 + + * #4 caught the framing); their purpose is regression-magnitude + * detection — if results overlap less than 50%, the change is too + * large and needs human review before shipping default-on. + * + * Gate 3 (HARD ABSOLUTE FLOOR): recall@5 must NOT drop by more than + * 5 absolute points (catastrophic regression catch). + * + * Hermetic via in-memory PGLite seeded from the fixture. No API keys. + * Skips gracefully when the fixture is missing. + * + * Paired bootstrap implementation: 10,000 resamples (D13=A spec). + * Per-question observation is binary (recall@5 hit/miss); paired + * pairing is on the same question id across on/off branches. Test + * statistic: mean(on) - mean(off). p-value is the two-tailed + * proportion of resamples where the resampled delta is on the + * opposite side of the observed delta. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + createBenchmarkBrain, + resetTables, +} from '../../src/eval/longmemeval/harness.ts'; +import { haystackToPages, type LongMemEvalQuestion } from '../../src/eval/longmemeval/adapter.ts'; +import { importFromContent } from '../../src/core/import-file.ts'; +import { hybridSearch } from '../../src/core/search/hybrid.ts'; +import type { PGLiteEngine } from '../../src/core/pglite-engine.ts'; + +const FIXTURE_PATH = join(import.meta.dir, '..', 'fixtures', 'longmemeval-mini.jsonl'); +const TOP_K = 5; + +let engine: PGLiteEngine; + +beforeAll(async () => { + if (!existsSync(FIXTURE_PATH)) return; // skipped at describe level + engine = await createBenchmarkBrain(); +}); + +afterAll(async () => { + if (engine) await engine.disconnect(); +}); + +// --------------------------------------------------------------------------- +// Run one question with graph_signals on or off; return recall@k hit + top-k +// session IDs (for Jaccard + top-1 stability metrics). +// --------------------------------------------------------------------------- + +interface QuestionResult { + question_id: string; + hit: boolean; + session_ids: string[]; // ordered top-K +} + +async function runQuestion( + q: LongMemEvalQuestion, + graphSignalsOn: boolean, +): Promise { + await resetTables(engine); + const pages = haystackToPages(q); + for (const p of pages) { + await importFromContent(engine, p.slug, p.content, { noEmbed: true }); + } + // Keyword-only path is most stable on tiny fixtures with no embedder. + // Use hybridSearch which honors per-call graph_signals override + // (v0.40.4 — typed field on SearchOpts, threaded into perCall in + // hybrid.ts via resolveSearchMode chain). + const results = await hybridSearch(engine, q.question, { + limit: TOP_K, + expansion: false, + graph_signals: graphSignalsOn, + }); + const sessionIds = uniqSessionIds(results); + const gt = new Set(q.answer_session_ids ?? []); + const hit = sessionIds.some(s => gt.has(s)); + return { question_id: q.question_id, hit, session_ids: sessionIds }; +} + +function uniqSessionIds(results: any[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const r of results) { + // slug is `chat/` per adapter.ts. + const sid = (r.slug ?? '').replace(/^chat\//, ''); + if (sid && !seen.has(sid)) { + seen.add(sid); + out.push(sid); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Paired bootstrap (D13=A). Bernoulli observations per question; pairing on +// question_id between on/off branches. Returns two-tailed p-value for the +// null hypothesis "no difference." +// --------------------------------------------------------------------------- + +export function pairedBootstrapPValue( + pairs: Array<{ on: number; off: number }>, // observed Bernoulli (0/1) per question + resamples = 10_000, + rng: () => number = Math.random, +): { pValue: number; observedDelta: number; ci95: [number, number] } { + const n = pairs.length; + if (n === 0) return { pValue: 1.0, observedDelta: 0, ci95: [0, 0] }; + + const observedDelta = pairs.reduce((s, p) => s + (p.on - p.off), 0) / n; + + // Center the distribution under the null (subtract observed mean to + // simulate "no real effect"). This is the conventional pairing-aware + // bootstrap-shift approach for binary outcomes. + const centered = pairs.map(p => (p.on - p.off) - observedDelta); + + const resampledDeltas: number[] = new Array(resamples); + for (let i = 0; i < resamples; i++) { + let sum = 0; + for (let j = 0; j < n; j++) { + const idx = Math.floor(rng() * n); + sum += centered[idx]; + } + resampledDeltas[i] = sum / n; + } + + // Two-tailed p-value: proportion of resampled |delta| >= observed |delta|. + const abs = Math.abs(observedDelta); + let extreme = 0; + for (const d of resampledDeltas) { + if (Math.abs(d) >= abs) extreme++; + } + const pValue = extreme / resamples; + + // 95% CI on the bootstrap (uncentered) for the observed delta. + const rawDeltas = pairs.map(p => p.on - p.off); + const sorted = [...rawDeltas].sort((a, b) => a - b); + const lowIdx = Math.max(0, Math.floor(0.025 * sorted.length)); + const highIdx = Math.min(sorted.length - 1, Math.ceil(0.975 * sorted.length)); + return { + pValue, + observedDelta, + ci95: [sorted[lowIdx] ?? 0, sorted[highIdx] ?? 0], + }; +} + +function jaccard(a: string[], b: string[]): number { + const sa = new Set(a); + const sb = new Set(b); + let intersect = 0; + for (const x of sa) if (sb.has(x)) intersect++; + const union = sa.size + sb.size - intersect; + return union === 0 ? 1.0 : intersect / union; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +const fixtureExists = existsSync(FIXTURE_PATH); +const describeFn = fixtureExists ? describe : describe.skip; + +describeFn('v0.40.4 — graph_signals eval gates (longmemeval-mini A/B)', () => { + let questions: LongMemEvalQuestion[]; + let onResults: QuestionResult[]; + let offResults: QuestionResult[]; + + beforeAll(async () => { + if (!fixtureExists) return; + const raw = readFileSync(FIXTURE_PATH, 'utf8'); + questions = raw.trim().split('\n').map(l => JSON.parse(l)); + + // Run each question twice: off, then on. resetTables between runs is + // handled inside runQuestion. + offResults = []; + onResults = []; + for (const q of questions) { + offResults.push(await runQuestion(q, false)); + onResults.push(await runQuestion(q, true)); + } + }); + + test('Gate 0: fixture loaded with >=3 questions (sanity)', () => { + expect(questions.length).toBeGreaterThanOrEqual(3); + }); + + test('Gate 1 (QUALITY): no statistically significant regression in wrong direction', () => { + // Build paired observations. recall@5 hit = 1 if any retrieved session + // matches ground truth. + const pairs = onResults.map((r, i) => ({ + on: r.hit ? 1 : 0, + off: offResults[i].hit ? 1 : 0, + })); + + // Deterministic RNG for stable test runs. Linear-congruential. + let seed = 0xDEADBEEF; + const rng = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0xFFFFFFFF; + }; + + const result = pairedBootstrapPValue(pairs, 10_000, rng); + // If signals-on is significantly WORSE (delta < 0) AND p < 0.05, fail. + // If p >= 0.05 either direction OR delta >= 0, pass. + if (result.pValue < 0.05 && result.observedDelta < 0) { + throw new Error( + `Gate 1 FAILED: graph_signals on is significantly worse than off ` + + `(delta=${result.observedDelta.toFixed(3)}, p=${result.pValue.toFixed(3)}, ` + + `n=${pairs.length}). Recall@5 dropped meaningfully.`, + ); + } + // Log the observed delta for posterity (informational, not assertion). + if (process.env.GBRAIN_EVAL_VERBOSE) { + console.log(`Gate 1: observedDelta=${result.observedDelta.toFixed(3)}, p=${result.pValue.toFixed(3)}`); + } + expect(true).toBe(true); // gate passed + }); + + test('Gate 2a (CHANGE-MAGNITUDE): mean Jaccard@5 >= 0.5', () => { + const jaccards = onResults.map((r, i) => jaccard(r.session_ids, offResults[i].session_ids)); + const mean = jaccards.reduce((s, x) => s + x, 0) / jaccards.length; + expect(mean).toBeGreaterThanOrEqual(0.5); + }); + + test('Gate 2b (CHANGE-MAGNITUDE): top-1 stability >= 0.7', () => { + let stable = 0; + for (let i = 0; i < onResults.length; i++) { + const onTop = onResults[i].session_ids[0]; + const offTop = offResults[i].session_ids[0]; + if (onTop && offTop && onTop === offTop) stable++; + } + const rate = stable / onResults.length; + expect(rate).toBeGreaterThanOrEqual(0.7); + }); + + test('Gate 3 (HARD ABSOLUTE FLOOR): recall@5 drop <= 5pt', () => { + const onRecall = onResults.filter(r => r.hit).length / onResults.length; + const offRecall = offResults.filter(r => r.hit).length / offResults.length; + const dropPts = (offRecall - onRecall) * 100; + expect(dropPts).toBeLessThanOrEqual(5); + }); +}); + +// Always-on tests for the pure bootstrap function — these run even +// without the fixture so the helper has coverage. +describe('pairedBootstrapPValue — pure-function tests', () => { + test('empty input → p=1.0 (no information)', () => { + const r = pairedBootstrapPValue([], 100); + expect(r.pValue).toBe(1.0); + expect(r.observedDelta).toBe(0); + }); + + test('all pairs equal → delta=0, p large', () => { + const pairs = Array(50).fill(0).map(() => ({ on: 1, off: 1 })); + const r = pairedBootstrapPValue(pairs, 1000); + expect(r.observedDelta).toBe(0); + expect(r.pValue).toBeGreaterThan(0.5); // null is true; p should be large + }); + + test('strong positive effect (on always wins) → small p, positive delta', () => { + const pairs = Array(30).fill(0).map(() => ({ on: 1, off: 0 })); + let seed = 42; + const rng = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0xFFFFFFFF; + }; + const r = pairedBootstrapPValue(pairs, 5000, rng); + expect(r.observedDelta).toBe(1.0); + expect(r.pValue).toBeLessThan(0.01); + }); + + test('strong negative effect (off always wins) → small p, negative delta', () => { + const pairs = Array(30).fill(0).map(() => ({ on: 0, off: 1 })); + let seed = 42; + const rng = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0xFFFFFFFF; + }; + const r = pairedBootstrapPValue(pairs, 5000, rng); + expect(r.observedDelta).toBe(-1.0); + expect(r.pValue).toBeLessThan(0.01); + }); + + test('determinism: same seed → same p-value', () => { + const pairs = Array(20).fill(0).map((_, i) => ({ on: i % 3 === 0 ? 1 : 0, off: i % 4 === 0 ? 1 : 0 })); + let s1 = 12345; + const r1 = pairedBootstrapPValue(pairs, 1000, () => { s1 = (s1 * 1664525 + 1013904223) >>> 0; return s1 / 0xFFFFFFFF; }); + let s2 = 12345; + const r2 = pairedBootstrapPValue(pairs, 1000, () => { s2 = (s2 * 1664525 + 1013904223) >>> 0; return s2 / 0xFFFFFFFF; }); + expect(r1.pValue).toBe(r2.pValue); + expect(r1.observedDelta).toBe(r2.observedDelta); + }); +}); diff --git a/test/search-mode.test.ts b/test/search-mode.test.ts index 1205ec1a8..77f13ff2a 100644 --- a/test/search-mode.test.ts +++ b/test/search-mode.test.ts @@ -70,6 +70,7 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => { reranker_timeout_ms: 5000, floor_ratio: undefined, ...CROSS_MODAL_DEFAULTS, + graph_signals: false, ...CR_DISABLED_DEFAULT, contextual_retrieval: 'none', }); @@ -93,6 +94,7 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => { reranker_timeout_ms: 5000, floor_ratio: undefined, ...CROSS_MODAL_DEFAULTS, + graph_signals: true, ...CR_DISABLED_DEFAULT, contextual_retrieval: 'title', }); @@ -114,6 +116,7 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => { reranker_timeout_ms: 5000, floor_ratio: undefined, ...CROSS_MODAL_DEFAULTS, + graph_signals: true, ...CR_DISABLED_DEFAULT, contextual_retrieval: 'per_chunk_synopsis', }); @@ -302,15 +305,15 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => { // all appended per CDX2-F13 append-only convention so a text-mode cache // hit can never silently serve to an image-mode caller, and a query // against `embedding_voyage` never shares a cache row with `embedding`. - // v0.40.3.0: bumped 3→4 to add contextual_retrieval (CRMode) and - // contextual_retrieval_disabled (kill switch) to the hash inputs. - // A query against a brain on tokenmax (per-chunk synopsis) must not - // be served from a cache row written when the brain was on balanced - // (title-only) — different embedding spaces. - // v0.40.3.0 (D8): bumped 4→5 to sequence behind salem's pending v=4 - // graph-signals work. Master shipped v=4 via schema-pack hash fields - // (pack name + pack version) so our contextual-retrieval additions - // land at v=5. + // v0.40.4 (salem) + v0.39 T21 (master): bumped 3→4 to fold graph_signals + // (so a graph-on cache write cannot be served to a graph-off lookup) AND + // schema-pack hash fields (pack name + pack version, so cross-pack + // contamination is structurally impossible). + // v0.40.3.0 (D8): bumped 4→5 to add contextual_retrieval (CRMode) and + // contextual_retrieval_disabled (kill switch). A query against a brain + // on tokenmax (per-chunk synopsis) must not be served from a cache row + // written when the brain was on balanced (title-only) — different + // embedding spaces. Sequenced behind salem's v=4 graph-signals work. expect(KNOBS_HASH_VERSION).toBe(5); }); @@ -417,3 +420,60 @@ describe('Type-only smoke test (compiler sees SearchMode union)', () => { expect(valid.length).toBe(3); }); }); + +describe('v0.40.4 — graph_signals knob', () => { + test('default per mode: conservative=false, balanced=true, tokenmax=true', () => { + expect(MODE_BUNDLES.conservative.graph_signals).toBe(false); + expect(MODE_BUNDLES.balanced.graph_signals).toBe(true); + expect(MODE_BUNDLES.tokenmax.graph_signals).toBe(true); + }); + + test('config key search.graph_signals overrides bundle (true → false)', () => { + const ov = loadOverridesFromConfig({ 'search.graph_signals': 'false' }); + expect(ov.graph_signals).toBe(false); + const resolved = resolveSearchMode({ mode: 'balanced', overrides: ov }); + expect(resolved.graph_signals).toBe(false); + }); + + test('config key search.graph_signals overrides bundle (false → true)', () => { + const ov = loadOverridesFromConfig({ 'search.graph_signals': '1' }); + expect(ov.graph_signals).toBe(true); + const resolved = resolveSearchMode({ mode: 'conservative', overrides: ov }); + expect(resolved.graph_signals).toBe(true); + }); + + test('per-call overrides config + mode bundle', () => { + const resolved = resolveSearchMode({ + mode: 'balanced', + overrides: { graph_signals: false }, + perCall: { graph_signals: true }, + }); + expect(resolved.graph_signals).toBe(true); + }); + + test('knobsHash distinct for graph_signals=true vs =false', () => { + const on = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { graph_signals: true } })); + const off = knobsHash(resolveSearchMode({ mode: 'balanced', perCall: { graph_signals: false } })); + expect(on).not.toBe(off); + }); + + test('SEARCH_MODE_CONFIG_KEYS includes search.graph_signals', () => { + expect(SEARCH_MODE_CONFIG_KEYS).toContain('search.graph_signals'); + }); + + test('attributeKnob reports source correctly for graph_signals', () => { + const input = { mode: 'balanced', perCall: { graph_signals: false } }; + const resolved = resolveSearchMode(input); + const attr = attributeKnob('graph_signals', input, resolved); + expect(attr.source).toBe('per-call'); + expect(attr.value).toBe(false); + }); + + test('attributeKnob mode source when no override', () => { + const input = { mode: 'tokenmax' }; + const resolved = resolveSearchMode(input); + const attr = attributeKnob('graph_signals', input, resolved); + expect(attr.source).toBe('mode'); + expect(attr.value).toBe(true); + }); +}); diff --git a/test/search/attribution-stamping.test.ts b/test/search/attribution-stamping.test.ts new file mode 100644 index 000000000..b28dc82e5 --- /dev/null +++ b/test/search/attribution-stamping.test.ts @@ -0,0 +1,213 @@ +/** + * v0.40.4.0 — per-stage attribution stamping (T6, D12=A). + * + * Every boost stage that mutates SearchResult.score also stamps a field + * recording WHAT it multiplied. The `--explain` formatter (T7) reads + * these fields to attribute final score to its components. + * + * Pinned: + * - applyBacklinkBoost stamps backlink_boost + * - applySalienceBoost stamps salience_boost + * - applyRecencyBoost stamps recency_boost + * - applyExactMatchBoost stamps exact_match_boost + * - runPostFusionStages stamps base_score ONCE at entry (idempotent) + * - applyReranker stamps reranker_delta (rank delta, positive = improved) + * - applyGraphSignals stamps graph_adjacency_boost, + * graph_cross_source_boost, session_demote_factor (covered in + * graph-signals.test.ts) + */ + +import { describe, test, expect } from 'bun:test'; +import type { SearchResult } from '../../src/core/types.ts'; +import { + applyBacklinkBoost, + applySalienceBoost, + applyRecencyBoost, + runPostFusionStages, +} from '../../src/core/search/hybrid.ts'; +import { applyExactMatchBoost } from '../../src/core/search/intent-weights.ts'; +import { applyReranker } from '../../src/core/search/rerank.ts'; + +function r(slug: string, score: number, page_id = 1): SearchResult { + return { + slug, + page_id, + title: slug, + type: 'note', + chunk_text: `body of ${slug}`, + chunk_source: 'compiled_truth', + chunk_id: page_id * 1000, + chunk_index: 0, + score, + stale: false, + source_id: 'default', + }; +} + +describe('applyBacklinkBoost — attribution', () => { + test('count > 0 → backlink_boost stamped with the actual factor', () => { + const results = [r('a/b', 1.0, 1)]; + const counts = new Map([['a/b', 10]]); + applyBacklinkBoost(results, counts); + expect(results[0].backlink_boost).toBeGreaterThan(1.0); + expect(results[0].backlink_boost).toBeLessThan(2.0); + // Score should be multiplied by the same factor. + expect(results[0].score).toBeCloseTo(results[0].backlink_boost!, 5); + }); + + test('count = 0 → no stamp', () => { + const results = [r('a/b', 1.0, 1)]; + applyBacklinkBoost(results, new Map([['a/b', 0]])); + expect(results[0].backlink_boost).toBeUndefined(); + expect(results[0].score).toBe(1.0); + }); + + test('below floor → no stamp', () => { + const results = [r('weak', 0.1, 1)]; + applyBacklinkBoost(results, new Map([['weak', 10]]), 0.5); + expect(results[0].backlink_boost).toBeUndefined(); + expect(results[0].score).toBe(0.1); + }); +}); + +describe('applySalienceBoost — attribution', () => { + test('score > 0 → salience_boost stamped', () => { + const results = [r('a/b', 1.0, 1)]; + const scores = new Map([['default::a/b', 5]]); + applySalienceBoost(results, scores, 'on'); + expect(results[0].salience_boost).toBeGreaterThan(1.0); + expect(results[0].score).toBeCloseTo(results[0].salience_boost!, 5); + }); + + test('score = 0 → no stamp', () => { + const results = [r('a/b', 1.0, 1)]; + applySalienceBoost(results, new Map(), 'on'); + expect(results[0].salience_boost).toBeUndefined(); + expect(results[0].score).toBe(1.0); + }); +}); + +describe('applyRecencyBoost — attribution', () => { + test('date present + non-evergreen prefix → recency_boost stamped', () => { + const results = [r('media/notes/today', 1.0, 1)]; + const dates = new Map([['default::media/notes/today', new Date()]]); + const decayMap = { 'media/': { halflifeDays: 7, coefficient: 0.5 } }; + const fallback = { halflifeDays: 30, coefficient: 0.2 }; + applyRecencyBoost(results, dates, 'on', decayMap, fallback); + expect(results[0].recency_boost).toBeGreaterThan(1.0); + expect(results[0].score).toBeCloseTo(results[0].recency_boost!, 5); + }); + + test('evergreen prefix (halflife=0) → no stamp', () => { + const results = [r('docs/forever', 1.0, 1)]; + const dates = new Map([['default::docs/forever', new Date()]]); + const decayMap = { 'docs/': { halflifeDays: 0, coefficient: 0 } }; + const fallback = { halflifeDays: 30, coefficient: 0 }; + applyRecencyBoost(results, dates, 'on', decayMap, fallback); + expect(results[0].recency_boost).toBeUndefined(); + expect(results[0].score).toBe(1.0); + }); +}); + +describe('applyExactMatchBoost — attribution', () => { + test('slug exact-match → exact_match_boost stamped', () => { + const results = [r('people/garry-tan', 1.0, 1)]; + applyExactMatchBoost(results, 'garry-tan', { exactMatchBoost: 1.5 } as any); + expect(results[0].exact_match_boost).toBe(1.5); + expect(results[0].score).toBe(1.5); + }); + + test('no match → no stamp', () => { + const results = [r('people/alice', 1.0, 1)]; + applyExactMatchBoost(results, 'bob', { exactMatchBoost: 1.5 } as any); + expect(results[0].exact_match_boost).toBeUndefined(); + expect(results[0].score).toBe(1.0); + }); + + test('boost = 1.0 (no-op intent) → no stamp', () => { + const results = [r('people/alice', 1.0, 1)]; + applyExactMatchBoost(results, 'alice', { exactMatchBoost: 1.0 } as any); + expect(results[0].exact_match_boost).toBeUndefined(); + }); +}); + +describe('runPostFusionStages — base_score stamp', () => { + test('stamps base_score on every result at entry', async () => { + const results = [r('a/b', 5.0, 1), r('c/d', 3.0, 2)]; + await runPostFusionStages({} as any, results, { + applyBacklinks: false, + salience: 'off', + recency: 'off', + }); + expect(results[0].base_score).toBe(5.0); + expect(results[1].base_score).toBe(3.0); + }); + + test('idempotent: preserves base_score if caller pre-stamped', async () => { + const results = [r('a/b', 5.0, 1)]; + results[0].base_score = 99; // caller-stamped + await runPostFusionStages({} as any, results, { + applyBacklinks: false, + salience: 'off', + recency: 'off', + }); + expect(results[0].base_score).toBe(99); + }); + + test('base_score captures pre-boost score even when boosts run', async () => { + const results = [r('a/b', 10.0, 1)]; + const fakeEngine = { + getBacklinkCounts: async () => new Map([['a/b', 5]]), + } as any; + await runPostFusionStages(fakeEngine, results, { + applyBacklinks: true, + salience: 'off', + recency: 'off', + }); + expect(results[0].base_score).toBe(10.0); + expect(results[0].score).toBeGreaterThan(10.0); // boosted + expect(results[0].backlink_boost).toBeGreaterThan(1.0); + }); + + test('empty results → no-op (no stamping)', async () => { + await runPostFusionStages({} as any, [], { + applyBacklinks: false, salience: 'off', recency: 'off', + }); + // No throw == pass. + }); +}); + +describe('applyReranker — attribution', () => { + test('rank improved → reranker_delta positive', async () => { + const results = [ + r('a', 10, 1), + r('b', 9, 2), + r('c', 8, 3), + ]; + // Reranker says: c is the best (index 2 → position 0), then a (0 → 1), then b (1 → 2). + const fakeRerank = async () => [ + { index: 2, relevanceScore: 0.95 }, + { index: 0, relevanceScore: 0.85 }, + { index: 1, relevanceScore: 0.75 }, + ]; + const reordered = await applyReranker('query', results, { + enabled: true, + topNIn: 30, + topNOut: null, + model: 'test', + timeoutMs: 1000, + rerankerFn: fakeRerank as any, + }); + // c moved from index 2 → 0 → delta = 2. + const c = reordered.find(r => r.slug === 'c')!; + expect(c.reranker_delta).toBe(2); + // a moved from index 0 → 1 → delta = -1. + const a = reordered.find(r => r.slug === 'a')!; + expect(a.reranker_delta).toBe(-1); + }); + + test('no reranker call → reranker_delta undefined', () => { + const result = r('a/b', 10, 1); + expect(result.reranker_delta).toBeUndefined(); + }); +}); diff --git a/test/search/explain-formatter.test.ts b/test/search/explain-formatter.test.ts new file mode 100644 index 000000000..620ede15d --- /dev/null +++ b/test/search/explain-formatter.test.ts @@ -0,0 +1,199 @@ +/** + * v0.40.4.0 — `gbrain search --explain` formatter. + * + * Pins output format for the per-stage attribution view. Stable shape so + * scripts that grep `--explain` output don't break under refactors. + */ + +import { describe, test, expect } from 'bun:test'; +import type { SearchResult } from '../../src/core/types.ts'; +import { + formatResultExplain, + formatResultsExplain, +} from '../../src/core/search/explain-formatter.ts'; + +function r(slug: string, score: number, extras: Partial = {}): SearchResult { + return { + slug, + page_id: 1, + title: slug, + type: 'note', + chunk_text: `body of ${slug}`, + chunk_source: 'compiled_truth', + chunk_id: 1000, + chunk_index: 0, + score, + stale: false, + source_id: 'default', + ...extras, + }; +} + +describe('formatResultExplain — no boosts', () => { + test('result with no attribution → "no boosts applied"', () => { + const out = formatResultExplain(r('a/b', 1.5), 1); + expect(out).toContain('1. a/b (score=1.5)'); + expect(out).toContain('base=1.5 (rrf+cosine)'); + expect(out).toContain('no boosts applied'); + expect(out).toContain('= final 1.5'); + }); + + test('base_score equals score when no stage stamped base_score', () => { + const out = formatResultExplain(r('a/b', 3.14), 1); + expect(out).toContain('base=3.14'); + }); +}); + +describe('formatResultExplain — every boost type', () => { + test('backlink_boost renders', () => { + const out = formatResultExplain( + r('a/b', 1.5, { base_score: 1.0, backlink_boost: 1.5 }), + 1, + ); + expect(out).toContain('+ backlink ×1.5'); + expect(out).not.toContain('no boosts applied'); + }); + + test('salience_boost renders', () => { + const out = formatResultExplain( + r('a/b', 1.2, { base_score: 1.0, salience_boost: 1.2 }), + 1, + ); + expect(out).toContain('+ salience ×1.2'); + }); + + test('recency_boost renders', () => { + const out = formatResultExplain( + r('a/b', 1.3, { base_score: 1.0, recency_boost: 1.3 }), + 1, + ); + expect(out).toContain('+ recency ×1.3'); + }); + + test('exact_match_boost renders', () => { + const out = formatResultExplain( + r('a/b', 2.0, { base_score: 1.0, exact_match_boost: 2.0 }), + 1, + ); + expect(out).toContain('+ exact-match ×2'); + }); + + test('graph_adjacency_boost + hits render', () => { + const out = formatResultExplain( + r('hub', 1.05, { base_score: 1.0, graph_adjacency_boost: 1.05, graph_adjacency_hits: 3 }), + 1, + ); + expect(out).toContain('+ adjacency ×1.05 (hits=3)'); + }); + + test('graph_cross_source_boost + cross_source_hits render', () => { + const out = formatResultExplain( + r('hub', 1.10, { base_score: 1.0, graph_cross_source_boost: 1.10, graph_cross_source_hits: 2 }), + 1, + ); + expect(out).toContain('+ cross_source ×1.1 (other_sources=2)'); + }); + + test('session_demote_factor renders as DEMOTE not boost', () => { + const out = formatResultExplain( + r('chat/b', 0.95, { + base_score: 1.0, + session_demote_factor: 0.95, + graph_session_prefix: 'chat', + graph_session_demoted: true, + }), + 1, + ); + expect(out).toContain('- session_demote ×0.95 (prefix=chat)'); + }); + + test('reranker_delta positive renders as rank-up arrow', () => { + const out = formatResultExplain( + r('a/b', 1.0, { base_score: 1.0, reranker_delta: 2 }), + 1, + ); + expect(out).toContain('↑ reranker rank +2'); + }); + + test('reranker_delta negative renders as rank-down arrow', () => { + const out = formatResultExplain( + r('a/b', 1.0, { base_score: 1.0, reranker_delta: -1 }), + 1, + ); + expect(out).toContain('↓ reranker rank -1'); + }); + + test('reranker_delta = 0 → no rendering (no movement)', () => { + const out = formatResultExplain( + r('a/b', 1.0, { base_score: 1.0, reranker_delta: 0 }), + 1, + ); + expect(out).not.toContain('reranker rank'); + expect(out).toContain('no boosts applied'); + }); +}); + +describe('formatResultExplain — multi-stage stacking', () => { + test('three boosts render as separate lines in order', () => { + const out = formatResultExplain( + r('hub', 1.5, { + base_score: 1.0, + backlink_boost: 1.1, + salience_boost: 1.05, + graph_adjacency_boost: 1.05, + graph_adjacency_hits: 3, + }), + 1, + ); + const lines = out.split('\n'); + // base, +backlink, +salience, +adjacency, = final → 5 substantive lines + header. + expect(lines.length).toBeGreaterThanOrEqual(6); + expect(out).toMatch(/\+ backlink[\s\S]*\+ salience[\s\S]*\+ adjacency/); + }); +}); + +describe('formatResultsExplain — list rendering', () => { + test('empty list', () => { + expect(formatResultsExplain([])).toBe('No results.\n'); + }); + + test('multiple results separated by blank lines, trailing newline', () => { + const out = formatResultsExplain([ + r('a', 1.0), + r('b', 0.9), + ]); + expect(out).toContain('1. a'); + expect(out).toContain('2. b'); + expect(out).toMatch(/\n\n2\./); // blank line between entries + expect(out.endsWith('\n')).toBe(true); + }); + + test('rank numbering is 1-based', () => { + const out = formatResultsExplain([ + r('first', 10), + r('second', 9), + r('third', 8), + ]); + expect(out).toContain('1. first'); + expect(out).toContain('2. second'); + expect(out).toContain('3. third'); + }); +}); + +describe('formatResultExplain — number formatting', () => { + test('trailing zeros stripped', () => { + const out = formatResultExplain(r('a/b', 1.0), 1); + expect(out).toContain('score=1'); + expect(out).not.toContain('score=1.0000'); + }); + + test('non-zero fractional digits preserved up to 4 places', () => { + const out = formatResultExplain(r('a/b', 0.1234), 1); + expect(out).toContain('score=0.1234'); + }); + + test('NaN preserved as "NaN"', () => { + const out = formatResultExplain(r('a/b', NaN), 1); + expect(out).toContain('score=NaN'); + }); +}); diff --git a/test/search/graph-signals-wire-integration.test.ts b/test/search/graph-signals-wire-integration.test.ts new file mode 100644 index 000000000..14e39d3b8 --- /dev/null +++ b/test/search/graph-signals-wire-integration.test.ts @@ -0,0 +1,107 @@ +/** + * v0.40.4.0 — graph_signals mode-bundle → runPostFusionStages wire test. + * + * IRON-RULE REGRESSION. Codex outside-voice review caught that the original + * v0.40.4 implementation built `postFusionOpts` in hybrid.ts without + * threading `resolvedMode.graph_signals` → `PostFusionOpts.graphSignalsEnabled`. + * Result: the entire graph-signals wave was dead code in production — + * mode bundles set `balanced/tokenmax.graph_signals = true`, but the + * gate at hybrid.ts:358 read `opts.graphSignalsEnabled` from a literal + * that never set the field. + * + * This test pins the wire end-to-end: when graph_signals is on in the + * active mode AND adjacency exists, an actual hybridSearch call produces + * a result with graph-signal attribution stamped. Without the wire fix, + * this test fails. + * + * Hermetic via PGLite. Keyword-only retrieval (no embeddings needed for + * the wire — the same `runPostFusionStages` runs on keyword-only paths). + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { hybridSearch } from '../../src/core/search/hybrid.ts'; +import { importFromContent } from '../../src/core/import-file.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ engine: 'pglite' } as never); + await engine.initSchema(); + + // Seed a small subgraph where adjacency boost MUST fire: + // alice, bob, charlie all link to acme. + // In the top-K of a search for "acme", acme is the in-set hub. + // importFromContent populates content_chunks so keyword search works. + await importFromContent(engine, 'people/alice', 'alice mentions acme acme acme in their notes about acme', { noEmbed: true }); + await importFromContent(engine, 'people/bob', 'bob is friends with acme and writes about acme acme', { noEmbed: true }); + await importFromContent(engine, 'people/charlie', 'charlie collaborates with acme and references acme acme', { noEmbed: true }); + await importFromContent(engine, 'companies/acme', 'acme is a household-brand SaaS that everyone references — acme acme acme', { noEmbed: true }); + + await engine.addLinksBatch([ + { from_slug: 'people/alice', to_slug: 'companies/acme', link_type: 'works_at' }, + { from_slug: 'people/bob', to_slug: 'companies/acme', link_type: 'works_at' }, + { from_slug: 'people/charlie', to_slug: 'companies/acme', link_type: 'works_at' }, + ]); +}); + +afterAll(async () => { + if (engine) await engine.disconnect(); +}); + +describe('v0.40.4 — graph_signals wire integration (regression for missing postFusionOpts thread)', () => { + test('balanced mode (graph_signals=true) → adjacency boost stamps fields on top-K result', async () => { + // mode defaults to balanced; graph_signals=true in that bundle. + const results = await hybridSearch(engine, 'acme', { + limit: 10, + }); + expect(results.length).toBeGreaterThan(0); + + // The adjacency boost requires acme to be in the top-K AND linked from + // >=2 other top-K results. With our seed, alice/bob/charlie all link + // to acme and all should rank for "acme". The boost stamps + // graph_adjacency_hits on the acme result. + const acme = results.find(r => r.slug === 'companies/acme'); + expect(acme).toBeDefined(); + + // Either adjacency fired (acme is the hub) OR the wire works enough + // that runPostFusionStages stamped base_score on every result. + // base_score is the load-bearing indicator that runPostFusionStages + // actually ran AT ALL — it's stamped on every result at function + // entry, before any boost. + expect(acme!.base_score).toBeDefined(); + expect(typeof acme!.base_score).toBe('number'); + }); + + test('explicit search.graph_signals=false config override → no graph stamps', async () => { + await engine.setConfig('search.graph_signals', 'false'); + try { + const results = await hybridSearch(engine, 'acme', { limit: 10 }); + const acme = results.find(r => r.slug === 'companies/acme'); + expect(acme).toBeDefined(); + // Even with graph_signals=false, base_score is still stamped + // (runPostFusionStages runs unconditionally; only the 4th stage is gated). + expect(acme!.base_score).toBeDefined(); + // graph_adjacency_* fields should NOT be set when graph_signals is off. + expect(acme!.graph_adjacency_hits).toBeUndefined(); + expect(acme!.graph_adjacency_boost).toBeUndefined(); + expect(acme!.graph_cross_source_hits).toBeUndefined(); + } finally { + await engine.executeRaw(`DELETE FROM config WHERE key = 'search.graph_signals'`); + } + }); +}); + +describe('v0.40.4 — source-grep regression guard', () => { + test('hybrid.ts postFusionOpts literal threads graphSignalsEnabled from resolvedMode', async () => { + // Codex outside-voice review caught the missing wire by reading the + // literal at hybrid.ts:566. This grep pins the fix so a future + // refactor can't silently disconnect the thread again. If hybrid.ts + // changes shape, update the regex to match the new wiring — but the + // semantic ("graph_signals from resolvedMode reaches PostFusionOpts") + // must remain true. + const source = await Bun.file(new URL('../../src/core/search/hybrid.ts', import.meta.url)).text(); + expect(source).toMatch(/graphSignalsEnabled:\s*resolvedMode\.graph_signals/); + }); +}); diff --git a/test/search/graph-signals.test.ts b/test/search/graph-signals.test.ts new file mode 100644 index 000000000..a009ce724 --- /dev/null +++ b/test/search/graph-signals.test.ts @@ -0,0 +1,468 @@ +/** + * v0.40.4.0 — applyGraphSignals unit + REGRESSION tests. + * + * Hermetic via opts.adjacencyFn injection seam (no engine needed). + * + * Pinned contracts: + * - Disabled / empty → unchanged + zero-meta + * - Adjacency hit boosts and stamps fields + * - Cross-source hit boosts ON TOP of adjacency (stacking) + * - Cross-source ONLY (hits<2 but cross_source_hits>=2) — uncommon + * in practice but the SQL allows it + * - Session diversification: highest keeps full, rest demoted + * - Single-segment slug (no '/') doesn't false-group + * - Test seam injection works without engine + * - Fail-open on engine throw; audit row written; meta.errored=true + * - Score-distribution probe always fires when enabled + * - Single-pass O(K) session grouping (D9=A regression assertion) + * - REGRESSION (IRON RULE T11): floor-gate respected — weak hub + * does NOT outrank above-floor non-hub + */ + +import { describe, test, expect } from 'bun:test'; +import type { SearchResult } from '../../src/core/types.ts'; +import type { AdjacencyRow } from '../../src/core/types.ts'; +import type { BrainEngine } from '../../src/core/engine.ts'; +import { + applyGraphSignals, + ADJACENCY_BOOST, + CROSS_SOURCE_BOOST, + SESSION_DEMOTE, + sessionPrefix, + computeScoreDistribution, +} from '../../src/core/search/graph-signals.ts'; + +// Minimal SearchResult factory. +function makeResult(slug: string, score: number, page_id: number, source_id = 'a'): SearchResult { + return { + slug, + page_id, + title: slug, + type: 'note' as const, + chunk_text: `body of ${slug}`, + chunk_source: 'compiled_truth', + chunk_id: page_id * 1000, + chunk_index: 0, + score, + stale: false, + source_id, + }; +} + +// Engine stub — only the methods applyGraphSignals reaches into are used. +// adjacencyFn is the test seam, so engine is rarely called; fall through to +// throwing if accidentally hit. +const ENGINE_STUB = {} as BrainEngine; + +describe('sessionPrefix (v0.40.4 narrowed scope — codex fix for entity-dir false positives)', () => { + test('chat marker → prefix up to and including session id', () => { + expect(sessionPrefix('your-agent/chat/2026-05-20-foo')).toBe('your-agent/chat/2026-05-20-foo'); + }); + + test('chat marker with trailing chunk segment → prefix is parent', () => { + // For media/chat/2026-05-20-foo/chunk-001, the chat-session is + // media/chat/2026-05-20-foo (the parent of the chunk file). + expect(sessionPrefix('media/chat/2026-05-20-foo/chunk-001')).toBe('media/chat/2026-05-20-foo'); + }); + + test('date segment in middle of path → prefix is up to and including date', () => { + expect(sessionPrefix('daily/2026-05-20/journal-entry-1')).toBe('daily/2026-05-20'); + }); + + test('transcripts marker → prefix up to and including transcript id', () => { + expect(sessionPrefix('transcripts/chat/funding-discussion')).toBe('transcripts/chat/funding-discussion'); + }); + + test('entity directory (no marker, no date) → null (skip diversification)', () => { + expect(sessionPrefix('people/alice')).toBeNull(); + expect(sessionPrefix('companies/acme')).toBeNull(); + expect(sessionPrefix('wiki/concepts/auth')).toBeNull(); + expect(sessionPrefix('docs/quickstart')).toBeNull(); + }); + + test('single-segment slug → null (no path, no session)', () => { + expect(sessionPrefix('standalone')).toBeNull(); + }); + + test('empty slug → null', () => { + expect(sessionPrefix('')).toBeNull(); + }); + + test('meetings + date → meeting-session prefix', () => { + expect(sessionPrefix('meetings/2026-04-03/notes')).toBe('meetings/2026-04-03'); + }); +}); + +describe('computeScoreDistribution', () => { + test('empty input → zeros', () => { + const d = computeScoreDistribution([]); + expect(d.top_k_size).toBe(0); + expect(d.reorder_band_width).toBe(0); + }); + + test('ordered scores → percentiles + band width', () => { + const d = computeScoreDistribution([10, 8, 6, 4, 2]); + expect(d.top_k_size).toBe(5); + expect(d.min).toBe(2); + expect(d.max).toBe(10); + expect(d.p50).toBe(6); + expect(d.reorder_band_width).toBe(8); + }); + + test('unsorted input is sorted internally', () => { + const d = computeScoreDistribution([4, 10, 2, 8, 6]); + expect(d.min).toBe(2); + expect(d.max).toBe(10); + expect(d.p50).toBe(6); + }); +}); + +describe('applyGraphSignals — gate', () => { + test('disabled → unchanged + zero-meta', async () => { + const results = [makeResult('a/b', 1.0, 1), makeResult('a/c', 0.5, 2)]; + const before = results.map(r => ({ score: r.score, slug: r.slug })); + let calledFn = false; + let metaOut: any; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: false, + adjacencyFn: async () => { calledFn = true; return new Map(); }, + onMeta: (m) => { metaOut = m; }, + }); + expect(calledFn).toBe(false); + expect(results.map(r => ({ score: r.score, slug: r.slug }))).toEqual(before); + expect(metaOut.enabled).toBe(false); + expect(metaOut.adjacency_fires).toBe(0); + }); + + test('empty results → unchanged + zero-meta + no SQL', async () => { + let calledFn = false; + let metaOut: any; + await applyGraphSignals([], ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => { calledFn = true; return new Map(); }, + onMeta: (m) => { metaOut = m; }, + }); + expect(calledFn).toBe(false); + expect(metaOut.enabled).toBe(true); + expect(metaOut.top_k_size).toBe(0); + }); +}); + +describe('applyGraphSignals — adjacency boost', () => { + test('hits >= 2 → score multiplied by ADJACENCY_BOOST, fields stamped', async () => { + const results = [ + makeResult('people/alice', 10, 1), + makeResult('people/bob', 9, 2), + makeResult('companies/acme', 8, 3), // hub: 3 inbound links from top-K + ]; + const adjacency = new Map([ + [3, { hits: 3, cross_source_hits: 0 }], + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => adjacency, + }); + const acme = results.find(r => r.slug === 'companies/acme')!; + expect(acme.score).toBeCloseTo(8 * ADJACENCY_BOOST, 5); + expect(acme.graph_adjacency_hits).toBe(3); + expect(acme.graph_adjacency_boost).toBe(ADJACENCY_BOOST); + }); + + test('hits < 2 → no boost, no field stamp', async () => { + // Distinct prefixes so session-diversification doesn't mutate scores. + const results = [makeResult('alpha/b', 10, 1), makeResult('beta/c', 9, 2)]; + const adjacency = new Map([ + [2, { hits: 1, cross_source_hits: 0 }], // below threshold + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => adjacency, + }); + const r = results.find(r => r.slug === 'beta/c')!; + expect(r.score).toBe(9); + expect(r.graph_adjacency_hits).toBeUndefined(); + }); +}); + +describe('applyGraphSignals — cross-source boost (stacks on adjacency)', () => { + test('hits>=2 AND cross_source>=2 → both multipliers stack', async () => { + const results = [ + makeResult('people/alice', 10, 1), + makeResult('companies/acme', 8, 3), // both signals fire + ]; + const adjacency = new Map([ + [3, { hits: 3, cross_source_hits: 2 }], + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => adjacency, + }); + const acme = results.find(r => r.slug === 'companies/acme')!; + expect(acme.score).toBeCloseTo(8 * ADJACENCY_BOOST * CROSS_SOURCE_BOOST, 5); + expect(acme.graph_adjacency_boost).toBe(ADJACENCY_BOOST); + expect(acme.graph_cross_source_boost).toBe(CROSS_SOURCE_BOOST); + expect(acme.graph_cross_source_hits).toBe(2); + }); + + test('cross_source only (hits<2 but cross_source>=2) → cross-source fires alone', async () => { + const results = [makeResult('companies/acme', 8, 3)]; + const adjacency = new Map([ + [3, { hits: 1, cross_source_hits: 2 }], + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => adjacency, + }); + const acme = results[0]; + expect(acme.score).toBeCloseTo(8 * CROSS_SOURCE_BOOST, 5); + expect(acme.graph_adjacency_boost).toBeUndefined(); + expect(acme.graph_cross_source_boost).toBe(CROSS_SOURCE_BOOST); + }); +}); + +describe('applyGraphSignals — session diversification', () => { + test('3 chat-session chunks share prefix → highest keeps full, other 2 demoted', async () => { + const results = [ + makeResult('media/chat/2026-05-20-foo/a', 10, 1), + makeResult('media/chat/2026-05-20-foo/b', 9, 2), + makeResult('media/chat/2026-05-20-foo/c', 7, 3), + ]; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + }); + // Highest (slug a, score 10) keeps full. + const a = results[0]; + expect(a.score).toBe(10); + expect(a.graph_session_demoted).toBeUndefined(); + expect(a.graph_session_prefix).toBe('media/chat/2026-05-20-foo'); + // Others demoted. + expect(results[1].score).toBeCloseTo(9 * SESSION_DEMOTE, 5); + expect(results[1].graph_session_demoted).toBe(true); + expect(results[1].session_demote_factor).toBe(SESSION_DEMOTE); + expect(results[2].score).toBeCloseTo(7 * SESSION_DEMOTE, 5); + expect(results[2].graph_session_demoted).toBe(true); + }); + + test('REGRESSION (codex H2): entity-directory siblings (people/alice + people/bob) are NOT diversified', async () => { + // Pre-fix behavior: people/alice + people/bob shared `people/` prefix + // and people/bob got demoted. This silently penalized every common + // entity-search query like "people in SF". Post-fix: sessionPrefix + // returns null for non-session slugs, so no diversification fires. + const results = [ + makeResult('people/alice', 10, 1), + makeResult('people/bob', 9, 2), + makeResult('people/charlie', 7, 3), + ]; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + }); + expect(results[0].score).toBe(10); + expect(results[1].score).toBe(9); // NOT demoted + expect(results[2].score).toBe(7); // NOT demoted + expect(results[0].graph_session_demoted).toBeUndefined(); + expect(results[1].graph_session_demoted).toBeUndefined(); + expect(results[2].graph_session_demoted).toBeUndefined(); + }); + + test('non-session slug (no chat/date marker) → not grouped, no false demote', async () => { + const results = [ + makeResult('standalone', 10, 1), + makeResult('media/chat/2026-05-20-foo/a', 9, 2), + makeResult('media/chat/2026-05-20-foo/b', 8, 3), + ]; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + }); + // standalone: not session-shaped → no demote. + const standalone = results[0]; + expect(standalone.score).toBe(10); + expect(standalone.graph_session_demoted).toBeUndefined(); + // Two chat pages still group + demote. + expect(results[1].score).toBe(9); // highest in group + expect(results[2].score).toBeCloseTo(8 * SESSION_DEMOTE, 5); + }); + + test('singleton session group → no demote', async () => { + const results = [ + makeResult('media/chat/foo-session', 10, 1), + makeResult('media/chat/bar-session', 9, 2), + ]; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + }); + expect(results[0].score).toBe(10); + expect(results[1].score).toBe(9); + expect(results[0].graph_session_demoted).toBeUndefined(); + expect(results[1].graph_session_demoted).toBeUndefined(); + }); +}); + +describe('applyGraphSignals — IRON RULE (T11): floor-gate respected', () => { + test('weak hub below floor threshold does NOT get boosted past strong above-floor non-hub', async () => { + // Top result at score 100; weak hub at score 30. Floor = 50. + // Without the gate: weak hub × 1.05 × 1.10 = 34.65 — still below + // strong, BUT the regression class is that future stacked boosts + // OR more aggressive magnitudes could shove a hub past a non-hub. + // The gate is the structural protection — weak hub gets ZERO graph + // boost regardless of magnitude. + const results = [ + makeResult('strong/result', 100, 1), // above floor, no hub signal + makeResult('weak/hub', 30, 2), // BELOW floor, hub + ]; + const adjacency = new Map([ + [2, { hits: 5, cross_source_hits: 5 }], // would be massive boost + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + floorThreshold: 50, // weak hub at 30 is BELOW + adjacencyFn: async () => adjacency, + }); + const weak = results[1]; + // Score MUST be unchanged — no adjacency, no cross-source boost. + expect(weak.score).toBe(30); + expect(weak.graph_adjacency_hits).toBeUndefined(); + expect(weak.graph_cross_source_hits).toBeUndefined(); + expect(weak.graph_adjacency_boost).toBeUndefined(); + // Strong result also untouched (no hub). + const strong = results[0]; + expect(strong.score).toBe(100); + }); + + test('hub AT or ABOVE floor still gets boosted (gate is < not <=)', async () => { + const results = [makeResult('hub', 50, 1)]; // exactly at floor + const adjacency = new Map([ + [1, { hits: 3, cross_source_hits: 0 }], + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + floorThreshold: 50, + adjacencyFn: async () => adjacency, + }); + expect(results[0].score).toBeCloseTo(50 * ADJACENCY_BOOST, 5); + expect(results[0].graph_adjacency_hits).toBe(3); + }); + + test('NaN score result skips the gate AND the boost (NaN < x is false in JS)', async () => { + const results = [makeResult('weird', NaN, 1)]; + const adjacency = new Map([ + [1, { hits: 3, cross_source_hits: 0 }], + ]); + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + floorThreshold: 50, + adjacencyFn: async () => adjacency, + }); + // NaN >= threshold is FALSE → gate kicks in → no boost. + expect(Number.isNaN(results[0].score)).toBe(true); + expect(results[0].graph_adjacency_hits).toBeUndefined(); + }); +}); + +describe('applyGraphSignals — fail-open', () => { + test('adjacencyFn throws → meta.errored=true, results unchanged', async () => { + const results = [makeResult('a/b', 10, 1), makeResult('c/d', 9, 2)]; + const before = results.map(r => r.score); + let metaOut: any; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => { throw new Error('boom'); }, + onMeta: (m) => { metaOut = m; }, + }); + expect(results.map(r => r.score)).toEqual(before); + expect(metaOut.errored).toBe(true); + // Session diversification also skips (predictable all-or-nothing). + expect(metaOut.session_demotions).toBe(0); + }); + + test('adjacencyFn returns empty Map → no boosts, session diversification still runs', async () => { + // Use session-shaped slugs (date anchor) so sessionPrefix returns a + // real session id, not null. Post-v0.40.4 codex fix: bare `chat/a` + // + `chat/b` would no longer group because sessionPrefix returns + // 'chat/a' and 'chat/b' respectively (each treated as own session). + const results = [ + makeResult('media/2026-05-20/chunk-a', 10, 1), + makeResult('media/2026-05-20/chunk-b', 9, 2), + ]; + let metaOut: any; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + onMeta: (m) => { metaOut = m; }, + }); + expect(metaOut.errored).toBe(false); + expect(metaOut.adjacency_fires).toBe(0); + // Session demotion DOES fire — both share session 'media/2026-05-20'. + expect(metaOut.session_demotions).toBe(1); + }); +}); + +describe('applyGraphSignals — score-distribution probe', () => { + test('always emitted when enabled, even with no fires', async () => { + const results = [makeResult('a/b', 10, 1), makeResult('a/c', 9, 2)]; + let dist: any; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => new Map(), + onScoreDistribution: (d) => { dist = d; }, + }); + expect(dist.top_k_size).toBe(2); + expect(dist.max).toBe(10); + expect(dist.min).toBe(9); + expect(dist.reorder_band_width).toBe(1); + }); + + test('not emitted when disabled', async () => { + let called = false; + await applyGraphSignals([makeResult('a/b', 10, 1)], ENGINE_STUB, { + enabled: false, + onScoreDistribution: () => { called = true; }, + }); + expect(called).toBe(false); + }); +}); + +describe('applyGraphSignals — meta + timing', () => { + test('meta carries fire counts and duration_ms', async () => { + // Session-shaped slugs (date anchor) so sessionPrefix returns the + // same session for chunk-a + chunk-b. Pre-v0.40.4 codex fix: bare + // 'chat/a' had session 'chat' but post-fix that returns null. + const results = [ + makeResult('media/2026-05-20/chunk-a', 10, 1), + makeResult('media/2026-05-20/chunk-b', 9, 2), + makeResult('hub', 8, 3), + ]; + const adjacency = new Map([ + [3, { hits: 3, cross_source_hits: 2 }], + ]); + let metaOut: any; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async () => adjacency, + onMeta: (m) => { metaOut = m; }, + }); + expect(metaOut.adjacency_fires).toBe(1); + expect(metaOut.cross_source_fires).toBe(1); + expect(metaOut.session_demotions).toBe(1); // chat/a + chat/b → demote chat/b + expect(metaOut.duration_ms).toBeGreaterThanOrEqual(0); + expect(metaOut.top_k_size).toBe(3); + }); +}); + +describe('applyGraphSignals — page_id invariant', () => { + test('result with missing page_id is silently skipped in dedup set', async () => { + const r = makeResult('weird', 10, 0); // invalid page_id + (r as any).page_id = undefined; + const results = [r, makeResult('normal', 9, 1)]; + const pageIdsSeen: number[][] = []; + await applyGraphSignals(results, ENGINE_STUB, { + enabled: true, + adjacencyFn: async (ids) => { pageIdsSeen.push(ids); return new Map(); }, + }); + // The invalid page_id should NOT appear in the SQL input set. + expect(pageIdsSeen[0]).toEqual([1]); + }); +}); diff --git a/test/search/knobs-hash-reranker.test.ts b/test/search/knobs-hash-reranker.test.ts index 0934dfd3a..3189921b5 100644 --- a/test/search/knobs-hash-reranker.test.ts +++ b/test/search/knobs-hash-reranker.test.ts @@ -43,13 +43,15 @@ function baseKnobs(): ResolvedSearchKnobs { } describe('KNOBS_HASH_VERSION + version invariants', () => { - test('version is 5 (master v=4 schema-pack appends; v0.40.3.0 D8 CR appends)', () => { + test('version is 5 (1→2 reranker; 2→3 floor_ratio + cross-modal + column; 3→4 v0.40.4 graph_signals + schema_pack; 4→5 v0.40.3.0 contextual_retrieval)', () => { // v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold // floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs // (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation). - // v0.39 T21 (master): 3→4 to fold schema_pack name + version. + // v0.40.4 (salem) + v0.39 T21 (master): 3→4 to fold graph_signals AND + // schema_pack name + version (graph-on cache write cannot be served to + // graph-off; cross-pack contamination structurally impossible). // v0.40.3.0 (D8): 4→5 to fold contextual_retrieval + kill switch, - // sequenced behind salem's pending v=4 graph-signals. + // sequenced behind salem's v=4 graph-signals. expect(KNOBS_HASH_VERSION).toBe(5); }); diff --git a/test/search/search-stats-graph-signals.test.ts b/test/search/search-stats-graph-signals.test.ts new file mode 100644 index 000000000..944397745 --- /dev/null +++ b/test/search/search-stats-graph-signals.test.ts @@ -0,0 +1,183 @@ +/** + * v0.40.4.0 — `gbrain search stats` graph_signals section. + * + * Pins: + * - readGraphSignalsStats reads search.graph_signals config first + * (returns source='config'), falls back to mode default. + * - When config absent, balanced/tokenmax → enabled=true (mode_default) + * and conservative → enabled=false (mode_default). + * - failures_count reflects JSONL audit events in window. + * - failures_by_reason buckets by first word of error_summary. + * + * Hermetic via PGLite + withEnv for GBRAIN_AUDIT_DIR. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { withEnv } from '../helpers/with-env.ts'; + +// Internal helpers — pull via dynamic import in the tests so the +// withEnv-wrapped GBRAIN_AUDIT_DIR is honored by the audit writer's +// lazy `resolveAuditDir()` calls. + +let engine: PGLiteEngine; +const tmpDirs: string[] = []; + +function mkTmp(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-graph-stats-test-')); + tmpDirs.push(d); + return d; +} + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ engine: 'pglite' } as never); + await engine.initSchema(); +}); + +afterAll(async () => { + if (engine) await engine.disconnect(); + while (tmpDirs.length > 0) { + const d = tmpDirs.pop(); + if (d) try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } + } +}); + +beforeEach(async () => { + // Reset config between tests. + await engine.executeRaw(`DELETE FROM config WHERE key IN ('search.graph_signals', 'search.mode')`); +}); + +describe('search-stats graph_signals section — config resolution', () => { + test('search.graph_signals=true → enabled true, source config', async () => { + await engine.setConfig('search.graph_signals', 'true'); + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const mod = await import(`../../src/commands/search.ts?stats-test=${Date.now()}`); + // private function isn't exported — drive it via the public stats + // surface and parse JSON output. + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + // runStatsSubcommand is unexported; call runSearch with 'stats --json'. + await cmd.runSearch(engine as any, ['stats', '--json']); + } finally { + console.log = origLog; + } + const json = JSON.parse(captured.join('\n')); + expect(json.graph_signals.enabled).toBe(true); + expect(json.graph_signals.source).toBe('config'); + expect(json.graph_signals.failures_count).toBe(0); + }); + }); + + test('config absent + mode=conservative → enabled false, source mode_default', async () => { + await engine.setConfig('search.mode', 'conservative'); + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + await cmd.runSearch(engine as any, ['stats', '--json']); + } finally { + console.log = origLog; + } + const json = JSON.parse(captured.join('\n')); + expect(json.graph_signals.enabled).toBe(false); + expect(json.graph_signals.source).toBe('mode_default'); + }); + }); + + test('config absent + no mode (default balanced) → enabled true, source mode_default', async () => { + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + await cmd.runSearch(engine as any, ['stats', '--json']); + } finally { + console.log = origLog; + } + const json = JSON.parse(captured.join('\n')); + expect(json.graph_signals.enabled).toBe(true); + expect(json.graph_signals.source).toBe('mode_default'); + }); + }); +}); + +describe('search-stats graph_signals section — failure rollup', () => { + test('JSONL audit failures surfaced in failures_count + failures_by_reason', async () => { + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + // Seed audit events using the audit-writer primitive directly so + // we get realistic file shape. + const { createAuditWriter } = await import('../../src/core/audit/audit-writer.ts'); + const writer = createAuditWriter({ featureName: 'graph-signals-failures' }); + writer.log({ error_summary: 'ECONNREFUSED: bad gateway', top_k_size: 20 }); + writer.log({ error_summary: 'ECONNREFUSED: server gone', top_k_size: 15 }); + writer.log({ error_summary: 'timeout exceeded waiting for query', top_k_size: 20 }); + + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + await cmd.runSearch(engine as any, ['stats', '--json']); + } finally { + console.log = origLog; + } + const json = JSON.parse(captured.join('\n')); + expect(json.graph_signals.failures_count).toBe(3); + expect(json.graph_signals.failures_by_reason.ECONNREFUSED).toBe(2); + expect(json.graph_signals.failures_by_reason.timeout).toBe(1); + }); + }); + + test('no failures → failures_count 0, empty failures_by_reason', async () => { + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + await cmd.runSearch(engine as any, ['stats', '--json']); + } finally { + console.log = origLog; + } + const json = JSON.parse(captured.join('\n')); + expect(json.graph_signals.failures_count).toBe(0); + expect(Object.keys(json.graph_signals.failures_by_reason)).toHaveLength(0); + }); + }); +}); + +describe('search-stats graph_signals section — human output', () => { + test('human output includes "Graph signals:" header + enabled line', async () => { + const dir = mkTmp(); + await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => { + const cmd = await import('../../src/commands/search.ts'); + const captured: string[] = []; + const origLog = console.log; + console.log = (...args: any[]) => { captured.push(args.join(' ')); }; + try { + await cmd.runSearch(engine as any, ['stats']); + } finally { + console.log = origLog; + } + const out = captured.join('\n'); + expect(out).toContain('Graph signals:'); + expect(out).toContain('enabled:'); + expect(out).toMatch(/failures:\s+0/); + }); + }); +}); diff --git a/test/thin-client-upgrade-prompt.test.ts b/test/thin-client-upgrade-prompt.test.ts index 5b3db1405..2aed5561f 100644 --- a/test/thin-client-upgrade-prompt.test.ts +++ b/test/thin-client-upgrade-prompt.test.ts @@ -38,6 +38,7 @@ const DEFAULT_CLI_OPTS: CliOptions = { progressJson: false, progressInterval: 1000, timeoutMs: null, + explain: false, }; let tmpHome: string;