Let the memvara provider select turns by role and fill a token budget - #1
Open
sethigoldy wants to merge 10 commits into
Open
Let the memvara provider select turns by role and fill a token budget#1sethigoldy wants to merge 10 commits into
sethigoldy wants to merge 10 commits into
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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— theksent on every search (default 30).MEMVARA_ROLE_SELECT—off,userorroute.userkeeps user turns only.routekeeps 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 sameo200k_baseencoder the harness reportscontextTokenswith. 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-routed720andmemvara-useronly720; findings are recorded in the memvara repository underdocs/superpowers/plans/dated 2026-09-03.Two things a reviewer should look at rather than skim:
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.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 andtsc --noEmitclean on the changed files; six pre-existing files elsewhere still failformat:checkand 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_ONLYvalidated; 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; thek: 30test 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 ingetModelConfigso both the answering client and the judge pick it up; the alias the run is started with and the checkpoint records staysgpt-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=v2adds 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.v1is 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_FILEA 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,renderMemvaraContextreturns 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 measurecontextTokensis 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, addsranked: trueto the search request and nothing else — the request body is byte-unchanged with it unset, pinned by a test. A ranked hit'sselected/spanoutcome 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.memvaraProviderSettingsrefuses at startup a stack that pairsMEMVARA_RANKED=1withMEMVARA_ROLE_SELECTorMEMVARA_CONTEXT_FILE, since either would hide the server's own order from what the parity run is measuring.src/scripts/score-ranked.tsis that offline screen: it reads a run checkpoint's search results, matches them against LongMemEval's ownhas_answerlabels, and reports gold recall and the non-gold keep rate of the kept set in the shape the design's ownextract.pyprints. 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.tscounted every turn a ranked search returned as kept or missed, soMEMVARA_SEARCH_Kset past the selector's owntop_nfolded 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 pasttop_nas missed even though the model never got the chance to keep it — the printed numbers were not comparable toextract.py's.selected: nullis now skipped rather than scored.sawRankingused to go true on anyselected !== undefined, so a run where every question was served unranked (selected: nullthroughout — 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 (trueorfalse), so an all-null run reports nothing to score instead.has_answerturns) were excluded from the non-gold denominator entirely;extract.pyscores every question's candidates regardless of whether it has gold turns, so they are folded back in.extract.py's ownnorm()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.MEMVARA_RANKEDmutual-exclusion guard now also coversMEMVARA_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.selectionobject (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-turnselectedfields alone.MEMVARA_RANKED, its exclusion rules, and the scoring script insrc/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 --noEmitand Prettier clean on every changed file.