docs/EVAL.md
Rama main · réplica de lectura del repositorio original.
# 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.