Skip to content

Golden-prompt utility: iterate on agent prompts as data (closes #2118) - #2121

Merged
malibio merged 4 commits into
mainfrom
issue-2118-golden-prompt-runner
Aug 17, 2026
Merged

malibio merged 4 commits into
mainfrom
issue-2118-golden-prompt-runner

Conversation

@malibio

@malibio malibio commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

A small utility that reads a case file, calls the model through NodeSpace's real inference path, and prints what came back. The human reads the output and judges it.

The case files are the deliverable — a set of prompt strings that reliably get the right tool call, which the assembly pipeline is then engineered to reproduce. The bin is scaffolding for producing them.

Rescoped mid-PR. The first commit built the harness the original issue text specified; the scope correction retracted that. The second commit removes the assertion engine, scoring, batch execution, and watch mode. Net is now +1,450 / −0, additive only, entirely within packages/agent.

What's here

file lines what
src/golden/case.rs 544 TOML case format, loading, whitespace normalization
src/golden/runner.rs 415 execute against the engine, print the result
src/bin/golden_runner.rs 182 CLI
src/golden/mod.rs 41 module docs
goldens/*.toml 261 two worked cases

~420 lines of logic, ~250 of tests, ~230 of doc comments.

Fidelity — the load-bearing constraint

Goes through LlamaChatInferenceEngine → nlp-engine::ChatEngine, so apply_chat_template (PEG_GEMMA4), chat/parser.rs, and repair_tool_call_arguments are production's own, the last applied at the same parse boundary agent_loop.rs:1370 applies it.

Out of the path: daemon, DB, gRPC, LocalAgentLoop, PromptAssembler, routing.

This is why it's a Rust bin and not curl against llama-server: ADR-038's findings were gathered that way and ADR-046 recorded they did not transfer through NodeSpace's inference path.

No assertions, by design

No expectation kinds, no scoring, no pass/fail. Nothing automated consumes expectations here — the snapshot gate holds production to a frozen golden, and the matrix does end-to-end scoring. A third scoring system would duplicate both, and would be new surface for the failure mode this project has already paid for: a harness bug reported as a model failure.

expect survives as an inert free-text field, printed beside the result and never compared, so a tuned case can answer "what was this tuned to produce?" from the file itself. Free-text on purpose — the moment it has structure, something will assert on it.

One real bug worth calling out

TOML's """ does not strip indentation the way Rust's "…\ does. Without normalization every prompt reaches the model with a leading newline, a per-line indent, and doubled spaces at each continuation. One ported history block measured 35 characters longer than the Rust literal it reproduces.

For a utility whose entire premise is the file is exactly what the model sees, that's the one defect that silently poisons every result it produces. normalize_case_text fixes it; 4 tests pin it, including that relative indentation and paragraph breaks survive (narrative shape is sometimes the variable under test).

Verification

Both cases run 3/3 against the locked native model:

[indirect-reference-resolves] rep 1/3 turn1: resolve_query({"node_type":"Product","request":"The 2400 one came back — set it to returned"})

[scenario6-full-sequence] rep 1/3 turn1-create-schema: create_schema({"fields":[{"name":"isReturned","type":"boolean"},{"name":"replacementCost","type":"number"}],"name":"Equipment Checkout Record"})
[scenario6-full-sequence] rep 1/3 turn2-create-node:  create_node({"properties":{"equipmentName":"laser cutter","isReturned":false,"replacementCost":2400},"node_type":"Equipment Checkout Record"})
[scenario6-full-sequence] rep 1/3 turn3-resolve-and-update: resolve_query({"node_type":"Equipment Checkout Record","request":"the 2400 one"})

Byte-identical across all reps. Turn 2 picks up turn 1's actual schema name — the isolated Rust turns guessed equipment and equipment_item, neither of which turn 1 emitted.

Recorded honestly in the case notes: node_type is not stable across sessions (equipment one session, Product another). Nothing in that case tells the model what types exist, so it invents one. The tool name is the signal.

Also observed and left for its own issue: turn 3 resolves and then stops rather than continuing to update_node — the single-turn-pull boundary.

  • bun run test:all — exit 0, 4704 frontend + 24 browser + 46 Rust suites, matches baseline
  • bun run quality:fix — clean, no collateral reformatting
  • cargo clippy -p nodespace-agent --all-targets — no warnings
  • 17 unit tests in the default run

Not touched

golden_scenario6_handauthored.rs and golden_scenario6_sequence.rs stay in tests/ as the validated Rust-side reference proving the runner is faithful to production templating and parsing. Earlier commits deleted them and repointed citations in the daemon; both reverted.

To capture the post-template string for the snapshot gate, NODESPACE_PROMPT_DUMP works unchanged when running this bin — it hooks the chokepoint in nlp-engine's chat/mod.rs and needs nothing from here. Noted in the bin's docs.

🤖 Generated with Claude Code

Tune agent prompts by editing a TOML file and re-running — no recompile, no
NodeSpace plumbing in the path. The prompt text becomes the artifact under
iteration; the assembly line is then engineered to reproduce it.

This generalizes the methodology golden_scenario6_handauthored.rs and
golden_scenario6_sequence.rs proved but kept bespoke: one scenario,
hand-rolled engine loading, assertions in Rust, prompts as string literals.
Every tweak needed a recompile, which is why it never became the general
tuning loop.

Separating two questions is the point. "Does this exact prompt text get the
right tool call?" is answerable in seconds here. "Does the real pipeline
assemble that prompt?" is a separate, deterministic, zero-model-call question.
Conflating them is what made every prior checkpoint cost a daemon rebuild, a
DB purge, and minutes per iteration.

Fidelity: the runner goes through LlamaChatInferenceEngine -> ChatEngine, so
it gets the model's own chat template, llama.cpp's tool-call parsing, and
production's repair_tool_call_arguments applied at the same parse boundary
agent_loop.rs applies it. Tuning against a hand-rolled call or llama-server
HTTP would tune against a fiction — ADR-038's findings were gathered that way
and ADR-046 recorded that they did not transfer.

- src/golden/case.rs — format + expectation evaluation, model-free and
  unit-tested in the default run. Expectation kinds: tool (with recursive
  subset argument matching), sequence (with min_properties_on, so a case can
  require the write actually carried properties), clarify (a first-class pass,
  per ADR-038's clarification contract), text, observe.
- src/golden/runner.rs — execution. Multi-turn cases chain a turn's REAL
  output into the next turn's history as a well-formed assistant-plus-tool-
  result exchange, closing the loop the sequence golden left as a TODO.
  chain = false isolates a turn so one file can hold independent arms of one
  experiment.
- src/bin/golden_runner.rs — a bin, so it is outside cargo test entirely and
  cannot be pulled into the default run (it loads a ~5GB GGUF).

Reps are mandatory, defaulting to 3: single runs are not decision-grade.
"No tool call parsed" and "wrong tool called" are distinct outcomes with
distinct tally lines — they have opposite fixes.

Five case files committed as worked reference instances, all verified 3/3
against the locked native model, reproducing the ported goldens' recorded
results. Two findings the ported probes were built to answer now have clean
answers recorded in their notes: the resolve_query history defect is
size/shape rather than "any history at all", and narrowing further, STYLE
rather than size — a terse arm at the failing arm's token count resolves,
while the narrative arm at that count refuses. Carried-forward history should
therefore be compacted into terse facts, not shorter narrative summaries.

The two Rust golden test files are deleted, not left alongside: they are fully
expressible as cases now, and their doc-comment references elsewhere are
repointed at the case files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malibio

malibio commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Pragmatic Code Review — PR #2121

Review Type: Initial Review (0 prior reviews, 0 prior comments)
Scope: full diff origin/main...HEAD — 17 files, +3077 / −889


Summary

This is a strong, well-motivated change and a clear net improvement. It converts a bespoke, recompile-per-edit tuning ritual into a data-driven instrument, and it does so while increasing fidelity to production rather than trading it away. The module and case-file documentation is genuinely excellent: it records not just what the code does but which findings it produced and why particular design choices are load-bearing. The expectation-evaluation layer is model-free and covered by 40 unit tests that run in the default cargo test, which is exactly the right seam.

I verified independently: cargo test -p nodespace-agent --lib golden:: → 40 passed, 0 failed; cargo clippy -p nodespace-agent --all-targets → clean; golden_runner --check packages/agent/goldens → all five cases parse and their schemas convert as intended.

Fidelity claim: verified. runner.rs drives LlamaChatInferenceEngine::load → ChatEngine (inference.rs:34-64) — the same type production uses, so the same chat template, the same chat_format resolution, and the same chat/parser.rs tool-call extraction. RUNNER_N_CTX = 32768, temperature 0.1, max_tokens 512 reproduce the deleted goldens' engine config byte for byte. parse_output (runner.rs:344-382) is a faithful mirror of the private LocalAgentLoop::parse_chunks (agent_loop.rs:2430-2477): same Vec-not-map accumulator preserving emission order, same .rev().find() id matching, same handling of Done/Error. The single intentional divergence — dropping Reasoning instead of accumulating it — is documented and correct here, since folding chain-of-thought into text would let a text expectation match reasoning. repair_tool_call_arguments is applied at the same parse boundary agent_loop.rs:1370 applies it, and it is the same public function, not a copy.

Worth stating plainly: the deleted tests were less faithful than what replaces them. Their run_turn helper (golden_scenario6_handauthored.rs:95-143) never applied repair_tool_call_arguments at all, and it flattened arguments by concatenating every ToolCallArgs chunk across all calls into one string. The new runner fixes both. The port is a fidelity upgrade, not merely a reorganization.

Two things are worth resolving, neither in the runner code itself. First, TOML multi-line string handling has injected whitespace into ~30 of the strings the model actually sees, and the drift is unevenly distributed across the arms of the one case that treats token count as a controlled variable. Second, the "fully expressible as cases now" claim holds for golden_scenario6_handauthored.rs but not for golden_scenario6_sequence.rs, four of whose five tests are dropped rather than ported — taking one assertion and one still-cited history string with them. In an instrument whose purpose is measuring prompt text, both are the same class of problem: the artifact says something about itself that is not quite true. Everything else below is minor.


Requirements Check

# Acceptance criterion Status Notes
1 Case-file format defined and documented, holding system / tools / history / user / expectation / reps as data ✅ case.rs:1-41 module doc states the shape; every field carries a doc comment; five committed files are worked instances
2 Runner bin in packages/agent/src/bin/ executing through LlamaChatInferenceEngine (same template, parser, argument repairs) ✅ bin/golden_runner.rs; verified against inference.rs:34 and agent_loop.rs:1370
3 No daemon, DB, gRPC, agent loop, PromptAssembler, routing in the path ✅ runner.rs imports only agent_types, repair_tool_call_arguments, and LlamaChatInferenceEngine. The one local_agent::routing reference is the ROUTE_CLARIFY_TOOL string constant (case.rs:285) — a name, not routing behavior, and deliberately shared so a rename breaks the case rather than silently mismatching
4 Expectation kinds tool (with optional argument assertions), sequence, clarify, text ✅ case.rs:240-282
5 reps supported; per-rep outcome plus N/N tally, never a bare pass/fail ✅ reps is non-Option with DEFAULT_REPS = 3; reps = 0 rejected at validation; render_report always prints N/M reps passed
6 Multi-turn chaining a real turn's output into the next turn's history ✅ run_rep (runner.rs:155-230); closes the TODO the sequence golden left open
7 Editing a prompt and re-running requires no recompile ✅ Prompts are TOML data
8 The two scenario-6 goldens expressible as case files, reproducing current results ⚠️ handauthored fully ported (5/5 probes → 3 case files). sequence only partially: 1 of its 5 tests is a port, 4 are dropped — see 🟡 finding 2
9 A worked case file committed alongside the format definition ✅ Five, not one
10 "No tool call parsed" reported distinctly from "wrong tool called" ✅ Separate Outcome variants, separate tag(), separate tally lines; guarded by a test asserting the tags never collapse
11 expect.kind = "observe" — runs reps, prints, never fails ✅ case.rs:281, Outcome::Observed is a pass by construction
12 Argument assertions are subset matches, not full-blob equality ✅ subset_mismatch (case.rs:723-779), recursive, with a dedicated test
13 A sequence assertion can require the write carried properties (min_properties_on) ✅ properties_shortfall (case.rs:657-680), null-valued keys correctly excluded
14 Both scenario-6 goldens ship as case files reproducing current results (near-duplicate of #8) ⚠️ Same evidence as #8
15 Runner excluded from the default test run; documented invocation ⚠️ Correctly excluded — a bin is outside cargo test entirely, which is a better mechanism than --ignored. But the documented invocation is broken: see 🟢 finding 4

12 met, 3 met-with-a-defect. No criteria entirely missing.


Code Review Findings

🟡 Important

1. TOML multi-line strings inject whitespace into ~30 of the prompts the model actually sees
📁 packages/agent/goldens/*.toml (all five files)

Indented """ blocks preserve their indentation, the closing delimiter's own indentation lands in the value, and a \ continuation followed by an indented next line leaves that indentation embedded mid-string. Rust's "…\ continuation strips the newline and the next line's leading whitespace; TOML's does not. Parsing the committed files confirms it. From scenario6-compact-history.toml:48-51:

"\n  Context: the user has an 'equipment' type with a replacement_cost field.   They logged one equipment item with replacement_cost 2400.\n  "

The Rust literal it was ported from (golden_scenario6_handauthored.rs:194-199) was exact:

"Context: the user has an 'equipment' type with a replacement_cost field. They logged one equipment item with replacement_cost 2400."

So the ported case sends a leading newline, two leading spaces, a doubled internal space, and a trailing "\n " that the original never sent. The same pattern affects tool description fields in all five files, the medium-verbose-412t history in scenario6-history-size-vs-style.toml, and the tool_results JSON strings in scenario6-full-sequence.toml (which become "\n {\"ok\": true, ...}\n " — still parseable, but replayed verbatim into the prompt).

Why this is Important rather than Nit — and it is sharper than a general tidiness complaint. The scenario6-history-size-vs-style case draws a style-not-size conclusion from three histories whose token counts are the controlled variable. The whitespace is not distributed evenly across the arms: the two terse arms are byte-identical to their Rust originals, while the medium-verbose arm gains 35 characters (1686 vs 1651). So the arm labelled medium-verbose-412t is no longer at the 412-token point the Rust measured, and the drift lands specifically on the arm whose size is the thing being held constant. The conclusion still looks safe — 35 characters will not move a style-vs-size verdict — but the case is now weaker evidence for its own claim than it reads as being, and that is the one property this file cannot afford to lose.

It also breaks the port's premise: the files claim to reproduce the deleted tests' results, and reproduction requires the same input. And the next person editing a case will not know which whitespace is intentional.

The engineering principle is correctness of the measuring device before correctness of the measurement: an instrument that silently perturbs its own input cannot distinguish a prompt effect from an artifact of its own encoding.

Two ways to fix, either acceptable:

  • Author the affected strings so the TOML produces exactly the intended text — unindent the """ blocks to column 0 and put the \ continuation's next line at column 0 too; use """\ on the opening line to suppress the leading newline.
  • Or normalize on load in case.rs (dedent + trim), and unit-test that normalization — which additionally makes the format forgiving of indentation for every future case author. Given that the indentation is clearly deliberate for readability, this is probably the better option: keep the files pretty, make the loader responsible for what the model sees.

Whichever route, please add a unit test asserting a representative indented case file parses to the exact expected string. Without a test this regresses the moment someone adds a sixth case.

2. Four of golden_scenario6_sequence.rs's five tests are dropped, not ported — including one asserting test
📁 deleted packages/agent/tests/golden_scenario6_sequence.rs; 📁 packages/agent/goldens/scenario6-full-sequence.toml

The commit message and the case-file notes present the sequence golden as "fully expressible as cases now". For golden_scenario6_handauthored.rs that is exactly true — all 5 probes are accounted for across three case files. For golden_scenario6_sequence.rs it is not. Only golden_turn1_schema_creation has a port; golden_turn2_instance_creation, golden_turn3_resolution_after_two_terse_facts, golden_chained_turn2_given_turn1_actual_output, and golden_chained_turn3_given_turn2_actual_output have none.

turn2-create-node and turn3-resolve-and-update in the TOML are new turns fed by the runner's live chain replay — not ports of the deleted chained tests, which supplied hand-authored prior-turn facts.

I want to be careful here, because most of this is defensible and the notes argue the case well: replacing hand-authored stand-ins with real chained output is the stated point of the feature, and the notes explain persuasively why the isolated variants should not survive (they each passed while silently disagreeing on the schema id). I agree with deleting the isolated turn-2/turn-3 probes.

Two things still need attention:

  • One assertion is lost outright. golden_turn3_resolution_after_two_terse_facts asserted resolve_query given two terse prior-turn facts. That is a distinct hypothesis from what the chained case measures — it is the terse-facts-shape probe at sequence scope — and nothing in the new corpus covers it. Given the PR's own headline finding is that terse facts are what carried-forward history should be compacted into, dropping the one sequence-scope test of that shape is the deletion I would most want reconsidered. It ports to a case file cheaply.
  • Four hand-authored history strings vanish, and one is still cited. In particular "Fact: a schema with id 'equipment_checkout_record' was created, with fields isReturned (boolean) and replacementCost (number)." exists nowhere in the new corpus. This matters because this PR re-points packages/daemon/tests/golden_scenario6_real_pipeline.rs:6-7 at scenario6-full-sequence.toml with the claim that it "established, with hand-authored terse Fact: ... history strings, that turn 3 …". That file contains zero Fact: strings — by design, since it chains live output instead. The daemon test also hard-codes and asserts on the derived id equipment_checkout_record (lines 120, 129, 214), which appears in the cited file's notes only as a display name, never as the id.

The daemon test still passes — it builds its own ToolExecutionRecords and does not read the TOML — so this is not a broken dependency. It is a broken provenance citation: the re-point sends a reader to a file that does not contain the evidence being cited, which defeats the purpose of citing it. Either restore the terse-facts case and cite that, or reword the daemon test's header to cite what scenario6-full-sequence.toml actually records.

The principle in both halves is the same: a deletion is safe when the value is genuinely relocated, and the claim of relocation should be checkable. Here it is asserted in three places and is only partly true.

🟢 Suggestions

3. mod.rs points readers at two files this PR deletes
📁 packages/agent/src/golden/mod.rs:5-6

//! `tests/golden_scenario6_handauthored.rs` and
//! `tests/golden_scenario6_sequence.rs`: assemble a sequence of literal prompt

The prose reads as a live pointer ("The methodology this generalizes was proved by …"), not as history, so a reader will go looking for files that are gone. live_update_node_noop_gate.rs was correctly repointed at packages/agent/goldens/ in this same PR; mod.rs was missed. The goldens/*.toml notes that say "Ported from golden_scenario6_handauthored.rs" are fine as-is — those are provenance statements about a deletion, and rewording them would lose real information. This one should either name the case-file directory or be phrased explicitly in the past tense.

4. The documented invocation names a case file that does not exist
📁 packages/agent/src/bin/golden_runner.rs:14

//! cargo run -p nodespace-agent --bin golden_runner -- packages/agent/goldens/scenario6-turn3-zero-history.toml

The committed file is scenario6-zero-history-control.toml. Copy-pasting the documented command — which is the whole point of documenting it, and is acceptance criterion 15 — fails with a file-not-found. Small fix, but it is the first thing a new user of this tool will run.

5. Expectation::Tool and Clarify judge only the first call — correct, but undocumented
📁 packages/agent/src/golden/case.rs:558, case.rs:604

Both use output.calls.first(), so a model that calls the expected tool second is reported as WrongTool. I believe this is the right default — it is strict rather than vacuous, and sequence is the escape hatch for "appears somewhere in order" — but the variant doc comments describe it as "A named tool was called", which reads as position-independent. Since the doc comments elsewhere in this file are unusually careful about stating intent, one sentence on Tool ("the first call must be this tool; use sequence when position is not the assertion") would keep that standard, and would prevent someone diagnosing a WrongTool result from misreading it.

6. tool_result_message hand-builds what ChatMessage::tool_result constructs
📁 packages/agent/src/golden/runner.rs:271-280

The literal is field-for-field identical to ChatMessage::tool_result (nlp-engine/src/chat/types.rs:172-185), including reasoning: None and the Some(...) wrapping. Calling the constructor removes a place where a future field added to ChatMessage compiles here but silently defaults, and it is the same constructor production uses at agent_loop.rs:1937. Given that this module's stated value proposition is "the same shapes production uses", using production's constructor reinforces that (DRY, and single-source-of-truth for the message shape).

7. A vacuous assertion is expressible, though not currently written
📁 packages/agent/src/golden/case.rs:594, case.rs:672

min_properties_on = { create_node = 0 } always passes (count >= 0), and arguments = {} is a trivially-satisfied subset. Neither appears in the committed cases, and both are arguably the author explicitly asking for no constraint. I raise it only because you note in the code that a vacuous assertion is worse than none: rejecting a 0 in min_properties_on at validation time (alongside the existing "names a tool outside the sequence" check) would close the one that has no legitimate reading. arguments = {} I would leave alone — it is semantically identical to omitting the field.

I checked the neighbouring cases for genuine vacuity and found none: properties_shortfall's ? early-return cannot silently pass, because validate() guarantees the named tool is in tools and is_subsequence has already proven it was called. is_subsequence consumes its iterator, so a repeated needle ([a, a] against [a]) correctly fails.

8. subset_mismatch numeric comparison uses f64::EPSILON as an absolute tolerance
📁 packages/agent/src/golden/case.rs:767-770

(x - y).abs() < f64::EPSILON is an absolute epsilon, so it does not behave as a tolerance for large magnitudes — for values above ~2^52 it is stricter than ==, and it will not absorb genuine float error at any scale above 1.0. In practice this is fine: the stated purpose (2400 from TOML matching 2400.0 from the model) is exact-integer round-tripping, where both sides convert to the identical f64 and the comparison succeeds. Flagging only so the intent is not misread later as "approximate numeric matching" — it is not, and shouldn't be, since a golden asserting 2400 should not accept 2400.3. If you want to make that explicit, x == y with a comment saying integer/float unification is the only goal is clearer than an epsilon that implies tolerance it does not provide.

Verified clean

  • Multi-turn chaining (runner.rs:155-230) — tool_call_id pairing is correct and tested: assistant_turn assigns call_{i} in emission order and tool_result_message(i, …) reuses the same index, so results pair with their calls rather than orphaning. The chain / cuts_the_chain logic is right and subtle in a good way: a failed turn cuts the rep only when a later turn actually chains, so independent arms are not discarded over a sibling's failure, while a genuinely dependent turn never runs against a history the case never described. RepRun::is_pass requires turns.len() == expected_turns, so a cut rep cannot be scored as a pass off partial evidence — this is the one place where a plausible implementation would have silently inflated results, and it has a dedicated test.
  • Replayed message well-formedness — the assistant turn carries tool_calls (not just text) so the template has something for the following tool messages to pair with; DEFAULT_TOOL_RESULT is deliberately contentless so a case cannot accidentally inherit a fact from the harness.
  • Failure isolation — an inference error is surfaced as TurnOutput.text rather than a panic, so one bad rep does not discard the reps already collected. Correct call: the tally is the deliverable.
  • No assertion was silently weakened into observe. I checked this specifically, since an observe that used to assert would be the worst possible regression in a measurement tool. It did not happen. Every one of the four observe arms corresponds to a Rust probe that genuinely asserted nothing — each carried the literal comment // Not asserted pass/fail — this is the discriminating experiment itself. Two ports are stronger than their origin: turn1-create-schema asserts where the Rust only printed, and turn2-create-node adds min_properties_on where the Rust checked only the tool name. The one lost assertion (finding 2) is lost by deletion, not by downgrade.
  • Content fidelity of what was ported — word-for-word, verified by re-evaluating the Rust literals and diffing. Fact arrays match exactly (24 in arm 3, 10 in arm 2; 1602 chars on both sides for arm 3, 599 for arm 2). All four parameters_schema bodies round-trip to structurally identical JSON with enum values and required arrays intact. The only divergence is the whitespace in finding 1.
  • Reference hygiene — grep for golden_scenario6_handauthored|golden_scenario6_sequence across packages/, scripts/, and ../nodespace-docs/ finds no dangling code references; the remaining hits are intentional provenance notes plus finding 3. local_agent_service.rs and live_update_node_noop_gate.rs were correctly re-pointed. golden_scenario6_real_pipeline.rs was re-pointed too, but at a file that does not contain what it cites — see finding 2.
  • Project standards — no lint suppression, no #[allow(dead_code)], no #[deprecated], no backward-compat scaffolding, no docs/ directory or stray .md. No issue numbers embedded in any new code or case file (the #1925 in golden_scenario6_real_pipeline.rs:4 pre-exists on main and is untouched by this PR). One new dependency, toml 0.8 — justified, already in the lockfile via transitive use, and appropriate for the stated "prompts as data" goal.
  • Eval-contamination guard — ambiguous-reference-clarify.toml is deliberately lexically distinct from the eval corpus (rehearsal rooms and chairs, not equipment and laser cutters), and says so. That is the right instinct and it is good to see it written down rather than assumed.

Recommendation

COMMENT — approve-in-spirit, with findings 1 and 2 worth resolving before merge.

The architecture, the fidelity to production, the separation of the model-free evaluation layer from the model-loading runner, and the documentation quality are all clearly above bar. This is a decisive net improvement to how prompt work gets done here, and the runner code itself I have no substantive objection to — findings 3–8 are minor and several are optional. On the central question the issue raises, the fidelity claim holds up under line-by-line checking, and the replacement is measurably more faithful to production than what it deletes.

The two I would not wave through are both about the corpus and its claims rather than the machinery:

  • Finding 1 is a correctness bug in a measurement instrument, and cheap to fix. The tool's value rests on the case file being exactly what the model sees; right now it is not, and the drift is concentrated on the arm least able to absorb it. A loader-side dedent plus one unit test settles it permanently and makes the format friendlier for every case authored after this.
  • Finding 2 needs a decision rather than necessarily a code change. Dropping the isolated turn-2/turn-3 probes is well-argued and I would keep that deletion. But golden_turn3_resolution_after_two_terse_facts was the only sequence-scope test of the terse-facts shape — the very shape this PR's headline finding recommends — and the daemon test now cites a file that contains none of the Fact: strings it attributes to it. Restoring that one case would resolve both halves at once; alternatively, reword the daemon header and say plainly in the notes that the sequence golden was superseded rather than ported.

Neither is a design objection. Both are the artifact not yet matching what it says about itself, which for this particular tool is the thing most worth getting right.


🤖 Generated with Claude Code

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated pragmatic-code-review: COMMENT (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.

The issue was rescoped after this branch started: a small utility that reads a
case file, calls the model through NodeSpace's real path, and prints what came
back. The human reads the output and judges it. Not a harness.

Removed from the previous commit:
- The whole expectation/assertion engine — Expectation kinds, evaluate(),
  subset_mismatch, is_subsequence, properties_shortfall, min_properties_on
- Outcome/CaseRun/RepRun, pass/fail, N/N tallies, failure-kind breakdown
- Directory batch execution and multi-case invocation
- The chain flag and cuts_the_chain — both existed only to keep independent
  ASSERTION arms from contaminating each other; with no assertions, no arms
- 27 unit tests covering the above

Nothing automated consumes expectations here. Holding production to a frozen
golden is the snapshot gate's job and end-to-end scoring is the matrix's, so a
third scoring system duplicated both — and would have been new surface for the
failure this project has already paid for, a harness bug reported as a model
failure.

Restored: golden_scenario6_handauthored.rs and golden_scenario6_sequence.rs
stay in tests/ as the validated Rust-side reference that proves the runner is
faithful to production templating and parsing. The daemon and
live_update_node_noop_gate.rs edits are reverted — they were doc-comment
repoints forced by deleting those files, and are unnecessary now.

Kept, and load-bearing:
- LlamaChatInferenceEngine -> ChatEngine, so apply_chat_template (PEG_GEMMA4),
  chat/parser.rs, and repair_tool_call_arguments are production's own
- TOML whitespace normalization. Rust's "...\ strips the next line's indent;
  TOML's """ does not, so without it every prompt reaches the model with a
  leading newline, a per-line indent, and doubled spaces at continuations. One
  ported block measured 35 chars longer than the literal it reproduces. For a
  utility whose premise is "the file is exactly what the model sees", that is
  the one bug that poisons every result. 4 tests pin it.
- An inert `expect` field: free-text prose, printed beside the result, never
  compared. A tuned case handed downstream has to answer "what was this tuned
  to produce?" from the file itself. Free-text on purpose — the moment it has
  structure something will assert on it.

Two committed cases, both verified 3/3 against the locked native model:
- indirect-reference-resolves.toml — the worked reference instance. Calls
  resolve_query rather than asking for a node id. Notes record that node_type
  is NOT stable across sessions ("equipment" vs "Product"), since nothing in
  the case tells the model what types exist; the tool name is the signal.
- scenario6-full-sequence.toml — the multi-turn example, chaining each turn's
  real output forward. Byte-identical across reps, with turn 2 picking up turn
  1's actual schema name rather than a guess.

1,182 lines total across 4 files (~420 logic, ~250 tests, ~230 docs), down
from 2,215. 17 unit tests in the default run. test:all exit 0 at baseline;
clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malibio malibio changed the title Golden-prompt runner: iterate on agent prompts as data (closes #2118) Golden-prompt utility: iterate on agent prompts as data (closes #2118) Aug 17, 2026
@malibio

malibio commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rescoped, and review findings addressed

Two things happened since the review: the issue was rescoped to a one-time utility, and the review's findings 1 and 2 both turned out to matter. Commit 99c0550 handles both.

Rescope — what was cut

removed why
Expectation kinds, evaluate(), subset_mismatch, is_subsequence, properties_shortfall, min_properties_on nothing automated consumes expectations here
Outcome, CaseRun, RepRun, pass/fail, N/N tallies scoring is the matrix's job
directory batch execution, multi-case invocation one case per invocation
chain flag, cuts_the_chain existed only to isolate assertion arms; no assertions, no arms
27 unit tests covered the above

The snapshot gate holds production to a frozen golden and the matrix does end-to-end scoring, so a third scoring system duplicated both — and would have been new surface for the failure mode this project has already paid for.

2,215 → 1,182 lines. Diff vs main is now +1,450 / −0, additive only, entirely within packages/agent.

Review findings

🟡 1 — TOML whitespace. Confirmed and fixed. You were right, and the uneven distribution was the sharp part. Measured after the fix, all three arms match their Rust originals exactly:

arm before after Rust original
medium-verbose 1686 1651 1651
terse-control 599 599 599
terse-matched 1602 1602 1602

All 83 model-facing strings across the corpus now parse free of leading/trailing newlines and doubled spaces. normalize_case_text + 4 tests.

Writing those tests caught a second bug in my own fix: the space-collapsing pass was eating the relative indentation the dedent had just preserved. Collapsing now applies only within a line, so paragraph breaks and numbered lists survive — which matters, since narrative shape is sometimes the variable under test.

🟡 2 — Dropped tests. Resolved by reverting the deletion. Rather than porting the missing probes, golden_scenario6_handauthored.rs and golden_scenario6_sequence.rs are restored and stay in tests/ as the validated Rust-side reference. That also dissolves the broken provenance citation — the daemon and live_update_node_noop_gate.rs edits are reverted, since they were doc-comment repoints forced by the deletion.

🟢 3, 4 — fixed (stale mod.rs pointer, wrong filename in the documented command).
🟢 5 — moot; the expectation variants are gone.
🟢 6 — done, tool_result_message now calls ChatMessage::tool_result.
🟢 7 — moot; min_properties_on is gone.
🟢 8 — fixed; exact == with a comment saying integer/float unification is the only goal, no implied tolerance.

On expect

Kept as an inert free-text field, printed beside the result and never compared, so a tuned case can answer "what was this tuned to produce?" from the file itself weeks later. Free-text on purpose — the moment it has structure, something will assert on it.

Verification

Both cases re-run 3/3 against the locked native model after the simplification, byte-identical across reps. Turn 2 of the sequence picks up turn 1's actual schema name.

Recorded honestly in the notes: node_type is not stable across sessions (equipment one session, Product another) — nothing in that case tells the model what types exist, so it invents one. The tool name is the signal. My earlier PR description implied that argument was stable; it isn't.

test:all exit 0 at baseline, quality:fix clean, clippy clean, 17 unit tests.

🤖 Generated with Claude Code

@malibio

malibio commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Pragmatic Code Review — PR #2121 (Re-Review)

Review Type: Re-Review
Scope: cumulative diff origin/main...HEAD — 9 files, +1,450 / −0, entirely additive, entirely within packages/agent. Reviewed as the merge artifact, not as an increment, because 99c05509 largely deleted and rewrote 625263a0 rather than patching it.


Summary

The rescope is a genuine improvement and it was executed honestly. 2,215 → 1,182 lines, the assertion engine and scoring are gone, the diff is now additive-only, and — the part that matters most — the simplification did not quietly take correctness with it. I re-verified the fidelity claim on the rewritten parse_output, not on the version the first review saw, and it still holds line-for-line against production.

The two 🟡 findings from the prior review are both properly resolved, one of them by a better route than the one suggested. Finding 2 in particular: restoring golden_scenario6_handauthored.rs and golden_scenario6_sequence.rs rather than porting the missing probes dissolves the dropped-assertion problem and the broken provenance citation at once, and it makes the diff purely additive. git diff origin/main -- packages/agent/tests packages/daemon is empty; I confirmed that directly. That is the right call and a better one than what I proposed.

One real defect remains, and it is in exactly the code the rescope introduced and the prior review asked for: normalize_case_text panics on multibyte leading whitespace (🟡 1). It is not reachable from either committed case file, so nothing today is broken — but it is a panic in the one function whose job is rewriting what the model sees, in a project whose stated constraint is no more harness bugs masquerading as model failures. The fix is two characters of API choice, not new machinery.

Everything else below is small. Against the "keep it simple" constraint I looked specifically for remaining fat and found less than the 1,182-line count suggests: ~420 lines of logic, ~250 of tests, ~230 of doc comments, and the doc comments are carrying real findings rather than restating the code. I am not going to argue for cuts I cannot justify — see the note at the end on the one place I would have, and why I withdrew it.

Verification I ran independently: cargo test -p nodespace-agent --lib golden:: → 17 passed, 0 failed. cargo clippy -p nodespace-agent --all-targets → clean. golden_runner --check on both case files → parses, and I byte-compared the output against the restored Rust literals (below). Did not run against the model, per instruction.


Previous Review: 8 recommendations

# Finding Status
🟡 1 TOML whitespace injection Addressed — with a residual defect in the fix, see 🟡 1 below
🟡 2 Dropped tests / broken provenance citation Addressed — resolved better than suggested
🟢 3 Stale mod.rs pointers Addressed
🟢 4 Wrong filename in documented invocation Addressed
🟢 5 Tool/Clarify first-call-only, undocumented Moot — variants deleted, genuinely gone
🟢 6 Use ChatMessage::tool_result Addressed
🟢 7 Vacuous min_properties_on = 0 Moot — min_properties_on deleted, genuinely gone
🟢 8 f64::EPSILON as absolute tolerance Moot — subset_mismatch deleted with the assertion engine

7 addressed or genuinely moot, 1 addressed with a residual defect, 0 lost, 1 newly introduced (🟡 1, which is a defect in the fix for prior finding 1).

I checked that 5, 7 and 8 are moot rather than silently retained: grep for Expectation, min_properties_on, subset_mismatch, is_subsequence, properties_shortfall, cuts_the_chain, Outcome, RepRun across packages/ and ../nodespace-docs/ returns nothing. Nothing was renamed and kept.

Verification of resolution 2 specifically, since it was the structurally largest change:

  • git diff origin/main --stat -- packages/agent/tests packages/daemon → empty. The deletions and the doc-comment repoints are both fully reverted.
  • Both golden_scenario6_handauthored.rs and golden_scenario6_sequence.rs are present in packages/agent/tests/.
  • golden_turn3_resolution_after_two_terse_facts — the one assertion the prior review said was lost outright — is back with its assertion intact.
  • The daemon test's provenance citation is untouched and therefore correct again.
  • No dangling references to the five case files that commit 625263a0 created and 99c05509 removed.

Requirements Check — revised acceptance criteria only

The original criteria list is superseded; scoring against the 7-item list in the scope correction.

# Revised criterion Status Evidence
1 Bin in packages/agent/src/bin/ reads a case file (system, tools, optional history, user) ✅ bin/golden_runner.rs; Turn carries all four (case.rs:90-128)
2 Calls the model through LlamaChatInferenceEngine — same template, parser, argument repairs ✅ Re-verified below; runner.rs:50, runner.rs:270
3 Prints tool call name + arguments, distinguishing "no tool call parsed" from a wrong call ✅ TurnOutput::summary (runner.rs:78-91) has a dedicated no-call branch, split again on whether text came back; a wrong call prints as name(args) and is visually distinct. Guarded by summary_distinguishes_no_tool_call_from_a_call
4 --reps N runs the same case N times with one model load ✅ load_engine called once in main (golden_runner.rs:127), before run_case's rep loop (runner.rs:100). --reps overrides the file's value (golden_runner.rs:109) and rejects 0 at both the flag and the file
5 No daemon, DB, gRPC, agent loop, PromptAssembler, routing ✅ runner.rs imports only agent_types, repair_tool_call_arguments, LlamaChatInferenceEngine. The one local_agent import is the repair function itself, which is required by criterion 2. The routing::ROUTE_CLARIFY_TOOL reference the first commit had is gone with the clarify expectation
6 Editing the prompt and re-running requires no recompile ✅ Prompts are TOML; the bin takes a path
7 One committed example case, ported from the validated zero-history resolve_query golden, reproducing its known result ✅ Two, and I byte-verified the port — see below

7 of 7 met. No criterion missing, none partially met.

On criterion 7, I checked the port rather than taking the notes' word for it, since the restored Rust file makes it directly diffable. Comparing --check output against the literals in golden_scenario6_handauthored.rs::golden_zero_history_control:

  • system — 211 chars both sides, identical
  • resolve_query description — 174 chars both sides, identical
  • user — "The 2400 one came back — set it to returned", identical including the em-dash
  • Both parameters_schema bodies round-trip to structurally identical JSON, required arrays and property descriptions intact
  • update_node description and schema identical

So the whitespace fix worked: the case file now reproduces the Rust prompt byte for byte, which is the property prior finding 1 said it had lost. The three-arm size-vs-style case that made that finding sharp is no longer in the corpus at all (it lived in the deleted files), so that specific risk is retired rather than merely mitigated.


Findings

🟡 Important

1. normalize_case_text panics on a line whose leading whitespace is multibyte
📁 packages/agent/src/golden/case.rs:252-256

let dedented = if line.len() >= indent {
    &line[indent..]
} else {
    line
};

indent is a byte count and line[indent..] is a byte slice, but the guard line.len() >= indent only proves the index is in range — not that it lands on a char boundary. Rust panics on a non-boundary slice.

I probed this against an extracted copy of the function. It survives everything the case files actually contain — em-dashes mid-line, the literal "The 2400 one came back — set it to returned", NBSP, tabs, CRLF, all-blank, empty string, only-spaces — all fine. The reason is that indent is a min() over every non-blank line's own leading-whitespace byte length, so for any line it is normally ≤ that line's own ASCII-space lead, and slicing inside the ASCII run is always safe.

It breaks when a line's leading whitespace is multibyte whitespace. char::is_whitespace — which trim_start uses — accepts U+3000 IDEOGRAPHIC SPACE (3 bytes), U+2003 EM SPACE, U+00A0, and friends. Then that line's own lead is 3 bytes for one visual space, min() can be 2 from a conventional line, and the slice lands mid-character:

input:  "\n\u{3000}alpha\n  beta\n"
panic:  byte index 2 is not a char boundary; it is inside '\u{3000}' (bytes 0..3) of ` alpha`

Reproduced three ways: leading-position, trailing-position, and — the most plausible of the three — a blank line made of two ideographic spaces between two conventionally-indented lines ("\n alpha\n\u{3000}\u{3000}\n beta\n"). That last one is the realistic path: it is a blank line, so it is filtered out of the indent computation and contributes nothing, but the per-line map still slices it. It is invisible in an editor, and a stray U+3000 or U+00A0 is exactly the kind of thing that arrives by pasting prompt text out of a chat window, a doc, or a model response — which is precisely how case files get authored here.

Severity is 🟡 rather than 🔴 because nothing committed triggers it and the blast radius is one CLI invocation. But the principle is robustness at the trust boundary, and this function sits at the only trust boundary the utility has: it is the code that decides what the model sees, taking arbitrary hand-pasted text as input. A panic there produces a stack trace with no case name and no line number, and the author's next move is to suspect their prompt edit. That is the failure mode the rescope was explicitly meant to stop creating.

The fix does not need new machinery — it needs the char-oriented API instead of the byte-oriented one:

let dedented = line
    .char_indices()
    .nth(indent_chars)
    .map_or("", |(byte_idx, _)| &line[byte_idx..]);

with indent computed as a char count (l.chars().count() - l.trim_start().chars().count()), which also removes the line.len() >= indent guard entirely. Or, keeping bytes, line.strip_prefix(&line[..indent]) guarded by line.is_char_boundary(indent) with a fallback to trim_start(). Either is a few lines and no new concepts.

Please add the panic case as a unit test. The existing four normalizer tests are good and cover the cases the prior review asked for — leading-newline strip, common-indent over non-blank lines only, interior-vs-leading collapsing, idempotence — but all four use ASCII whitespace, so none of them would catch this. One test with a U+3000 in it closes it permanently.

🟢 Suggestions

2. Unconditional chaining is right for this tool, but a multi-arm case file will now silently mislead
📁 packages/agent/src/golden/runner.rs:110-154

Removing the chain flag and cuts_the_chain was the correct simplification — they existed to isolate assertion arms, and there are no assertions. run_rep is much easier to reason about now, and I verified the replay it builds is well-formed:

  • assistant_turn carries tool_calls, not just text (runner.rs:164-179), so the following tool messages have a call to pair with in the template.
  • call_id(index) is assigned in emission order and reused verbatim by the ChatMessage::tool_result on the next line (runner.rs:148-152), so ids pair rather than orphan. Covered by a_replayed_tool_result_pairs_with_its_originating_call.
  • A turn that made no calls pushes a plain assistant text message and no tool results — no orphans on that path either.
  • An inference error becomes TurnOutput.text (runner.rs:223-226) rather than a panic, so one bad rep does not discard the reps already printed. Right call: the printed record is the deliverable.

The footgun is narrower than "chaining is wrong". Because every turn now unconditionally inherits every prior turn, a case file with independent arms — several turns probing different phrasings of the same question, which is a natural thing to reach for and is exactly what the deleted multi-arm files did — will feed arm 1's exchange into arm 2's history. The reader sees three results, and would reasonably read them as three independent measurements when they are a chain. Given the whole point is to distinguish a prompt effect from an artifact, that is the wrong default to leave undocumented.

I am not suggesting bringing the flag back — that is the machinery the rescope correctly deleted, and one case file per arm is the simpler answer. What I would do is spend one sentence on it in case.rs's doc for turns (case.rs:67-69) or in mod.rs: every turn sees every prior turn; for independent arms, use separate case files. The Turn doc comments in this file are unusually careful about stating intent elsewhere, and this is the one load-bearing semantic a reader cannot infer from the field name.

3. tool_results keyed by tool name collapses repeated calls to the same tool
📁 packages/agent/src/golden/case.rs:120-127, used at runner.rs:143-147

tool_results: BTreeMap<String, String> is keyed by name, so if a turn calls search_nodes twice, both replayed results are the same string. For the two committed cases this never arises, and keying by name is genuinely the more readable thing to write in a TOML file.

Flagging it only so the constraint is on the record rather than discovered by someone whose case behaves oddly: the count is right (one result per call, ids pair correctly), only the content is shared. A sentence on the field's doc comment would settle it. I would not change the data structure — an indexed form would be worse to author for the case it fixes, and YAGNI applies until a case actually needs it.

4. expect is normalized as though the model would see it
📁 packages/agent/src/golden/case.rs:349

turn.expect = normalize_case_text(&turn.expect); runs inside normalize(), whose own doc comment (case.rs:339-344) says it applies "to every string the model will see" and explains that notes is excluded because "it never reaches the model". But expect never reaches the model either — runner.rs:136-138 only prints it.

The behavior is fine and arguably desirable (a """ block in expect prints tidily rather than with its TOML indent, which is why the two committed files' multi-line expect values render correctly in --check). It is the doc comment that is now inaccurate about its own scope, in a file where that accuracy is otherwise the standard. Either widen the sentence to "every string that gets printed or sent", or say explicitly that expect is normalized for display. One clause.

5. strip_prefix('\n') does not strip a CRLF-authored leading blank line
📁 packages/agent/src/golden/case.rs:237

normalize_case_text("\r\n a b\r\n c\r\n") returns "\na b\nc" — the leading empty line survives, because the raw string starts \r, not \n. Everything downstream is fine (lines() handles \r\n), so this is only the one leading newline.

Both committed files are LF-only and tab-free (verified: zero \r\n, zero \t), and the repo has no .gitattributes forcing a conversion, so this is currently unreachable. It is one character to make robust — raw.strip_prefix("\r\n").or_else(|| raw.strip_prefix('\n')).unwrap_or(raw) — and worth folding into the same edit as 🟡 1, since both are the same class: the normalizer being less total than its doc comment claims.


Verified clean

  • Fidelity, re-verified on the rewritten code. parse_output (runner.rs:242-279) is still a faithful mirror of the private LocalAgentLoop::parse_chunks (agent_loop.rs:2430-2467) after the rewrite. Diffed statement by statement: same Vec-not-map accumulator preserving emission order, same .rev().find() id matching, same push_str fragment reassembly, same no-op on Done/Error. The single divergence — dropping Reasoning where production accumulates it into a separate return value — is documented at runner.rs:258-260 and is correct here: production returns reasoning separately and never folds it into text, so folding it into the printed text would be less faithful, not more. repair_tool_call_arguments is applied at the same boundary agent_loop.rs:1370 applies it — immediately after chunk accumulation, before the calls become anything else — and it is the same public function, not a copy. runner.rs:50 drives LlamaChatInferenceEngine::load with ModelFamily::Gemma4, so apply_chat_template/PEG_GEMMA4 and chat/parser.rs are production's own.
  • Engine config matches the restored goldens exactly. RUNNER_N_CTX = 32768, default_temperature: 0.1, max_tokens: 512, ModelFamily::Gemma4, same catalog path — byte-for-byte the config in golden_scenario6_handauthored.rs:49-56. Worth noting explicitly that this differs from production, which computes n_ctx dynamically against a N_CTX_MINIMUM floor of 16,384 (nlp-engine/src/chat/mod.rs:1577). Pinning 32,768 is the right choice here — it reproduces what the ported tests measured, which is the stated fidelity target — but it means a case that only fits in 32k is not evidence about a machine that gets 16k. Not a finding; the tool is honest about being a prompt-tuning instrument, not a capacity test.
  • Sequence case file faithfully reproduces its Rust origin. All three turns' system and tool description strings match the literals in the restored golden_scenario6_sequence.rs. The documented difference — real chained output instead of hand-authored Fact: stand-ins — is the feature, is stated plainly in the notes, and no longer creates a provenance problem now that the Rust file is back to hold the terse-facts variant.
  • --check is genuinely useful and does what it claims. Prints every model-facing string escaped, without loading the model, and validates schemas eagerly. validate() converts tool schemas before the 5GB load (case.rs:371-377) so a malformed one fails in milliseconds — small thing, right instinct.
  • deny_unknown_fields on all four case structs. A typo'd key would otherwise read as "the tuning change had no effect", which is the worst possible failure for this tool. Covered by a test that says so.
  • Honest notes. indirect-reference-resolves.toml:22-27 records that node_type is not stable across sessions and that only the tool name is the signal, explicitly correcting an earlier overclaim. scenario6-full-sequence.toml:38-42 records that turn 3 stops rather than continuing to update_node. Recording a measured negative in the artifact rather than the PR thread is the behavior you want from a measurement tool.
  • Project standards. No lint suppression, no #[allow(dead_code)], no #[deprecated], no backward-compat scaffolding. No issue numbers in any new code or case-file content. No docs/ directory, no stray .md, nothing added outside packages/agent. bin/golden_runner.rs is a bin, so it is outside cargo test entirely and cannot be pulled into the default run.
  • Dependency. One new direct dep, toml = "0.8" — already in Cargo.lock and already a direct dep of packages/daemon at the identical version spec, so it matches existing convention and adds no new tree. Justified by the "prompts as data" goal.
  • Module reachability. golden:: is imported nowhere in packages/ except bin/golden_runner.rs, so the module cannot drift into production paths. The case layer is model-free and unit-tested in the default run; runner's model-dependent half is reachable only through the bin. That split is the right seam and it is what mod.rs:33-36 claims.

On the line count

The brief asked whether there are justified cuts, given 1,182 lines against a ~300 target. My honest answer is that I found one candidate and withdrew it.

The candidate was the ~230 lines of doc comments. But they are not restating the code — they record why the fidelity line sits at the template (ADR-038 gathered findings via llama-server, ADR-046 recorded they did not transfer), why reps default to 3 rather than 1 (identical code scoring 6/7/7 on the matrix), why the normalizer exists at all, and why expect is deliberately unstructured. That is the institutional memory that stops the next person rebuilding the assertion engine, and deleting it to hit a line target would trade the exact thing this project has repeatedly paid for losing. The ~420 logic lines are close to the target once tests and prose are set aside, and I could not find a block of logic that is doing nothing.

The one place I would push back if it grows: print_case (golden_runner.rs:148-182) is 35 lines of formatting for a debug flag. It earns its place today because escaped output is how the whitespace bug was found and how the next one will be. If it starts acquiring options, that is the signal.


Recommendation

COMMENT — approve-in-spirit. One 🟡 worth fixing before merge; the rest are optional.

The rescope did what it said. The assertion engine, scoring, batch execution, and chain flag are genuinely gone rather than renamed, the diff is additive-only, the restored Rust tests resolve the prior review's finding 2 more cleanly than the porting route I suggested, and the fidelity claim — which is the entire justification for this being a Rust bin — holds up under line-by-line re-checking of the rewritten parse_output. The prompt-byte-fidelity property that prior finding 1 said had been lost is now verifiable and verified: 211 and 174 characters, both matching their Rust originals exactly.

The one thing I would fix first is 🟡 1. Not because it breaks anything today — it does not, and both case files run clean — but because a panic in the normalizer is the specific failure this rescope was meant to stop manufacturing: a harness bug that presents to the author as a problem with their prompt. It is a byte-index-versus-char-index choice, a few lines, and one U+3000 in a unit test. Findings 3, 4 and 5 are doc-comment accuracy and a one-character robustness fix that can ride along with it. Finding 2 is one sentence documenting a semantic that is correct but not inferable.

None of these are design objections. The shape of the thing is right, and it is simpler than what it replaced.


🤖 Generated with Claude Code

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated pragmatic-code-review (re-review): COMMENT (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.

Found by re-review. `char::is_whitespace` accepts U+00A0, U+2003, and U+3000,
so an indent counted in BYTES and then sliced lands mid-character:

  input:  "\n\u{3000}alpha\n  beta\n"
  panic:  byte index 2 is not a char boundary; it is inside '\u{3000}'

Unreachable from either committed case file, but the carrier is mundane and
invisible: paste prompt text out of a chat window, get a stray NBSP or
ideographic space, and the likeliest form is a "blank" line of them between
normally-indented lines — filtered from the indent computation, still sliced
by the per-line map.

That matters more than its reachability suggests. This is the one function
that rewrites what the model sees, and its panic names no case file, so an
author's next move would be to suspect their prompt edit. Manufacturing that
particular confusion is what the rescope existed to stop.

Indentation is now counted and skipped in CHARACTERS, which removes the panic
class rather than special-casing the three code points that trigger it. Also
strips a leading CRLF, not just LF.

Verified the rewrite is output-identical on both committed cases: the system
prompt is still the same 211 characters as its restored Rust original, em-dash
intact.

Four new tests: multibyte leading whitespace (U+3000/U+00A0/U+2003, including
the reachable blank-line form), CRLF and tabs and degenerate input, and
multibyte content mid-line.

Also from the re-review, doc accuracy only, no behavior change:
- `turns` now states that turns always chain, so independent probes belong in
  separate files
- `tool_results` now states that keying by tool name shares content across
  repeated calls to the same tool
- `normalize()` now describes its real scope, which includes `expect`

20 unit tests. test:all exit 0 at baseline; quality:fix and clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malibio

malibio commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review findings addressed — d399ba9

🟡 1 — normalize_case_text panic. Confirmed and fixed.

The finding was right and my earlier reasoning was wrong: I'd assumed leading whitespace is always ASCII, but char::is_whitespace accepts U+00A0, U+2003, and U+3000, so a byte-counted indent slices mid-character.

input:  "\n\u{3000}alpha\n  beta\n"
panic:  byte index 2 is not a char boundary; it is inside '\u{3000}'

Indentation is now counted and skipped in characters, which removes the panic class rather than special-casing the three code points that reach it. Also strips a leading CRLF (finding 5), not just LF.

Verified output-identical on both committed cases after the rewrite — the system prompt is still the same 211 characters as its restored Rust original, em-dash intact.

Four new tests: multibyte leading whitespace including the reachable blank-line-of-ideographic-spaces form; CRLF, tabs, and degenerate input ("", "\n", " "); multibyte content mid-line. The prior four normalizer tests were all-ASCII, which is exactly why this got through.

🟢 2, 3, 4 — doc accuracy, no behavior change. turns now states turns always chain and independent probes belong in separate files. tool_results now states that keying by tool name shares content across repeated calls. normalize() now describes its real scope, which includes expect.

🟢 5 — folded into the 🟡 1 fix.

20 unit tests. test:all exit 0 at baseline, quality:fix and clippy clean.


On the reviewer's line-count note

Agreed, and worth recording: the ~230 doc-comment lines carry why — ADR-038/046 on where the fidelity line sits, the 6/7/7 matrix variance behind DEFAULT_REPS, why expect is deliberately unstructured. Cutting those to hit a number would trade away the thing this project has repeatedly paid for losing.

Scope note for whoever picks this up

This PR delivers the utility and two ported case files. The prompts in them are reproductions of the existing Rust goldens, run 3/3 against the real model to confirm the port is faithful — they are not newly tuned prompts. No edit-rerun-compare loop has been run. Authoring tuned prompts is downstream work.

One genuine negative result did fall out of running it, and it is cheaply reproducible now: in the sequence case, turn 3 resolves and then stops — it never continues to update_node, 0/3. That is the single-turn-pull boundary.

🤖 Generated with Claude Code

Running /address-review properly (it was skipped last round — findings were
applied by hand, so the triage and re-review decision never happened) turned
up an overclaim in the previous commit.

d399ba9's message said stripping a leading CRLF was a robustness fix. It is
not: `.lines()` already strips a trailing `\r` from every line, so the opening
`strip_prefix` only ever removes the blank first line, and both branch
orderings are behaviorally identical. Verified by running the two side by side
over CRLF-at-head, mid, and tail inputs — SAME on all four. The reviewer's
suggested ordering is still the better-reading one and is now used, but the
comment says what it actually does instead of implying a bug that was not
there.

Also demonstrated, rather than asserted, that the two normalizer regression
tests fail when their fix is removed:

- multibyte: reverting to byte-slicing gives "byte index 1 is not a char
  boundary; it is inside '\u{3000}'" — the test bites.
- CRLF: reverting the prefix order does NOT fail the test, because there is no
  bug to catch. The test was rewritten to assert the guarantee that actually
  holds (no `\r` survives, via `.lines()`) and to name where it comes from,
  instead of implying it guards the prefix order.

That second one is the point of checking: a regression test that passes with
the fix removed is worse than no test, because it reads as coverage.

21 unit tests. test:all exit 0 at baseline; quality:fix and clippy clean. Both
case files still parse byte-identical — system prompt 211 chars, em-dash
intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malibio

malibio commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

/address-review — triage of the re-review

Process note first: the previous round applied the re-review's findings by hand and skipped this step, so the triage and the re-review decision never got produced. Running it properly surfaced an overclaim, which is the argument for not skipping it.

Disposition

# Sev Finding Disposition
1 🟡 normalize_case_text panics on multibyte leading whitespace Addressed (d399ba9), and now proven — see below
2 🟢 Unconditional chaining misleads a multi-arm case file Addressed — doc sentence on turns
3 🟢 tool_results keyed by tool name collapses repeated calls Addressed — doc sentence
4 🟢 expect normalized though the model never sees it Addressed — normalize() doc now states its real scope
5 🟢 strip_prefix('\n') misses a CRLF leading blank line Addressed, and my earlier claim about it corrected

Addressed 5, skipped 0.

The correction (2f15f53)

d399ba9's message presented finding 5 as a robustness fix. It isn't. .lines() already strips a trailing \r from every line, so the opening strip_prefix only ever removes the blank first line — and both branch orderings are behaviorally identical. Ran the two side by side over CRLF at head, middle, and tail: SAME on all four inputs.

The reviewer's ordering is still the better-reading one and is now used, but the comment describes what it does rather than implying a bug that was never there.

Proving the regression tests bite

Both normalizer tests were checked by removing their fix and confirming failure:

  • multibyte — reverting to byte-slicing produces byte index 1 is not a char boundary; it is inside '\u{3000}'. The test bites.
  • CRLF — reverting the prefix order produces no failure, because there is no bug to catch. So that test was rewritten to assert the guarantee that actually holds (no \r survives, via .lines()) and to name where it comes from, rather than implying it guards the prefix ordering.

A regression test that still passes with its fix removed is worse than no test: it reads as coverage. Worth checking rather than assuming — I'd have shipped the false version otherwise.

State

21 unit tests. test:all exit 0 at baseline (4704 frontend / 24 browser / 46 Rust suites), quality:fix and clippy clean. Both case files parse byte-identical — system prompt 211 chars, em-dash intact.

Re-Review Decision

Decision: NO RE-REVIEW NEEDED

Rationale: The one 🟡 was already reviewed as a fix in d399ba9 and is unchanged in substance; this round only strengthened its evidence. 2f15f539 contains no behavior change at all — the two strip_prefix orderings are demonstrably equivalent — plus one rewritten test assertion and comment accuracy. Findings 2–4 are doc-only. Nothing new was introduced, no logic moved, and the full gate is green at baseline.

The PR is ready for merge, pending your decision. Not merging — that's your call.

🤖 Generated with Claude Code

@malibio
malibio merged commit 709e487 into main Aug 17, 2026
@malibio
malibio deleted the issue-2118-golden-prompt-runner branch August 17, 2026 21:45
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