# Memory evaluation harness Independent, reproducible evaluation of SynaptoMind retrieval quality. It scores labeled scenarios (recall@k, precision@k, MRR, hit rate) and records a baseline so memory changes — embeddings, search, supersession — become measurable. Implements item 1 of [issue #124](https://github.com/zumik3-del/synaptomind/issues/124). The harness is read-only against services and builds its own file-backed temp DB per scenario (vec0 requires a real file, not `:memory:`). ## Running ```bash bun run eval # deterministic mode (default, CI-safe) bun run eval --real # real embedder + production search service bun run eval --top-k 10 # override default top-k bun run eval --out /tmp/eval.json bun run eval --update-baseline # recompute eval/thresholds.json from this run bun run eval --real --update-baseline # record/refresh the real-mode baseline bun run eval --no-gated # skip the report-only second pass (one pass, half the work) ``` Exit code is non-zero **only** on a threshold regression (or a failed assertion in a non-xfail scenario). Scenarios marked `xfail` are reported but never fail the run. Metric gating applies per mode: the `deterministic` baseline gates `bun run eval`, and the recorded `real` baseline gates `bun run eval --real`. The report-only gated pass (see below) never affects the exit code. The JSON report is written to `eval/report.json` (gitignored) and contains every scenario, query, retrieved id, metric and the regression list. ### Report-only gated pass Every run executes the scenarios twice. The first pass is the measurement above (ungated, `minRelevance: 0`, comparable to the recorded baselines). The second pass replays the same scenarios with the relevance gate the production search service applies when a caller omits `min_relevance` — nothing is pinned, the service resolves its own default (`DEFAULT_MIN_RELEVANCE`, `0.4` today; the harness reports the value it actually resolved). The gated pass is printed after the main table as a side-by-side comparison (recall / precision / MRR per scenario, then overall) with a header line naming the gate value and the number of rows it dropped: ``` Relevance gate pass (report-only — never gates the exit code, thresholds or regressions) gate: minRelevance=0.400 (production default) — dropped 117 of 152 candidate rows across 16/16 queries ``` `gated` rows are the survivors the gate kept; the dropped count is the service's own `filtered_out` bookkeeping, so a recall loss under the gate is attributable to a specific scenario instead of appearing as an unexplained dip. Both passes land in the JSON report: the top level stays the ungated pass (plus `relevanceGate`: `{ minRelevance, candidates, droppedRows, queriesWithDrops }`), and the gated pass is the nested `gated` object with the same shape. **The gated pass never gates.** It feeds neither `regressions` nor the exit code, and `--update-baseline` still records the ungated numbers only. This is deliberate visibility-first sequencing: gating on the gate would need its own baseline (a separate `gated`/`relevanceGate` threshold entry keyed by mode, so the recorded value carries the `minRelevance` it was measured at) and a decision on the tolerated recall delta per category before any floor is recorded. `--no-gated` skips that second pass (`bun run eval --no-gated`, one pass instead of two). It changes nothing about the gating: the ungated measurement, the thresholds, the exit code and `--update-baseline` are identical, the report just carries no `gated` object and the table says the pass was skipped. It is the switch for a run that has no reader for the gate comparison — the `--real` mode otherwise pays the model cost twice. ### Modes - **deterministic (default)** — a signed feature-hashing embedding (`eval/embedding.ts`) over the 384-dim space. No model download, no embedder child process, fully reproducible. It is lexical by design; semantic coverage comes from `--real`. - **`--real`** — seeds embeddings with the production embedder (`passage:` prefix) and queries through `searchThoughts` (`query:` prefix). Requires the model to be available locally. ## Metrics Computed per query and averaged per scenario / category / overall (non-xfail only): | Metric | Definition | |--------|------------| | recall@k | relevant retrieved / relevant | | precision@k | relevant retrieved / retrieved | | MRR | 1 / rank of the first relevant thought (0 if none) | | hit rate | fraction of queries with at least one relevant thought | ## Categories All eight categories from #124 are covered in `eval/datasets.ts`: | Category | Scenario | Status | |----------|----------|--------| | explicit fact recall | `explicit-fact-recall` | pass | | multi-hop / compositional | `compositional-multi-hop` | pass | | temporal (current vs old) | `temporal-current-vs-old` | pass | | supersession | `supersession-old-not-current` | pass | | contradiction | `contradiction-resolution` | **xfail** | | consolidation | `consolidated-knowledge` | pass | | project scope isolation | `project-scope-isolation` | pass | | retrieval quality | `retrieval-quality` | pass | `xfail` scenarios exercise known gaps for observability. Supersession (issue #124 item 3) is implemented: `supersession-old-not-current` is now a gated pass — the superseded thought is dropped and the replacement is returned. Contradiction remains the only `xfail`: the `contradiction-resolution` fixture asserts that one of two conflicting facts is excluded, but contradiction is symmetric (neither endpoint is authoritative) and contradicted thoughts are never suppressed, so retrieval alone cannot pick a winner. If an `xfail` scenario unexpectedly passes it is reported as `xpass` and is still not a failure. ### Negative (no-match) scenarios The `negative-no-match` probe is `measureOnly: true` (reported and assertion-gated, but its zero metrics stay out of the aggregates and baseline), so it can never destabilise the recorded floors. It carries two hard contracts: - `noRelevant: true` — none of the scenario's own (non-`distractor`) thoughts may be retrieved. Shared `DISTRACTORS` are not part of that set and may fill top-k; the assertion is scoped to the scenario's topical thoughts, not "empty result". - `expectNoStrongMatch: true` — **no** retrieved hit may be a strong match (`low_confidence === false`). The per-hit `low_confidence` / `similarity` / `match_source` signals from `SearchResult` are captured into the eval-local `RetrievedHit` view (`eval/types.ts`, threaded through `QueryRun`), so an off-topic query that the real embedder nonetheless returns with confident relevance fails loudly even when the confident hit is only a distractor. The assertion reports a missing-confidence error rather than passing vacuously. A query with `relevant: []` alone is indistinguishable from a normal query that simply missed (`computeQueryMetrics` returns zeros either way), which is why these explicit negative contracts exist. The `negative-no-match` query shares no token with any thought or distractor, so nothing receives a BM25 anchor; a real embedder similarity at or above `search.confidence.vectorFloor` (`0.9`) is the regression this probe is designed to catch. ### Scope of the categories The harness measures **retrieval of a knowledge state**, not the pipelines that produce it. Two categories are deliberate v1 proxies: - `multi-hop` is multi-fact lexical recall (a query sharing tokens with several supporting thoughts), not graph traversal or semantic chaining. - `consolidation` checks that an already-formed cluster thought is retrievable from its content; it does not exercise auto-cluster consolidation. True multi-hop reasoning and the consolidation pipeline are therefore not yet covered — extend the datasets or add pipeline-level scenarios when that matters. The supersession scenario runs through the production search service with the agent-facing `suppress` / `flag` modes (see `eval/search.ts`), so it exercises the standing/suppression path rather than the raw DB search. Deeper relevance re-scoring beyond deterministic standing ordering is out of scope and left as follow-up. ## Dataset schema Datasets are plain TypeScript (`eval/types.ts`): ```ts interface EvalScenario { name: string category: EvalCategory description: string outcome?: 'pass' | 'xfail' // default: pass measureOnly?: boolean // reported + assertion-gated, excluded from aggregates thoughts: EvalThought[] // id, content, projectId?, status?, createdAt?, isCluster?, importance? edges?: EvalEdge[] // source, target, type? queries: EvalQuery[] } interface EvalQuery { query: string relevant: string[] // ids that should be retrieved topK?: number projectFilter?: string forbid?: string[] // ids that must NOT appear (hard assertion) rankBefore?: { before: string; after: string } // ordering assertion noRelevant?: true // negative query: no own thought may be retrieved expectNoStrongMatch?: true // negative query: no hit may be a strong match } ``` ### Adding a scenario 1. Append an `EvalScenario` to `EVAL_SCENARIOS` in `eval/datasets.ts`. Include the shared `DISTRACTORS` so precision@k stays meaningful. 2. Use distinctive tokens in both the thought content and the query — the deterministic embedder matches on shared tokens. 3. Run `bun run eval` to see the metrics, then `bun run eval --update-baseline` to record the new baseline + thresholds. For a scenario that should also hold under the real embedder, verify with `bun run eval --real` and refresh that baseline with `bun run eval --real --update-baseline`. 4. Document any known gap with `outcome: 'xfail'`; mark a feature probe that must not disturb the aggregates with `measureOnly: true`. ## Thresholds `eval/thresholds.json` is keyed by mode (`deterministic`, `real`). Each entry stores the recorded `baseline` (overall + per category) and the `thresholds` used for gating. `--update-baseline` upserts the entry for the current mode and sets each threshold to the measured value minus a 0.02 margin, so a recorded run always passes while real regressions are caught. A missing category is also a regression. Both modes are recorded: `deterministic` (the CI gate) and `real` (the production embedder). `bun run eval --real` is metric-gated against the `real` entry, so a real-embedder relevance regression now fails the run instead of passing silently. Refresh the real baseline after an intentional change to the embedder, search ranking or datasets: ```bash bun run eval --real --update-baseline # re-record the real entry bun run eval --real # verify it is green and gated bun run eval # deterministic stays green ``` The model is cached locally (`data/huggingface/Xenova/multilingual-e5-small`), so refreshing needs no network. Do **not** regenerate the `deterministic` baseline unless the deterministic datasets or embedder intentionally changed. Hard assertions (`forbid`, `rankBefore`, `noRelevant`, `expectNoStrongMatch` in a non-xfail scenario) gate the run regardless of whether a baseline exists for the mode. Metric regressions only gate when a baseline entry is present: `--real` is metric-gated only after `bun run eval --real --update-baseline`; without a `real` entry the run stays green on metrics but still fails on a broken assertion. ## CI scheduling decision A separate workflow (`.github/workflows/eval-real.yml`) runs `bun run eval --real` on a **weekly cron schedule** (Monday 06:00 UTC) and via **`workflow_dispatch`**. It does **not** fire on `pull_request` or `push` — that keeps PR gating in the existing `ci.yml` intact and avoids paying the ~465M model download on every PR. Rationale (task #891): the deterministic gate in `ci.yml` covers lexical/scenario regressions fast. The real embedder gate catches semantic-relevance drift (model updates, embedding-space shifts) on a cadence that matters for production without slowing PRs down. A cached HuggingFace model path (`data/huggingface/Xenova/multilingual-e5-small`) keeps reruns from re-downloading.