Skip to content

Read-path scaling and observability: bounded candidates, tenant-filter pushdown, segment reranking, live metrics - #21

Open
ly-wang19 wants to merge 25 commits into
mainfrom
claude/intelligent-hypatia-25de98
Open

ly-wang19 wants to merge 25 commits into
mainfrom
claude/intelligent-hypatia-25de98

Conversation

@ly-wang19

Copy link
Copy Markdown
Owner

Measured work on the read path, plus the harness that measures it. Every claim traces to a committed log
with a reproduce command, and the results that came out negative are recorded as negative.

The read path was O(n)

eval/scaling.py measures it: ms per 1k facts holds constant at ~17–20 from 100 to 10,000 facts, and a
single query already costs 177ms at 10k — past the <100ms target, at a store size that is small next to
the 10M-token goal.

Bounded candidate retrieval (engram/store/indexed.py, off by default) scores a candidate pool
instead of the whole store: a lexical inverted index, the vector store's search, and the facts hanging
off the query's anchor entities, unioned and slot-completed. With a pool at least as large as the live
fact count it returns bit-identical results to the full scan — the test that keeps both paths honest.

The tenant filter now pushes into the vector index. Multi-tenant retrieval filters by user on every
query, and the only way to express that was a Python predicate — opaque to the backend, so LanceDB
materialised the whole table every time. Configuring the scale backend never bought one sub-linear read.
Lifting user_id into a real column takes 75.38ms → 1.35ms at 10k rows and 302.36ms → 1.83ms at 40k,
with latency essentially flat as the table grows.

Query anchoring costs the query, not the store. query_entity_ids() walked every entity on every
retrieval. With a term index it is 0.004ms whether there are 500 entities or 10,000. The limit is stated:
entity names sharing high-frequency tokens degenerate the index to a scan.

Negation no longer triggers an entity scan. 509ms → 0.0009ms at 5,000 entities.

Correctness found on the way

Long sessions were reranked on their opening quarter. A cross-encoder reads ~512 tokens; a session is
~2000. The project had already documented this in lean_context's comment, but retrieve_episodes()
still passed whole sessions and no test covered that path. Sessions are now scored segment by segment,
each taking its best segment.

Observability

/metrics reports live latency percentiles, volume and token totals — the <50ms write and <100ms read
targets were assertions until something measured them. Aggregate-only by construction, so it stays open
like /health; a test drives real traffic and asserts neither the namespace nor the content appears.

What did not work

  • Bounding the lexical and fusion work alone bought nothing (177.03ms vs 177.88ms at 10k) because the
    semantic channel was itself a full scan. That is why the tenant pushdown exists.
  • Splitting the read context for prompt-caching saves 5–9% across long sessions and is a net loss below
    about five turns. The session map costs more than it returns until ~20 turns, so it is off by default.
  • The first entity-index benchmark measured 1.5x because its synthetic names shared tokens across every
    entity. That was the benchmark's fault, and it is written up so nobody repeats the data and concludes
    the index does not work.

Verification

458 → 503 tests, all passing; lint unchanged at its baseline; zero-setup check passes. Correctness tests
are built to fail against the old behaviour — the prefilter test hides the wanted tenant outside the
query's neighbourhood, and the rerank test asserts whole-document scoring picks the decoy before
asserting the fix. LanceDB tables written before the tenant column keep working by probing the real
schema instead of assuming it.

Accuracy is not claimed anywhere: quantifying the reranking recovery needs a keyed LongMemEval run.

Reproduce: python3 eval/scaling.py — evidence in results/bounded_candidates_scaling.md,
results/entity_anchor_index.md, results/layered_context_tokens.md.

claude added 25 commits August 12, 2026 13:30
The export->import roundtrip was broken: /v1/export payloads could not be
imported back (raw 500), so a namespace could not move between instances.

- Native 'engram' import format (auto-sniffed by engram_export_version):
  Memory.import_export() restores facts with their original ids, bi-temporal
  stamps, supersession chains, and provenance; episodes arrive consolidated
  so System-2 never re-extracts duplicates; idempotent by id; the target
  re-embeds locally, which is also the embedder-migration path.
- /v1/import returns 400 with the parser's reason on malformed payloads.
- MCP streamable-HTTP transport gains a Bearer gate (--http-token /
  ENGRAM_MCP_HTTP_TOKEN); non-loopback binds without a token fail closed.
- ENGRAM_STORAGE env selects the vector backend (memory|lancedb), rejecting
  unknown values at startup so /health reports the real backend.
- Import CLI local mode writes through MemoryService: same digest-backed
  namespace dirs and locks as HTTP/MCP (a/b no longer lands in a stray dir).
- /v1/stats filters by canonical linked identity like every other read path.

Docs: API.md migration recipe, agent-adapters --http-token, architecture
map + full report entries, and the cross-account memory roadmap.
Several capabilities were written, tested, and then never merged; the memory ledger records them as
shipped while main does not have them. Triage each one against today's main by capability rather than
by filename, since main evolved 100+ commits independently and often implements the same thing under a
different design.

Two conclusions worth keeping: main's JSONL persistence layer is stronger than the branch's SQLite
snapshot (so that one is dropped, not merged), and five worktrees hold 2000+ lines that were never
committed to any branch at all — deleting those worktrees would destroy the work.
…ss that measures it

The read path scored every live fact on every query. eval/scaling.py measures what that costs: ms per
1k facts holds constant at ~17-20 from 100 to 10,000 facts -- the signature of O(n) -- and a single
query already takes 177ms at 10k facts, past the <100ms read-path target, at a store size that is small
next to the 10M-token goal.

Bounded retrieval scores a candidate pool instead: a lexical inverted index, the vector store's own
search, and the facts hanging off the query's anchor entities, unioned, then completed so every
candidate's conflict slot arrives whole (otherwise a superseded fact whose slot head fell outside the
pool would survive -- a correctness bug, not a ranking one). Corpus statistics stay tenant-scoped so BM25
scores a subset exactly as a full scan would, and candidates are resolved in store order because
rank-based fusion breaks ties by position, so an unordered set would make the same query answer
differently run to run.

The measured result is negative where it matters, and that is the useful finding: with the semantic
channel on, bounding the rest buys nothing (177.03ms vs 177.88ms at 10k), because neither backend has a
real ANN index -- the in-memory store brute-forces cosine and sorts, and LanceDB materialises the whole
table whenever a Python predicate is supplied, which multi-tenant retrieval always supplies. With that
channel off the same code is 14.5x faster at 10k and genuinely sub-linear (ms/1k falls 6.17 -> 1.23). So
the candidate-pool design is sound and the blocker is the missing filtered ANN, which is now the P0 on
the optimization map.

Both flags default off: the published numbers came from the full scan, and dropping the semantic channel
would lose the recall the hybrid thesis depends on. A pool at least as large as the live fact count
returns bit-identical results to the full scan, which is the test that keeps the two paths honest.

A decorator maintains the index so none of the eight existing upsert/delete call sites had to change.
…ng for it

Multi-tenant retrieval filters by user on every single query, and the only way to express that was a
Python predicate. A backend cannot see inside one, so it had to hand every row to Python before ranking:
LanceDB called to_arrow().to_pylist() on the whole table. The consequence is that configuring the scale
backend never bought a single sub-linear read -- there was a vector file, not a vector index.

Lift user_id out of the opaque JSON payload into a real column and give VectorStore.search() a
declarative user_id= alongside the general predicate, so LanceDB can prefilter inside its own index.
Measured on a skewed two-tenant table: 4.04ms -> 1.13ms at 500 rows, 75.38ms -> 1.35ms at 10k, and
302.36ms -> 1.83ms at 40k. The pushed-down path barely moves as the table grows 80x while the predicate
path is strictly linear -- 165x at the top end.

The correctness risk with any filtered ANN is that the filter runs after the search, which silently
returns too few rows or none. test_prefilter_finds_hits_beyond_the_unfiltered_neighbourhood is built to
fail in exactly that case: the majority tenant fills the query's whole neighbourhood and the tenant being
asked for sits far away, so a post-filter finds nothing.

Tables written by earlier releases have no such column. _has_tenant_column() probes the real schema
rather than assuming, so those keep reading and writing -- falling back to a scan -- instead of failing
on a schema mismatch.

eval/scaling.py now reports this alongside the retriever benchmark, skipping it when lancedb is absent.
…sh key lookups into LanceDB

Every retrieval runs query_entity_ids(), which ends by calling graph_excluded_entity_ids(), which ran
two regexes against every entity name in the store looking for "not Lisbon" style constraints. Almost no
query contains a negation, so almost every retrieval paid for a full entity scan that found nothing. At
5,000 entities a plain query spent 509ms there; it now spends 0.0009ms.

The matcher is anchored to the end of the text preceding an entity mention, so it cannot be run against
the whole query directly. What can is a necessary condition: if no cue word appears anywhere, no slice
can contain one. Both regexes are now built from a single cue list so they cannot drift.

Writing that cue test the obvious way was wrong, and the test caught it. A non-ASCII entity name has no
word-boundary guard in _entity_name_mentions, so the preceding slice can end mid-word: in "not上海" the
slice "not" ends on a boundary the full string does not have, and \bnot\b misses it -- silently dropping
an exclusion. The cue test therefore drops its trailing \b and is deliberately weaker than the real
matcher. Over-matching costs a scan that finds nothing; under-matching loses a constraint the user
stated. test_exclusion_shortcut checks the implication over every prefix of its samples, so adding a cue
to one regex and not the other fails loudly.

Separately, LanceDBVectorStore.get() materialised the table and scanned it in Python for one key, which
makes any id-at-a-time access quadratic. It now issues a filter-only query so the predicate runs inside
LanceDB. (table.query() does not exist in 0.33 and pylance is not installed; search().where() is what
works, verified rather than assumed.)

Still linear, and now written down: the exclusion scan itself when a query does contain a cue (its
matching is substring-based, which Chinese names require, so a token index cannot replace it), and the
name/alias matching in query_entity_ids, which is tokenised and could be indexed.
Five worktrees held work that existed in no branch at all, so deleting them would have destroyed it. It
is now committed as-is on each worktree's own branch, with the triage recording what each one is worth
against today's main. Preserved is not merged: all of it predates 100+ commits of main and needs
re-checking before any of it is ported.

Two items are not superseded and are worth acting on. segment.py describes a defect that is still live --
the cross-encoder reranks whole ~2000-token sessions at max_length=512, scoring only the first quarter of
each one. And the read context is still assembled as a single flat string, so nothing in it can be reused
by provider prompt-caching across a multi-turn session.

The privacy hold is cleared: the demo corpus uses a pseudonym per the repo owner, and a scan for other
identifying data found none.
…arter

A cross-encoder reads a bounded window -- 512 tokens for the BGE rerankers -- and a LongMemEval session is
around 2000. Handed a whole session it does not fail; it scores the first quarter and discards the rest,
so a session whose answer sits late ranks as if it were irrelevant.

The project already knew this. lean_context reranks only the fact pool and its comment says reranking
whole sessions "truncates to 512 and mis-ranks -- a known _S regression". But retrieve_episodes() still
passed whole ep.content to the reranker, and no test covered that path, so the documented defect stayed
live in the one place it applied.

Sessions are now split on paragraph and sentence boundaries into segments that fit the window, and each
session scores as its best segment. Best, not mean: a long session earns retrieval because one passage
answers the question, and averaging dilutes exactly the signal being looked for. Short candidates produce
a single segment, so nothing changes for them.

rerank_long takes any object with .rerank rather than a concrete reranker, so segmentation is testable
without loading a cross-encoder -- the zero-setup invariant means the default test path cannot import
sentence-transformers. The regression test asserts the precondition first (whole-document scoring picks
the decoy) and then the fix, so it fails if the old behaviour ever returns.

Measured accuracy is not claimed here: quantifying the recovery needs a keyed LongMemEval run with
reranking enabled, which is real API spend. What is verified is that the truncation is gone and that the
answer-bearing session survives reranking.
…store

query_entity_ids() runs on every retrieval and walked every entity in the store to work out which ones
the query names. InMemoryGraphStore now keeps a (user_id, stemmed term) -> entity ids index, so only the
query's own terms are looked up.

With distinctive entity names -- people, places, companies, which is what a real graph holds -- the cost
stops tracking the store entirely: 0.004ms whether there are 500 entities or 10,000, against 16.05ms for
the scan at 10k.

The limit is worth stating plainly, because the first benchmark walked straight into it. Synthetic names
of the form "entity number {i}" share two tokens across every entity, so those posting lists are as long
as the store and the index degenerates to a scan with a lookup in front of it -- 1.4x, still linear. That
measurement was the benchmark's fault, not the index's, and it is written up in results/ so nobody
repeats the data and concludes the index does not work.

The alias-anchor filter (length, digits, stop words) tests the term rather than the entity holding it,
which is what lets the same decision come from a term index as from the full walk; it is now a named
predicate used by both paths instead of being inlined in one.

Equivalence is tested against the real retriever: the same entities in a store with the index and in one
with the lookup removed must anchor identically. Graph backends that do not implement the lookup keep
the scan, since that is all the GraphStore interface guarantees.
The architecture states a <50ms write path and a <100ms read path, and the project's discipline is that a
number nobody can reproduce does not exist. Neither target was measured on the running service, and the
token-saving claim came only from offline benchmark logs -- never from what the service actually served.

engram/metrics.py adds sliding-window percentiles per operation, monotonic counters, and token totals.
Pure stdlib and bounded memory: the window keeps percentiles describing current behaviour instead of
averaging a regression away under months of history, and the process cannot grow by collecting samples.
remember, recall, import and close_session are instrumented, plus a remember_degraded counter --
consolidation failures still return success, so without a counter a silently degrading write path looks
perfectly healthy.

This was rewritten against today's service rather than replayed from the branch it was preserved on, and
that surfaced a real bug in the original: the savings ratio divided a full-history total by a context
total drawn from a larger set of calls. Only the answer path computes a baseline, so nine cheap recalls
plus one measured one reported roughly 1x -- memory appearing to save nothing -- where the comparable call
showed 10x. The ratio is now computed from paired samples only, and is absent rather than invented until
at least one pair exists.

/metrics is unauthenticated like /health, which is a claim that has to hold: the payload is aggregate-only
by construction, so it cannot reveal that a tenant exists, let alone what they stored. A test drives real
traffic through the app and asserts the namespace and the content are both absent from the response.
…y half

lean_context returns one flat string that callers drop into the user turn, so a multi-turn session
re-sends and re-processes the parts that did not change -- the profile, the instructions on how to use a
retrieved slice -- on every single turn. Splitting it lets the unchanging half sit in the system prompt
where provider prompt-caching can reuse it, while only this query's evidence varies. The retrieved
evidence is identical either way, so accuracy is unchanged by construction; this is a tokens-and-latency
change and should not be expected to move a benchmark score.

The whole thing rests on the stable half being query-independent, so that is the test: the same user's
different questions must produce a byte-identical stable block, or caching misses every turn and the
split costs more than it saves.

Measured before claiming anything, and the numbers are more modest than the module this was preserved
from implied. The pure split saves 5-9% across long sessions and is a net LOSS below about five turns,
because the flat context is small enough that the first turn's extra structure never pays back. Its
ceiling is simply the profile's share of the context.

The session map costs more than it returns. It is content the flat context does not carry, so the first
comparison was really flat-versus-flat-plus-a-new-block: -93% at one turn, breaking even around twenty.
It is therefore off by default and opt-in for long sessions or when progressive disclosure is wanted for
its own sake. Both non-adoptions are written into the optimization map rather than left implied by a
default.

Writing the tests surfaced a real wrinkle too: the usage guide told the model to consult a MEMORY MAP
that a redacted context had removed, pointing it at something it could never be given. The map sentence
is now appended only when a map is actually present.

Not wired into lean_context or the OpenAI-compatible proxy: short sessions lose, and the published
numbers came from the flat path. Callers choose per session length via Memory.layered_context().
Two different ways the multi-tenant surface was exposed. Without a rate limit one caller can spend the
whole process, and once the LLM-backed paths are wired, the whole budget. Without idempotency a client
that retries after a network timeout stores the same episode twice and pays to consolidate it twice --
the first request succeeded, only its response was lost.

The limit is enforced inside auth() because that is the one place every protected route already passes
through to learn who is calling, so a new endpoint cannot forget to be limited. /health and /metrics stay
unauthenticated and therefore unlimited, which is deliberate: probes must keep working while a tenant is
being throttled, or one busy caller takes the deployment down with it.

Rewriting rather than replaying the preserved implementation caught a leak in it: the per-tenant hit map
was a defaultdict that was never swept, so every tenant that ever called stayed in memory forever. That
is the kind of slow growth that only bites the deployment with the most tenants, which is the one that
can least afford it. Entries whose windows have emptied are now pruned, opportunistically so the server
still needs no background thread.

Two properties are easy to get subtly wrong and are tested directly. A rejected request is not recorded,
because counting rejections keeps a retrying client's window permanently full and it never recovers. And
the idempotency cache is keyed by tenant as well as key, so two namespaces choosing "retry-1" cannot read
each other's response -- that one is a cross-tenant leak, not an inconvenience. Only successful responses
are cached; replaying an exception would turn a transient failure into a permanent one.

Both are in-process and documented as such: behind several replicas the effective limit is per_min x
replicas and a retry routed elsewhere re-runs. Saying so beats shipping something that looks distributed
and is not.

Rate limiting is off by default, so the zero-setup demo and existing deployments are unchanged.
…in token

Authentication was a static env map — edit ENGRAM_API_KEYS and restart to add a tenant — or open mode,
where anyone is a tenant. A hosted deployment needs to mint keys while running and revoke them
immediately, without ever holding the secret in a form a leaked file would expose. Keys are minted as
sk-engram-*, returned exactly once, and only their SHA-256 digest is persisted.

The admin surface fails closed: with ENGRAM_ADMIN_TOKEN unset there is no key-management endpoint at
all, so an open-mode deployment cannot have tenants minted against it by whoever finds the URL.
Resolution order is issued keys, then the static map, then open mode, so a revoked key cannot be
resurrected by a stale env entry and existing deployments keep working untouched.

Rewriting rather than replaying the preserved implementation caught a data-loss bug in it. A corrupt key
file was swallowed and the store started empty — which rejects every issued key, and then the first
issue() rewrites the file and destroys records that were only unreadable. It now refuses to load and
leaves the file exactly as found: failing to start is recoverable, overwriting is not. Two smaller fixes
came with it — last_used_at was mutated outside the lock, and the key file was written with default
permissions even though it enumerates the tenants on the deployment.

Because this is an auth surface the tests are mostly about refusing: admin absent unless enabled, wrong
admin token rejected, revoked key rejected and revocation surviving a restart, an unreadable store
answering 503 rather than falling through to another auth path, and neither the secret nor its digest
appearing in any listing — publishing the digest would let anyone verify a guessed key offline.

Lint caught a weak test on the way: the isolation case issued bob a key and never used it, so it would
have passed even if bob's key resolved to alice's namespace. It now reads through the API as bob.
The agent ecosystem is Python-first and the only SDK was TypeScript, so a Python caller had to hand-roll
HTTP against the API. This ships inside the core package and speaks over stdlib urllib, keeping the
promise that installing Engram pulls in nothing. Method names mirror the TS client so the two read the
same way and neither drifts into being the real one, and it covers what the server actually exposes
today -- including /metrics, /v1/admin/keys and Idempotency-Key, none of which existed when the
preserved version was written.

Writing it surfaced a flaw in my own first draft: the transport returned (status, body), so
EngramError.retry_after could never be populated -- Retry-After is a header. A field that is always None
is worse than no field, because a caller writes a backoff around it and it silently never fires. The
transport contract now carries headers, which is also what a custom transport needs in order to be a
faithful substitute.

The tests drive the SDK against the real application through the injectable transport rather than a
mock. A mocked client test only proves the SDK agrees with itself, and agreeing with itself while
drifting from the server is precisely how client libraries fail. So the rate-limit test provokes a real
429 and asserts retry_after survives, the idempotency test asserts one episode landed rather than two,
and the admin tests check that a tenant key cannot mint tenants.
…tion actually do

Three defences shipped over the last rounds and all three are invisible from outside. An operator cannot
tell whether throttling has ever fired, whether clients set Idempotency-Key at all, or whether a rising
tide of rejected keys means a misconfiguration or someone trying keys. /metrics now carries
rate_limited, idempotent_replays, auth_rejected and auth_misconfigured.

Deliberately aggregate, with no per-tenant breakdown. The endpoint is unauthenticated, and a counter
keyed by tenant would tell any caller which tenants exist on the deployment — the same reason the rest of
the payload carries no namespace names.

The counters route through a helper that swallows its own failures, because instrumentation must not
change behaviour. Building the service can itself fail on a misconfigured backend, and that surfacing
from inside an exception handler would replace a precise 401 with a generic 500 — losing exactly the
diagnosis the counter exists to support.

auth_misconfigured is separate from auth_rejected on purpose: a 503 means the operator broke the key
configuration, a 401 means the caller presented the wrong key, and an operator paging on the wrong one
of those wastes the outage.
Runtime key issuance, rate limiting, Idempotency-Key, the defence counters and the Python SDK all
shipped without a line of documentation. For a project whose pitch is that it is reproducible and
self-hostable, a capability nobody can find is a capability that does not exist — and the audience most
affected is the operator deploying it, who reads deploy/.env.example and never learns these knobs are
there.

API.md gains the key lifecycle, retry-safe writes and the metrics payload, each with the operational
caveat that matters rather than just the happy path: the admin surface is absent unless
ENGRAM_ADMIN_TOKEN is set, the plaintext key exists only in the issuing response, a corrupt key store
answers 503 rather than starting empty, and both the limiter and the idempotency cache are in-process so
several replicas multiply the effective limit and can re-run a retry.

deploy/.env.example gains the new variables, with ENGRAM_ADMIN_TOKEN deliberately left commented out —
an example file that hands operators a working admin credential would undo the fail-closed default it is
supposed to explain.

Both READMEs get the Python SDK, kept in sync as the contributing rules require. The copy sticks to
what the code does — no competitor names, no scaling claims a committed log does not back.
The premise written into layered.py was wrong for this surface. The OpenAI-compatible proxy never put
memory in the user turn -- it puts the whole retrieved slice in the system prompt. So the split does not
buy a placement change here; it buys prefix stability. Provider prompt-caching matches on a prefix, and
with the per-query evidence sitting in the system block that prefix changed every single turn, so nothing
was ever reusable.

With the query-independent half moved to the front of the system block and this turn's evidence riding
with the user turn, the system block becomes byte-identical across a session: five distinct blocks over
five turns becomes one. The response reports cacheable_tokens_est so a caller can reason about it rather
than guess.

The honest measurement is that this does not save tokens. Compared like for like it is -1.1%, which is
noise. What it produces is a 61-token stable prefix and a property that did not exist before. Whether 61
tokens is worth having depends on a provider's cache-read pricing, which this measurement does not
model, so it stays opt-in via {"memory": {"layered": true}}: worth it across a session, pointless on a
one-shot call.

I walked into the same measurement trap twice. The first pass counted RECALL_GUIDE and reported +14%,
"the split is more expensive" -- but the guide is content the flat path does not carry, exactly the
flat-versus-flat-plus-a-new-block comparison that made the session map look bad last time. Fixing the
comparison also exposed a real duplication: the proxy already frames memory with _MEMORY_PREAMBLE, so
the library's guide was a second copy of the same instruction, costing 14% and changing nothing. The
proxy now passes guide=False, and the optimization map carries a warning about the trap itself.
Re-running an identical configuration moves 6-10 of 500 answers, so "83.6 to 84.4, we improved" has
never been a finding on this benchmark — and a long list of mechanisms was retired on exactly that
basis, one expensive run at a time. What was missing is an instrument that decides, before and after,
whether a difference is real.

eval/significance.py adds McNemar's exact test over paired per-question outcomes, a seeded bootstrap
interval on the difference, and — the part that changes what gets run at all — the minimum detectable
effect, computed from the item count and how often two systems disagree. Only disagreements carry
information, so the usable sample is far smaller than the headline item count: 500 questions at a 22%
disagreement rate resolve about 2.94 points, not fractions of one.

Two results follow immediately, both in results/significance_headline.md.

The published headline holds up: engram_lean 83.6% against full_context 73.2% is 81 wins to 29 over 110
disagreements, p < 0.0001, plausibly +6.4 to +14.4 points. That claim now has a test behind it rather
than two percentages side by side.

And the gap to the top of the leaderboard is 1.6 points, which is *below this benchmark's resolution at
n=500*. It is not "not caught up yet"; it is not decidable with this measurement. Grinding algorithms
against it produces noise, not rank. The optimization map now says so up front, with the consequence
spelled out: only attempt changes expected to move more than ~3 points, and aim them at the two large
weak categories (multi-session and temporal-reasoning, 248 of 500 questions between them), because a
+10 in one category is only about +2.5 overall.

Pure stdlib, exact binomial rather than a chi-square approximation — the discordant counts here are
small enough that the approximation is wrong precisely where the decision is closest.
Everyone here knows the answerer flips "roughly 6-10 of 500" between identical runs. That number is the
floor under every accuracy claim the project can make — a mechanism gaining less than it cannot be shown
to work — and it has never actually been measured. The three committed lean logs are three different
configurations, so none of them can supply it.

eval/noise_floor.py takes two or more runs of the same config and reports what changed: how many answers
flipped, in which direction, the widest accuracy gap between supposedly identical runs, and the smallest
gain that survives as a consequence. Flips are the apparatus, not a regression, so both directions are
reported as one phenomenon rather than as one run beating the other.

It also warns when two "identical" runs differ by more than chance, because that is not noise — it means
the runs were not identical, and averaging it in would bake a configuration difference into the floor.

The runbook spells out what the measurement needs (two provider keys and the dataset, neither present in
this environment), what it costs from the committed logs rather than a guess (4.78M context tokens and
about 8.4 hours per run), and the requirement that the two commands differ only in --out.

Before asking anyone to spend on this, the free check: the offline harness gives byte-identical results
on this branch and on main (100.0%/70.0% accuracy, 5.4/14.2 context tokens), with lower latency from the
negation early-out. So flips measured later are the answerer's, not this branch's.
… that misdirects reproduction

Accuracy says how many questions were missed. It does not say whether the system had nothing to offer,
was off by one, or answered confidently and wrongly — and those need different mechanisms. Designing
against an aggregate means designing against a shape no individual failure has.

eval/error_modes.py classifies each miss mechanically (abstained / numeric / wrong value) and reports
what fixing each mode would be worth against what this benchmark can resolve. Run free against a
committed log, before anyone is asked to pay for a run.

Two findings on the headline log, both new relative to what was recorded before.

The two large weak categories fail in different ways, so one mechanism cannot serve both:
multi-session is 76% numeric errors (counting and cross-session aggregation) while temporal-reasoning is
63% abstentions (declining a question the memory could answer). Only the aggregate split was known
before, which hid this.

And the numeric errors go both ways — 16 under, 12 over — not the systematic undercount previously
believed. That distinction decides the target: a one-sided miss means evidence was not recalled and
recall expansion would fix it; a two-sided one means the evidence is largely there and the counting
itself fails. The earlier belief would have aimed a mechanism at the wrong problem.

Sizing against the 2.94-point floor: attacking either category alone lands right at the edge of
measurability (19 questions = +3.8, 15 questions = +3.0). Both together are 34 questions, +6.8 points,
comfortably resolvable. So the two lines have to be worked as one experiment.

The tool's own first version had the bug it now warns about: it folded `_abs` items into their base
category. Those are the benchmark's unanswerable variants, graded by a different judge, where refusing
IS the right answer — counting them as abstention failures inflates the target and points the next
mechanism at nothing. It now splits them exactly as report.py does, and the category counts agree.

Separately, the optimization map pointed at results/headline_500.jsonl as "the current headline raw log".
It is not: that file is a different run at 79.0/76.0. Anyone reproducing the published 83.6/73.2 from it
would fail to match. Both real logs are now named, with a note that the two numbers come from two runs
sharing the answerer, judge and item set.
The obvious reading of 34 abstentions is that retrieval failed to find anything, and the obvious fix is
to retrieve more. The committed log says otherwise: the median retrieved context is 9,666 tokens on the
questions the system refused and 9,600 on the ones it answered correctly — a one percent difference
across every outcome type. The refusals were handed as much evidence as the successes.

That relocates the problem. Whatever fails on those 34 questions happens after retrieval: either the
right amount of the wrong evidence came back, or the evidence is present in a form that cannot support
the reasoning asked for — dates retrieved, but no interval, duration or ordering derived from them.
Either way, spending on recall expansion would buy nothing, which is what the previous reading would
have recommended.

error_modes.py now reports this split, so the check is one command rather than a one-off script.

Separating the two remaining explanations needs the retrieved context itself, and the committed logs
carry only its size. The harness already computes answer_session_hit; the existing context dumps happen
to cover three of these questions, which is not a sample worth drawing from — noted as directional at
most. A targeted re-run of just the 64 questions in the two failure modes would settle it at about an
eighth of a full run, and that is written up as the one paid step this analysis actually needs.
report.py put two accuracy percentages side by side and left the reader to subtract them. On this
benchmark an unchanged configuration moves several answers on its own, so a gap can be smaller than the
apparatus that measured it — and nobody reliably remembers to run the test separately before believing a
number. Every multi-system log now carries the verdict under the table.

It immediately caught a presentation bug in my own code. The difference is computed B-minus-A, so when A
was the winner the sentence said "A beats B by 10.0 points, plausibly [-13.4, -6.6]" — a reader cannot
tell from that whether the effect is positive or negative. The interval is now reoriented to match
whichever system the sentence names first, with a test pinning both directions.

report.py runs as a script, so the repo root was not importable and the sibling analysis modules could
not be reached; it now puts the root on the path the same way the other eval tools do.
…full detail

Three separate signals now agree that retrieval is not what fails. Refusals get as much context as
correct answers. The answer-bearing session is retrieved for 79 of 82 failures. And the numeric errors go
both ways rather than systematically low. Each ruled out a different retrieval explanation; together they
locate the problem after the evidence is in hand.

Rank makes it sharper than membership did. The run rendered its top 2 sessions in full and compressed the
rest to summaries, and 60 of 82 failures had the answer session inside that full-detail window. The
answerer was looking at the raw evidence and got it wrong anyway — 65% of the refusals, 77% of the
counting errors.

That retires a whole class of work. Anything aimed at retrieving more is worth at most 3 questions, or
+0.6 points, against a floor of 2.94 — unmeasurable by construction. The optimization map's lightweight
n-hop/PPR expansion is downgraded on that evidence rather than on taste.

Widening the full-detail window is also declined, with its own numbers. Going from 2 chunks to 15 would
promote 19 questions from summary to detail, a nominal ceiling of +3.8 points. But 60 questions already
failed *with* full detail, so the conversion from "shown" to "correct" is demonstrably well under one,
and the token cost moves back toward the full-context baseline the headline claim is built against.
Buying a smaller-than-stated gain by conceding the thesis is a bad trade.

The check itself is LLM-free — session retrieval runs on the local embedder and BM25, and the benchmark
labels which session holds the answer — so all of this cost nothing to establish.
…shows half of it

The previous conclusion here was wrong, and wrong in a way that would have sent the next mechanism at
the wrong layer. It recorded only the rank of the *first* matching answer session and concluded that 73%
of failures had the evidence in full detail, so the failure must be after retrieval.

Most of these failures are counting questions whose answer spans several sessions. For those, "the
best-ranked answer session made the top 2" can mean one of four made it — which is precisely why the
count comes out wrong. The right measure is coverage, not first hit.

Measured properly: retrieval recall is 86%, but only 14 of 58 multi-session failures (24%) had all their
answer sessions inside the full-detail window, at an average coverage of 48%. The evidence is fetched and
then half of it is compressed to summaries, because assembly renders the top 2 by relevance while
counting needs coverage.

That also explains what nothing had explained before: why the numeric errors go both ways. Sessions
compressed to a summary get missed (16 undercounts); an ambiguous summary line gets counted twice (12
overcounts). The earlier reading — that arithmetic itself fails — was a layer off.

So the mechanism this points at is narrow and testable: for aggregation queries, let the full-detail
window cover the evidence set rather than the top N by relevance. The trigger already exists and already
works — plan_evidence().aggregation fires on 90% of the counting failures against 79% on the counting
successes, so widening the trigger, the obvious next idea, is also ruled out. What it does not do is
influence how many chunks get rendered in full.

This is deliberately not "widen chunks to 15", which was rejected on cost: aggregation queries are about
a fifth of the traffic, so the extra tokens land only where they are needed instead of pushing every
query back toward the full-context baseline.

Sized against the floor: 44 questions lack full coverage; at even a 50% conversion that is +4.4 points.
The conversion rate is the untested premise and is checked next, offline — 60 questions already failed
with full detail in view, so it is certainly not 1.
…says why

Traced the counting failures to a one-line cause: evidence.py hands aggregation queries n_chunks=1, the
lowest non-zero budget there is, while preference, procedural, exact-lookup, multi-hop and duration all
get 2. On the committed headline log those failures needed a median of 3 answer sessions, and the planner
had already generated a median of 3 subqueries to find them — then asked for one chunk.

It looked like a one-line fix. It is not, and measuring it before shipping it is the point.

Widening the budget lifts answer-session coverage from 38% to 56% at cap=5 and only 59% at cap=12, while
rendered sessions go 2.0 -> 4.4. Raising the budget 2.4x buys three points of coverage, because the
subqueries run out of distinct sessions at about four. Best case is +1.2 points, under a conversion of 1
that the same log refutes — below the 2.94-point floor either way, and paid for in tokens that push the
lean context back toward the full-history baseline. No-go, recorded as such.

The measurement also kills my own sizing. I had estimated +4.4 points from "44 questions lack coverage x
50% conversion", which silently assumed the mechanism would achieve full coverage. It reaches 56%.
Estimating a gain from an unverified premise produces a wish, not a prediction.

What it does establish is where the real constraint is. 89% of the answer sessions sit in the main
query's top-15, and the round-robin selects 59% of them however large the budget. So the binding
constraint is which sessions get picked, not how many may be rendered: interleaving subqueries by rank
optimises for finding the most relevant session per angle, while a count needs every relevant session.
Those two goals conflict at selection time, and that is the next hypothesis to test offline.

The knob stays, inert at 0, documented with the numbers that say not to turn it on — a coverage-driven
selector would want the budget it provides.

eval/coverage_check.py mirrors the real detail_eps loop rather than a top-N simplification, because
measuring a selection strategy the code does not have would have reported a gain that evaporates in
production; a test pins that ordering.

Also in this change, from the parallel-repair verification: the TypeScript client had retryAfterOf()
defined and never called, so EngramError.retryAfter was undefined on every response including the 429 it
exists for — the same trap the Python SDK hit earlier. Wired up, with tests that provoke a real 429.
Removed EngramAdminOptions, whose JSDoc pointed at an EngramClient.admin method that does not exist, and
thirteen type imports that no signature used. And eval/locomo10.json (2.8MB, third-party) is now
gitignored: the existing data/ rule covered only the fallback path, not the documented one.
Left over from the lint pass. os was imported and never referenced; verified by grep and by running
the script, and it is one of the 33 lint findings the CI gate would flag once that gate exists.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants