Skip to content

Let the memvara provider select turns by role and fill a token budget - #1

Open
sethigoldy wants to merge 10 commits into
memvara-providerfrom
memvara-budget-arms
Open

Let the memvara provider select turns by role and fill a token budget#1
sethigoldy wants to merge 10 commits into
memvara-providerfrom
memvara-budget-arms

Conversation

@sethigoldy

@sethigoldy sethigoldy commented Sep 3, 2026

Copy link
Copy Markdown
Member

Three switches on the memvara provider, every one off by default so the shipped provider renders byte-identically — pinned by a test that compares the default render against a fixture.

  • MEMVARA_SEARCH_K — the k sent on every search (default 30).
  • MEMVARA_ROLE_SELECToff, user or route. user keeps user turns only. route keeps assistant turns only when the question asks what the assistant said — fourteen literal phrasings on word boundaries, case-insensitive, fitted on the 301 LongMemEval questions outside the 199-question sample and frozen — and user turns otherwise.
  • MEMVARA_TOKEN_BUDGET — fills the turns block greedily in the order memvara returned, stopping at the first line that would push the whole block past the budget and always keeping the first, counted with the same o200k_base encoder the harness reports contextTokens with. Nothing is re-sorted.

Why: measured offline on the 199-question sample, gold turns render at a median of 165 tokens and sit at median rank 2, while the shipped 15-turn context costs 4,089; selecting on role is worth about eight times reranking at a 720-token budget, and the only question type a user-only rule loses is the one whose answer is something the assistant said — which the routing rule recovers. The judged arms these knobs exist for are memvara-routed720 and memvara-useronly720; findings are recorded in the memvara repository under docs/superpowers/plans/ dated 2026-09-03.

Two things a reviewer should look at rather than skim:

  1. The token counter passes the special-token check off (encode(text, [], [])), matching the existing counter's fallback. One turn in the 500-question set carries a literal <|endoftext|>; without this a budget arm fails a question the control answers, and the gap would read as accuracy.
  2. The two older truncation knobs (MEMVARA_HEAD_WHOLE, MEMVARA_TAIL_CHARS) now refuse values that are not non-negative integers instead of silently rendering the control on a typo.

The comment above the truncation knobs now carries both measurements: answer-string presence favours assistant turns; the dataset's own labels put 842 of 896 gold turns in user turns, 51 of the 54 assistant ones in single-session-assistant.

Every test was shown to fail under a mutation of the code it watches (18 mutations); the one gap that found — dropping the question from the renderer — got its own test. bun test src/providers/memvara: 60 pass. Prettier and tsc --noEmit clean on the changed files; six pre-existing files elsewhere still fail format:check and are untouched.


Review, and what it changed. A high-effort review filed twelve findings, nine confirmed. All are addressed in the second commit: one env parser for the six knobs with one rule for blank values; MEMVARA_TURNS_ONLY validated; both truncation knobs validated whenever either is set; the fill skips the encoder only when the block's UTF-8 byte length proves it fits (bytes, because a character can be several tokens); the recorded token count encodes with the special-token check off like the budget does; a block with a cut turn is no longer headed "verbatim"; selection that empties a turn list logs a warning; the k: 30 test is guarded against the arm's environment; the resolved settings are logged at init. The one finding not changed in code — the routing phrasings also match questions about the user's own history — is the benchmark-fitted behaviour the design records, and the docstring now says so.

The arms' renders are pinned byte-identical to the first commit by a fixture captured before any edit and re-derived independently from that commit; 4,560 differential renders agree. bun test src/providers/memvara: 93 pass.

Third commit — routing through a gateway that names models by provider. OPENAI_MODEL_PREFIX, unset by default, is prepended to the id sent for OpenAI-provider models only (openai/gpt-5.4), applied once in getModelConfig so both the answering client and the judge pick it up; the alias the run is started with and the checkpoint records stays gpt-5.4, so a run through such a gateway is comparable with one that went direct. Five tests; 98 pass overall.

Fifth commit — a second answer prompt. MEMVARA_ANSWER_PROMPT=v2 adds three reading bullets (later-dated value wins; list then count; ground advice in stated preferences) and one instruction (answer when the reasoning has the values), targeted at the thirteen judged misses where every gold excerpt was in the prompt. v1 is the default and byte-identical to before, pinned against the whole-prompt fixtures; the review checked 46,080 knob combinations against the previous file with zero mismatches. Selection and token counts do not change between the prompts. 108 tests.

Sixth commit: MEMVARA_CONTEXT_FILE

A judged arm can answer from pre-rendered context blocks: a JSONL file of {question, block} rows keyed by the question text with whitespace collapsed. When the knob is set, renderMemvaraContext returns the block verbatim and a question missing from the file aborts the run, so an arm never mixes overridden and provider-rendered contexts. The empty-context build the answer phase uses to measure contextTokens is left alone, so the recorded count is the block's size. Unset, rendering is byte for byte what it was; the pinned goldens still match. It exists so that a selection or rendering rule can be screened offline and judged before it is ported into the provider; the arms that used it are documented in the agent-memory repository's benchmark branch.

Seventh commit — MEMVARA_RANKED, and the offline scoring script for the parity run. One knob, off by default, adds ranked: true to the search request and nothing else — the request body is byte-unchanged with it unset, pinned by a test. A ranked hit's selected/span outcome now carries through onto the turn the provider returns, since the offline screen ahead of the judged run needs it to tell the kept set from the rest; a plain read or an older server leaves both fields off the turn entirely rather than null. memvaraProviderSettings refuses at startup a stack that pairs MEMVARA_RANKED=1 with MEMVARA_ROLE_SELECT or MEMVARA_CONTEXT_FILE, since either would hide the server's own order from what the parity run is measuring. src/scripts/score-ranked.ts is that offline screen: it reads a run checkpoint's search results, matches them against LongMemEval's own has_answer labels, and reports gold recall and the non-gold keep rate of the kept set in the shape the design's own extract.py prints. No judged or paid run has been made against this change yet — that is the next step, from the two branches this design also touches in the memvara core and cloud repositories. bun test: 135 pass.

Eighth commit — review of the seventh, fixing a scoring-population gap. A high-effort review of the ranked knob and the scoring script filed ten findings, eight confirmed and two plausible. All eight confirmed are fixed:

  • score-ranked.ts counted every turn a ranked search returned as kept or missed, so MEMVARA_SEARCH_K set past the selector's own top_n folded turns the selector never evaluated (selected: null) into the same population as the ones it actually saw (true/false). That inflated the non-gold denominator and counted any gold turn ranked past top_n as missed even though the model never got the chance to keep it — the printed numbers were not comparable to extract.py's. selected: null is now skipped rather than scored.
  • sawRanking used to go true on any selected !== undefined, so a run where every question was served unranked (selected: null throughout — no key, the switch, or a fallback) could still print a low recall that looked like a real result. It now requires a turn actually seen (true or false), so an all-null run reports nothing to score instead.
  • Abstention questions (no has_answer turns) were excluded from the non-gold denominator entirely; extract.py scores every question's candidates regardless of whether it has gold turns, so they are folded back in.
  • Gold matching now normalizes whitespace the way extract.py's own norm() does — a turn's text can pick up a line break between the dataset file and what memvara's search returns without the words changing.
  • The MEMVARA_RANKED mutual-exclusion guard now also covers MEMVARA_TAIL_CHARS, which would otherwise truncate a turn by its position in the returned list regardless of whether the selector kept it — the one thing a ranked read promises never happens to a kept turn.
  • The search response's selection object (outcome, candidates, kept) is now on the client type and carried into the search checkpoint as one extra context item, so a caller can tell an applied ranked call from a fallback or unconfigured one instead of inferring it from per-turn selected fields alone.
  • Documented MEMVARA_RANKED, its exclusion rules, and the scoring script in src/providers/README.md, which previously named neither.

One finding is declined: matching a gold turn by its raw text alone can in principle collide with a non-gold turn sharing identical text in the same haystack. Fixing it needs a turn identity threaded from ingest through search results, which nothing in this pipeline carries today, and the dataset this scoring script runs against has zero such collisions across its 896 gold turns — recorded in the code rather than fixed.

Reviewed on the session model. bun test: 145 pass. tsc --noEmit and Prettier clean on every changed file.

`MEMVARA_HEAD_WHOLE` renders that many of the highest-ranked turns whole and
`MEMVARA_TAIL_CHARS` cuts the rest to that many characters. Both default to 0, which
renders every turn whole as before. These are the knobs the `memvara-adaptive-trim` and
`-trim800` arms ran with (5 and 400, 5 and 800), judged at 84.9% and 85.9% against the
untruncated 87.4% at 2,028 and 2,409 median context tokens against 3,700. The code was
in the working tree from that work and had not been committed; this records it on its
own so the change that follows does not absorb it.
Three switches, every one off by default so the shipped provider renders byte-identically,
pinned by a test that compares the default render against a fixture.

`MEMVARA_SEARCH_K` sets the k sent on every search (default 30). `MEMVARA_ROLE_SELECT`
is `off`, `user` or `route`: `user` keeps user turns only; `route` keeps assistant turns
only when the question asks what the assistant said — fourteen literal phrasings on word
boundaries, case-insensitive, fitted on the 301 LongMemEval questions outside the
199-question sample and frozen — and user turns otherwise. `MEMVARA_TOKEN_BUDGET` fills
the turns block greedily in the order memvara returned, stopping at the first line that
would push the whole block past the budget and always keeping the first, counted with the
same o200k_base encoder the harness reports context tokens with. Nothing is ever
re-sorted.

The counter passes the special-token check off, as the existing counter's fallback does:
one turn in the 500-question set carries a literal `<|endoftext|>`, and a budget arm would
otherwise fail a question the control answers. The two older truncation knobs now refuse
values that are not non-negative integers instead of silently rendering the control.

The comment above the truncation knobs now carries both measurements: answer-string
presence favours assistant turns, and the dataset's own labels put 842 of 896 gold turns
in user turns, 51 of the 54 assistant ones in single-session-assistant.

Every test was shown to fail under a mutation of the code it watches; the eighteenth
mutation, dropping the question from the renderer, found the one gap and got its test.
Twelve findings, every one applied except the routing rule's reach, which is documented
rather than changed. The arms' renders are pinned byte-identical to the pre-change code by
a fixture captured before any edit and re-derived independently from the previous commit;
4,560 differential renders across budgets, roles and non-ASCII text agree.

One env parser (`env.ts`) now serves all six knobs with one rule — unset, empty or
whitespace means off — and each knob's own rule for zero; `MEMVARA_TURNS_ONLY` accepts
only "1"; the truncation knobs are validated whenever either is set; and the resolved
settings are logged once at provider init, so an arm's configuration is in its log.

The budget fill skips the encoder only when the block provably fits: every o200k token
stands for at least one byte, so a block whose UTF-8 byte length is within the budget
cannot exceed it — bytes, not characters, because a single character can be several
tokens. The stop rule is unchanged, and a test with a three-tokens-per-character witness
fails if the bound is ever written in characters.

The recorded token count now agrees with the budget on a turn carrying a tokenizer literal,
because `countOpenAITokens` encodes with the special-token check off like the budget does
instead of falling back to a character estimate. A block in which a turn was actually cut
is headed as cut rather than "verbatim"; the header is byte-identical when nothing was.
Selection that empties a non-empty turn list logs a warning naming the knob. The `k: 30`
assertion is guarded against the arm's exported environment.

The `wantsAssistant` docstring now says the fourteen phrasings were fitted on LongMemEval,
where "remind me" occurs only in assistant-answer questions, and that a false fire on
another corpus discards every user turn with no failsafe at a small budget.
…the run's model

`OPENAI_MODEL_PREFIX`, unset by default, is prepended to the id sent for every
OpenAI-provider model and to nothing else. A gateway that fronts several providers names
models `openai/gpt-5.4`; the alias a run is started with, and the alias its checkpoint
records, stay `gpt-5.4`, so a run through such a gateway is comparable with one that went
direct. Applied once, in `getModelConfig`, which both the answering client and the judge
resolve through; read at call time; never doubled; the shared registry entry is not
mutated. Five tests.
…s, counts and advice

`MEMVARA_ANSWER_PROMPT` is `v1` by default — the prompt exactly as it was, pinned
byte-for-byte against the whole-prompt fixtures — or `v2`, which keeps every line and adds
three bullets to "How to read the context" and one to the instructions. They are written
against the thirteen judged misses where every gold excerpt was in the prompt and the
answer was still wrong: when two excerpts give different values for the same fact, the
later-dated one is current; when the question asks how many or which, list every matching
item with its date and then count the list; when it asks for advice, build the answer on
the user's stated preferences and say which preference each part rests on. The last
instruction says to give the answer when the reasoning has already found the values, and
to say "I don't know" only when nothing in the excerpts bears on the question; the
abstention rule itself is unchanged.

Selection, budget and rendering are untouched, so the context block and its token count
are identical between the two prompts. The setting is logged at init with the other
knobs; anything but `v1` or `v2` throws naming the variable.
…xt blocks

The file is JSONL, one {question, block} per line, keyed by the question
text with whitespace collapsed. When it is set, renderMemvaraContext returns
the block verbatim and a question missing from the file aborts the run, so an
arm never mixes overridden and provider-rendered contexts. The empty-context
build the answer phase uses to measure contextTokens is left alone, so the
recorded count is the block's size. Unset, rendering is byte for byte what it
was; the pinned goldens still match.
One knob, MEMVARA_RANKED=1, adds ranked: true to the search request the
provider already sends and nothing else -- every existing arm's request
body is unchanged with it off. A ranked hit's selected/span outcome now
carries through to the turn the checkpoint stores, since the offline
screen ahead of the judged run needs it to tell the kept set from the
rest. memvaraProviderSettings throws at startup when MEMVARA_RANKED=1
is asked for alongside MEMVARA_ROLE_SELECT or MEMVARA_CONTEXT_FILE,
since either would measure something other than the server's ranked
order.

score-ranked.ts is the scoring script the offline screen needs before
any judged spend: it reads a run checkpoint's search results, matches
them against LongMemEval's own has_answer labels, and prints gold
recall and the non-gold keep rate of the kept set in extract.py's
shape.

The excerpt-cap check that has to run before Step 1 freezes the prompt
fixture -- how many of the 528 kept spans in extractions.jsonl a
500- or 2,000-character cap would cut, and how many are verbatim,
verbatim after stripping the timestamp prefix, or neither -- reproduces
the design document's own numbers (14 and 0 cut; 489, 34 and 5) from
the source files directly; the check and its numbers are in
local/compress/cap-check.md, outside this repository.
score-ranked.ts counted every turn a ranked search returned as either kept
or missed, so MEMVARA_SEARCH_K past the selector's own top_n folded turns
the selector never evaluated (selected: null) into the same population as
the ones it actually saw (true/false). That inflated the non-gold
denominator and any gold turns ranked past top_n counted as missed even
though the model never got a chance to keep them, so the printed recall
and keep rate were not the numbers extract.py prints for the same
candidate list. selected: null is now skipped rather than scored, and
sawRanking is only set once a turn is actually seen (true or false), so a
run where every question was served unranked reports nothing to score
instead of a misleadingly low recall.

Also: abstention questions (no has_answer turns) were being excluded from
the non-gold denominator entirely; extract.py scores every question's
candidates regardless. Gold matching now normalizes whitespace the way
extract.py's norm() does, since a turn's text can pick up a line break
between the dataset file and what memvara's search actually returns
without the words changing. The RANKED/truncation guard now also covers
MEMVARA_TAIL_CHARS, which would otherwise cut a kept turn by its position
in the list -- the one thing a ranked read promises never happens to a
kept turn.

The search response's selection object (outcome, candidates, kept) is now
carried through the client type and into the search checkpoint as one
extra context item, so a caller can tell an applied ranked call from a
fallback or unconfigured one instead of inferring it from per-turn
selected fields alone. Documented MEMVARA_RANKED and the scoring script in
providers/README.md.

Declined: matching a gold turn by its raw text alone can in principle
collide with a non-gold turn sharing identical text in the same haystack.
Fixing it needs a turn identity threaded from ingest through search
results, which nothing in this pipeline carries today, and the actual
dataset has zero such collisions across its 896 gold turns. Left as is.
… as user turns with the name in front

LoCoMo's conversations are between two people. The harness maps the second speaker to
the assistant role, so the server's routing rule drops that speaker's turns unless the
question asks what the model said, and the turn is stored without the name a question
like "what did Melanie read" needs. On the 250-question sample 109 questions lost every
evidence turn this way. With the flag set, every turn that carries a speaker is stored as
a user turn prefixed with the name, which is what the Zep provider already does.
…ns, and file LoCoMo's categories under the dataset's own numbers

v3 is v2 plus one instruction: check the question's premise against the excerpts, and
when it attributes something to a person, event or object the excerpts show belongs to
another, or that nothing supports, say so instead of answering about the nearest similar
thing. Seven of the ten adversarial misses on the named-speakers rerun had every excerpt
in front of the reader. The LoCoMo category map read 1 as single-hop and 3 as temporal;
the dataset's evaluation scores 1 as multi-hop, 2 temporal, 3 open-domain, 4 single-hop,
5 adversarial, and a sample of the questions under each number agrees, so every
per-category number a report printed was filed under the wrong name. A test pins the map.
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.

1 participant