ADR 0010 — Per-group aggregates¶
- Status: accepted — implementation on
per-group-aggregates; ships in 0.6.0. The document landed first and the code was written against it, the way ADR 0006, ADR 0007 and ADR 0008 were - Date: 2026-09-08
- Assumes: ADR 0002 §2 (the
payload stays where it is born, the verdict travels) and §10 (the aggregate is
the gate, the per-case is the diagnosis, and where to put a threshold),
ADR 0006 §5 (the noise floor
is a rule of
compare()) and §7 (an aggregate gets an interval of its own, by re-evaluation per sample index), ADR 0007 §2 (aggregates are entries in the same list) and §4 (cases.jsonis a JSON array of objects whose keys areCase's fields) - Touches: nothing in the fixed section of
CLAUDE.md. Decision 3 (no vacuously green assertion) is reaffirmed rather than amended — §10 is the place where honouring it costs something visible, and it is paid - Extends: ADR 0006 §7 is used unchanged. The per-sample re-evaluation that gives an aggregate its interval is not modified here; it is handed a shorter list (§7)
Context¶
An aggregate today is a statement about the whole run. precision 0.727 is one
number over twenty cases, and ADR 0002 §10 established why it and not the
per-case verdict is what gates a release: four runs of one unchanged prompt
agreed with the human mark on 14, 14, 15, 15 cases out of 21 while individual
cases moved by three votes. The aggregate is stable exactly where the per-case
is not.
That stability has a cost, and it is the cost of every average. A whole-run
aggregate hides a class that is broken. The classifier example is a live
instance of it. Its committed baseline reports precision 0.727 and
accuracy 0.850, both comfortably above their thresholds, both green in every
document the product produces. Split the same twenty cases by the expense
category their vars already carry, and the same baseline says:
| group | TP / FP / TN / FN | precision | accuracy |
|---|---|---|---|
hotel |
2 / 0 / 1 / 0 | 1.000 | 1.000 |
meal |
4 / 1 / 2 / 0 | 0.800 | 0.857 |
taxi |
1 / 0 / 3 / 0 | 1.000 | 1.000 |
tools |
1 / 1 / 1 / 0 | 0.500 | 0.667 |
travel |
0 / 1 / 2 / 0 | 0.000 | 0.667 |
The classifier does not work on travel expenses. It has never worked on travel expenses. Every run, every report, every green pipeline in that example's history has been arithmetically correct and has not said so, because seventeen cases the classifier gets right are enough to carry three it does not.
This is not a defect in the aggregate. It is what an aggregate is for, and narrowing the denominator to fix it would give back the wobble ADR 0002 §10 was written to remove — a three-case group moves by 0.333 when one case changes its mind, which is the per-case noise wearing an aggregate's clothes. So the answer is not to replace the whole-run figure. It is to put the classes beside it, under the same machinery, with no new semantics anywhere.
Two things make that cheap, and they are why this is a small ADR rather than a
subsystem. A RunAssertion is a pure function from a list of CaseOutcome to a
Verdict — hand it a shorter list and it is already a group aggregate, with its
threshold, its tolerance and its noise interval intact. And compare() and
diff() pair on Verdict.assertion_id, so an aggregate that has a distinct
identity is an ordinary check to everything downstream, including a group that
appears or disappears between two runs.
Decision¶
1. group is a field on Case, and it is descriptive¶
Case gains group: str | None = None. Absent means the case belongs to no
group.
It is data, and it changes nothing about execution. The driver does not read
it, no target sees it, no per-case assertion is given it, and a suite that
declares by_group nowhere behaves as though the field did not exist. It is
read in exactly one place — the expansion of §2 — and read there as a label,
never as a switch.
Being a field on Case is the whole of its implementation in data suites. ADR
0007 §4 made cases.json a JSON array of objects whose keys are Case's
fields, and the loader builds them by splatting the object at the constructor
after refusing unknown keys against fields(Case). So a "group": "travel" in
cases.json works the day the field exists, in the TOML form and the Python
form alike, with no line written in cli/toml_suite.py.
An empty string is refused. None already spells "no group", so ""
would be a second spelling of it that expands into precision[group=] — a
public name with a hole in it, and a gate nobody can read. Case raises, for
the reason Contains(needle="") and an empty suspension reason do.
A string and not a list. A case belongs to one group or to none. Multiple membership is a different feature with a different arithmetic — a case counted in two denominators is a case counted twice — and nothing has asked for it. A string is also what makes §2's expansion total and its identity stable; see Alternatives.
It does not reach a run file. CaseResult records case_id, suspended
and the verdicts, and gains nothing here. What crosses a boundary about a group
is the group's name, inside the expanded aggregate's name, and the group's
confusion matrix, which is seven integers. Which cases were in it stays in the
repository. That is ADR 0002 §2 holding without being asked to: the counts are a
measurement of the system, the membership is the customer's data about their own
cases, and the split falls where it already falls.
2. by_group expands, and the expansion adds¶
Every aggregate gains by_group: bool = False. When True, the declared
aggregate expands into:
- one instance scoped to each group present in the suite's cases, and
- the ungrouped whole-run instance, unchanged.
The expansion adds; it never replaces. Precision(over="…", by_group=True)
yields the whole-run precision it always yielded, plus one per group. A suite
that turns the flag on loses no figure it had, and the number that gated its
releases keeps gating them.
Groups come from the cases, not from a declaration. There is no list of
expected groups anywhere. A group exists because a case carries its name, and it
stops existing when the last case carrying it is removed or renamed — at which
point its aggregates are absent from the run and compare() reports them
missing, which is §9 and is the correct sentence. Declaring the set of groups
somewhere would create a second place for the truth to live and a fourth failure
mode (declared but empty) that nobody needs.
A case with no group is in no group. It is counted in the whole-run aggregate and in nothing else. There is no implicit "ungrouped" bucket, because a bucket named after the absence of a label is a group nobody chose, with a threshold nobody set, and it would appear and vanish as cases were labelled.
All of them or none. There is no way to ask for one group; §5 is where that is refused and why.
3. The expanded name is the identity, and it is a public string¶
An expanded aggregate is named:
So precision[group=travel], accuracy[group=meal], f1[group=refunds]. The
whole-run instance keeps its bare name.
Three properties, each load-bearing:
It is the Score.name, not a field beside it. report/pages.py builds the
run grid's columns from the distinct score.names across runs and indexes each
row with {v.score.name: v for v in run.aggregate}. Two aggregates sharing a
name do not collide loudly — one silently overwrites the other and the grid
shows a column that is sometimes one group and sometimes another. So the name
has to carry the group, and carrying it anywhere else would leave that bug in
place.
It changes the identity, for free and correctly. name is a declared field
on Precision, Recall, Accuracy and F1, and it is not in
IDENTITY_EXCLUDED, so dataclass_identity already folds it in. Renaming the
instance is renaming the check, which is exactly right here: precision over
travel is not precision over the run, it is a different question with a
different answer and a different baseline. Nothing new is invented to make two
group aggregates distinguishable; the existing rule does it.
It is a public string, and this ADR is where it is fixed. It lands in
Run.aggregate[].assertion in every run file and every promoted baseline, in
compare --json's assertion, in diff --json, and in both rendered
documents. Changing the grammar later is a diff of every artifact anyone has
kept. So it is written down here, gated by a test, and not left to a format
string in a helper.
The bracket form was chosen over precision.travel and precision/travel
because a group name is user data: it can contain a dot, and a slash reads as a
path in a product that already writes anthropic/claude-haiku-4-5 for a model
label. [group=…] is unambiguous at a glance about which axis was split,
which matters the day a second axis exists — and if that day comes, the form
extends rather than needing to be replaced.
4. Neither the flag nor the group enters the identity¶
Both join threshold and tolerance in RunAssertionBase.IDENTITY_EXCLUDED.
This is not tidiness; without it §2's promise is false, and the second exclusion
is the one that matters most. dataclass_identity hashes every declared field
minus the exclusions, so:
- if
by_groupcounted, the whole-runprecisionwould change identity the moment the flag was set; - if
groupcounted, it would change identity — asNone— for every suite in existence, the day this release landed, whether or not anybody had heard of groups.
Either way compare() would report the figure that gates a suite as missing
with a new one beside it: the expansion replacing what it was supposed to add,
in the one place it was promised not to. The classifier's whole-run precision
is 31e12a0068b3f1db and stays 31e12a0068b3f1db under this release, flag set
or not, and that literal is in a test.
The justification is the same shape as the one for threshold. Identity answers
what is measured. by_group does not change what the whole-run aggregate
measures; it declares that further aggregates exist beside it. group says
which subset this instance was handed — and §3's name already carries that,
in a field the identity does count, so the two are distinguished without it.
Both are statements about the suite rather than about the check.
And config_hash still moves, which is what makes this safe. The hash is
built from (identity, threshold, tolerance) over run_assertions — and after
§6's expansion, run_assertions is the expanded set. Turning by_group on
adds entries, so the hash changes, so a baseline promoted before the flag is
comparable but not promotable as the reference for the suite that now
declares more gates. That is the behaviour ADR 0002 §8 already specifies for a
raised threshold, reached here without a special case: look at the diff, then
re-baseline deliberately.
5. There is no single-group form¶
Precision(over="…", group="travel") cannot be written, in Python or in TOML.
The reason is the feature's whole point: the class that degrades is the one
you were not watching. A form that lets an author name a group lets them name
the three they already suspect, and the fourth — the one nobody thought to
list — goes on being averaged away. Watching a group you chose is watching your
own assumptions. by_group is total by construction, and totality is the
property being bought.
Mechanically, group is declared as a non-constructor field:
The expansion sets it with object.__setattr__ after replace() — the pattern
RunAssertionBase._normalize already uses on a frozen slotted dataclass. Two
consequences fall out without a line of validation. A Python caller cannot pass
it. And the TOML loader's _init_fields filters on f.init, so group = "x"
in an [[assertions]] entry is already an unknown-parameter error carrying the
existing message and the list of parameters that do exist — the same refusal
that catches threshold on a repeated entry, for the same reason.
What separates the identities is the name (§3), never this field — group
is excluded from the fingerprint under §4 and is only the carrier the driver
filters on. So the one thing that must be constructible is the one thing that
cannot be constructed, and nothing depends on it being otherwise.
6. The expansion happens in Suite.__post_init__¶
Suite is where cases and run_assertions meet, and it is the only place they
do. It already rewrites one of its own fields there — min_agreement, through
as_agreement — so the mechanism is established rather than introduced. It sits
upstream of config_hash(), which is what makes §4 work. And both authoring
forms pass through it: the TOML loader builds a Suite like any Python caller,
so §1 and §2 reach data suites with no loader change, which is ADR 0007 §9's
"equal citizens" holding on its own.
The expansion is a module-level function in digline.core.aggregate, and it
reads the flag with getattr(assertion, "by_group", False).
The RunAssertion protocol is not touched. It is structural, and
docs/api.md invites third parties to satisfy it by subclassing
RunAssertionBase. Adding a member would break anything satisfying it
structurally, to buy a static guarantee about a field the expansion is happy to
find absent. A custom aggregate that never heard of groups reports False,
never expands, and keeps working — which is the correct behaviour and also the
honest one: it cannot be expanded, because nothing has told it how to name
itself per group.
Stated because it will surprise somebody: after construction,
suite.run_assertions is not the sequence the author wrote. It is longer. The
expansion is deterministic and ordered — whole-run instance first, then groups
in sorted order, per declared aggregate — so two identical suites produce
identical config_hashes and identical documents, and a test says so.
7. The filter sits in the driver, so the noise floor is restricted at the source¶
driver._outcomes already builds the CaseOutcome list an aggregate is
evaluated over, reading a {case_id: label} map off the suite. It gains a
{case_id: group} map beside it and, when the aggregate carries a group,
returns only the outcomes whose case is in it.
Everything else is unchanged. with_noise_interval is called with the shorter
list and does what ADR 0006 §7 says: compute the folded verdict, slice the same
cases as each sample index saw them, re-evaluate the pure assertion per slice,
record the N values as samples, sample_min and sample_max. The
per-sample re-evaluation is restricted to the group because the list it is given
is. No branch, no parameter, no second code path — which is decision 3 of the
brief satisfied by construction rather than by care.
Filtering here rather than inside the aggregate is a real choice, and the
difference is visible. per_sample_outcomes returns nothing unless every judged
case in the list it is given carries the same number of samples: reading across
lists of different lengths would align sample 2 of one case with sample 3 of
another and call the result a measurement. Filter in the driver and that
condition is evaluated over the group, so a group's interval is undefined for
the group's own reasons. Filter inside the aggregate and it is evaluated over
the whole run, so one odd case anywhere — a case added at a different samples,
a case whose target failed on three of five — silences the interval on every
group at once. An aggregate about travel expenses should not go quiet because of
a hotel.
The CaseOutcome value is unchanged. It never learns what a group is, because
by the time it exists the filtering has happened.
8. Small groups: what goes quiet, what still works, what an error means¶
Group aggregates inherit the declared threshold and tolerance of the aggregate they expanded from. There is no per-group threshold and no per-group tolerance; see Alternatives. Three things follow, and a reader is owed all three before they turn the flag on.
The declared tolerance goes quiet. A tolerance is measured over a
denominator, and the group's is smaller. The classifier's tolerance="3/20"
was set from eight runs of the whole suite, where it means "three cases"; on a
three-case group one case changing its mind moves precision by 0.333, so the
tolerance is no longer the control that speaks. It is not wrong — 0.15 is still
0.15 — it has simply stopped being the wider of the two.
The measured floor still works, and it is what does the work. compare()
checks the declared tolerance first and the baseline's measured interval second,
saying in the reason which one spoke (ADR 0006 §5). A three-case group at
samples=5 produces a genuinely wide interval, because one case flipping in one
sample index moves the group's figure a third of the way. The control that
sizes itself to the denominator is the one that was already sized by
measurement. This is the answer to "aren't small groups too noisy": they are,
and the noise floor is the thing that knows it.
An errored group aggregate is a true statement. Recall over a group whose
cases are all marked negative has an empty denominator, and ADR 0002 §10 fixed
what that is: error, never 1.0. Under by_group this stops being an edge
case and becomes ordinary — a small group will often contain one label only.
The sentence to keep is that it is not a malfunction: the suite has asserted
something the group cannot answer, and error is the accurate report of that.
Its consequences are unchanged, and precisely: an errored aggregate does not
enter Headline.unjudged, which counts case verdicts only, so it moves no exit
code. It is an errored delta in the comparison, it appears in the document's
"What could not be judged" section and in compare --json, and compare still
exits 0. Nothing here alters that, and nothing here should — an aggregate that
could not be computed is not a regression, which is ADR 0001 §1's third state
doing its job.
The remedy, when a reader wants one, is a declaration and not a flag: the group is too small, or the aggregate is the wrong question for it. Both are answered by editing the suite, in a diff somebody reviews.
9. compare, diff and the grid: nothing is special-cased¶
An expanded aggregate is an ordinary run-scoped verdict. index_verdicts keys
it ("run", "", assertion_id, occurrence) like any other, and both compare()
and diff() read that index — one pairing rule, which ADR 0008 §5 established
and which this ADR is not permitted to fork.
So a group appearing or disappearing between two runs follows the existing
new/missing rules, with no code and no new outcome. Add a case carrying a
group nobody had used and its aggregates are new: absent from the baseline.
Remove the last case of a group and its aggregates are missing: present in the
baseline but not in this run. A group renamed is one of each, which is the same
sentence compare() already prints for a renamed case or a renamed assertion,
and it is the correct one — nothing knows that travel became transport, and
guessing would be worse than reporting.
diff() is the exception, and it belongs to ADR 0008 rather than being a gap
in this one. A group set comes from the cases and §2 turns it into gates, so
changing it changes config_hash (§4) — and ADR 0008 §3 refuses a diff across
configurations before it reads a single verdict, because the two runs were
measured against different rulers. So two runs whose group sets differ cannot be
diffed at all; they can be compared, and compare() reports the new and the
missing above. That division is the right one: compare() is built to hold a
run against a reference under changed rules and to say the rules changed,
diff() is built to weigh two candidates and needs them weighed on one scale.
Both keep the behaviour they have, and neither learns what a group is. Two runs
whose group sets agree diff normally, group keys and all.
Scope gains no third member. A group aggregate is scoped to the run: it is
computed once per run, belongs to no case, and carries an empty case_id. What
distinguishes it from the whole-run figure is its identity, which is where
distinctions between checks belong.
The grid: whole-run first, then groups alphabetically. report/pages.py
builds one column per distinct aggregate name, so a suite with two aggregates
and five groups goes from two columns to twelve. The ordering is fixed here
because it is the thing that makes the widened table readable — the figure that
gates the release stays leftmost, where it was, and the classes below it are in
an order that does not change between runs. Whether long rows fold, scroll or
truncate is a rendering question, and it is deferred to the report block rather
than decided in an ADR that would be guessing at it.
10. The classifier ships its red rows¶
The classifier example gets group on its cases — the expense category its
vars already carry — by_group=True on both aggregates, a re-promoted
baseline, and a README section showing the per-class table.
And two of those rows are red, at the thresholds the example already
declares: precision[group=tools] at 0.500 and precision[group=travel] at
0.000, against a threshold of 3/5. This is deliberate, and it is the reason
this section exists rather than being an implementation note.
The alternative was to tune the example until every row was green — lower the thresholds, or pick an axis that splits more evenly, or set per-group thresholds — and every version of that is fixed decision 3 in demonstration form: an assertion arranged so it cannot fail, shipped as the thing users copy first. An example whose bars were moved until nothing tripped would teach the one habit this product exists to prevent. The classifier does not work on travel expenses; the example says so.
Nor is this the red-by-construction threshold ADR 0002 §10 warns against. That warning is about a bar set where somebody wishes the system were, which makes a gate permanently red and therefore ignored. This bar is set where the system measurably is — 0.727 over the run, threshold at 0.600 below the worst of eight runs — and the group rows are the discovery that the same bar is not met by two of five classes, on either measure. The measurement did not move. What moved is how much of it is visible.
And the gate does not change colour, which is what makes shipping it
honest rather than reckless. compare and report exit on movement:
worse counts regressions against the baseline, unjudged counts case verdicts
that errored. A group aggregate that fails in the run and failed in the baseline
is unchanged — the score did not move — so the example's check.yml workflow
goes on passing, and the red row is a fact in the document rather than a broken
pipeline. A reader gets the finding without the example
being unusable, and the day travel degrades further, that is a regression and
the gate says so.
This is the example's third act, and the three read in order: a declared tolerance somebody measured and wrote down, a measured noise floor the baseline records for itself (ADR 0006), and now per-class visibility — the aggregate that was the gate, told to say which class it was averaging.
11. Compatibility¶
No schema change. SCHEMA_VERSION stays at 9 and no migration is owed. A
run file records an aggregate as its name, its assertion_id, its score,
threshold, tolerance, status, reason and matrix metadata; an expanded aggregate
is an ordinary verdict under an ordinary name. Nothing in run_to_json or
run_from_json learns a field. A run file written by this release and read by
the previous one parses, and shows aggregates whose names contain brackets.
A suite that does not set by_group is byte-for-byte unchanged. Same
config_hash, same identities, same run file, same document — and that is a
statement about baselines promoted before this release, not only about two
runs of the current one: an aggregate's identity is what compare() pairs on,
so a fingerprint that shifted here would silently unpair every stored aggregate
in the world. §4's second exclusion is what makes the sentence true, and the
classifier's literal id is the test that keeps it true. Case.group on a case
in a suite with no by_group anywhere changes nothing at all.
A suite that does set it is comparable and not promotable against a baseline
promoted before it (§4). That is the intended friction, and the remedy is
digline promote after reading the report — the human act ADR 0002 §8 makes the
whole meaning of a baseline.
cases.json gains an optional key. A cases file written before this release
loads unchanged; one written after it does not load on an older release, which
refuses unknown keys by design (ADR 0007 §6). That direction is correct: a file
declaring a group to a version that would silently ignore it is exactly the
silently-dropped key that refusal exists to prevent.
Plugins are untouched. No protocol in digline.run changes, so no plugin
needs a release. A third-party RunAssertion that does not declare by_group
never expands (§6).
Consequences¶
A suite can now be red in a place the pipeline is green. A document showing
precision[group=travel] 0.000 / 0.600 beside an exit code of 0 is a new
combination, and the first reaction to it will be that something is broken. It
is not: compare has always gated on movement, and a failing check that failed
identically in the approved baseline is a fact somebody already signed. The
report and the README say this in words, because the combination is legitimate
and will otherwise be read as a bug.
Aggregate counts grow multiplicatively, and the run grid with them. Two
aggregates over five groups is twelve figures per run where there were two, and
twelve columns in digline view's run list. The arithmetic is free — a
RunAssertion is a pure function over verdicts that already exist, and §7's
re-evaluation costs no call to anything — but the reading is not, and a suite
with twenty groups will produce a table nobody scans. That is a real limit on
how fine a group should be, and it is a limit of attention rather than of
compute.
Small groups will error, routinely. Recall over an all-negative group,
Precision over a group the system kept nothing from. §8 fixes what that means
and leaves the behaviour alone, but the volume is new: an aggregate erroring was
an event, and under by_group it becomes a row.
The group name is now a public string in a committed file. Renaming a group
is a new plus a missing in every comparison until the baseline is
re-promoted, exactly like renaming a case. Somebody will rename one and be
surprised, and the sentence compare() prints is already the right one.
Case has acquired its second purely descriptive field, after metadata.
The difference between them is worth keeping straight: metadata is payload and
is redacted at a boundary; group is read by the suite to build gates and its
name travels inside an aggregate's name. A future field that wants to be one
or the other has these two to be compared against.
Alternatives considered¶
A single-group form: Precision(over=…, group="travel"). The obvious API,
and rejected in §5. It buys the ability to watch the groups you already suspect,
which is the ability this feature exists to make unnecessary. Its cost is
silent: the class that degrades is the one nobody listed, and a suite using the
single-group form looks thorough while remaining blind in exactly the place the
whole-run figure was already blind.
Per-group thresholds. Tempting, because §8 admits the inherited tolerance
stops speaking on a small group, and because it would let the classifier ship
green. Rejected on both counts. It is a second declaration surface — a mapping
from group name to threshold, in data, that has to be kept in step with a set of
groups that comes from the cases — and its failure mode is a group that appears
without a threshold, for which the only answers are "inherit" (which is this
ADR) or "error" (which makes adding a case a breaking change). And in the
classifier it would be used to move a bar until a demo passed, which §10 is
about.
Weighting: a group contributing to the whole-run figure in proportion to
something. Out of scope by the brief, and it would be a change to what the
whole-run aggregate means — the figure that has gated releases
would start answering a different question under an unchanged name and an
unchanged identity. If a weighted aggregate is ever wanted it is a new
RunAssertion with its own name, not a mode of the existing four.
Group hierarchies: travel/rail, travel/air. Rejected as a shape, not
just as scope. Nesting means a case is counted in two denominators, which means
two answers to "how many cases did this aggregate consider", which is the one
number ADR 0002 §10 insists never gets separated from the ratio. A hierarchy is
also a second axis wearing a delimiter, and §3's naming form already has room
for a second axis done properly.
Cross-group comparison: an assertion that precision[group=a] is within some
distance of precision[group=b]. A real question — it is the fairness
question — and genuinely a different one. It is a verdict about a relationship
between two aggregates, which no current type expresses: a RunAssertion maps
outcomes to a verdict, and this maps verdicts to a verdict. It would need its
own ADR, and it would need to answer what happens when one side errored. Refused
here, not refused in principle.
Making group a Sequence[str], so a case can be in several. Rejected with
hierarchies and for the same arithmetic: a case counted twice makes considered
ambiguous. It also makes the expansion's totality harder to state — "every group
present in the cases" is unambiguous over a scalar field and needs a flattening
rule over a list.
Deriving groups from Case.metadata instead of a new field. The metadata is
already there and already carries {"quarter": "2026-Q3"} in the classifier.
Rejected because metadata is payload — redacted at a boundary under ADR 0002
§2 — and a group name is not: it travels inside an aggregate's name to world 2
by design. Building gates out of a bag whose contents are removed at a boundary
would mean the same suite producing different aggregates depending on where it
was read.
Test plan¶
Beyond a failing case for every new rule, which the conventions already require:
Expansion stability is a property, tested as one. The same suite constructed
twice produces the same config_hash, the same ordered run_assertions, and
the same set of assertion_ids — over several shapes: groups in different
declaration orders in cases.json, a group whose name sorts before and after
the others, mixed grouped and ungrouped cases, two aggregates over the same
over. The names are asserted literally, character for character, because §3
makes them a public format and a test that only checked uniqueness would let the
grammar drift.
The whole-run identity does not move. The classifier's precision keeps
31e12a0068b3f1db with by_group=True set, asserted against the literal id, so
§4's exclusion cannot be undone without a test naming what it costs. Beside it:
config_hash does change, so the pair is checked together and neither can be
satisfied by weakening the other.
A group vanishing between runs. Build two runs from suites differing only in
that the last case of a group has been removed, and assert compare() reports
that group's aggregates missing and nothing else — no regressed, no
errored, and the whole-run figures untouched. The mirror case for a group
appearing, and the rename, which must produce exactly one new and one
missing. Beside them the refusal above: the same pair handed to diff()
raises, and the test says why rather than merely that it does. Symmetry is
tested on a pair whose group sets agree, in both argument orders, because ADR
0008 §2's promise covers these keys like any other.
The noise floor is restricted to the group, and demonstrably. A suite where one group's cases are unanimous across samples and another's disagree: the first's interval must be zero-width and the second's wide, from one run. Then the case §7 chose its filter placement for — a case outside a group sampled a different number of times, or errored on some samples — which must leave that group's interval intact. That test fails if the filter is moved inside the aggregate, which is the point of writing it.
The three shapes of a small group. An all-negative group makes Recall
error and Precision not; an empty-kept group makes Precision error; and in
both cases compare exits 0 and Headline.unjudged is unchanged, asserted
directly so §8's "behaviour unchanged" is a test and not a claim.
The single-group form is refused from both directions. Precision(group="x")
raises TypeError in Python, and group = "x" in a TOML [[assertions]] entry
raises UsageError naming the parameters that do exist — the second asserted on
the message, because it is the existing unknown-parameter refusal and the test
is that it reaches this case unchanged.
by_group is inert without groups, and group is inert without by_group.
Two runs, one from a suite with group on every case and by_group nowhere,
one from the same suite with the field removed: identical config_hash,
identical run JSON but for the timestamp.
The example is gated as an example. test_examples.py already runs each
example's whole cycle from nothing; the classifier additions ride on it. Added
beside them: the per-class table in the README matches the promoted baseline,
number for number, so the third act cannot rot the way a hand-written table
does.