Repository navigation
Answering stack: one implementation for evaluate/benchmark/answers and arms/subjects #228
Description
Split out of #94 (§5 row X), as @antonwolfy asked in his review: merging the two answering stacks is the largest refactor in the CLI plan, and "blocked on" hid its size. Facts below are from main @ 85b8d84a; paths are under dokimi/.
Problem
dokimi has two code paths that answer a question with an LLM, with and without docs, and judge the answers. They were written separately, and they give different numbers for the same question set.
Stack P: EvaluationPipeline + Answerer |
Stack A: ArmRunner |
|
|---|---|---|
| Core | orchestrator/pipeline.py:61 (answer :615, judge :647), eval/answerer.py:49 |
eval/arm_runner.py:123, treatments/arms.py |
| CLI | evaluate (commands/orchestrate.py:23), benchmark run (commands/benchmark.py:30, batch :222/:244/:300), answers generate (commands/answers.py:98, Answerer only) |
arms run (commands/arms.py:82, _build_arms_output :606), arms matrix-run (:109), subjects run (commands/subjects.py:151) |
| Records | answers.v1 / eval.v1 |
arms.v1 |
No CI workflow runs either stack today, so the drift has gone unnoticed.
Differences that change scores
- Retrieval request.
- Stack P sends
max_tokens=4000and leavesmax_resultsat its default (answerer.py:308).top_kis stored (:102) and never read; a TODO at :307 says so. - Stack A sends
max_results=top_kand leavesmax_tokensat the client default of 8000 (treatments/arms.py:81-83). - The clients cut context at
max_tokens*4characters, so that is 16k vs 32k. Context7'stokensparameter and cache key differ too (mcp/context7.py:119, :144), so the two stacks retrieve different docs for the same question.
- Stack P sends
- No docs found.
- Stack P judges the literal string
"[FALLBACK: No relevant documentation found]"as the with-docs answer, with no model call (answerer.py:248-260). - Stack A makes a real call with the plain prompt, which is the baseline.
- Stack P judges the literal string
- Rerank exception. Stack P falls back to the placeholder (
answerer.py:337). Stack A uses the raw docs (treatments/arms.py:93-98). - Answer call.
- Stack P calls
llm_call_with_usagewith no system prompt,max_tokens, temperature, seed or cache prefix (answerer.py:379, :417). - Stack A passes all five (
arm_runner.py:377). - The prompts differ as well. Stack P puts the question first and says "use ONLY the documentation". Stack A puts the context first and says "where relevant".
- Stack P calls
- What the judge sees.
- Stack P shows 200-character
Doc i:snippets (eval/judge.py:381, :606), and its baseline gets "(No documentation provided)". - Stack A shows the full material through
eval/judge_context.py:278, stampedtreatment_material.v1.
- Stack P shows 200-character
- Judge temperature.
benchmarkdoes not pass it (commands/benchmark.py:52);evaluatedoes. --max-tokensmeans opposite things. In Stack P it is the retrieval budget (benchmark.py:358). In Stack A it is the output ceiling (arms.py:270). This is the same drift that CLI API: the evaluation loop at the top level, subject and eval groups below, one enforced flag vocabulary (proposal v2.1) #94 §2.4 lists.- Library id. The pipeline resolves it without the registry (
pipeline.py:622);answers generateandarmsuse the registry. - Statistics. Stack P reports unpaired means and deltas. Stack A reports paired tests with holm/bh and interactions.
Only in one stack
- Only in Stack A:
- N arms, agentic arms, skills, sessions
--judge-panelcost_summary(USD, cache hits, latency)--grounded-from-topics
- Only in Stack P: personas, question generation and the question-set hash.
- Neither has: a budget cap, or resume. Only
answers generateandeval scoresave incrementally.
Already shared
llm.py, the Judge class (Stack P calls evaluate_answers at judge.py:267; Stack A calls score_answer at :586), reranker.py, the MCP clients and factory, metrics/usage.py, records.py (it reads both shapes), save_artifact and defaults.py.
About ten consumers read answers.v1/eval.v1, including trust_gate, the dashboard, report, model_compare and ragas.
Dead code in Stack P
Answerer.llm (answerer.py:89-101) is never used. The empty-docs branch at :348-363 cannot be reached.
Drift is happening now
#214 adds a verbatim prompt style to both answerer.py and arm_runner.py, in two different shapes. #217 touches commands/arms.py, and #166 touches llm.py.
Why it blocks #94
#94 turns evaluate, benchmark and subjects into the pipeline, benchmark and subject recipes of plan/run (§2.2). With two stacks, a recipe would run on one stack while run arms runs on the other, and compare would put numbers side by side that were measured in different ways.
Proposal: keep Stack A
Stack A already covers Stack P's two arms as a special case: DocTreatment is with_docs and Baseline is without_docs. It also has paired statistics, cost accounting and panels. Stack P keeps what it does upstream of answering: personas, question generation and the answers.v1/eval.v1 consumers.
Steps. Each is its own PR, and a PR that changes numbers is never mixed with a refactor (#4 conventions):
-
Characterization test, no behaviour change. Run both stacks on one fixture and record, for each stack:
- the retrieval arguments
- the prompts
- the provider call counts
- the judge context
- the scores
- the cost
It uses
fake_llmandprovider_calls(tests/conftest.py:53), plus a fake MCP client that recordsmax_results/max_tokens, a stub reranker and frozen timestamps. Cases: docs found, no docs, rerank exception, a ground-truth chunk.The test pins today's differences as expected values. Later PRs flip them one at a time, so each change in numbers is visible in the diff.
The tests that exist today do not cover this:
test_pipeline_question_setstubs whole pipeline steps.test_eval_answererpatchesllm_call_with_usageinstead of usingfake_llm.test_provider_call_budgetscounts calls but never compares the two stacks.
-
Fix Stack P in place. This changes numbers.
- Honour
top_k. - Pass the judge temperature in
benchmark. - Resolve the library id through the registry.
- Put placeholder judging behind an explicit flag, so a missing doc is no longer scored as an answer by default.
- Delete the dead code.
Stamp a
method_versionin the artifacts so that old and new runs are not compared silently. - Honour
-
One retrieval path. Stack P retrieves through
DocTreatment. After this, the test from step 1 shows identical retrieval arguments. -
The pipeline delegates answering and judging to
ArmRunner.- Add an adapter from
arms.v1toanswers.v1/eval.v1, so the ten consumers keep working. - Add incremental save to
ArmRunnerfirst. Without it,evaluatewould lose the partial results it saves today.
- Add an adapter from
-
Flag.
- In the Stack P commands,
--max-tokensbecomes--retrieval-max-tokens, with a tombstone (CLI API: the evaluation loop at the top level, subject and eval groups below, one enforced flag vocabulary (proposal v2.1) #94 §2.4/F1b, concept "tokens"). - In Stack A,
--max-tokenskeeps its meaning as the output ceiling.
- In the Stack P commands,
-
Remove the rest.
- The consumers read through
records.py. Answererand the adapter are deprecated, then removed.answers generatebecomesrun answerson the shared stack.
- The consumers read through
Before step 3, decide how #214 adds verbatim: either as one style in a shared prompt builder, or by landing it in Stack A only.
Risks
- Historical comparability. Steps 2–4 change scores, so old and new runs are not comparable. The
method_versionstamp letscomparerefuse to put them side by side, or mark the comparison as exploratory. - The trust gate. Its thresholds were calibrated on rows where the placeholder was judged. Re-check them after step 2.
- Old
eval.v1files have no judge-material stamp.records.pymust read them as "material unknown". - Scripts. The meaning of
--max-tokensflips for existing scripts. The tombstone in step 5 has to catch the old use. - Cache. The Context7 cache key changes, so the first runs after step 3 are slower and cost more.
- Complexity baselines.
scripts/growth_baseline.jsonhas entries forArmRunner.judgeandEvaluationPipeline.run. Update them in the PR that moves the code.
Out of scope
- The other duplicated subsystems: comparison statistics, artifact writers and so on. They are tracked in CLI API: the evaluation loop at the top level, subject and eval groups below, one enforced flag vocabulary (proposal v2.1) #94 §5.
- New features in
ArmRunner.
Links: #94 (§2.2, §2.4, §5 X), #4 (waves 4 and 5), #121 (user loop), #214, #217, #166.
Activity
test(answers): characterize the two answering stacks (#228 step 1) (#253
napetrov commentedon Oct 29, 2026
Corrections to the text above, from step 1 (#253). They were re-derived on main b69b15f; the ones marked pinned below are pinned in
tests/test_answering_stack_parity.py. Step 2 (#274) works from these.max_tokens*4character cut is not in the Context7 client. It is in the okf, local_markdown and url clients. Context7 sendsmax_tokensto the server and has no local cut.top_kis stored atanswerer.py:111(not :102). The TODO is at :320 (not :307).answerer.py:266. Pinned: P makes no docs-arm call and judges the placeholder; A makes two plain-prompt calls on q-empty.systemis None and no cache prefix is sent (cache_blocksFalse), so the fixture pins both as None/False on both stacks.judge.py:392). The doc-line format is atjudge.py:610.benchmark runhas no--judge-temperatureflag at all; the parser does not define one.arms runhas the flag.dokimi report eval(significance_test) runs a paired test on P, uncorrected. The unpaired means are only in theEvaluationPipeline.run()summary. A's paired test uses Holm.EvaluationPipelinewith nocontext7_id(orchestratewithout--context7-id,orchestrate.py:70).benchmark runpasses the registry'scontext7_id(benchmark.py:61). The D8 row pins the first case.stats.delta_meangap (13.33 vs 16.67) comes from D3. P's q-rerank is judged with no docs on both arms (delta 0), while A falls back to raw docs (delta 20). So a D3 fix also moves the D9 row.cost_summaryexists only in A. P's per-answer records do carrymetrics(cost_usd,latency_sec), so cost per record is not A-only.Not checked yet: "no CI workflow runs either stack", "neither has a budget cap or resume", the
judge_context.py:278/treatment_material.v1claim, and the--grounded-from-topics/--judge-panelclaims.