Containment: a broken instrument no longer rejects the subject
This is the change to read first, because it alters results you have already stored. In one evaluation corpus, 20 of 39 runs had a jury that errored or abstained, and every one was recorded as a subject that did not pass. The instrument failed and the subject was charged for it, silently, in more than half the corpus. 0.17 separates the two:- A strategy that throws, returns
null, or returns an aggregate it was not entitled to produce now yields anERROR aggregation_failedverdict marked undecided, with every judge’s own result intact. Previously it discarded the whole jury, and inside a cascade the enclosing tier with it.Erroris not caught. - A meta-jury member that throws, returns an undecided verdict, or excludes itself without declaring
it may is a stage failure: excluded from strategy input, its actual verdict kept on the
attempt, the aggregate
ERROR stage_failed. - A
JudgeWithMetadatawhosemetadata()returnsnullor throws no longer escapesSimpleJury.vote(). The jury reads every seat’s key once, on the caller’s thread, before any judge runs. Such a seat becomesERROR judge_metadata_unreadable, keyedJudge#N, for theErrorPolicyto resolve, and the judge does not run.
The one cascade rule
A cascade tier that did not produce a usable determination but had already established a genuine individualFAIL stops — under REJECT_ON_ANY_FAIL only. ACCEPT_ON_ALL_PASS escalates past
it, because a broken reduction cannot demonstrate that a subject is fine.
When it stops that way, no FAIL and no score is manufactured. The rejection is carried by the
decision; the root aggregate stays a machinery error.
NOT_APPLICABLE is its own status
Five statuses now. The difference between the last three is what a denominator does with them.
ABSTAIN no longer means “not applicable” anywhere. Missing evidence is still ABSTAIN.
Exclusion must be declared in advance
Exclusion is the one outcome that removes a judge from its own denominator, which makes it the one outcome an instrument could use to dodge a question it does not like the look of.JudgeMetadatagainsnotApplicableWhen: the condition under which this judge may exclude.Judges.notApplicableCapability(Judge)is the one lookup, and walks wrapper chains outward in, so a deduplicating rename cannot strip a capability off the judge underneath.- A
SimpleJuryrefuses a capable seat under a strategy that refuses exclusions, andJuries.metarefuses a possibly-excluding member — both at construction. - At vote time, an exclusion from an undeclared seat becomes
ERROR undeclared_not_applicable. Jury.aggregateMayBeNotApplicable()defaults tofalse, so an opaque jury makes no pre-spend guarantee and is checked at runtime.
NotApplicablePolicy
REFUSE (the default), EXCLUDE, TREAT_AS_FAIL. Every built-in strategy takes one, and
VotingStrategy.notApplicablePolicy() declares it so composition can be validated before anything
runs.
Every ERROR names its cause
Judgment gains reasonCode, a closed vocabulary in two families.
Instrument codes, required on every ERROR: judge_failed, judge_metadata_unreadable,
judge_reported, undeclared_not_applicable, errors_propagated, not_applicable_refused,
aggregation_failed, stage_failed, no_tier_decided.
The subject code subject_empty, optional on a FAIL. A FAIL with no code is an uncoded
rejection, by deliberate choice.
errors_propagated must carry its origin: a non-empty errorCodeCounts block naming the terminal
causes it propagated, flattened through nested wrappers. Judgment.propagatedError builds it
atomically, and construction refuses the code without it.
Configuration is refused at construction
The seven-component verdict
Seat records where each judgment sat and under what key, so the ordered list and the keyed map can
be joined without guessing; only a DECLARED key is an identity. Decision says what produced the
aggregate — this jury’s own reduction, a named direct tier, or nothing at all — so a copied outcome
is no longer indistinguishable from a computed one.
requireDeclaredNames() is worth turning on wherever a verdict is stored: it rejects positional
seats, duplicate declared names, and the collision that makes it necessary — a judge declaring the
name Judge#2, which is exactly the key an unnamed second seat takes.
What you may have to change
One intended compile break
Exhaustive switches overJudgmentStatus no longer compile, because there is a fifth constant.
That break is intended: every exhaustive switch is a place where a decision about NOT_APPLICABLE
has to be made rather than defaulted. fromWire stays exact and throws on an unknown name.
JudgeMetadata’s three-argument constructor is retained
new JudgeMetadata(name, description, type) still works and delegates to the canonical
four-argument constructor with a null condition.
A judge that never excludes a subject needs no change. Absence of notApplicableWhen is the
statement that it never excludes, not a blank or missing declaration, and it is what
Judges.notApplicableCapability(Judge) and the seat guard read. Declaring notApplicableWhen is
the deliberate opt-in, taken through the four-argument constructor.
Hand-built verdicts
A hand-builtVerdict — a test fixture, a re-scorer, a replay tool, a fake jury in a consumer test
— now needs seats that line up with individual and individualByName, and a decision.
build() throws without one.
For the ordinary case Verdict.of(aggregated, individualByName) is the whole call: it takes the
individuals from the map’s values in encounter order, seats each entry as DECLARED at 0..n-1,
and records Decision.own().
Pass an ordered map (
LinkedHashMap) if the positions matter — Map.of does not specify an order.
Verdict.single(name, judgment) still covers one judge. Duplicate declared names, positional keys,
weights, seat gaps, and any decision other than own are still built with Verdict.builder() and
explicit seats.Requirements judges: an empty roster no longer passes
EarsJudge.create and Rfc2119Judge.create reject an empty roster, and the rollup refuses one
independently. A roster is a denominator, and a conjunctive rollup over an empty one is vacuously
true.
A stored PASS with a criteriaTotal or constraintsTotal of 0 identifies a run affected
before this fix. Parsing is unchanged: a document that matches nothing still yields an empty list,
because that is a fact about the document. The judge is what refuses.
Downstream readers
agent-experiment’s RecordedJudgmentStatus must learn not_applicable before any run that
can emit it, and Python readers must raise on an unknown status rather than defaulting it. Counts
are stored and rates are derived, so a reader that persisted a pass rate should recompute it.
A legacy ABSTAIN carrying the label not_applicable is never reinterpreted automatically. A
deliberate re-curation records that it reclassified.
Reading verdicts you have already stored
New construction is strict; reading old data is a separate decision. The live types refuse an incomplete result by name, because a reader lenient enough to load a 0.14 document is lenient enough to accept a 0.17 result that lost a required fact in transit — and it would accept it silently. So reading stored verdicts is a separate entry point.Verdicts.interpret returns one
Interpretation for any verdict, live or stored since 0.13:
interpret reads, it does not
rewrite.
See Interpreting verdicts for the full shape, what each
stored form yields, and a worked consumer example.