Skip to content

fix(measure): say whether a refusal was the part's doing or partspec's - #390

Merged
CameronBrooks11 merged 1 commit into
mainfrom
fix/371-tool-fault-is-not-a-part-fault
Sep 6, 2026
Merged

fix(measure): say whether a refusal was the part's doing or partspec's#390
CameronBrooks11 merged 1 commit into
mainfrom
fix/371-tool-fault-is-not-a-part-fault

Conversation

@CameronBrooks11

Copy link
Copy Markdown
Member

refused carried two things that mean opposite things, as prose, at exit 0.

Reproduced side by side on main:

TOOL FAULT (backend raised)
  exit: 0   refused['area']: "the mesh backend could not measure area here: ..."
PART FAULT (the part defeated the measurement)
  exit: 0   refused['center_of_mass']: "this mesh encloses no volume, so it has no centre of mass"

Both dict[str, str], both exit 0. The only discriminator was the English — in a block
whose other entries mean the part's defect, so a bug in this tool read as a finding
about the part.

Two decisions, stated plainly because both change a released contract

1. Additive discriminator, not a widened refused. refused_by maps each refused
name to "part" or "tool". #371 offered "§6.1's vocabulary or a narrower refused_by";
§6.1's origin is environment | model | null and none of those fits "the backend
raised", so the narrower one is the honest spelling. A separate block because adding a
field is non-breaking under §7.1 while changing dict[str, str] to dict[str, object]
is not — a consumer written before this reads refused unchanged.

2. A "tool" refusal now exits 4. #371 left this open ("worth deciding whether the
backstop path should exit 0 at all"). I think it must not: a discriminator alone lets a
consumer branch while the default answer still reads as success, which is half a fix.
Exit 4 is the code this verb already returns when it could not measure what it was
given. A part that merely defeats a measurement still exits 0 — that is an answer about
the part, and measure decides nothing about parts.

#369's property is untouched. The payload is still emitted in full: one raising
backend costs one name and not the other thirteen. Verified — the tool-fault run emits 8
measurements alongside its exit 4. That gain is a property of the output; the exit code
is a separate channel and now says something true.

After

TOOL FAULT -> exit 4,  refused_by {'area': 'tool'},            8 other measurements kept
PART FAULT -> exit 0,  refused_by {'center_of_mass': 'part'}

A new state for consumers, documented rather than discovered

Exit 4 with a complete payload did not previously exist. AGENT-CONTRACT.md §2.4
said measure exits 4 only on a build failure, and that "exit 4 and stdout is empty"
means the contract raised or partspec itself failed — with empty as the discriminator.
Both statements are corrected there. The two shapes stay distinguishable at a glance: the
failure shape carries error and no measurements, this one the reverse.

SPEC-report.md §7.3 gains refused_by with its emission rule and the exit-code
consequence.

Tests

Three added, plus two existing ones updated — both of which failed for the right reasons
(_measure asserts exit 0; the minimal-key-set test correctly saw one more key).

With cli.py reverted and tests kept, all 5 go red. The standing control is
pre-existing and untouched: test_one_unmeasurable_quantity_does_not_suppress_the_other_thirteen
drives a part-fault run through _measure, which asserts exit 0, and it still passes — so
"exit 4 for every refusal" would not survive.

just check clean, 1396 passed (baseline 1393).

Closes #371

`refused` carried two things that mean opposite things. "the part
defeated this measurement" is a finding about the design; "partspec could
not perform it" is not a statement about the design at all. Both landed in
one dict[str, str], so an agent had to string-match English to tell them
apart -- and the run exited 0 either way, in the block whose other entries
are about the part.

Two changes, and both are needed to close it. A discriminator alone would
let a consumer branch while the default answer still read as success.

`refused_by` maps each refused name to "part" or "tool". A separate block
rather than widening `refused`'s value type, because adding a field is
non-breaking under SPEC-report 7.1 and changing dict[str, str] to
dict[str, object] is not. Emitted exactly when `refused` is and keyed
identically.

A "tool" refusal now exits 4. That is the code this verb already returns
when it could not measure what it was given, and exit 0 asserted the run
was fine while partspec had failed at its job for a name. The payload is
still emitted in full, so #369's property is untouched: one raising
backend costs one name and not the other thirteen. A part that merely
defeats a measurement still exits 0 -- that is an answer about the part,
and measure decides nothing about parts.

This creates a state no consumer has seen: exit 4 with a complete
payload. It is distinguishable from the failure shape at a glance -- that
carries `error` and no `measurements`, this one the reverse -- and
AGENT-CONTRACT 2.4 said measure exits 4 only on a build failure, with
empty stdout as the discriminator for "partspec itself failed". Both
statements are now corrected there rather than left to be discovered.

Closes #371
@CameronBrooks11
CameronBrooks11 merged commit e5061f2 into main Sep 6, 2026
10 checks passed
@CameronBrooks11
CameronBrooks11 deleted the fix/371-tool-fault-is-not-a-part-fault branch September 6, 2026 14:13
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.

A tool fault in measure is emitted as a part fault, at exit 0, distinguishable only by prose

1 participant