Skip to content

Shared eval runner with a preflight gate: stop scoring scenarios when the environment is broken, and make a new eval a fixture instead of a copied harness - #1778

Merged
malibio merged 4 commits into
mainfrom
worktree-issue-1759-eval-runner
Jul 28, 2026
Merged

malibio merged 4 commits into
mainfrom
worktree-issue-1759-eval-runner

Conversation

@malibio

@malibio malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #1759

@malibio

malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review: PR #1778 — Shared eval runner with a preflight gate

Review Type: Initial Review (single commit de6413d0, 18 files, +1297/-779)


Requirements Check

# Acceptance Criterion Status
1 Shared runner exists; both evals ported with no behavior change ✅ Verified byte-for-byte against the deleted originals
2 Unreachable daemon / unloaded model / too-small context aborts with clear error + distinct exit code, never partial results ⚠️ Mostly — gate is correct at startup, but a mid-run daemon death still produces partial results (see 🔴 #1)
3 Adding an eval requires only a fixture module ✅ EvalFixture interface + 10-line entry wrapper; no plumbing
4 Both evals compare against a committed baseline by default ⚠️ Deliberately reinterpreted — see "Deviation 1" below
5 Results record model, date, host memory, granted n_ctx ✅ Provenance adds commit + dirty flag beyond the ask
6 test:all runs the scoring test ✅ test:scripts in both test:all and test:all:db; 21 tests, 57ms
7 Eval setup documented in nodespace-docs ✅ development/agent-eval.md; scripts/aichat-1329-trial.md deleted

Assessment of the Deliberate Deviations

Deviation 1 — no committed baseline (AC #4). Sound; endorse. Committing results-e4b.json (11/12) would bake in a number that #1760 exists to retract, #1764 owns re-recording, and #1775 will move again. The issue's own framing — "a number that outlived the conditions that produced it" — argues against committing this particular figure. The PR substitutes something stronger: mandatory Provenance in every results file makes a number self-dating, so it cannot outlive its conditions. That addresses the actual failure mode; "compare by default" was the mechanism, not the goal.

The residual gap is real but small: comparison is now opt-in, so a run that forgets --baseline silently reports no regressions. scripts/eval/runner.ts:358 — mitigable in a follow-up by defaulting --baseline to a conventional path when it exists (see 🟢 #7).

Deviation 2 — the 4th preflight check + proto/daemon/CLI change. Belongs here; endorse. The check was added because this branch's own eval runs wrote schemas and chat nodes into ~/.nodespace. A gate whose entire purpose is "refuse to run when the environment cannot produce a valid result" that permits corrupting live user data would be incomplete on its own terms. It is not scope creep — it is the same requirement applied to a failure mode discovered during implementation.

The proto/daemon/CLI change is likewise load-bearing rather than opportunistic: AC #2 and AC #5 both require granted_n_ctx, and that value existed only in-process. There was no way to satisfy the issue without exposing it. Implementation is correct — reporting the catalog id from active_model_id rather than the resolved GGUF path (local_agent_service.rs:726-733) is the right call, since no path substring matches gemma-4-e4b-q4km.


Code Review Findings

🔴 Critical

1. 📁 scripts/eval/runner.ts:69-96 — a mid-run daemon death still produces the exact false-pass the issue was written about

This is the highest-value item. runTurn swallows a non-zero exit and returns toolsCalled: []. newChat throws EnvironmentError, but only fires between groups. The agent-matrix fixture has three groups, so a daemon that dies during group 2 or 3 produces:

runTurn → exitCode != 0 → { toolsCalled: [], reply: "(send failed: ...)" }
→ assertExpectation({kind:"noTools"}, []) → { passed: true }

Scenarios 1 and 2 are noTools — they are scored in groups 1 and 2, before any newChat would catch the death. The routing fixture has 15 single-scenario groups, so newChat does fire between each — but the daemon can die after newChat succeeds and during the send; clarify scenarios fail correctly there since isClarification("(send failed: ...)") returns false. The noTools matrix scenarios are the live hazard.

The main agent hit precisely this during testing — a run reported "9/12 scenarios failed" from a dead daemon, meaning 3 scored as passes from a corpse.

The preflight gate proves the environment was good at t=0. It does not prove it stayed good, and an eval run is 8+ minutes. The principle at stake is the issue's own: an eval that can silently score a broken environment is worse than no eval, because its output gets cited.

Suggested fix — a consecutive-failure abort in the run loop:

let consecutiveFailures = 0;
// inside the scenario loop, after `const scored = runTurn(...)`:
if (scored.reply.startsWith("(send failed:")) {
  if (++consecutiveFailures >= 2) {
    throw new EnvironmentError(
      `${consecutiveFailures} consecutive turns failed to send — the daemon went away mid-run.`,
      `Partial scores from an aborted run are not comparable to a complete one, so none were written.`,
    );
  }
} else {
  consecutiveFailures = 0;
}

The existing catch at runner.ts:309-312 already routes EnvironmentError to abortOnEnvironment, so this needs no new plumbing. A threshold of 2 tolerates a single flaky turn while catching a systemic death within one extra turn instead of a whole run.


🟡 Important

2. 📁 scripts/eval/preflight.ts:158-173 — readServedDatabasePath fails open, defeating the destructive-risk check exactly when it matters

Both error paths return "", and preflight at line 193 guards on status.databasePath && — so an empty string skips the check entirely. The failure modes that produce "" are: the CLI has no database list subcommand (older build), the RPC fails, or the JSON shape changed. In each case the daemon is still serving something, and the eval proceeds to write into it.

For the other three checks, failing open is merely a missed detection. For this one it is irreversible damage to live user data — the specific harm that motivated adding the check. A guard against an unrecoverable outcome should fail closed.

Recommend throwing EnvironmentError when the path cannot be determined, with a remedy explaining how to confirm isolation (grep served_db_path <daemon log>).

Secondary: the check only catches $HOME/.nodespace/. A daemon serving any other real database passes. Inverting the check to require the served path be under the configured test home would be strictly safer.

3. 📁 scripts/eval/preflight.ts:223 — status.modelId.includes(env.model) admits false positives on model families

  • NS_MODEL=gemma-4-e4b matches loaded gemma-4-e4b-q4km ✅ intended
  • NS_MODEL=gemma-4-e4b also matches loaded gemma-4-e4b-q8 ❌ different quantization, different scores

The check exists so scores are not filed against a model that did not produce them — quantization changes tool-calling behavior materially. Since local_agent_service.rs:726-733 now returns active_model_id, exact equality should hold in the normal path; containment is only needed for the fallback where active_model_id is None. Suggest equality first, path-basename fallback, warn when only the fallback matched.

4. 📁 packages/daemon/src/services/local_agent_service.rs:718-721 — get_status can now block a tokio worker for the length of a generation

service.model_spec() → engine().model_info() → LlamaChatEngine::model_info() (chat/mod.rs:1120) takes a std::sync::Mutex. generate_blocking (chat/mod.rs:343) holds that same mutex for the entire duration of inference. So get_status — previously cheap — now blocks on an inference that can run 60–180s, on a tokio worker thread, while holding service.read().

Mitigating: local_agent_status is currently unused by the frontend (only the new CLI path calls it), and service.read() is a read lock. But this is a latent trap: the moment a status poller is added to the UI, it will hang during streaming.

Lock ordering is safe — every site touching service and active_model_id (lines 161, 194–210, 215–221, 720–726, 1119, 1251) acquires service first and releases before active_model_id. No deadlock.

Suggest caching the spec at load time alongside active_model_id (it only changes on engine swap, and replace_engine is the single choke point), or documenting the blocking behavior.

5. 📁 packages/daemon/src/services/local_agent_service.rs:720 — unwrap_or(None) collapses "engine error" into "no model loaded"

An InferenceError and a genuinely absent model are indistinguishable to the caller; preflight will suggest model load ... for what may be an engine fault. A tracing::warn! on the error arm preserves degrade-don't-fail while leaving a trail.


🟢 Suggestions

6. 📁 scripts/eval/env.ts:11 — stale doc path. References agent-evals.md; the file is agent-eval.md (singular). preflight.ts:121 has it right.

7. 📁 scripts/eval/runner.ts:358 — closing the AC #4 gap cheaply. Once #1764 records the honest baseline, default baselinePath to a conventional location when the file exists, keeping --baseline as an override. Worth a note on #1764.

8. 📁 scripts/eval/types.ts:47 — the [key: string]: unknown index signature weakens typo detection, but the alternative is worse. Making EvalFixture<S extends Scenario = Scenario> generic would remove both the index signature and every cast. Right shape, but a refactor of a brand-new abstraction with two consumers — reasonable to defer until a third eval exists.

9. 📁 packages/agent/src/agent_guidance.rs:318-340 — the read_dir guard is well-built; one gap. Anti-vacuity assertions are excellent and verified non-vacuous. Gap: a non-fixture .ts file added to fixtures/ (a shared helper) would have no prompt: literals and fail the build with a misleading parser-drift message. Consider filtering on presence of a prompt: literal and asserting the set is non-empty.

10. 📁 scripts/eval/preflight.ts:67,78 — constants verified. AGENT_SYSTEM_PROMPT_TOKENS = 6600 matches N_CTX_MINIMUM's doc comment (chat/mod.rs:1352-1358) verbatim. 6600 + 4096 = 10,696 sits below N_CTX_MINIMUM = 16_384, so the gate never rejects a window the engine itself accepts — correctly a floor, not a duplicate. Nit: nothing enforces the "kept in step with" claim.

11. 📁 package.json:23 — test:scripts scope is exact and the bun test exception is legitimate. find scripts -name "*.test.ts" returns exactly one file. Forward risk: a future scripts/*.test.ts needing DOM would be silently misrouted — worth a line in CLAUDE.md noting scripts/ tests must stay DOM-free.

12. Nit: 📁 scripts/eval/env.ts:29,39 — timeoutMs and log are dead in the runner. Both read into EvalEnv but never used; aichat.ts reads them from process.env directly. Per the "delete unused code" standard, either drop them or comment that they document the child process's contract.

13. Nit: 📁 scripts/eval/runner.ts:220-222,229 — usage errors exit EXIT_FAILED (1), same as a scenario failure. Argv parsing is otherwise robust (flag-before-label, flag-at-end, missing-value all tested).


Verification performed

  • Behavior preservation — verified. Diffed both fixtures against git show origin/main:scripts/aichat-matrix.ts and :scripts/routing-eval.ts. All 12 matrix scenarios and all 15 routing fixtures byte-identical (prompts, expectations, flags, priorTurns, group boundaries). assertExpectation and assertFixture moved verbatim including failure strings. All routing helpers transcribed intact. The old assertionTurns slice is preserved by the runner passing [scored] to both score and extra.
  • bun run test:scripts → 21 pass / 0 fail in 57ms; bun test scripts/ matches exactly one file.
  • cargo test -p nodespace-agent guidance_is_not_contaminated_by_eval_prompts → passes against the new read_dir enumeration.

Recommendation

REQUEST CHANGES

This is a strong, well-reasoned PR and a clear net improvement: it eliminates five duplicated concerns, makes adding an eval a fixture file, ports both evals with verified byte-for-byte scoring fidelity, and closes the silent-corruption hole the issue was written about. Both deviations from the issue text are well-argued and endorsed.

Two items should land before merge, both in the load-bearing preflight/runner path:

  • 🔴 Set up Tauri + Svelte project structure #1 — mid-run daemon death still scores noTools scenarios as passes. This is the precise failure mode the issue exists to eliminate, and the fix is ~10 lines reusing plumbing already written.
  • 🟡 Create design system foundation #2 — readServedDatabasePath failing open. Every other check failing open costs a missed detection; this one costs irreversible writes to live user data.

🟡 #3, #4, and #5 are worth addressing but would not block on their own — #4 is latent rather than active, since nothing polls that RPC today. The 🟢 items are polish and follow-up material.

No architectural regression, and no project standard violated — no any, no lint suppression, no backward-compat scaffolding, no #[allow(dead_code)], Bun-only, docs correctly in nodespace-docs with no issue numbers in doc content.

malibio added a commit that referenced this pull request Jul 28, 2026
…n daemon death

Reworks the WIP checkpoint into its final form and addresses the remaining
review findings from PR #1778.

🔴 #1 — mid-run daemon death scored noTools scenarios as passes. Preflight
proved the environment good at t=0 but not that it stayed good, and a run takes
8+ minutes. A daemon dying mid-group left every subsequent turn calling no
tools, so negative assertions went green — the exact false result this harness
exists to prevent, relocated past the gate. TurnRecord now carries `sendFailed`
(a turn that never reached the model is not the same as a turn that called no
tools), and two consecutive failed sends abort the run as an environment error.
Verified against a live daemon killed mid-group: exit 2, 10 scored scenarios
discarded, no results file. The same case previously reported "9/12 scenarios
failed" with exit 1.

🟡 #2 — readServedDatabasePath failed open. Every other check failing open costs
a missed detection; this one guards against writes into live user data that
cannot be undone, so "I could not determine which database this is" must stop
the run. Now throws on non-zero exit, unparseable JSON, or an empty database
list. Verified the real-database refusal still wins over the model check.

🟡 #3 — substring model matching accepted a different quantization
(NS_MODEL=gemma-4-e4b would match a loaded gemma-4-e4b-q8, whose tool-calling
behavior differs). Now exact-match first, with a filename fallback for the case
where the daemon reports a resolved GGUF path instead of a catalog id, and a
warning when only the fallback matched.

🟡 #5 — get_status collapsed an engine error into "no model loaded". Added a
tracing::warn! on the error arm; the degrade-don't-fail behavior is unchanged.

🟢 #6 stale doc path (agent-evals.md → agent-eval.md).
🟢 #9 the contamination guard now selects fixtures by whether they declare
   scenario prompts, so a shared helper dropped in fixtures/ is skipped rather
   than failing the build with a misleading parser-drift message. Re-verified
   the guard still fires on a planted example and is not vacuous.
🟢 #11 CLAUDE.md records that tests under scripts/ must stay DOM-free, so the
   test:scripts exception cannot quietly widen.
🟢 #12 documented that env.log/env.timeoutMs exist to state the contract
   scripts/aichat.ts consumes from the environment directly.
🟢 #13 usage errors now exit 64 rather than sharing 1 with scenario failures.

Also corrected the abort footer, which claimed "no scenarios were run" when a
mid-run abort had already scored some.

Deferred, with reasons:
- 🟡 #4 (get_status can block a tokio worker for the length of a generation)
  is real but latent — nothing polls that RPC today. The fix is a daemon-internal
  refactor beyond this PR's scope; filed as #1779.
- 🟢 #7 (default --baseline path) depends on #1764 recording the honest baseline
  first; wiring it now would default to a file that does not exist.
- 🟢 #8 (generic EvalFixture<S>) is the right shape but a refactor of a
  brand-new abstraction with two consumers — the reviewer's own YAGNI call.
- 🟢 #10 (enforce the 6600-token constant matches N_CTX_MINIMUM) — both sites
  carry cross-referencing comments and the value is a floor, not an exact
  accounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
@malibio

malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings addressed

Commits 61ba6d0d (checkpoint) + 4ed1af7d.

✅ Addressed (9)

# Finding What changed
🔴 1 Mid-run daemon death scored noTools as passes TurnRecord.sendFailed distinguishes "never reached the model" from "called no tools"; two consecutive failed sends abort as an environment error
🟡 2 readServedDatabasePath failed open Now fails closed — throws on non-zero exit, unparseable JSON, or empty database list
🟡 3 Substring model match accepted wrong quantization Exact match first, filename fallback for the resolved-path case, warning when only the fallback matched
🟡 5 unwrap_or(None) hid engine errors tracing::warn! on the error arm; degrade-don't-fail behavior unchanged
🟢 6 Stale doc path agent-evals.md → agent-eval.md
🟢 9 Guard would fail confusingly on a non-fixture helper Fixtures now selected by whether they declare scenario prompts, not filename
🟢 11 test:scripts exception could widen CLAUDE.md records that tests under scripts/ must stay DOM-free
🟢 12 Dead timeoutMs/log fields Documented as the contract aichat.ts consumes from the environment
🟢 13 Usage errors shared exit 1 Usage now exits 64

Also fixed the abort footer, which claimed "no scenarios were run" when a mid-run abort had already scored some.

Verification of the two blocking items

🔴 #1 — reproduced the original bug, then confirmed the fix. Live daemon, killed mid-group (inside the 5-scenario chain, where newChat never fires):

  • Before: 9/12 scenarios failed, exit 1, results file written — 3 dead-daemon turns scored as passes.
  • After: exit 2, 10 scenario(s) had been scored; they are NOT written, no results file.

🟡 #2 — confirmed the destructive check still wins. With a model loaded and the daemon serving the real database via the NODESPACED_DB_PATH registry-override trap, the DB refusal fires ahead of the model check.

Also re-verified the contamination guard after the #9 change: a helper file with no prompt: literals is skipped, and a planted example still fails the build (checked with a scenario-8c prompt pasted into SCHEMA_CREATION_RULES) — so the guard is not vacuous.

⏭️ Deferred (4), with reasons

Tests

bun run test:all → exit 0. Frontend 4096/4096 across 169 files (unchanged from baseline), skill 20/20, Rust suites green. test:scripts 21/21. Pre-push gate (incl. e2e) passed.

@malibio

malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review: PR #1778 — Re-Review

Review Type: Re-Review
Delta reviewed: de6413d0..HEAD (61ba6d0d, 4ed1af7d) — 10 files, +233/−68
Scope: the two new commits only. Behavior-preservation, lock ordering, and constant verification from the original review carry forward unchanged (the delta does not touch those areas).


Previous Review Summary

Count
Findings in original review 13 (1 🔴, 4 🟡, 8 🟢)
Addressed 9 — #1, #2, #3, #5, #6, #9, #11, #12, #13
Deferred with reasons 4 — #4 (filed as #1779), #7, #8, #10
New issues found in delta 1 🟡 residual, 2 🟢 nits

All four deferrals are legitimate. #4 is latent (nothing polls the RPC) and its fix is a daemon-internal refactor at the replace_engine choke point — correctly out of scope and tracked. #7 genuinely blocks on a baseline that does not yet exist; defaulting to a nonexistent file would be worse than opt-in. #8 was my own YAGNI call, and #10 is tolerable by construction since the constant is a floor rather than an exact accounting.


Requirements Check — issue #1759

# Acceptance Criterion Status
1 Shared runner; both evals ported with no behavior change ✅ Re-verified: delta's fixture edits are formatting-only
2 Unreachable daemon / unloaded model / too-small context aborts, never partial results ⚠️ Substantially closed, one hole remains (see 🟡 A)
3 Adding an eval requires only a fixture module ✅ Unchanged
4 Compare against a committed baseline by default ⚠️ Deviation endorsed in original review; unchanged
5 Results record model, date, host memory, granted n_ctx ✅ Unchanged
6 test:all runs the scoring test ✅ 21/21 in 65 ms, re-run
7 Eval setup documented in nodespace-docs ✅ Path reference corrected

The specific case the original review named — a noTools matrix scenario scoring a corpse as a pass — is genuinely fixed. A structurally identical case survives on the priorTurns path.


Findings

🟡 Important

A. 📁 scripts/eval/runner.ts:296-317 — the abort covers scored turns but not priorTurns

runTurn is called on two paths; the new sendFailed check guards only one:

for (const prior of scenario.priorTurns ?? []) {
  priorTurns.push(runTurn(env, chatId, prior));   // ← no sendFailed check
}
const scored = runTurn(env, chatId, scenario.prompt);
if (scored.sendFailed) { ... }                     // ← guarded

Two routing fixtures use priorTurns, both loadBearing: true:

  • instance-not-schema-invoice — 1 prior turn, the only fixture testing "existing type → Node Creation, not Schema Creation"
  • clarification-then-fallthrough — 2 prior turns, the only fixture testing the clarification contract

The uncaught case: a prior turn fails but the scored turn succeeds. The counter never increments, so the scenario runs against a chat that never received its context — and clarification-then-fallthrough expects {kind: "search"} from a model that was never clarified to. That scores as a legitimate routing verdict when it is an environment artifact. A silent failure is less dangerous than a silent pass, but it is still a number filed under "the model got routing wrong" when the truth is "the fixture never ran as designed."

Also: since priorTurns never touch the counter, a daemon that dies during a 2-prior-turn setup and stays dead is only caught two scenarios later, after burning three failed sends.

Fix reuses what is already written:

const record = (t: TurnRecord) => {
  if (t.sendFailed) {
    if (++consecutiveSendFailures >= MAX_CONSECUTIVE_SEND_FAILURES) throw new EnvironmentError(...);
  } else {
    consecutiveSendFailures = 0;
  }
  return t;
};
priorTurns.push(record(runTurn(env, chatId, prior)));
const scored = record(runTurn(env, chatId, scenario.prompt));

Engineering principle: a guard placed on one of two structurally identical call sites is a guard with a documented bypass. The comment on MAX_CONSECUTIVE_SEND_FAILURES — "if the daemon dies mid-run every subsequent turn calls no tools" — applies verbatim to prior turns, so the omission reads as oversight rather than decision.

Not a merge blocker on its own. The 🔴 that motivated REQUEST CHANGES is genuinely fixed; this is a smaller, differently-shaped version on a two-fixture path.


Verification of the items probed hard

🔴 #1 — counter semantics, single-failure behavior, threshold. All correct.

  • Reset: consecutiveSendFailures = 0 on every non-failed send — measures a run, not a total. ✅
  • Single failure still scores normally: 1 >= 2 is false, control falls through to fixture.score(...) exactly as before. This was the property most at risk from an over-eager fix, and it survived. ✅
  • sendFailed on every failing path: runTurn has exactly one non-success return (runner.ts:80-89) and it sets the flag. No third path. newChat throws directly — different mechanism, already correct. ✅
  • Threshold of 2 vs group boundaries: the group structure makes it stronger. newChat fires at each boundary and throws immediately, so the counter only has to survive within a group. Worst case (group 3, 8a–8e): death at 8a aborts at 8b, one wasted turn. ✅
  • Abort path traced: throw at :307 → catch at :344-347 → abortOnEnvironment → exit(2). Bun.write(outPath, ...) is at :364, after the try/catch — no results file on the abort path. ${results.length} is accurate because results.push happens after the abort check. ✅

🟡 #2 — fails closed correctly; the compatibility concern is moot.
All three failure modes throw (non-zero exit :173, unparseable JSON :180, empty list :187). The call-site short-circuit is gone — :217 no longer has status.databasePath &&, so an empty path cannot skip the check and cannot even reach it. The "older CLI now hard-fails" risk does not exist: database list and model status both trace to 1a167e91 (ADR-053, 2026-07-09). No CLI has one without the other.

🟡 #3 — the fallback does not reintroduce the q4km/q8 confusion.
This is what I most expected to find broken. It holds. The fallback is only reachable when active_model_id is None and the daemon reports a resolved GGUF path — a state where the id is genuinely unavailable and no check can confirm the exact build. Degrading to "matched by filename, exact build unconfirmed, plus a warning saying so" is correct when the authoritative signal is absent. console.error is the right channel (scripts/ is exempt from the createLogger rule).

🟢 #9 — the guard-on-a-guard is not vacuous.
Selection keys on sites (literal sites seen), not prompts (successfully parsed). That distinction is what makes it safe:

  1. Parser stops recognising prompt: → sites == 0 everywhere → zero files → !eval_scripts.is_empty() fires.
  2. Parser sees sites but cannot parse literals → files still selected → per-file assert_eq!(prompts.len(), sites) (:376-383) fires, with a better message.
  3. Non-fixture helper added → sites == 0 → skipped, not failed. The intended improvement, and it cannot mask a real fixture.

Failure modes are disjoint, each has a live assertion, and assert!(checked > 0) at :414 is a third backstop. The two-value (prompts, sites) return is precisely what makes content-based selection safe — nice design leverage.

Fixture claim independently verified. Whitespace-normalized comparison against de6413d0: routing.ts byte-identical modulo whitespace; the other two differ only in trailing commas and argument wrapping. No character inside any quoted string changed across all 12 matrix and 15 routing prompts. bunx prettier --check scripts/eval/ clean.

Other confirmations: EXIT_USAGE = 64 (EX_USAGE) at both sites and documented. timeoutMs/log doc comments accurately describe the cross-process contract — right call vs. deleting fields that document a real interface. CLAUDE.md note explains why. tracing::warn! includes error = %e and preserves degrade-don't-fail.

Unprompted catch, noted approvingly: abortOnEnvironment previously claimed "No scenarios were run" — false once a mid-run abort exists. Now reads "No results file was written." Tracing that the new abort path invalidated an existing message is easy to skip.

Tests re-run: cargo test -p nodespace-agent --lib guidance → 11/11 (incl. the anti-vacuity pin). bun run test:scripts → 21/21.


🟢 Nits

  • 📁 packages/agent/src/agent_guidance.rs:346 — eval_prompts(&source).1.gt(&0) reads awkwardly; > 0 scans faster. Cosmetic.
  • 📁 scripts/eval/preflight.ts:260-265 — the fallback-match warning is stderr-only. A modelMatchedByPath: true in provenance would let the caveat outlive the terminal session. Low value; provenance.model being a path is already a hint.

Recommendation

APPROVE

Both blocking items are addressed, and addressed well rather than minimally. 🔴 #1's fix distinguishes "never reached the model" from "called no tools" at the type level rather than string-matching the reply, preserves single-failure-as-real-failure, resets correctly, and routes through existing plumbing. 🟡 #2 fails closed on all three paths, and the compatibility concern turns out to be moot.

Both claims I probed hard hold: fixture changes are formatting-only (verified by normalized comparison, not by reading the diff), and the #9 content-based selection is not vacuous.

One residual 🟡 A: the sendFailed abort guards the scored turn but not priorTurns. Same class as the 🔴, smaller blast radius, one helper closure to fix. Worth taking in this PR if quick, but not holding the merge — the cited-number hazard that motivated the original REQUEST CHANGES is genuinely gone.

No project standard violated: no any, no lint suppression, no backward-compat scaffolding, no #[allow(dead_code)], no issue numbers in doc content or code comments, Bun-only, docs in nodespace-docs.

malibio added a commit that referenced this pull request Jul 28, 2026
… death

Re-review of PR #1778 approved the fix but found the consecutive-failure guard
sitting on only one of two structurally identical call sites.

🟡 A — `runTurn` is called for prior-context turns as well as the scored turn,
and only the scored turn counted against the failure budget. Two routing
fixtures use `priorTurns`, both loadBearing: instance-not-schema-invoice (the
only fixture testing existing-type vs new-type routing) and
clarification-then-fallthrough (the only fixture testing the clarification
contract, with two prior turns).

The uncaught case was a prior turn failing while the scored turn succeeded: the
counter never incremented, so the scenario ran against a chat that never
received its setup and the verdict was filed as the model's routing behavior
rather than as an environment artifact. A daemon dying during a two-prior-turn
setup was also only caught two scenarios later, after three failed sends.

Hoisted the counting into a `turn()` helper both call sites go through, so the
budget covers every send. Verified against a live daemon killed during a
prior-context turn: exit 2, 7 scored scenarios discarded, no results file.

Nits from the same review:
- `eval_prompts(&source).1.gt(&0)` → `> 0` (reads faster; same semantics).
- A fallback filename match now records `modelMatchedByPath` in the results
  file's provenance, so the "exact build unconfirmed" caveat outlives the
  terminal session that saw the stderr warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
@malibio

malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review residual addressed — a61c0270

🟡 A — priorTurns not covered by the consecutive-failure guard. Fixed exactly as suggested: hoisted the counting into a turn() helper that both call sites go through, so every send counts against the budget rather than only the scored turn.

Verified live on the path the re-review identified. Ran the routing eval, waited until it reached a [context] turn, then killed the daemon:

=== EVAL EXIT: 2 ===
  7 scenario(s) had been scored; they are NOT written, ...
  No results file was written: a score from this environment would not mean anything.
--- results file? ---
ls: /tmp/ns-prior/out.json: No such file or directory

Daemon was isolated via NODESPACE_HOME (serving: /tmp/ns-prior/.nodespace/database/nodespace.db), so no live data was touched.

Both nits also taken:

  • eval_prompts(&source).1.gt(&0) → > 0
  • A fallback filename match now records modelMatchedByPath: true in the results file's provenance, so the "exact build unconfirmed" caveat outlives the terminal session that saw the stderr warning

Tests: test:all exit 0 · test:scripts 21/21 · nodespace-agent guidance suite 11/11 · pre-push gate (incl. e2e) passed.

All 13 original findings + the 1 re-review residual + 2 nits are now either addressed or deferred with a stated reason (#4 tracked as #1779, #7 blocked on #1764, #8 YAGNI, #10 tolerable by construction).

malibio and others added 4 commits July 28, 2026 18:45
Both agent evals independently implemented the same five concerns — the NS_*
environment contract, argv parsing, results assembly, the summary table, and
baseline comparison — and neither could tell a real result from a broken
environment.

Shared runner (scripts/eval/):
- runner.ts owns env, preflight, argv, chat-node lifecycle, results, summary,
  baseline diff, and exit codes. An eval is now a fixture module exporting
  scenarios plus a score function; adding one is a file in fixtures/, not
  another copy of the plumbing.
- Both existing evals port over with their scoring logic moved verbatim, so
  scenario behavior is unchanged. Scenarios gained stable `id`s because
  baseline diffing joins on them — prompts get reworded (decontamination
  rewrote every one) and joining on text reads as remove-and-add.

Preflight gate — the load-bearing part. Before scoring anything it asserts the
environment can produce a valid result, exits 2 (distinct from a scenario
failure), and writes no results file:
- daemon reachable
- NOT serving the real user database
- the requested model is loaded, and is the one being scored
- the granted context window exceeds the agent's system prompt

The context check is what the issue was written about: a run once scored its two
"no tools called" scenarios as PASSING while every turn died on context overflow
before inference — a failed turn calls no tools, so a negative assertion goes
green. It catches that in ~2s instead of ~8min of half-broken inference.

The database check was added after this branch's own eval runs wrote schemas and
chat nodes into ~/.nodespace. NODESPACED_DB_PATH — what the old reproduce steps
prescribed — is overridden by the database registry (ADR-053), so the daemon
serves live user data while looking correctly configured. NODESPACE_HOME is the
variable that actually isolates; the gate now refuses to run without it.

Granted n_ctx over the wire:
- LocalAgentStatusResponse gains model_id and granted_n_ctx; get_status reports
  the catalog id the model was loaded by (not the resolved GGUF path, which no
  requested-id substring matches) and the window the engine actually allocated.
- New `nodespace model status [--json]` surfaces it. Previously this value
  existed only in-process and had to be scraped out of daemon log text.

Provenance in every results file — model, timestamp, host RAM, granted n_ctx,
commit, and dirty flag — so a cited number carries the conditions that produced
it. No blessed baseline is committed: the evals are nondeterministic and
host-specific, #1764 owns recording the honest one, and #1775's fixes will move
it. Comparison is first-class via --baseline instead.

Contamination guard now enumerates scripts/eval/fixtures/ rather than naming two
files, so an eval added later is guarded automatically. Verified it still fires
on a planted example from the new location and is not vacuous.

test:scripts runs the scoring unit test (21 tests, ~20ms, no model or daemon) and
joins test:all. It ran nowhere before. This is a deliberate exception to the
"never use bun test" rule, which exists to stop DOM tests bypassing the
Happy-DOM vitest config — not applicable to a standalone script test.

Eval setup moved to nodespace-docs/development/agent-eval.md and
scripts/aichat-1329-trial.md deleted, per the docs standard.

The evals themselves stay out of test:all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
Critical #1: mark failed sends with sendFailed and abort the run after two
consecutive failures, so a daemon that dies mid-run cannot score noTools
assertions as passes.

Important #2: readServedDatabasePath now fails CLOSED — an undeterminable
database path stops the run instead of skipping the check that guards live
user data.

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

Reworks the WIP checkpoint into its final form and addresses the remaining
review findings from PR #1778.

🔴 #1 — mid-run daemon death scored noTools scenarios as passes. Preflight
proved the environment good at t=0 but not that it stayed good, and a run takes
8+ minutes. A daemon dying mid-group left every subsequent turn calling no
tools, so negative assertions went green — the exact false result this harness
exists to prevent, relocated past the gate. TurnRecord now carries `sendFailed`
(a turn that never reached the model is not the same as a turn that called no
tools), and two consecutive failed sends abort the run as an environment error.
Verified against a live daemon killed mid-group: exit 2, 10 scored scenarios
discarded, no results file. The same case previously reported "9/12 scenarios
failed" with exit 1.

🟡 #2 — readServedDatabasePath failed open. Every other check failing open costs
a missed detection; this one guards against writes into live user data that
cannot be undone, so "I could not determine which database this is" must stop
the run. Now throws on non-zero exit, unparseable JSON, or an empty database
list. Verified the real-database refusal still wins over the model check.

🟡 #3 — substring model matching accepted a different quantization
(NS_MODEL=gemma-4-e4b would match a loaded gemma-4-e4b-q8, whose tool-calling
behavior differs). Now exact-match first, with a filename fallback for the case
where the daemon reports a resolved GGUF path instead of a catalog id, and a
warning when only the fallback matched.

🟡 #5 — get_status collapsed an engine error into "no model loaded". Added a
tracing::warn! on the error arm; the degrade-don't-fail behavior is unchanged.

🟢 #6 stale doc path (agent-evals.md → agent-eval.md).
🟢 #9 the contamination guard now selects fixtures by whether they declare
   scenario prompts, so a shared helper dropped in fixtures/ is skipped rather
   than failing the build with a misleading parser-drift message. Re-verified
   the guard still fires on a planted example and is not vacuous.
🟢 #11 CLAUDE.md records that tests under scripts/ must stay DOM-free, so the
   test:scripts exception cannot quietly widen.
🟢 #12 documented that env.log/env.timeoutMs exist to state the contract
   scripts/aichat.ts consumes from the environment directly.
🟢 #13 usage errors now exit 64 rather than sharing 1 with scenario failures.

Also corrected the abort footer, which claimed "no scenarios were run" when a
mid-run abort had already scored some.

Deferred, with reasons:
- 🟡 #4 (get_status can block a tokio worker for the length of a generation)
  is real but latent — nothing polls that RPC today. The fix is a daemon-internal
  refactor beyond this PR's scope; filed as #1779.
- 🟢 #7 (default --baseline path) depends on #1764 recording the honest baseline
  first; wiring it now would default to a file that does not exist.
- 🟢 #8 (generic EvalFixture<S>) is the right shape but a refactor of a
  brand-new abstraction with two consumers — the reviewer's own YAGNI call.
- 🟢 #10 (enforce the 6600-token constant matches N_CTX_MINIMUM) — both sites
  carry cross-referencing comments and the value is a floor, not an exact
  accounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
… death

Re-review of PR #1778 approved the fix but found the consecutive-failure guard
sitting on only one of two structurally identical call sites.

🟡 A — `runTurn` is called for prior-context turns as well as the scored turn,
and only the scored turn counted against the failure budget. Two routing
fixtures use `priorTurns`, both loadBearing: instance-not-schema-invoice (the
only fixture testing existing-type vs new-type routing) and
clarification-then-fallthrough (the only fixture testing the clarification
contract, with two prior turns).

The uncaught case was a prior turn failing while the scored turn succeeded: the
counter never incremented, so the scenario ran against a chat that never
received its setup and the verdict was filed as the model's routing behavior
rather than as an environment artifact. A daemon dying during a two-prior-turn
setup was also only caught two scenarios later, after three failed sends.

Hoisted the counting into a `turn()` helper both call sites go through, so the
budget covers every send. Verified against a live daemon killed during a
prior-context turn: exit 2, 7 scored scenarios discarded, no results file.

Nits from the same review:
- `eval_prompts(&source).1.gt(&0)` → `> 0` (reads faster; same semantics).
- A fallback filename match now records `modelMatchedByPath` in the results
  file's provenance, so the "exact build unconfirmed" caveat outlives the
  terminal session that saw the stderr warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qFMbS5rEGLQL6CCkg6Xjs
@malibio
malibio force-pushed the worktree-issue-1759-eval-runner branch from a61c027 to dbd5420 Compare July 28, 2026 16:59
@malibio

malibio commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Clean end-to-end verification run ✅

Rebased onto 8c4c091d (clean — the one new commit touches only frontend files), test:all green on the new base, then ran the eval end to end against a freshly built daemon and a purged, isolated database.

Result: 9/12 — reproduces the honest baseline exactly

   Label:    final-verify
   Model:    gemma-4-e4b-q4km
   Context:  n_ctx 32768 · host RAM 17.2 GB
   Commit:   dbd54200
   Passed:   9/12

  ✓ 1  ✓ 2  ✓ 3  ✗ 4  ✓ 5  ✗ 6  ✓ 7  ✓ 8a  ✓ 8b  ✗ 8c  ✓ 8d  ✓ 8e

The three failures are scenarios 4, 6, and 8c — the same three as PR #1763's decontaminated run, and exactly the defects tracked in #1775 (4 and 8c) and #1637 (6). An independent reproduction of the known-honest baseline is good evidence the ported harness scores identically to the originals.

Provenance recorded (AC #5)

{
  "model": "gemma-4-e4b-q4km",
  "recordedAt": "2026-07-28T16:50:11.762Z",
  "hostMemoryGb": 17.2,
  "nCtx": 32768,
  "evalCommit": "dbd54200",
  "dirty": false
}

dirty: false — clean tree, so this number is fully attributable. Per-scenario failure strings preserved, which is what #1764 asks for.

Baseline comparison (AC #4)

All four transition states exercised against a synthetic prior run:

   REGRESSION  4
   REGRESSION  6
   FIXED       7
   NEW         8e → pass
   REMOVED     phantom
   regressions: 2

Environment discipline held

Daemon logged served_db_path=/tmp/ns-final/.nodespace/database/nodespace.db and seeded fresh (created_roots=27, not skipped) — so this measured current compiled guidance, not stale seeded prompts. Confirmed 0 nodes written to the real user database during the run.

Every acceptance criterion is now verified by execution rather than inspection.

@malibio
malibio merged commit 989ef07 into main Jul 28, 2026
2 checks passed
@malibio
malibio deleted the worktree-issue-1759-eval-runner branch July 28, 2026 17:00
malibio added a commit that referenced this pull request Jul 28, 2026
…d fields, and calls with empty arguments (#1781)

* Report argument-validation failures so the model can locate and repair them (#1775)

The aichat-matrix failures in scenarios 3/4 and 8c are one defect, not two: the
model constructs an invalid tool call, and the error it gets back does not say
what to fix, so its retries degrade the arguments instead of repairing them.

Ground truth from the daemon log — four consecutive create_schema retries:

  fields[1] missing "type"       -> "missing field `type`"
  (retry)  same shape            -> "missing field `type`"
  (retry)  fields[0] lost "name" -> "missing field `name`"
  (retry)  both fields nameless  -> "missing field `name`"

Only the second entry was ever wrong; by the fourth attempt the model had
corrupted the entry that started out correct. Serde reports the absent key with
no position, so there is nothing in the message identifying which element to fix.

Three changes, all at the reporting seam:

- schema/mod.rs: locate malformed `fields` / `add_fields` entries before serde
  sees them, naming the index and the entry's own name, reporting every bad
  entry at once, and instructing the caller to leave the others untouched.
- tools.rs: format schema errors with Display rather than Debug. `{:?}` wrapped
  the guidance in `InvalidParams("...")`, burying the repair instruction the
  message already carried — the comment there claimed the error was returned
  "so the model can self-correct", which the formatting defeated.
- agent_loop.rs: stop substituting `{}` for unparseable arguments. That sent the
  tool a payload the model never wrote, surfacing as a missing-required-field
  error that described the substitute rather than the malformed JSON. An
  argument-less call still passes through, since `{}` is the faithful reading.

No guidance rules were added. The rules for both failures already exist
(agent_guidance.rs:25-26, the create_schema tool schema) and were in-prompt when
the model emitted the bad calls; restating them is the degenerate move #1760 was
filed to prevent.

Also fixes the stale doc comment on MarkdownError claiming the agent formats
with `{:?}`, and notes that Display text reaches the model verbatim.

Found while investigating, filed separately as #1777: description-only
create_schema fails for every inferred field, and the two create_schema routes
disagree on whether field names carry a `custom:` prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Record why the #1775 matrix re-run produced no usable score

The re-run ran against a purged DB and a daemon rebuilt with the fix, but the
host had three concurrent agent sessions contending for 16GB and one GPU — two
nodespaced daemons resident at ~7.4GB and ~5.3GB, plus a full cargo build under
a second user. Scenario 1 hit the 240s timeout, inference reported "no model
loaded", and a model reporting "loaded" still returned no assistant reply.

Recording the contention rather than a number: a score measured under that load
would describe the machine, not the agent. score and effective_passed are left
at their previous values so nothing downstream reads an unverified result.

Also notes that the no-model-loaded mode silently PASSES the noTools scenarios,
since they assert the absence of tool calls and inference never ran — the same
false-pass class this file already documents for scenario 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Tell the caller which fields a title_template needs (#1775)

The matrix re-run surfaced a shape the first fix missed. The model sent:

  {"name":"Equipment Check-out Tracker","title_template":"{equipment} ({status})"}

with no `fields` array at all. `describe_malformed_fields` inspects entries
WITHIN that array, so an absent array passed straight through, and the request
died at the title_template validator with no equipment schema persisted — which
is why scenario 4 still had nothing to instantiate.

The existing validator error was already correct and, after the Display fix,
readable: "title_template references undefined field 'equipment' — add it to
schema.fields". It states the rule but not the edit, and reports one placeholder
per round-trip. The model made no second attempt.

So `describe_unresolved_title_template` now reports every unresolved placeholder
at once and spells out the entries to add ({"name":"equipment","type":"text"}),
while naming only the genuinely missing ones — listing a field that is already
defined is what pushes a caller into rewriting a working part of the call.

Deliberately NOT wired into update_schema: there a template may reference fields
already on the stored schema, which the payload alone cannot see, so pre-checking
would reject valid calls. The existing validator remains the authority there, and
a test pins that.

Also corrects an assertion in the previous commit's tests that required the error
NOT to contain a resolved placeholder — the message echoes the whole template for
context, so what matters is that the field is not listed among those to add.

core 1013 passed, agent 308 passed, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Record the #1775 matrix results, including what the fix did not fix

Two runs on a purged DB with a daemon rebuilt from the fix, on a quiet machine.
Raw 10/12 then 9/12; effective 8/12 in run 2 — unchanged from the 8/12 baseline.

What is verified:
- 8c passes in BOTH runs. This is the defect the issue was filed for. The log
  shows the empty-argument create_node now reported as unparseable arguments
  rather than the harness's substituted {} surfacing as "missing field
  `node_type`".

What is not fixed, stated plainly:
- Scenario 3 is a FALSE PASS in both runs and this work did not fix it. The model
  sends a title_template with no fields array; the error now names every
  unresolved placeholder and prescribes the exact entries to add, and the model
  still does not repair the call — in run 2 it retried with empty arguments and
  gave up. Better error text was necessary but not sufficient for this model on
  this shape.
- Scenario 4 still cascades from 3, so the "instance request calls create_node"
  criterion is not demonstrated by scenario 4 at all. 8c/8d cover it instead,
  against types that do exist, and both pass.

Also records measured run-to-run variance: scenario 5 flipped between runs on
identical code (search_nodes -> search_semantic), the only scenario that moved.
A single-run delta of 1 on this matrix is noise, so the raw count should not be
read as movement — hence effective_passed is reported alongside it and left at 8.

Notes that the within-array field locating, the largest part of the change, went
unexercised by both runs and is covered by unit tests only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Re-run the #1775 matrix on the shared eval runner; record run 3 and a new defect

Rebased onto #1778's runner and re-ran on a purged DB with the preflight gate
passing. Raw 8/12, effective 7/12.

The preflight gate earns its keep immediately: run against a daemon with no model
loaded it exits 2, writes no results file, and prints the fix — catching exactly
the silent-false-pass mode that invalidated this issue's first re-run attempt,
where noTools scenarios "passed" because inference never ran.

Three runs of this work now exist and they disagree: 10/12, 9/12, 8/12, on code
differing only by the title_template addition between runs 1-2 and 3. Scenario 5
flipped twice across runs. The headline count is not a usable signal at this
variance; the caveats say so and point at the per-scenario traces instead.

Run 3 surfaced a defect the earlier runs did not: a CROSS-TURN duplicate write.
8c completes correctly with one create_node, then 8d's turn re-executes the
previous turn's instruction and writes the same node again. The duplicate-call
guard cannot catch this — seen_calls is per-turn and resets at the turn boundary,
by design. Because the eval scrapes tools per chat node, that single stray call
fails BOTH 8c and 8d, which reads as two defects and is one. Filed as #1780; it
is not a regression from this fix, and the argument-error reporting behaves
correctly in all three runs.

Adopts the runner's richer results schema (provenance, per-turn traces) rather
than continuing to hand-build the old shape.

Unchanged and still stated plainly: scenario 3 is a false pass in all three runs
and this work did not fix it, scenario 4 cascades from it, and the within-array
field locating remains unexercised by every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Address code review: remove the diverging title_template pre-check

Code review found a real defect I introduced, and it was the worst kind for this
change: a confident, prescriptive, WRONG repair instruction.

describe_unresolved_title_template was a second, hand-rolled implementation of a
rule that already had an authority (validate_template_tokens). I verified the two
disagree on real inputs by porting both algorithms:

  template   fields   pre-check said            authority says
  "{a} {b"   [a]      OK (silently passed)      unclosed '{' placeholder
  "{} {b}"   []       "add {name:b,type:text}"  empty '{}' placeholder
  "{{a}}"    [a]      "add {name:'{a'}"         undefined field '{a'

Rows 1-2 are the damaging ones. For "{} {b}" the caller follows the prescription
exactly and is rejected anyway — sending the model into precisely the
corrupt-the-good-parts retry loop this whole change exists to prevent. The pre-check
also compared against raw field names while the authority compares post-storage
names, a second divergence on the description-inferred path.

The rule: one source of truth per validation rule. A second implementation with
different scanning semantics is not a nicer message, it is a disagreeing validator.
Reverted, and my own eval evidence already showed the addition never achieved its
goal — the model received the prescriptive error and still failed to repair.

Its three tests are replaced by three against the real authority, two of which pin
the exact inputs where the pre-check diverged, so a future reintroduction fails.

Also from the review:
- get_node still formatted a MarkdownError with {:?} — the same defect fixed for
  create_schema/update_schema, in the same file, which made the newly documented
  "Display reaches the model verbatim" invariant already false in-tree.
- Dropped the "Invalid parameters:" prefix from the new message; Display already
  writes "invalid params: ", so the model read a doubled preamble first.
- Bounded consecutive unparseable-argument calls (MAX_CONSECUTIVE_PARSE_FAILURES).
  The duplicate guard keys on canonical args, so differently-malformed retries never
  match and could burn every iteration with zero tool executions. Set to 2, not 1:
  one malformed call then a correct retry is normal recovery and observed in run 3.

core 1013 passed, agent 309 passed, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Note in the results that the title_template pre-check was reverted

Runs 2 and 3 measured a pre-check that no longer exists. The recorded scenario-3
outcome is unchanged — the model still failed to repair — but the caveat claimed a
mechanism (all placeholders reported, entries prescribed) that was removed in review
for disagreeing with the authoritative validator. Says so plainly rather than leaving
a caveat describing code that is not in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

* Address review nit: stop shadowing the array-name parameter

`key` names the array being validated ("fields" / "add_fields") and is used as
that outside the loop, but the inner loop rebound it to a per-entry property
name. One identifier, two meanings, in a function whose whole purpose is naming
things precisely — renamed to `required_key`.

Skipped from the review, deliberately: the four pre-existing "Invalid parameters:"
stutter sites at mod.rs:218/433/501/581. They predate this branch and are
unrelated to argument-error reporting; fixing them here is scope creep on a change
about to merge. Worth a follow-up sweep with the other Display/Debug seams.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2fwCrhS3Bfq4uDGM61iCr

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant