Skip to content

v0.7.2

Choose a tag to compare

@github-actions github-actions released this 27 Jul 19:55

Engram grew an eval harness, pointed it at itself, and two layers lost. The
NLI model was replaced and search was retuned — both on numbers, both
reproducible from eval/.

Search finds more of what you meant

Two knobs moved together, and only together:

  • Keyword weight 0.5 → 0.15. A question that never names its subject
    scores zero on the keyword channel by construction, so a high weight caps
    how relevant it can ever be — while a distractor sharing one common word
    scores on both channels.
  • The reranker votes instead of deciding. Its ordering is folded into the
    retrieval ordering by a reciprocal-rank vote, so a result two independent
    channels ranked highly can't be buried by one confident cross-encoder
    mistake.
before after
recall, questions that name their subject 1.00 1.00
recall, questions that describe it instead 0.18 0.32
weighted recall 0.916 0.929
tokens per query 525 521

Nothing costs more. The gain is entirely on questions phrased the way people
actually ask them months later — "which thing did we pick because the
storage layer closes a chunk at a fixed cadence?"
— where the subject's name
is the one word you've forgotten.

Measured alone, each knob looks dead: at the old keyword weight the vote is
actively harmful, because the right answer is dropped from the candidate
set before the reranker is ever called. Their doc comments say to change them
together or not at all.

Search tuning now lives in PolicyConfig (keyword_weight, semantic_floor,
search_min_score, search_relative_cut, rerank_trust_weight,
rerank_vote_k) instead of compile-time constants, so a graph can be swept
rather than argued about. Existing graphs pick up the new defaults; anything
you set explicitly is untouched.

MobileBERT replaces DeBERTa-v3-small for NLI

Engram detects contradictions locally, with a small cross-encoder, where most
agent-memory tools spend an LLM call. That layer had never been scored end to
end — only the model in isolation, on sentence pairs, which is the wrong unit:
in check_claim the model only ever judges what retrieval hands it.

The new benchmark (eval/CONTRADICTIONS.md, --contradictions) scores the
whole path, and reports what the layer catches against what it costs. That
second number is the one that mattered: a layer answering "contradiction" to
everything scores a perfect catch rate. Measured on claims that restate a
stored note verbatim — statements the graph literally contains — the old model
called 80-86% of them contradictions.

Xenova/mobilebert-uncased-mnli is now the default:

catch false alarms ONNX
nli-deberta-v3-small (old) 97-99% 80-86% 172 MB
mobilebert-uncased-mnli (new) 95-97% 57-62% 27 MB

Five seeds, same corpus and retrieval, only the model swapped. Two points of
catch for twenty-three points of false alarms, from a model a seventh of the
size. Retrieval never missed once in any run — the entire headroom was the
model's.

Nothing to do on upgrade. The NLI layer is stateless, so there is no data
migration: the new model downloads on first serve/mcp (27 MB, one time).
The old model stays selectable under Settings → Choose models, and an existing
explicit selection keeps working. If you never picked one, you get the new
default. Suspects already queued keep the hints the old model gave them —
those only ever affected queue ordering.

Also corrected: nli.rs had long claimed the shipped model was "~34 MB". It
was 172 MB. Every candidate tested was smaller than what shipped.

check_claim gained a confidence gate

A contradiction the model is not confident about is now reported as silence
rather than as a conflict. The raw probabilities still ride along on the
verdict, so nothing is hidden — only unasserted.

This gate did not exist before. Its sibling, the write-time conflict sweep, has
held a similarity floor and a confidence gate for a year precisely because
MNLI-class models call unrelated same-shaped titles confident contradictions.
check_claim had neither and judged whatever the top-8 retrieval returned.

The threshold is 0.80, chosen on five seeds for stability rather than for
the best headline:

gate catch (worst seed) spread false alarms agreeing claims called conflicts
0.00 96% (95%) 2 pts 61% 7–13%
0.70 95% (94%) 1 pt 44% 2–6%
0.80 92% (90%) 4 pts 38% 1–2%
0.90 85% (79%) 11 pts 27% 0–2%
0.95 77% (71%) 13 pts 18% 0–1%

Tighter gates keep scoring a better catch-minus-false-alarms gap all the way to
0.95, where catch falls to seven in ten and swings thirteen points between
seeds — so the gap is not the criterion. 0.80 is the last gate before catch
comes apart.

The last column is the second reason for it. Asserting conflict against a note
a claim plainly agrees with is the worst thing this layer can do, and it was
happening to one agreeing claim in ten. The gate takes that to one or two in a
hundred.

Tunable per graph as policy.claim_contradiction_min_confidence. It is
deliberately contradiction-only: false supports has never been measured, and
gating it on the same number would be guessing.

End to end, against what 0.7.1 shipped: false alarms 80–86% → 38%, catch
98–99% → 92%.

Checked against a real graph

New --real-graph eval mode scores the suspect queue against every pair a human
has actually ruled on in a live graph, plus every conflicts-with edge. Run
against this repo's own memory (297 nodes, 42 judged pairs), it says three
things the synthetic corpus could not:

  • Real prose is not harder. Ungated false alarms are 62% on real notes
    against 61% on generated ones. The worry that mushier multi-paragraph notes
    would be worse turned out to be wrong.
  • In the product, the rate is 19%, not 38%. The queue skips pairs whose
    nodes are already linked, before the model is called — and nine of the
    thirteen pairs the model still flags at the shipped gate carry an edge
    already. Structure the user recorded, spent as precision.
  • The new model is the only candidate that catches the one real
    contradiction
    in this project's recorded history — at 0.80, and not at
    0.90. Both DeBERTas and DistilBERT miss it at every threshold. The incumbent's
    quiet queue was quiet because it barely fires on real technical prose at all.

check_claim's silent bucket is now sorted strongest-signal-first, so the
claims the model came closest to ruling on sit at the top of it.

Still open

19–38% false alarms is better, not good. The catch rate rests entirely on
generated prose: in 297 nodes this project has recorded exactly one
contradiction, so real data validates the cost side precisely and the benefit
side anecdotally. One graph, one project, one register. See
eval/CONTRADICTIONS.md.


Full Changelog: v0.7.1...v0.7.2