Skip to content

Re-theme the agent matrix around NodeSpace's dev-workflow domain - #2232

Merged
malibio merged 6 commits into
mainfrom
issue-1977-dev-workflow-eval
Aug 21, 2026
Merged

malibio merged 6 commits into
mainfrom
issue-1977-dev-workflow-eval

Conversation

@malibio

@malibio malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #1977.

The problem

All 16 matrix scenarios were generic personal CRUD — equipment checkouts, album and venue trackers. Model-lock decisions (ADR-046, ADR-056) and every model re-evaluation are scored off this matrix, so native-model selection was being decided by a model's ability to track laser cutters rather than to do the job the product claims.

Scope decision (AC 2)

Re-themed in place; not a second fixture. Rationale posted on the issue before implementing:

  • The problem was that the bar itself was measured in the wrong domain. A second fixture alongside leaves the generic one equally citable and makes "which is the bar?" ambiguous exactly where a model-lock decision needs one answer.
  • The eval is manual and costs minutes of real inference per run; single-run matrix numbers are already not decision-grade (Agent matrix 10/12 -> 6/12 does not reproduce: fixture change plus single-run variance, not a regression #1862). Two half-sized signals are worse than one.
  • The generic set's value was its tool mechanics, and those live in the Expectation model, not the vocabulary. Re-theming keeps all of them.

Every pre-existing scenario id is preserved, so a baseline recorded before this change still joins against a run after it — a domain-driven score change reads as a change rather than as 16 removals plus 16 additions. A unit test pins that.

What changed

Group Was Now
3–7, 9 equipment checkout / replacement cost feature write-ups, sign-off state, day estimate
8a–8e album + venue trackers calls on how the system is built + planning cycles
10a–10c task on a car task on a code change (still core task, still defined-but-unset due_date/status)
11a–11d — new: relationship record + traversal

New coverage

Every existing group acts on a node's own fields. Nothing measured whether the model can record a link between two nodes or follow one back — the half of the data model the product's framing rests on ("a decision means nothing without the work it constrains"), and never exercised end to end despite create_relationship and get_related_nodes both being registered tools.

Reachability was verified rather than assumed: both are whitelisted by the seeded Relationship Management skill, and neither requires_routed_guidance, so they are offered on the fail-open surface too — winnable via either routing path.

11c uses toolOnce, not toolSequence, deliberately: the id lookup has three legitimate spellings (search_nodes, search_semantic, get_node) and pinning one would red out the other two. The known cost — it cannot tell a call with two real ids from one with two invented ids — is stated in the code rather than hidden; that needs the tool result, which ToolCallRecord does not carry today.

Contamination (AC 3)

Dev vocabulary raises this risk rather than lowering it — the seeded skill descriptions already name Spec, ADR and Ticket. Every prompt was checked against guidance_is_not_contaminated_by_eval_prompts, which passes.

The guard fails open on a literal shape its parser cannot handle, so a pass alone is not evidence. Verified separately that it parses all 21 prompt: sites (sites == parsed), and re-verified after Prettier reformatting, since a prompt wrapped onto a second line would silently exempt itself.

Scoring (AC 4)

No new scoring mechanism. Every scenario uses the existing Expectation kinds; assertExpectation is unmodified.

Tests (AC 5)

bun run test:scripts passes: 115 → 120, the five new ones being fixture-level invariants that no per-turn assertion can see:

  • ids are unique (a duplicate silently reattributes a result in any id-keyed diff)
  • the carried-over ids survived the re-theme
  • no scenario targets a tool actionTools() strips — unwinnable by construction, and create_relationship/get_related_nodes are the first target tools added since ROUTING_TOOLS was written
  • minProperties is never asserted on create_relationship (its payload carries no schema field values, so fieldCount does not apply and it would fail every correct call)
  • score() reads each scenario's own expectation

Each was mutation-tested — I broke the invariant and confirmed the test fails — rather than trusting a green run.

Winnability

The binding constraint on wording. Each chain builds its own schema in its first scenario, and every later prompt names only values that schema can hold. A prompt asking for a field the type has nowhere to put is unwinnable: the model degrades reasonably and scores red, and the fixture then measures itself rather than the model (the trap documented on scenario 9 and in #1846).

Verification

  • bun run test:all — exit 0. Frontend 227 files / 4794 tests, identical to the baseline recorded on the issue. Rust green. Scripts 120/120.
  • Pre-push gate (test:all + cargo build + full e2e) passed.
  • Docs: nodespace-docs gains a "Scenario domain" section (AC 6) recording the replace-vs-add decision and the winnability constraint — 7b0b920.

Not done (per the issue's Non-Goals)

Re-running model comparisons against the new scenarios. The numbers on record were produced against the old domain and do not carry over — that is follow-up work now that the scenario set exists.

🤖 Generated with Claude Code

 #1977)

All 16 scenarios were generic personal CRUD — equipment checkouts, album and
venue trackers. Model-lock decisions (ADR-046, ADR-056) and every model
re-evaluation are scored off this matrix, so native-model selection was being
decided by a model's ability to track laser cutters rather than to do the job
the product claims: context infrastructure for AI-native development.

Re-themed IN PLACE rather than added as a second fixture. The problem was that
the bar itself was measured in the wrong domain, and a second fixture alongside
leaves the generic one equally citable while doubling a manual eval that costs
minutes of real inference per run. The generic set's value was its tool
MECHANICS, and those live in the Expectation model (noExtraTypes,
minProperties, noRetry+minCalls, toolSequence), not in the vocabulary — so
re-theming keeps every one of them.

Every pre-existing scenario id is preserved, so a baseline recorded before this
change still joins against a run after it: a domain-driven score change reads
as a change rather than as 16 removals and 16 additions.

New coverage: relationship traversal (11a-11d, new ids). Every existing group
acts on a node's OWN fields, so nothing measured whether the model can record a
link between two nodes or follow one back — the half of the data model the
product's framing rests on, and never exercised end to end despite
create_relationship and get_related_nodes both being registered tools. Both are
reachable whether routing matches Relationship Management or falls open, since
neither requires routed guidance.

Winnability was the binding constraint on wording: each chain builds its own
schema in its first scenario, and every later prompt names only values that
schema can hold. A prompt asking for a field the type has nowhere to put is
unwinnable — the model degrades reasonably and scores red for it, and the
fixture then measures itself rather than the model.

Dev vocabulary RAISES contamination risk rather than lowering it (the seeded
skill descriptions already name Spec, ADR and Ticket), so every prompt was
checked against guidance_is_not_contaminated_by_eval_prompts rather than
assumed clear. It passes, and the guard's parser sees all 21 prompt sites — it
fails open on a literal shape it cannot parse, so that was verified rather than
inferred from the pass.

Also adds five fixture-level invariants, covering properties the eval's
validity rests on that no per-turn assertion can see: id uniqueness, that the
carried-over ids survived the re-theme, that no scenario targets a tool
actionTools() strips (unwinnable by construction — and create_relationship /
get_related_nodes are the first target tools added since ROUTING_TOOLS was
written), that minProperties is never asserted on create_relationship (its
payload carries no schema field values, so fieldCount does not apply), and that
score() reads each scenario's own expectation. Each was mutation-tested to
confirm it fails on a real break rather than passing vacuously.

Baseline held: frontend 227 files / 4794 tests, scripts 115 -> 120 (the five
new invariants), Rust green.

Docs: ../nodespace-docs/development/agent-eval.md gains a "Scenario domain"
section recording the replace-vs-add decision and the winnability constraint.

Co-Authored-By: Claude <noreply@anthropic.com>
@malibio

malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Pragmatic Code Review — PR #2232

Review Type: Initial Review (no prior reviews or comments on the PR)

Scope: 2 files, +297/−53. scripts/eval/fixtures/agent-matrix.ts (re-theme + new group 11a–11d) and scripts/eval/fixtures/agent-matrix.test.ts (five fixture-level invariants). assertExpectation is unmodified, as claimed — verified by diff.


Summary

This is a net positive and should merge. The central argument is correct and well-evidenced: the matrix is the scoring surface for model-lock decisions, so its domain is part of the measurement rather than decoration, and re-theming in place (rather than adding a second fixture) is the right call for exactly the reason given — two half-signals are worse than one when single-run matrix numbers are already not decision-grade.

The quality bar in the comments is unusually high, and I verified the load-bearing claims independently rather than taking them on trust. Every one I checked held:

  • Winnability of chain 3→9 is sound. Scenario 3 implies exactly two fields (a sign-off state, a day count). Scenarios 4, 6, 7 and 9 each name only those two. No repeat of the create_schema copies a retrieved schema's fields onto an unrelated new type (album_tracker gets is_returned/replacement_cost) #1846 trap.
  • Scenario 6 stayed genuinely indirect. "The five-day one" keys on a property value, not the node's name, preserving the resolve_query test. Scenario 9's deliberate contrast (direct string match, "offline sync") also survived.
  • 11a–11d is reachable. Both create_relationship and get_related_nodes are whitelisted by the seeded "Relationship Management" skill (skill_pipeline.rs:337), and neither returns true from requires_routed_guidance (tools.rs:1758-1772), so both are on the fail_open_surface too. Winnable via either routing path, as the PR body claims.
  • Contamination guard is not passing vacuously. cargo test -p nodespace-agent guidance_is_not_contaminated passes; the guard's own prompts.len() == sites assertion covers the fail-open hole; and I independently confirmed all 20 scenario prompts are single-line literals that parse (21 sites / 21 parsed — the 21st is a comment artifact, see 🟢 below). Measured margins: nothing is at 4, one prompt at 3, everything else at 1–2.
  • All five invariant tests fail under targeted mutation, with on-point messages. The author's mutation-testing claim holds up.
  • bun run test:scripts → 120 pass / 0 fail, matching the stated 115→120.
  • AC 6 is genuinely satisfied — the nodespace-docs "Scenario domain" + "Winnability" sections are substantive, not a stub.

Findings below are refinements, not blockers. Two are worth acting on before this becomes the bar that model decisions are read off.


Requirements Check (issue #1977)

# Acceptance criterion Status
1 Scenario set designed around specs / decisions / planning cycles / work-to-decision linkage, matching the vision docs ✅ Met — chain 3–9 (feature write-ups + sign-off), 8a–8e (architectural calls + planning cycles), 11a–11d (decision→work linkage)
2 Decision made and documented on replace-vs-additional-fixture ✅ Met — replace-in-place, rationale in the PR body, the file header, and nodespace-docs
3 Prompts don't share wording with seeded guidance, verified by guidance_is_not_contaminated_by_eval_prompts ✅ Met — test passes; coverage verified independently (not fail-open); max shared run is 3
4 Scoring reuses the existing Expectation types rather than a parallel mechanism ✅ Met — assertExpectation byte-identical; every scenario uses an existing kind
5 bun run test:scripts passes ✅ Met — 120/120
6 Documented in ../nodespace-docs/development/agent-eval.md ✅ Met — commit 7b0b920, "Scenario domain" + "Winnability" sections

All six met.


Code Review Findings

🟡 Important: 11c scores green on a rejected create_relationship — a wider gap than the comment admits

📁 scripts/eval/fixtures/agent-matrix.ts:637-652

The comment states the known cost precisely and honestly:

The known cost, stated rather than hidden: this cannot distinguish a create_relationship carrying two real ids from one carrying two invented ones. Catching that needs the tool result, not the tool name, and ToolCallRecord does not carry the endpoint ids today.

That framing is accurate about ids, but it understates the gap. ToolCallRecord does carry isError, and 11c does not check it. Verified by running the committed assertExpectation directly:

11c, create_relationship REJECTED (isError:true) -> {"passed":true}
scenario 4, create_node REJECTED           -> {"passed":false,"failure":"create_node was rejected ..."}

The asymmetry is structural, not accidental: isError is only inspected inside callPersistedProperties (reached only when minProperties is set) and schemaCallsAreSound (only inspects create_schema). Scenario 4 catches rejection because it sets minProperties; 11c cannot set minProperties for the good reason the comment gives, and therefore inspects nothing.

So a model that calls create_relationship once with two hallucinated ids and gets rejected outright scores 11c green. That is the single most likely concrete failure for a small local model on this scenario, and it is currently invisible. It also propagates: 11d is deliberately independent of 11c, so the pair can both score green with no edge in the graph.

Recommendation: this does not need a new Expectation kind. The cheapest fix is to make toolOnce check isError on its target tool unconditionally — the existing callPersistedProperties already contains exactly the right failure message, and the property check is separately gated behind min. Something like extracting an targetCallSucceeded(toolCalls, tool) guard and calling it from the toolOnce branch before the minProperties check. That would close the hole for 11c and for scenarios 5, 8d, 10a, 11a and 11b, which have the identical blind spot today.

If you'd rather not touch shared scoring semantics in this PR (a defensible call given the re-score risk the file's own noRetry/minCalls comment cites), then at minimum correct the comment: the gap is "cannot tell a successful link from a rejected one, nor real ids from invented ones," not just the latter.

Engineering principle: an assertion's documented limitations should be the complete set, not a subset — a comment that names one hole implies the others are covered, which is how a known gap becomes an unknown one.

🟡 Important: 11d's noRetry reds out a correct two-endpoint traversal

📁 scripts/eval/fixtures/agent-matrix.ts:655-674

The comment justifies noRetry + minCalls: 1 by analogy to 10c ("for the same reason 10c uses it"). The analogy holds for the minCalls half but breaks on the noRetry half, because traversal has a natural two-call shape that search does not.

get_related_nodes takes a single node id and a direction defaulting to "both" (tools.rs:2856). A model answering "what did we settle on that the rebuild has to respect?" may legitimately:

  • traverse from the task, get nothing useful, then traverse from the decision node; or
  • traverse out then in explicitly rather than relying on the both default.

Either is two consecutive get_related_nodes calls, which scores red as a "retry loop". Confirmed against the committed scorer:

11d, ["get_node","get_related_nodes","get_related_nodes"] -> {"passed":false,"failure":"Expected no repeated 'get_related_nodes' calls (retry loop) ..."}

For 10c this risk is low — a second search_nodes is a genuine narrowing retry. For traversal, a second call on a different endpoint is not a retry at all; it is the correct exploration of a bidirectional edge. This is precisely the "scenario reds out correct behavior, so the fixture measures itself" failure mode the file's own header warns about.

Recommendation: this is the group's first run, so I'd hold the decision until there's trace data rather than guess. Concretely: either relax to { kind: "toolOnce", tool: "get_related_nodes" }-with-tolerance, or keep noRetry and add a note that a red here should be checked against the trace for the two-endpoint shape before being recorded as a model failure. At minimum, replace "for the same reason 10c uses it" with the actual reasoning, since the shapes differ.

Engineering principle: reusing an assertion by analogy requires the analogy to hold on the axis being asserted; here the retry semantics of a read differ from those of a traversal.

🟡 Important: 8c's winnability is weaker after the re-theme (inherited, but degraded)

📁 scripts/eval/fixtures/agent-matrix.ts:485-506

8a's prompt — "Start keeping the calls we make on how the system is built" — names no fields at all. 8c then asserts minProperties: 1, requiring "Priya's call" (a decider/owner value) to reach storage. Whether that is winnable depends entirely on the model having invented an owner-ish field in 8a.

This is a pre-existing shape, not a regression — the old 8c comment says as much ("unwinnable if album_tracker's schema itself has no artist field, see #1846"), and the expectation is unchanged. But the re-theme makes the guess harder, not easier: "albums I mean to listen to" sits in a domain where artist is a near-universal convention, so a model plausibly emits it unprompted. "The calls we make on how the system is built" is considerably more abstract, and who made the call is a less conventional field for it than artist is for an album.

Notably, the sibling chain (3→9) solves this correctly — scenario 3 explicitly names both downstream fields in the prompt. 8a does not, and 8c is the one scenario in the PR where I could not convince myself the prompt is answerable by construction.

Recommendation: bring 8a in line with scenario 3's pattern by naming the field 8c keys on — e.g. "Start keeping the calls we make on how the system is built and who made each one". That is a one-clause change, preserves the id, and converts 8c from "winnable if the model guesses well" to "winnable by construction". Worth re-running the contamination guard after (margins are comfortable, so this should be safe).

Engineering principle: the same winnability constraint the file states as a hard rule should hold uniformly across chains; a chain whose schema-creating prompt names no fields cannot support a minProperties assertion downstream.

🟢 Suggestion: the contamination guard checks the fixture's own doc comment as if it were a prompt

📁 scripts/eval/fixtures/agent-matrix.ts:52 / 📁 packages/agent/src/agent_guidance.rs:305-350

eval_prompts() reports 21 sites / 21 parsed for a fixture with 20 scenarios. The extra site is the header comment:

 * `guidance_is_not_contaminated_by_eval_prompts` enforces it by parsing the
 * `prompt:` literals out of this file: a prompt that also appears in guidance

The parser's key-detection guard rejects a preceding alphanumeric / _ / ., but not a backtick — so it treats the backtick as an opening quote and checks the following prose as a phantom prompt. That phantom currently scores a shared run of 3 against create_node's description.

Consequences are benign today (the guard passes and the sites == parsed coverage assertion is satisfied, because the shape does parse). But it means editing this file's own comments is contamination-checked, and a future comment edit could fail the guard with a message pointing at a "prompt" that is not a scenario. Cheap fix: add '`' to the rejected-preceding-char set in eval_prompts(). Out of scope for this PR — worth a follow-up issue.

🟢 Suggestion: two invariant tests are narrower than their names claim

📁 scripts/eval/fixtures/agent-matrix.test.ts:533-551 and :553-566

All five tests fail under targeted mutation — the mutation-testing claim holds. Two limits worth recording, since these tests are themselves load-bearing documentation:

  • minProperties is never asserted on create_relationship — the guard body executes exactly once (only 11c), and the toolSequence branch is dead code (no toolSequence scenario targets create_relationship). Re-target or delete 11c and the test silently becomes a no-op. It survives only because score() reads each scenario's own expectation happens to pin 11c's existence and tool — real, but incidental coupling rather than designed.
  • score() reads each scenario's own expectation — verifies the wiring for 2 of 20 scenarios. I confirmed a score() that ignores scenario.expect and looks expectations up from a hardcoded {"1": …, "11c": …} id table passes all 120 tests. Contrived as a real bug, but the name overstates the check. A loop over all 20 asserting pass-on-target / fail-on-wrong-tool would match the title.

Neither is worth blocking on; both are worth a sentence in the test comments so the next reader doesn't over-trust them.

🟢 Nit: create_schema is not covered by the routing-strip invariant

📁 scripts/eval/fixtures/agent-matrix.test.ts:514-531

no scenario targets a tool that actionTools() strips derives targets from toolOnce / noRetry / toolSequence only, so noExtraTypes scenarios contribute []. Scenario 3 implicitly targets create_schema through that kind. If create_schema ever landed in ROUTING_TOOLS, scenario 3 would become unwinnable and this test would not catch it — the exact class of regression the test was written for. One extra line mapping noExtraTypes → ["create_schema"] closes it.

🟢 Nit: stale old-domain example left in live seeded guidance

📁 packages/agent/src/skill_pipeline.rs:107

Not touched by this PR and not a contamination violation, but directly adjacent to it: the "ALREADY IN THIS CONVERSATION" rule still uses "the 2400 one" as its worked example of an indirect reference — which was scenario 6's verbatim old prompt. Post-re-theme it is merely stale (scenario 6 is now "the five-day one"), so contamination risk actually dropped. Flagging only because the PR's own thesis is that the eval's vocabulary is load-bearing; the guidance that primes the model still speaks the retired domain. Also skill_pipeline.rs:498's comment cites the old "replacement cost 2400" failure. Both are follow-up cleanups, not blockers.

🟢 Observation: comment/code agreement checks out

I specifically re-checked the class of defect the author already found and fixed once (a justification comment arguing for toolSequence while the code used toolOnce). Cross-reading all 24 expectation sites against their comments, I found no remaining disagreement. The 11c comment correctly describes toolOnce; the 11d comment correctly describes noRetry + minCalls; scenario 9's minProperties rationale matches its expectation; the header's claim that every scenario id is preserved is true and is pinned by a test. The one comment inaccuracy I did find is the 11c scope issue in the first 🟡 above, which is an omission rather than a contradiction.


Recommendation

APPROVE.

The scope decision is correct and well-argued, all six acceptance criteria are met, the winnability constraint is respected on the main chain, the contamination guard is genuinely (not vacuously) passing, and the new relationship group closes a real structural blind spot with reachability verified rather than assumed. The comments are load-bearing documentation and are accurate.

The two 🟡 items on 11c (isError blind spot) and 8c (weaker implied schema) are the ones I'd most want addressed, since this fixture is about to become the bar that model-lock decisions are read off — but neither makes the PR worse than main, and 8c is inherited rather than introduced. They are follow-up-issue material if you'd rather land this and iterate; 11d's noRetry question is genuinely better answered with trace data from the group's first real run than by guessing now.

One process note: the "Not done" section is right that the recorded model numbers were produced against the old domain and do not carry over. Until the re-run lands, any citation of matrix scores should be explicit about which domain produced them.


Automated pragmatic code review. Verified independently: bun run test:scripts (120/120), cargo test -p nodespace-agent guidance_is_not_contaminated (pass), guard coverage 21 sites / 21 parsed, per-prompt contamination margins (max run 3), mutation-testing of all five invariants, and direct execution of the committed assertExpectation against 11c/11d edge cases.

@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: APPROVE (posted as a comment — GitHub blocks self-approval on your own PR). Full findings in the review comment above.

All 6 acceptance criteria from #1977 are met. Independently verified rather than taken on trust: bun run test:scripts 120/120, cargo test -p nodespace-agent guidance_is_not_contaminated passes with coverage confirmed non-vacuous (21 sites / 21 parsed, max shared run 3 — nothing at the limit of 4), all five new invariant tests fail under targeted mutation, both new relationship tools confirmed reachable via the seeded Relationship Management whitelist and the fail-open surface, and chain 3→9 winnability checked prompt-by-prompt against its implied schema.

Three 🟡 items, none blocking: 11c scores green on a rejected create_relationship (wider than the comment's stated gap — isError is available but unchecked); 11d's noRetry reds out a legitimate two-endpoint traversal; and 8c's minProperties assertion rests on a schema 8a never specifies (inherited from the old fixture, but harder to guess in the new domain). Follow-up-issue material if you prefer to land and iterate.

malibio and others added 3 commits August 21, 2026 23:55
Addresses the two 🟡 findings and two 🟢 nits from the PR #2232 review.

🟡 A REJECTED TARGET TOOL SCORED GREEN

`isError` was only ever inspected on two narrow paths — `schemaCallsAreSound`
(create_schema only) and `callPersistedProperties` (only when a scenario opted
into `minProperties`). Every other scenario scored a rejected call green on
tool name alone: 5, 7, 8d, 10a, 11a, 11b, 11c, 11d.

That is not hypothetical for the scenario it matters most to. create_relationship
takes two node ids the model must recover, and two invented ids are rejected
outright — so the one scenario added to measure linking would have reported
success on precisely the failure it exists to catch.

Adds `targetToolWasNotRejected`, applied to toolOnce, toolSequence and noRetry.
`minProperties` keeps precedence where a scenario opted in, since it inspects
the same flag and returns a strictly more specific diagnosis. Only the TARGET
tool is judged — a model that tries a bad search, recovers, and then does the
right thing has still done the right thing.

The first live run of the new matrix immediately caught a real instance:
scenario 7's search_nodes fired and was REJECTED, which would have scored green
before this change.

🟡 8c WAS WINNABLE ONLY BY LUCK

8c asserts `minProperties: 1` on whose call it was, but 8a named no fields at
all, leaving it to chance whether the created type had anywhere to put that
value. The sibling chain already gets this right — scenario 3 explicitly names
the fields its downstream scenarios key on. 8a now names the field 8c needs.

Contamination margin re-measured rather than assumed: with MAX_SHARED_RUN
temporarily lowered to 2, the new wording does not appear among the hits at
all, so it sits at <=2 against a limit of 4.

🟡 11d's noRetry justification was wrong (comment only)

The comment claimed "the same reason 10c uses it". False: there a second
search_nodes is a genuine blind retry, whereas a second get_related_nodes could
be a legitimate walk of the other endpoint of a bidirectional edge. The
expectation is unchanged — this prompt asks about one endpoint, so one
traversal is the expected shape — but the comment now states the real reasoning
and tells a future reader to check the trace for the two-endpoint shape before
recording a red here as a model failure.

🟢 create_schema was uncovered by the routing-strip invariant

`noExtraTypes` names no tool in its payload, so scenario 3 contributed no
targets. If create_schema ever landed in ROUTING_TOOLS, scenario 3 would become
unwinnable and the test written for exactly that regression would not catch it.

🟢 score() wiring test verified 2 of 20 scenarios

The reviewer confirmed a score() that ignored `scenario.expect` and read from a
hardcoded id table passed all 120 tests. Now loops over every scenario,
synthesizing the turn its own expectation demands and a wrong turn per kind
(chosen per kind because bare noRetry legitimately passes on zero calls). The
hardcoded-table mutation now fails, verified.

Not addressed: the guard parsing this file's own doc comment as a phantom
prompt (harmless — it parses and clears the threshold; the fix belongs in the
guard's preceding-char set, not this fixture) and the stale old-domain example
in seeded guidance (out of scope for this PR).

test:scripts 120 -> 127, expect() calls 241 -> 279. Guard passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Ran the re-themed matrix end to end against E4B on a purged, isolated database.
10/19 passed. Triaging each failure against its trace found one fixture defect
that accounted for the entire relationship group's failures.

11a opened with "Note that we settled on ...", which reads as a preamble to a
statement rather than a request to record one. The model called search_semantic
looking for an existing note and replied "Found 0 nodes matching your request"
— a reasonable reading of the words it was given, scored red.

That cascaded through the rest of the group. With no node created, 11c's
create_relationship went out with `to_id: null` and was rejected:

  create_relationship is_error=true
  args={"from_id":"nodespace://b6f6…","relationship_type":"related_to","to_id":null}

and 11d had no edge to traverse. All three of the group's failures trace to this
one word choice — the same ambiguous-verb correction scenario 4 already needed,
missed here.

Deliberately NOT changing anything else on the strength of one nondeterministic
run:

- 6 and 9 both failed asking for an id they could not have, because scenario 4
  — which creates the chain's subject — was excluded as a degenerate empty
  generation. Their raw output shows create_node was correctly formed before
  the generation died, carrying both particulars, so the wording is sound and
  the cause was inference, not the prompt.
- 8e drew a clarifying question rather than a search. Arguably correct on a
  vague referent; borderline, and worth a second run before touching.

Tuning the fixture to one noisy run is the failure mode this issue exists to
prevent.

The run also validated the rejection fix from the review. Scenario 7's
search_nodes emitted malformed JSON and the model then hallucinated a tool
response, inventing a node id and reporting it to the user as real. That scored
GREEN before `targetToolWasNotRejected` — the first live scenario able to
exhibit the blind spot did exhibit it.

The relationship group remains effectively unmeasured: every one of its results
is explained by the 11a defect, so it needs a clean re-run before anything can
be said about linking behaviour.

Co-Authored-By: Claude <noreply@anthropic.com>
The first live run of the relationship group looked like a model failure and
was not. After fixing 11a's verb and re-running, 11c still failed — but the
model had done everything right:

  create_relationship is_error=true
  args={"from_id":"1fb911ba-…","relationship_type":"related_to","to_id":"c26d…"}
  error: Validation failed: Relationship 'related_to' not defined in schema
         'task'. Built-in relationships (member_of, has_child, mentions,
         has_role) are universal.

Two real ids, recovered without being handed either, correct direction. The
storage layer refuses the write because create_relationship only accepts a
relation defined on the SOURCE node's schema plus four universal built-ins.

And create_relationship's own tool description (tools.rs:1055) instructs the
model to use exactly what the validator refuses:

  "Use a name defined on the relevant schema(s) … otherwise a generic label
   (member_of, mentions, related_to, etc.)."

`related_to` is offered as a valid generic fallback and is not accepted. The
model was rejected for following its instructions. That made 11c unwinnable by
construction — no wording of "link these two" could succeed for an ad-hoc
relation between a task and a text node — and 11d unwinnable in turn, since
there was never an edge to traverse.

11c now asks for a link the system can actually record ("Point that rebuild
task at the decision it has to respect"), which maps onto `mentions`.

Winnability PROVEN against the real store rather than argued, on the exact two
nodes the scenario produces:

  $ nodespace relationship create --from ae0c2666-… --type mentions --to c26d…
  Created relationship: ae0c2666-… --[mentions]--> c26d6108-…
  $ nodespace relationship get ae0c2666-… --type mentions
  1 related node(s): { "content": "Reports page uses server-side rendering", … }

The edge writes and the traversal returns the decision node, so both halves of
the group are passable in fact. Re-running 11c with the new wording, the model
called update_node — writing the link into a field instead of recording an edge
— which is a legitimate red, and precisely the failure the scenario exists to
catch.

The underlying affordance gap (a documented generic label the validator
refuses) is a product bug, not an eval bug. Out of scope here; filing separately.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@malibio

malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

First live run of the new matrix — 10/19, with a fixture-defect triage

Ran the re-themed matrix end to end against E4B on a purged, isolated DB (NODESPACE_HOME=/tmp/ns-1977, served_db_path verified inside it; guidance provenance recorded; three stale nodespaced processes from earlier runs killed first to remove contention).

Result: 10 passed / 9 failed / 1 excluded (empty generation).

The number itself is not the point and is not decision-grade — single-run matrix numbers never are (#1862). The point was to find scenarios that red out correct behaviour before this fixture becomes the bar. Classifying each failure against its trace:

🔴 Fixture defects — my bugs, not the model's (3)

11a — ambiguous verb, exactly the class I fixed in scenario 4 and missed here.
Prompt: "Note that we settled on server-side rendering for the reports page". The model called search_semantic and replied "Found 0 nodes matching your request." "Note that…" reads as a preamble to a statement, not a record-creation request — so it went looking for an existing note instead of creating one. Same trap as the original "Write up offline sync".

11c — cascade from 11a, not an independent failure. The daemon log is unambiguous:

create_relationship is_error=true args={"from_id":"nodespace://b6f6…","relationship_type":"related_to","to_id":null}

to_id: null because 11a never created the second endpoint. The model did the best available thing with one endpoint. This scenario cannot be read as a link-behaviour measurement until 11a is fixed.

11d — cascade from 11c. No edge was ever recorded, so there was nothing to traverse. The model asked a clarifying question instead. My comment already documents that 11d is deliberately independent of 11c's success, and that holds — but it is not independent of 11a having produced a node at all.

Notably the two-endpoint false positive I documented as 11d's known risk did not occur — get_related_nodes fired zero times, not twice. The noRetry half stays unexercised.

🟡 Winnability worth a second look (2)

6 and 9 — both died the same way, and it is not the referent design:

"I need the ID of the feature write-up you are referring to in order to update its status."

The model wanted an id it had no way to have. Scenario 4 — which should have created that node — was the excluded empty generation, so the chain's subject never existed. So 6 and 9 were unwinnable in this run for an upstream reason. Worth noting the raw output shows 4's create_node call was correctly formed before the generation died:

call:create_node{content:Offline Sync Feature Writeup,
  field_values:{estimated_days:5,status:drafting},node_type:FeatureWriteup}

That is a well-formed call carrying both particulars — evidence the chain's wording is sound and that this was an inference failure, not a prompt defect.

8e — "Run through those calls for me" drew a clarifying question rather than a search. Arguably correct behaviour on a genuinely vague referent; the old wording ("Run through the albums for me") named a concrete type. This one is borderline and I'd want a second run before changing it.

✅ Real model failures the fixture correctly caught (3)

3 — five create_schema calls for one request. Exactly what noExtraTypes exists to catch.

8b — create_schema, update_schema, create_schema, ending in "⚠️ schema update encountered an error", while the reply claimed success.

7 — the most valuable catch, and it validates the review fix. The model emitted malformed JSON:

{"filters":[{"operator":"gt","property":"estimated_days\",\"value\":40}], …}

then hallucinated a tool response, inventing a node id (nodespace://a2b-3c4) and reporting it to the user as a real result.

Before the targetToolWasNotRejected fix from this review, scenario 7 scored GREEN — search_nodes appeared once, and that was all that was checked. The first live run caught a real instance of the exact blind spot the reviewer flagged, on the very first scenario able to exhibit it.

Re-run after fixing 11a — and 11c turns out to be unwinnable by construction

Fixed 11a's verb ("Log a decision: the reports page uses server-side rendering") and re-ran the group on a fresh isolated daemon. 11a and 11b now create their nodes correctly. 11c still failed — but for a completely different reason, and this one is not the model's fault:

create_relationship is_error=true
args={"from_id":"1fb911ba-…","relationship_type":"related_to","to_id":"c26d6108-…"}
error: Validation failed: Relationship 'related_to' not defined in schema 'task'.
       Built-in relationships (member_of, has_child, mentions, has_role) are universal.

The model did everything right: two real ids, recovered without being handed either, correct direction. The storage layer rejected the write because create_relationship only accepts relationship types defined on the source node's schema, plus four universal built-ins.

And the tool's own description tells the model to do exactly what was rejected (tools.rs:1055):

"Use a name defined on the relevant schema(s) … otherwise a generic label (member_of, mentions, related_to, etc.)."

related_to is named in the tool description as a valid generic fallback and is not accepted by the validator. The model followed its instructions precisely and was rejected for it.

So 11c as written is unwinnable by construction — an ad-hoc link between a task and a text node cannot be recorded at all. 11d then has no edge to traverse, which is why it called no tools on both runs.

This is the single most valuable thing the run surfaced, and it is a product bug, not an eval bug: a documented tool affordance that cannot succeed. It is out of scope for this PR to fix, and I'll file it separately.

I am not touching 6, 9 or 8e on the strength of one run. 6 and 9 have a fully-explained upstream cause (an inference failure, not a wording problem) and their raw output shows the intended shape; changing them now would be tuning the fixture to one noisy run, which is the failure mode this whole issue is about.

11c reworded, and winnability PROVEN rather than argued

Reworded 11c to ask for a link the system can record: "Point that rebuild task at the decision it has to respect", which maps onto mentions — universal, legal between any two nodes.

I did not want to claim winnability twice on reasoning alone, so I proved it against the real store, on the exact two nodes the scenario produces:

$ nodespace relationship create --from ae0c2666-… --type mentions --to c26d6108-…
Created relationship: ae0c2666-… --[mentions]--> c26d6108-…

$ nodespace relationship get ae0c2666-… --type mentions
1 related node(s): { "content": "Reports page uses server-side rendering", … }

The edge writes and the traversal returns the decision node. Both halves of the group are passable in fact.

Re-running 11c with the new wording, the model called update_node — writing the link into a field instead of recording an edge. That is a legitimate red: it is exactly the failure mode the scenario was built to catch (the same choice dev-relationship-creation.toml measures), and it is now a real measurement rather than an artifact.

Honest limits

  • One run, one model, nondeterministic. Not a baseline, and I am not filing it as one.
  • The relationship group (11a–11d) is still effectively unmeasured — every one of its results is explained by the 11a defect. It needs a clean re-run to say anything about linking behaviour.
  • 10/19 says nothing yet about E4B's fitness for the dev-workflow domain, since a third of the failures are fixture defects and another two are upstream cascades.

@malibio

malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Address-review summary

All findings triaged. 4 addressed, 2 skipped with reasons, plus 2 fixture defects the first live run exposed (reported separately above).

✅ Addressed

🟡 A rejected target tool scored green — targetToolWasNotRejected, applied to toolOnce, toolSequence and noRetry. minProperties keeps precedence where a scenario opted in, since it inspects the same flag and returns a strictly more specific diagnosis; only the TARGET tool is judged, so a model that tries a bad search, recovers, and then does the right thing still passes.

Confirmed the reviewer's reproduction before fixing, and the first live run then caught a real instance on scenario 7: the model emitted malformed JSON, the search was rejected, and it hallucinated a tool response inventing a node id (nodespace://a2b-3c4) which it reported to the user as real. That scored green before this change.

🟡 8c winnable only by luck — 8a now names the field 8c keys on, matching scenario 3's pattern. Contamination margin re-measured rather than assumed: with MAX_SHARED_RUN temporarily lowered to 2, the new wording does not appear among the hits at all (≤2 against a limit of 4).

🟡 11d's noRetry justification was wrong — expectation unchanged (this prompt asks about one endpoint, so one traversal is the expected shape), but the comment no longer claims "the same reason 10c uses it", which was false: there a second search_nodes is a blind retry, here a second get_related_nodes could be a legitimate walk of the other endpoint. It now states the real reasoning and tells a future reader to check the trace for the two-endpoint shape before recording a red as a model failure. The live run showed get_related_nodes firing zero times, not twice, so the false positive stayed hypothetical.

🟢 create_schema uncovered by the routing-strip invariant — noExtraTypes now contributes ["create_schema"]. Real hole: scenario 3 is scored entirely on create_schema's count, and the test written for that regression class would not have caught it.

🟢 score() wiring verified only 2 of 20 — now loops over every scenario, synthesizing the turn its expectation demands plus a wrong turn chosen per kind (bare noRetry legitimately passes on zero calls, so an empty turn is not a universal negative). Verified the reviewer's hardcoded-id-table mutation now fails; expect() calls went 241 → 279.

⏭️ Skipped

🟢 The guard parses this file's own doc comment as a phantom prompt. Harmless — it parses and clears the threshold, so the guard is not fail-open. The fix belongs in the guard's preceding-character set (a backtick should disqualify a prompt: match), not in this fixture; changing the fixture's prose to dodge a parser quirk would be treating the symptom. Worth a small follow-up on the guard itself.

🟢 Stale old-domain example in live seeded guidance. Out of scope: it is guidance text, not eval fixture, and editing seeded guidance in this PR would change what the eval measures while this PR is establishing the new scenario set. Better done deliberately, separately.

Also fixed — found by running the eval, not by the review

11a's ambiguous verb and 11c's unwinnability (create_relationship rejects ad-hoc relation names; the tool description recommends one the validator refuses). Details in the run report above. The underlying product bug is filed as #2234.

Verification

  • test:scripts 115 → 127, expect() calls 241 → 279
  • Contamination guard passes; margin re-measured at ≤2 against a limit of 4
  • test:all green (227 files / 4794 frontend tests, matching baseline); pre-push gate passed
  • 11c/11d winnability proven against the real store, not argued — a mentions edge writes between the exact two nodes the scenario produces, and traversal returns the decision node

Re-Review Decision

Decision: RE-REVIEW NEEDED

Rationale: The scoring logic changed — assertExpectation now fails a rejected target tool across three of the five expectation kinds, which is a behavioural change to how every scenario is scored, not a comment or wording edit. Three prompts changed (8a, 11a, 11c), one of them (11c) to work around a validator constraint discovered mid-review. A reviewer should check that the rejection check does not red out any legitimately-empty-but-successful call beyond the one case I tested, and that 11c still measures the create_relationship-vs-update_node choice it was built for now that its wording steers toward mentions.

The rejection check added in the previous commit looped over every call to the
target tool and failed the scenario if ANY was rejected. That contradicts
`noRetry`'s own semantics, which deliberately tolerate several non-adjacent
calls: a first call rejected for a malformed argument, corrected by the model
and retried successfully, is a turn that accomplished what the prompt asked for.

Verified against the shape observed live on 11a — create_node rejected for a
missing node_type, then supplied and persisted:

  noRetry, err-then-ok  BEFORE: red ("fired but was REJECTED")
                        AFTER:  green

A rejection now only sinks the turn when NOTHING to that tool ever succeeded,
which is the case the check exists for: the tool name appears in the trace and
the turn still ends with nothing written. The failure message reports how many
calls were rejected when there was more than one.

11a itself still reds, on `toolOnce`'s pre-existing exactly-once rule. That is a
separate and deliberate judgement about write tools and is not this helper's
call to make.

test:scripts 127 -> 129.

Co-Authored-By: Claude <noreply@anthropic.com>
@malibio

malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Pragmatic Code Review — PR #2232

Review Type: Re-Review (2nd pass)
Reviewed range: 2228e5b2...61e9319a — four commits, +288/−26 across two files.
PR head at review time: 61e9319a


Previous Review Summary

Recommendations made 7 (3 🟡, 4 🟢)
Addressed 5 (4 claimed + 1 partially, see below)
Skipped with stated reason 2
New issues found this pass 2 (1 🟡, 1 🟢)

Every claim in the address-review summary and the eval-run report was verified independently rather than taken on trust. All of them hold except one, which is the 🟡 below.

Verified addressed:

  • 🟡 A rejected target tool scored green — targetToolWasNotRejected added (agent-matrix.ts:255-276) and wired into toolOnce, toolSequence and noRetry. Mutation-tested: forcing the helper to return { passed: true } unconditionally fails 3 tests, one per wired kind. Not vacuous.
  • 🟡 8c winnable only by luck — 8a now names the field ("…and who made each one"). Matches scenario 3's pattern.
  • 🟡 11d's noRetry justification was wrong — the false "same reason 10c uses it" is gone, replaced with the real reasoning plus an explicit instruction to check the trace for the two-endpoint shape before recording a red. Expectation unchanged, correctly.
  • 🟢 create_schema uncovered by the routing-strip invariant — noExtraTypes → ["create_schema"] added.
  • 🟢 score() verified only 2 of 20 — now loops all 20. I re-ran my own prior mutation (a score() that ignores scenario.expect and reads a hardcoded {"1":…,"11c":…} id table): it now fails, where before it passed all 120 tests. The finding is genuinely closed.

Skipped — both reasons are sound, no disagreement:

  • 🟢 The guard parses this file's own doc comment as a phantom prompt. Correct call. The defect is in eval_prompts()'s preceding-character set, not in this fixture, and reshaping prose to dodge a parser quirk would treat the symptom. I re-measured: still 21 sites / 21 parsed for 20 scenarios, so the guard remains closed (not fail-open) with the phantom present.
  • 🟢 Stale old-domain example in seeded guidance. Correct call — editing seeded guidance in the same PR that establishes the new scenario set would change what the eval measures mid-flight.

Requirements Check (issue #1977)

# Acceptance criterion Status
1 Scenario set designed around specs / decisions / planning cycles / work-to-decision linkage ✅ Still met — the three reworded prompts stay in-domain
2 Decision documented on replace-vs-additional-fixture ✅ Unchanged
3 Prompts don't share wording with seeded guidance, verified by the guard ✅ Re-verified — guard passes; 21 sites / 21 parsed; at MAX_SHARED_RUN lowered to 2 the only hits are run-length 3, on scenario 10a and the phantom — none of the three changed prompts appear at all (≤2 against a limit of 4)
4 Scoring reuses the existing Expectation types ✅ Still met — no new kind; targetToolWasNotRejected is a helper inside assertExpectation, not a parallel mechanism
5 bun run test:scripts passes ✅ 127 pass / 0 fail, 279 expect() calls — matches the claimed 115→127
6 Documented in ../nodespace-docs/development/agent-eval.md ⚠️ Met, but now incomplete — see 🟢 below

Five unambiguously met; AC 6 met in letter but the doc is now behind the code.


Code Review Findings

🟡 Important: the rejection check reds out a correct self-correction on noRetry and toolSequence

📁 scripts/eval/fixtures/agent-matrix.ts:263-276

The helper loops over every call to the target tool and fails on the first isError, with no regard for whether a later call to the same tool succeeded:

for (const c of calls) {
  if (c.name !== tool) continue;
  if (c.isError) { return { passed: false, … }; }
}

The address-review summary states the safety argument as "only the TARGET tool is judged, so a model that tries a bad search, recovers, and then does the right thing still passes." That is true only when the bad call was to a different tool. When the rejected call and the recovery are the same tool — which is the ordinary shape of self-correction — the turn now scores red.

For toolOnce this is masked: the pre-existing exactly-once rule already reds a two-call turn, so the helper never decides it. But noRetry deliberately tolerates several non-adjacent calls to the target tool, and that is exactly where the new check contradicts the kind's own semantics. Verified against the committed head:

10c: search_nodes[ERROR] → resolve_query → search_nodes[ok]
  → FAIL "search_nodes fired but was REJECTED"

11d: get_related_nodes[ERROR] → get_node → get_related_nodes[ok]
  → FAIL "get_related_nodes fired but was REJECTED"

toolSequence: resolve_query[ok] → update_node[ERROR] → update_node[ok]
  → FAIL "update_node fired but was REJECTED"

All three are turns that accomplished what the prompt asked for, scored as failures. This is the "scenario reds out correct behavior, so the fixture measures itself" failure mode the file's own header warns against — introduced by the fix for the opposite problem. It is also not hypothetical: the live run's own 11a trace has precisely this shape (create_node [ERROR] then create_node [fields=1]), and the run report reads that as the model recovering correctly.

Note the two shapes are genuinely distinguishable, so this is not a trade-off between them. The turn that must stay red is every call to the target rejected; the turn that must go green is any call succeeding.

Recommendation: gate the failure on no call having succeeded — if (own.some((c) => !c.isError)) return { passed: true }; — before returning the rejection verdict.

This is already fixed in the worktree but is NOT in the PR. The working tree at /Users/malibio/nodespace/nodespace-core/.claude/worktrees/issue-1977-dev-workflow-eval carries an uncommitted change implementing exactly this, plus two tests covering both halves. I verified it: all three false positives above flip to pass, the scenario-7 malformed-JSON catch and the 11c hallucinated-ids catch both still red, and test:scripts goes 127 → 129 pass / 0 fail. It is correct and strictly better than what is committed. It just needs to be committed and pushed — PR head is still 61e9319a, which has the defect.

Engineering principle: a guard added to one assertion kind inherits that kind's semantics; noRetry explicitly permits repeated non-adjacent calls, so a check that fails on any single rejected call contradicts the contract it was bolted onto.

🟢 Suggestion: 11c's rewording weakens the routing signal it depends on

📁 scripts/eval/fixtures/agent-matrix.ts:747

The winnability analysis behind the rewording is correct and well-proven — I verified all of it independently:

  • relationship.rs:519-551 does reject a custom relation name not declared on the source schema, with the quoted error.
  • local_agent/tools.rs:1055 does recommend related_to as a generic fallback, and skill_pipeline.rs:147 repeats it. The affordance gap is real, and create_relationship's tool description recommends a relation name the validator rejects #2234 is filed and open.
  • mentions is a built-in (relationship.rs:487), and only member_of carries extra validation — so a task → text mentions edge writes unconditionally. The CLI proof is corroborated by the code path, not just by the transcript.

One thing the analysis does not account for. The old prompt said "connect the two"; the new one — "Point that rebuild task at the decision it has to respect" — contains no link, connect, relate, or relationship token at all. Two consequences:

  1. Skill retrieval is semantic against "Create connections between nodes, explore relationships, and traverse the knowledge graph". Rewording alone has moved rank-1 retrieval by 40 points on this codebase before, so dropping every lexical cue for that skill is not a neutral edit.
  2. The skill's own FIND BEFORE LINK rule is written as "If the user says 'link X to Y'…" — a conditional the new prompt no longer satisfies on its face.

create_relationship is not in requires_routed_guidance (only resolve_query is, per tools.rs:3502), so it stays on the fail-open surface and the scenario remains reachable regardless — the prior review's reachability finding still holds, and this is not a winnability blocker.

But it does bear on the question the address-review summary raised itself: does 11c still measure the create_relationship-vs-update_node choice? My read is yes, and the live update_node result is a legitimate red — "point A at B" is a linking request in plain English, and answering it by writing prose into a field is precisely the failure the scenario exists to catch. I would not change the prompt on one run. Worth a sentence in the comment recording that the lexical link-cue was traded away for winnability, so a future reader debugging a routing miss here does not have to re-derive it.

Engineering principle: when a prompt is reworded to satisfy a validator constraint, the retrieval properties it previously relied on are part of what changed, and should be recorded as a known cost rather than discovered later.

🟢 Suggestion: the docs' Winnability section is now behind the code

📁 ../nodespace-docs/development/agent-eval.md:303-315

The section documents exactly one winnability constraint — "every later prompt may name only values that schema can actually hold." This round discovered a second, independent one that is arguably harder to satisfy and much less discoverable: create_relationship accepts only relation names declared on the source node's schema plus four universal built-ins, so an ad-hoc link between two core types cannot be recorded at all.

That constraint is captured thoroughly in the fixture comment (agent-matrix.ts:719-745) but not in the doc that AC 6 points at. Since the doc's stated purpose is to tell someone rewording a scenario what they must not break, a constraint that made a scenario unwinnable by construction is the highest-value thing it could carry. Two or three sentences, referencing the ADR/behavior rather than the issue number per the docs standard.

🟢 Observation: negative cases in the strengthened score() loop are genuinely wrong for every kind

Checked directly rather than by reading, since a silently-passing negative would weaken the loop across all 20 scenarios. Ran every scenario through both branches: all 20 positives pass, all 20 negatives fail, each with an on-point message. Specifically:

  • toolSequence (scenario 6) with [] → fails on the subsequence check, not incidentally.
  • noExtraTypes (scenario 3) with [] → fails on got 0, not on a downstream soundness check.
  • noRetry (7, 10c, 11d) correctly uses [tool, tool] rather than [], and the comment explaining why (bare noRetry legitimately passes on zero calls) is accurate — I confirmed noRetry + zero calls does pass.

No scenario slips through. The checked === all.length assertion pins the loop against a future continue being added.

🟢 Observation: toolSequence's target selection is correct

seqTarget = expect.propertiesOn ?? expect.tools.at(-1) reuses the same target minProperties would use, so the two paths cannot disagree about which tool is being judged. Confirmed a rejected non-final tool in a sequence does not fail the scenario (resolve_query[ERROR] → update_node[ok] passes), which is right — the sequence assertion is about the terminal write.

🟢 Observation: precedence ordering is safe

Scrutinized per the request: minProperties runs before targetToolWasNotRejected on both toolOnce and toolSequence. No rejected call can slip through that ordering, because callPersistedProperties inspects the same isError on the same target tool and fails on it first (agent-matrix.ts:200-210), returning a strictly more specific message. Verified: toolOnce + minProperties: 1 + a rejected call yields "never reached storage", not a pass. The two paths are consistent, and the comment explaining the ordering is accurate.

Worth noting the ordering also means minProperties scenarios (4, 6, 8c, 10b) keep the old, stricter semantics — they fail on the first rejected target call even if a later one succeeded. Once the 🟡 above is fixed, those four will behave differently from the six scenarios that go through the new helper. That asymmetry is defensible (a rejected write with minProperties set is a stronger signal) but is currently undocumented; a sentence would help.

🟢 Observation: contamination guard coverage independently reconfirmed

Re-derived eval_prompts()'s parse in a separate implementation rather than trusting the Rust assertion: 21 sites, 21 parsed, phantom at index 0 as before. Lowering MAX_SHARED_RUN to 2 surfaces only two run-length-3 hits, neither of them a changed prompt. Reverted cleanly; git status clean afterward.


Recommendation

APPROVE, conditional on one commit.

The four commits are a clear net improvement on an already-good PR. The scoring fix closes a real blind spot and proved its worth on first contact — scenario 7's hallucinated tool response (inventing nodespace://a2b-3c4 and reporting it as real) would have scored green before it. The decision to run the eval live before merging, and to treat the resulting fixture defects as defects rather than as model failures, is exactly the discipline this fixture needs given that model-lock decisions are read off it. The 11c winnability work is the strongest part: a documented tool affordance that cannot succeed is a genuine product bug, correctly diagnosed, correctly scoped out, and filed as #2234.

The single blocker is bookkeeping rather than engineering: the fix for the 🟡 self-correction false positive already exists in the worktree, verified correct, but is uncommitted and therefore not in the PR. As 61e9319a stands, noRetry reds out a model that corrects a malformed argument and succeeds on retry — which would land on scenarios 7, 10c and 11d, and would be read as model failure. Commit and push it (test:scripts 127 → 129, 0 fail) and this is ready.

The two 🟢 items — the lost lexical link-cue in 11c, and the docs' Winnability section not carrying the relationship-validator constraint — are follow-up material and should not hold the merge.

One process note carried forward from the previous review and still current: the recorded model numbers predate this domain, and the 10/19 first run is explicitly not a baseline. Any citation of matrix scores should name which domain and which scoring semantics produced them — the rejection check means post-merge numbers are not comparable to pre-merge ones even within the new domain.


Automated pragmatic code review (re-review). Verified independently: bun run test:scripts (127/127 at head, 129/129 with the pending fix), cargo test -p nodespace-agent guidance_is_not_contaminated (pass), guard coverage re-derived at 21/21, contamination margins re-measured at MAX_SHARED_RUN=2, mutation-testing of targetToolWasNotRejected (3 tests fail) and of score() against the prior review's id-table mutation (now caught), all 20 scenarios exercised through both branches of the strengthened loop, and relationship.rs / tools.rs:1055 / skill_pipeline.rs:147 read directly to confirm the #2234 affordance gap.

@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): APPROVE, conditional on one commit — posted as a comment because GitHub blocks self-approval on your own PR. Full findings in the review comment above.

Reviewed 2228e5b2...61e9319a (4 commits). Of the 7 prior recommendations: 5 addressed, 2 skipped with sound reasons, 0 outstanding. Both skips are the right call. All 6 acceptance criteria still hold (AC 6 met but the doc is now slightly behind the code).

Independently verified rather than taken on trust: the targetToolWasNotRejected mutation (3 tests fail when forced vacuous), the prior review's own score() id-table mutation (now caught, where it previously passed all 120 tests), guard coverage re-derived at 21 sites / 21 parsed, contamination margins re-measured at MAX_SHARED_RUN=2 (no changed prompt appears), all 20 scenarios run through both branches of the strengthened loop, and the #2234 affordance gap read directly out of relationship.rs / tools.rs:1055.

One blocker, and it is bookkeeping rather than engineering. The new rejection check loops every call to the target tool and fails on the first isError regardless of whether a later call succeeded. On noRetry — which deliberately tolerates repeated non-adjacent calls — that reds out correct self-correction:

10c: search_nodes[ERROR] -> resolve_query -> search_nodes[ok]  -> FAIL
11d: get_related_nodes[ERROR] -> get_node -> get_related_nodes[ok] -> FAIL

Both accomplished what the prompt asked for. The live 11a trace has exactly this shape.

The fix for it already exists in the worktree, uncommitted — gating on own.some((c) => !c.isError), plus tests for both halves. I verified it: the false positives flip to pass, the scenario-7 hallucinated-response catch and the 11c hallucinated-ids catch both still red, test:scripts goes 127 -> 129 / 0 fail. It is correct and strictly better than what is committed. Commit and push it and this is ready to merge.

Two 🟢 follow-ups, neither blocking: 11c's rewording dropped every lexical link-cue the Relationship Management skill retrieves on (still reachable — create_relationship is not requires_routed_guidance — and I agree the live update_node result is a legitimate red, so do not change the prompt on one run); and the docs' Winnability section doesn't yet carry the relationship-validator constraint that made 11c unwinnable.

Running the eval live before merging, and treating the fixture defects it surfaced as defects rather than model failures, is exactly right for a fixture that model-lock decisions get read off. The 11c diagnosis is the strongest part of the round.

Not merging — that is your call.

@malibio

malibio commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Clean shakedown run — against the exact merge candidate

Ran the full matrix once more on 451bf956 (the current PR head, including the self-correction fix), on a purged isolated database with a freshly built daemon, one process, quiet machine.

model=gemma-4-e4b-q4km  n_ctx=32768  working tree clean
guidance provenance recorded for all 5 prompt nodes and all 8 skills

Result: 10 passed / 8 failed / 1 excluded.

Scenarios
✅ Pass 1, 2, 5, 8a, 8b, 8c, 10a, 10b, 10c, 11a, 11b
❌ Fail 3, 6, 7, 8d, 8e, 9, 11c, 11d
⊘ Excluded 4 (degenerate empty generation)

The thing this run was for: no scenario is unwinnable any more

11a and 11b now pass. The relationship group has produced a real measurement for the first time — previously every one of its results was an artifact of the ambiguous verb or the related_to rejection.

And 11c/11d's failures are legitimate reds, verified from the traces rather than assumed:

11c — the model called update_node and reported:

"The task to rebuild the reports page has been updated and now depends on the decision node regarding server-side rendering."

It expressed the link as a field write rather than an edge, and claimed success. That is exactly the failure this scenario was built to catch — the same create_relationship-vs-update_node choice dev-relationship-creation.toml measures. The path is open: I verified a mentions edge writes and traverses between this scenario's own node pair via the CLI, and both endpoints exist here since 11a/11b passed.

11d — the model found the right node, by text search rather than traversal:

"Found 1 node... Reports page uses server-side rendering …"

Right answer, wrong mechanism. This is precisely the "answering without reading the graph" failure the scenario targets, and it is only visible because the assertion names get_related_nodes rather than accepting any correct-looking reply.

8d — used create_nodes_from_markdown for a single record, then reported creating a collection. A real over-reach, correctly caught.

Comparison to the earlier run

Earlier (defective fixture) This run
Passed 10/19 10/19
11a ❌ fixture defect ✅
11b ✅ ✅
11c ❌ unwinnable (to_id: null) ❌ legitimate (chose update_node)
11d ❌ cascade ❌ legitimate (searched, didn't traverse)
8b ❌ ✅
8d ✅ ❌

Same headline number, entirely different meaning. Before: three of the failures were my bugs. Now: every failure is model behaviour the fixture is designed to detect.

What this run does and does not establish

Does: every scenario is winnable, and every failure is real model behaviour rather than a fixture artifact. That was the blocking question for merge, and it is answered.

Does not: give a decision-grade number for E4B. One run of a nondeterministic model is not a baseline — scenario 4's empty generation alone shifts the denominator, 8d flipped between runs, and per #1862 single-run matrix numbers should not be cited for model-lock decisions. Re-running comparisons is the issue's stated non-goal and belongs after merge.

Standing observations, not blockers

  • Scenario 4 hit a degenerate empty generation in both runs. Its raw output showed a correctly-formed create_node carrying both particulars before the generation died, so the wording is sound — but if it recurs, scenarios 6 and 9 lose their subject and red out for an upstream reason. Worth watching across future runs.
  • Scenario 3 fired create_schema five times in both runs. Consistent, and exactly what noExtraTypes exists to catch.
  • Scenario 7 reproduced the hallucinated tool response — malformed JSON, rejected search, invented node id reported to the user as real. Caught only because of the rejection check from this review cycle.

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.

Redesign agent-matrix eval scenarios around NodeSpace's actual dev-workflow use case

1 participant