What the mutation runs actually measured¶
just mutate produces a number. This file is what the numbers mean, because
twice now a mutation score has been quoted here that measured something other
than what it appeared to.
The #218 question, answered¶
Do property-based tests kill more mutants than example-based ones? Same module, same mutants, different suite.
Run with tools/mutate_compare.py over src/sdmxlib/query/_compile.py
(1,290 mutants), one arm the two oracle-backed property files, the other eight
example files covering the same predicate surface.
Restricted to the 742 mutants both arms execute — the only fair comparison, since each suite reaches code the other never runs:
| killed only by it | killed by both | killed by neither | |
|---|---|---|---|
| properties | 0 | 440 | 196 |
| examples | 106 |
Over all 1,290, including what each suite never reaches: properties killed 440, examples 755, and 535 survived both.
The properties killed a strict subset. Not "fewer" — a subset: every
mutant they caught, the examples caught too, and the examples caught 106 more.
The 106 cluster in _constraint_clause, _facet_clause and _conjoin.
What that does and does not license¶
It does not say the property files are redundant, and they are not being deleted. Three reasons, and the first is the one that matters:
Mutation kills and found defects are different measurements. The properties
have caught real bugs no example did — a filter fragment of ":", punctuation
that folds to the empty string and matches every non-null label; #255, where
one relation folded accents in label search and the other did not. Neither is a
small local fault of the kind mutmut injects. Mutmut perturbs an operator or a
constant; the defects the oracles find are disagreements about meaning, which
is a different failure class and the reason tests/support/oracle.py is
written as a specification rather than a second implementation.
They ran at 1× width. mutmut runs the default Hypothesis profile. The
documented finds came from just test-properties at 20×, and CLAUDE.md already
records that both defects fixed in !249's second commit were invisible at
default width. So this measures the properties with most of their search
switched off. Re-running the properties arm under the deep profile is the
obvious follow-up and was not done here: it multiplies an already 15-minute run
and pushes more mutants past their CPU budget (see below).
A property is worth what it specifies, not only what it kills. The oracles are the executable statement of what a predicate means. That value is in the reading as much as the running.
The honest caveats on these numbers¶
- Run-to-run variance. Two runs of the properties arm gave 454 kills / 0 timeouts and 440 kills / 13 timeouts. Hypothesis tests are slow enough that the per-mutant CPU budget sometimes expires, so the properties' kill count is a slight under-estimate — which, note, cuts in their favour and does not change the qualitative result.
mutate_only_covered_lines = falseis forced off by the_duckdbimport interaction documented inpyproject.toml, so uncovered lines yield uninteresting survivors.pytest_add_cli_args = ["-m", "unit and not slow"]excludes the concurrency tests.- The "examples" arm is eight files against the properties' two. It was not balanced for size, because the question is what each suite can catch, not what it catches per file.
The scope, and why it is what it is¶
304 asked for code_query.py, hierarchy_query.py and _query_common.py¶
(1,402 statements). #306 deleted all three. The work moved into _expr.py,
_compile.py, _joins.py and _common.py — 1,332 statements, so the scope
survived the migration nearly intact under different names, and those four are
now in only_mutate (2,300 statements total).
accessors.py (533), query.py (399) and _pushdown.py (221) are held back
deliberately: the first is declarative surface, and the other two deserve their
own decision rather than arriving as a side effect of this one.
The _compile.py baseline, and the first triage installment¶
Against the whole unit suite — the honest baseline, and the one to quote:
| mutants | killed | survived | timeout | no tests | |
|---|---|---|---|---|---|
| before the triage | 1,290 | 1,127 (87.4%) | 163 | 0 | 0 |
| after | 1,290 | 1,147 (88.9%) | 143 | 0 | 0 |
Note what the earlier two-arm comparison could not tell you: it reported 196 mutants neither arm killed, but 33 of those die to test files in neither arm. A survivor set is only meaningful against the suite you actually ship.
What the first cluster found¶
_window held 23 survivors, the largest group. Triaging them found a real
defect, not a test gap — which is the outcome #218 hoped for and did not
promise.
col.count().over(...) compiled to count(*) for every windowed count. The
counted column was dropped from the SQL while its bind parameters were still
emitted, so description["en"].count().over(...) died with Parameter
argument/count mismatch, and counting any nullable column silently included
its NULLs. The justification in the comment — that the fan-out marker is not a
real column — did not hold: the marker's path is to-many by construction, and
_flat_reference refuses that before a window is ever built, so the marker
cannot reach _window at all.
It survived because the one test counted id, where count(*) and
count(id) agree.
Four tests and the fix took _window from 23 survivors to 5, and 18 of the 20
mutants newly killed across the module were in it. The other three windowed
forms had no test at all: n_unique().over() (the DISTINCT argument and
the rename to count, since DuckDB has no n_unique), list().over() (whose
exclusion from the frame's ORDER BY is what keeps it from becoming a running
list), and lead(offset) — indistinguishable from plain lead at offset 1,
which is all that was tested.
The five that remain, classified¶
"XXcountXX"/"COUNT"in thecountbranch — equivalent, and the branch is now gone. Once corrected, that branch said exactly what theelsesaid. Two mutants surviving on the strength of being unable to tell a branch from its fall-through is a duplicate branch; deleting it is the fix.{"n_unique": "COUNT"}— equivalent. SQL function names are case-insensitive, so the rename lands on the same function.' DESC'→' desc'— equivalent, same reason.else source[1]in the sort fallback — dead code, not yet removed.window()suppliesorder_refwhenevercolumn.sourceis non-empty, and an empty source cannot construct aWin. Removing the fallback means tightening the parameter to non-optional and moving the caller's guard, which is a bigger change than this triage should smuggle in.
Installment 2: the two discriminator clauses, and a broken runner¶
The runner had been dead, and nothing said so¶
Before a single survivor could be read, the run had to work. It did not:
failed to collect stats. runner returned 1
1610 mutants killed 0 survived 0 no-coverage 0
test_data_format.test_the_cookbook_table_lists_every_member reads
docs/cookbook/data.md through Path(__file__).parents[2]. mutmut copies
src/, tests/, pyproject.toml and uv.lock into mutants/ and nothing
else, so that read raised FileNotFoundError there and took the stats
phase down with it — and mutation testing is not in just ci, so every run
since that test landed reported zero of everything and no one saw it.
Exactly the trap test_count_any.py documents against #284, one directory
over: *"tools/ is outside the trees mutmut copies … which is exactly what
284 did, unnoticed."* Two instances now, both found only when someone next¶
tried to run mutation. A third is a matter of time; the shape to watch for is
a test reading anything outside src/ and tests/.
The guard is keyed on docs/ being absent entirely, not on the file
missing, so a deleted cookbook page still fails in a real tree rather than
skipping quietly.
The new baseline¶
_compile.py has grown since installment 1 — 1,290 mutants then, 1,610 now —
so the numbers are not comparable to the table above, only to each other.
| mutants | killed | survived | segfault | timeout | |
|---|---|---|---|---|---|
| before installment 2 | 1,610 | 1,408 (89%) | 174 | 28 | 0 |
| after | 1,610 | 1,433 (90%) | 162 | 28 | 0 |
25 newly killed, 0 resurrected — the survivor set shrank by exactly the mutants aimed at, which is the check #324 asks for and the one a "killed 20, resurrected 1" fix fails.
What the two clusters found: a declared branch nothing could reach¶
_facet_clause (12 survivors) and _constraint_clause (12) are the same
function twice — absent / one value / several — and reading them explains why
no end-to-end test could kill 9 mutants in each:
Every declared facet and every declared root constraint carries exactly one
value. All three _edges.FACETS are single (("Dimension",),
("Attribute",), ("Measure",)), and all twenty __constraint__ attributes
in accessors.py are ("artefact_type", ("Something",)). The multi-value
IN (...) branch is unreachable through q.*.
Classified as dead code by declaration, not by design — the third kind, and
a new one for this file. Both docstrings state the capability (a facet "may
cover several stored kinds even when the domain exposes one name") and a facet
spanning Dimension and TimeDimension is an obvious future one. So the
branch stays and is now pinned directly, rather than waiting for whoever
declares that facet to discover whether it works.
The remaining three per function are reachable and emit SQL that runs, which
is why end-to-end tests pass over them: len(values) == 1 mutated to != 1
routes a single value through the IN branch and emits IN (?) — different
text, same rows. Only an assertion on the emitted SQL separates those, so
tests/unit/test_query_compile_clauses.py asserts text and params rather than
result sets.
One bonus kill outside the two clusters: _conjoin__mutmut_5, from asserting
that an empty clause is dropped by _conjoin rather than merely falsy.
The 162 that remain¶
20 _Scope._agg_body_from 14 _window 13 compile_page
11 compile_projection 11 _order_clause 10 _after_sql
9 _Scope._agg_subquery 6 _Scope.order 5 _Scope.grouped_having
5 _Scope.compile 5 _check_distinct_order
_Scope._agg_body_from is the obvious next installment: the largest cluster,
and aggregate-subquery construction is where a wrong join or a dropped
correlation returns plausible numbers rather than an error.
Installment 3 (#458): formats/gaps.py, and mutants nobody had tried¶
458 asked for the readers, the writers and formats/gaps.py. The register¶
went first. It is 289 rows built from small predicate factories, and its
carried predicates are what sl.unrepresentable and every dropped answer
rest on.
Half the survivors had been tried against one test¶
The first run against the unit suite reported 268 of 625 mutants surviving.
Every mutant of _any_node_has, _any_selection, _any_map_has, _url_has
and five other factories survived. Yet a fresh pytest of
test_field_coverage under the first _any_node_has mutant fails at once,
naming hierarchies.tree[].version.
mutmut credits a function to the tests that were running when it was hit. The
factories run while gaps.py imports, which happens during collection, before
any test. So every hit was credited to whichever test ran first
(test_3x_details.py::test_a_url_may_be_given_bare). Each factory mutant was
then run against that one test about annotation URLs, and it passed.
This is a stock mutmut behaviour, not something the fork workaround
introduced. It applies to any code evaluated at import: a module-level table,
a decorator, a registry built by calling a function. That makes it a
measurement fault for the readers and writers too, which build their dispatch
tables the same way. tools/mutmut_forksafe.py now takes collection-time hits
out before the first test starts, and credits each to every test that reaches
its module at run time. The tests are in
tests/unit/test_mutmut_import_time.py.
The fix also shrank the timeouts. 117 became 92. All 92 were then re-run against the six files that test the register, under a two-minute cap:
- 79 fail a test;
- 11 crash
gaps.pyon import; - 2 (
_follow, its loop condition) hang for real.
None was a survivor hiding behind a timeout.
| mutants | killed | timeout | survived | no test | |
|---|---|---|---|---|---|
| first-test credit | 625 | 224 | 117 | 268 | 16 |
| module credit | 625 | 413 | 92 | 104 | 16 |
| after this installment | 609 | — | — | 61, all equivalent | 0 |
The last row is the 104 survivors re-run, each in a fresh process, against
tests/unit/test_gaps_mutant_gaps.py: 43 killed, 0 resurrected. The 16
untested mutants are gone because their function is gone (below).
What the 43 were¶
Real gaps, each now with a test:
- A walk tried only where it fires first. The maximal hierarchy versions
its first top-level node, so
_any_node_hasreturned before it ever descended. A version only on a child, and a hierarchy with none, were never asked about (12 mutants). - 3.1 keeps availability constraints.
dropped(msg, "3.1")returning one passed, because the 3.1 round-trip test goes throughwithout_dropped, which keeps them whateverdroppedsays.without_droppedrebuilding a 3.0 message with them still in it passed for the same kind of reason. - Naming a draft is 2.1's limit only.
urn in out or target != "2.1"mutated toandleaves out, of a 3.0 write, anything naming a draft, as soon as aStructureSetis also left out. - A chain that needs a second pass. Categorisations precede provision
agreements in message order, so
Categorisation → PA → DF → draft DSDneeds_follow's loop to go round again.changed = Truemutated toNonepassed. - A reference to a value list outside the message. 2.1 cannot name it
either way. With the value list in the message,
_followcovered for a broken_names_the_unnameable. continuemutated tobreakin_unnameable_in_21: a component map with no value map ended the search.- 3.1's availability row (
_states_availability): Actual and fitting, and neither alone. - Text no test read: every reason,
describe,availability_label, and the unknown-target error. "SDMX-ML None output leaves out" passed. declared_gaps(type)with its filter inverted._beyond_2_1_urlsat exactly one URL and exactly two.
Dead code: _is_non_default_enum has had no caller since c797227 (#421)
moved the constraint role to _states_availability. Its 16 mutants were "no
test" because nothing could call it. Deleted.
The 61 that remain are equivalent¶
| class | count | why it cannot change behaviour |
|---|---|---|
a getattr default dropped or changed |
38 | every row names an attribute its applies_to type always has |
zip(..., strict=True) weakened |
9 | both lists are built from the same list, one element each |
_collect_refs(artefact, prefix) |
6 | the path it builds is discarded (_path) |
dropped's early return (!= to ==, a renamed "2.1") |
2 | it only saves the walk: nothing is left out on that path anyway |
_left_out: _NO_SUCH_CLASS.get(target, {}) without the default |
2 | every target that reaches it is a key; "storage" returns early |
_follow: changed = False to None |
1 | both are falsy |
_reach: many and isinstance to or |
1 | no non-[] step reaches an iterable (checked over every row on the maximal message) |
_annotation_at: rpartition(".") to rpartition("XX.XX") |
1 | every _-suffixed site is top-level (dimension_descriptor_, …), so neither finds a dot |
_cause: in out to not in out |
1 | owner_classes returns at most one class today, which its docstring states |
Three of these hold only because of a fact about the table, not the code:
- a row naming a misspelled attribute would make the
getattrdefaults matter; - a nested
..._-suffixed annotation site would make_annotation_at'srpartitionmatter; - a second owner class would make
_causematter.
The first is the one worth a guard. A typo'd carried attribute never fires,
and nothing says so. That is the subject of the row-firing gap below.
What the register still cannot prove¶
On the maximal message 262 of 289 rows fire; 27 never do. Each row is checked in one direction only:
test_field_coveragefails when a loss has no declaring row;- nothing fails when a row's predicate can never be true.
The 27 are candidates for a missing witness, or for a row that is wrong.
Installment 4 (#458): the SDMX-ML 2.1 reader, and a census that never compared text¶
sdmx_ml21/reader.py, unit suite minus 6 DuckLake files |
mutants | killed | survived | no test |
|---|---|---|---|---|
| baseline | 3,786 | 3,586 | 150 | 50 |
| after this installment | 3,736 | — | 44, all equivalent | 0 |
96% of covered mutants were killed before any work. The 150 survivors were
re-run in fresh processes against the new and tightened tests: 106 killed, 0
resurrected. The 50 no-test mutants were _read_scheme_map, which has had no
caller since 4e9651a (#93). It is deleted.
Two guards that could not fail¶
test_field_coverage had never compared a text. Twelve survivors read
an artefact's <com:Description> as its <com:Name>. maximal gives every
artefact a description different from its name, and the census compares every
field, so all twelve should have died there.
The cause is tests/support/field_census.py. It compared mappings through an
isinstance(…, dict) branch, commented "an InternationalString is a
mapping". But InternationalString has never been a dict, not even when
the census was written (c1c4d67, #354). So a text fell through to the
sequence branch, which iterates its keys. Every name, description and
annotation text in the model compared equal to any other in the same
languages, for every format, since the census existed.
This is the CLAUDE.md case of a guard that never could fail, not one that
stopped. Nothing it asserted was wrong, and its subject was never what the
comment said. The census now compares an InternationalString by its
entries. test_the_census_can_tell_every_kind_of_value_apart is the
discrimination check it lacked, and it fails against the old branch.
Re-running the suite with the fix found no text defect: the readers were
right, only unguarded.
xs:boolean had four reading rules. leveled's "false" default
survived. Reading why found xs:boolean read inline at a dozen sites:
leveled,isPresentational,isSequence,isFinal,hasFormalLevels,isRegExand a dimension'soptionalcompared against"true", so"1"read false;isInclusiveand a region'sincludecompared against"false", so"0"read true;isExtension,isIncludedand the_flagsites got both right.
The schema type's lexical space is true, false, 1 and 0. Every read
now goes through _common.flag / optional_flag / required_flag.
test_xs_boolean.py scans the readers so a fifth rule fails.
Moving the reads into flag would have taken them out of
test_reader_defaults_census (#474), which counts el.get(attr, default).
So the census now counts flag calls too. Nine reads that had gone through
_flag became visible to it for the first time:
- two were wrong.
isRESTDatasourceandisWebServiceDatasourceareuse="required"in every XSD and were read as false when absent, the pattern #474 removed elsewhere. They arerequired_flagnow, and a source without one is refused; - two carry no schema default (
removePrefix,isRegEx). The model holds abool, and every writer omits a false one, so they are classified with that reason.
What the other survivors were¶
Each now has a test, in test_ml21_reader_mutant_gaps.py unless named:
- Every maintainable's
@version, read and defaulted.maximalversions everything1.0, which is also 2.1's default, so reading the attribute and ignoring it agreed. That covers nine artefact kinds, and the three organisation schemes, whose fixed version is refused only because it is read. - A stub without its components block. It is valid 2.1 (
minOccurs="0"), and never tested. A reader initialisingtime_dimensionto""passed everything. - A level's malformed coding format names the attribute it came from. There were 27 mutants across seven attributes, and the message had never been read.
- A hierarchical code's version:
@versiontolerated,1.0by default (#476). Also a node with no code reference refused, not crashed. And the 3.0-style text<Level>the 2.1 reader accepts. - Within-message resolution of an MSD attribute's codelist and a DSD
component's. A second concept role. A comment in a metadata target.
A refused attachment constraint, a category scheme map's description, a
value map with no value type, an absent
maxOccurs. - The two-report refusal (#439), whose test matched a fragment, now
asserts the whole message (
test_nested_paths.py).
The 44 that remain are equivalent¶
| class | count | why |
|---|---|---|
| different only on XSD-invalid input | 23 | a fixed id (TIME_PERIOD, OBS_VALUE, REPORTING_YEAR_START_DAY); a required element (ConceptIdentity, AttributeRelationship, a measure dimension's LocalRepresentation, a map's Source/Target); a full code reference's agencyID and maintainableParentID, which come together or not at all |
on lines the xs:boolean fix rewrote |
7 | the mutated code no longer exists |
| the concept index's exact-URN entry | 5 | a fast path: lookup falls back to resolve_item, which finds the same concept |
a component list initialised to None |
4 | ItemList(None) is empty, and groups is None either way |
SdmxUrn.make's class argument |
2 | a phantom witness, unused at run time |
read_int's default under maxOccurs |
2 | reached only when the attribute is present |
a text <Level> that is empty |
1 | "" and "XXXX" both name no level |
What the census still does not reach¶
maximal holds no attachment constraint, availability constraint or category
scheme map. shapes covers the first and third. Availability constraints have
their own round-trip tests but no census. The fixture-population check is per
class present, so a class absent from the fixture is not demanded at all.
Installment 5 (#458): the SDMX-ML 2.1 writer, and what only the document holds¶
sdmx_ml21/writer.py, 16 direct test files, 4 children |
mutants | killed | survived | no test |
|---|---|---|---|---|
| baseline | 2,578 | 2,376 | 184 | 18 |
| after this installment | — | — | 14 equivalent, plus 3 rewritten away | 0 |
93% of covered mutants were killed before any work.
The whole unit suite was the wrong scope for a writer¶
The first run used the reader's selection, the unit suite minus six DuckLake files. Nearly every round-trip test writes 2.1, so each mutant carried hundreds of tests and a child ran for two to three minutes. After four hours it had tried 510 mutants and 248 had timed out. mutmut's limit comes from the clean run's timings, and eight children sharing four performance cores ran slower than that. A timeout is not a verdict, so half the run had measured nothing. It was stopped.
The run above used 16 files that test the writer directly (916 tests, 22 s). It took 20 minutes, with no timeouts.
The selection was too narrow in one place. shapes.message_21, the fixture
holding what only 2.1 has, is used by tests none of the 16 included. Fifteen
more files re-ran the 202 survivors and no-test mutants in fresh processes:
test_attribute_census, test_element_census, test_dsd_21_constructs,
test_constraint_attachment and eleven others. They killed 124, including
all 18 no-test mutants (_write_category_scheme_map). Choose a writer's
selection by its fixtures, not its filenames.
A round trip sees what the reader keeps¶
61 more survivors died to tests in test_ml21_writer_mutant_gaps.py and
test_nested_paths.py. Nearly all were one gap: the writer writes things the
2.1 reader never reads back, so no round trip could see them going wrong.
- A component's
position. The reader ignores it, so writing the measure dimension at0and time at4passed. - A concept role's scheme. The reader keeps the role's id, so
agencyID="sdmx"passed. - The synthetic wrappers' own fields. A map no set claims, or a hierarchy no HCL claims, is written in a marked wrapper that the reader drops (#421). The wrapper's version is the map's: a 2.0 map came back 1.0 under a wrapper left at the default. Its name, its marker, and the hierarchy wrapper's copy of the hierarchy's annotations and description were never asserted either.
- The synthetic MSD report layer's marker,
objectTypeandIdentifiableReferencetext type. - The envelope: the header, the
UTF-8declaration (lxml omits it unless asked), and that output is one line unless pretty-printed. - Section order when a neighbour is missing.
StructureSetssits betweenDataStructuresandConstraints. Without a DSD, or with a DSD and no constraint, two fallback branches place it. Neither had run.
Three gaps were not document-only:
- A
GeographicCodelistextending a plain codelist. 2.1 converts it to a plain codelist before inheriting, because a geographic one holds only feature-set codes.apply_extensionson the geographic one raisesTypeError.maximal's extension inherits nothing, so the order was untested. - The HCL map a write declares.
hierarchy_containerswas tested by calling it directly. Nothing checked thatwrite_keptsets it, so a node reference naming the wrong container passed. - An MSD attribute bounded at exactly 2.
1is the default and is left out, so writing!= 2read back as1.
test_write_pretty_print could not fail. It asserted b"\n" in the
pretty output, but the XML declaration ends a line of its own whether or not
the document is pretty-printed. It now compares line counts. Like installment
4's census, it was never a guard, rather than a guard that stopped being one.
The 17 that remain¶
| class | count | why |
|---|---|---|
| rewritten away | 3 | getattr(m, "valid_from", None) on a type union whose every member has the field; an isinstance(hc.code, Ref) the Ref \| None annotation already guarantees. Both now plain attribute access. |
| validation refuses the input first | 2 | an MSD with targets but no report structures, and a component map with a text format but no value type. validate refuses one without the other before a writer runs. |
equal-length zip |
3 | strict on a zip of a list and its own comprehension |
case _: return None deleted |
2 | the function falls off its end and returns None |
_plain's guard as and |
2 | the cases it now converts (a plain codelist with extensions, a geographic one without) are written the same either way |
inner_id against a container id nothing has |
2 | the hierarchy's container as None, a synthetic set treated as declared: these differ only for a member id ending @None or @SS_SYNTHETIC, which no reader produces |
| a built value map's filter | 1 | claimed already holds every built value map, and no set names one (#422) |
kind= the default |
1 | TargetObject.kind defaults to IdentifiableObjectTarget |
_s(None) |
1 | the f-string makes the same tag, {ns}None |
Installment 6 (#458): the SDMX-ML 3.x reader, and 2.1's shapes it still read¶
sdmx_ml/_v3_reader.py, 97 files that parse 3.x, 4 children |
mutants | killed | survived | timeout | no test |
|---|---|---|---|---|---|
| baseline | 3,905 | 3,520 | 236 | 5 | 144 |
| after this installment | — | — | 50 equivalent, plus 94 rewritten away | 0 | 0 |
94% of covered mutants were killed before any work.
- The 144 no-test mutants were three methods with no caller since the
3.0 and 3.1 readers merged (9850e32, #346):
_read_scheme_map,_read_id_pair_mapsand_read_source_target_refs. They are deleted. - The 5 timeouts remove a required argument from
__init__. The reader is built at import, so the suite cannot even be collected. A fresh process exits 4, which is a kill. mutmut 3.7's fork mode booked them as timeouts.
The reader read 2.1 inside 3.x¶
70 survivors were in branches that read 2.1's forms inside a 3.x document:
- a
<Ref id=…/>child where 3.x has text: an attribute's dimensions and group, a group'sDimensionReference, a concept role, a node'sLevel; - a draft's
@dimensionRefand<ObservationValue/>; - 2.1's unified
OrganisationSchemescontainer.
No 3.x fixture or recorded response uses any of them; every match is a 2.1 cassette. The closed-world check (#435) refuses each form before the reader runs, so the branches were dead, except one.
A refused container was still read. The closed-world check refuses
<str:OrganisationSchemes> and records it. The organisation-scheme reader
then looked the container up by name and read its schemes anyway. Under
strict=False they came back both in refused and as artefacts.
The branches are deleted. test_v3_reader_mutant_gaps.py pins each refusal,
so the behaviour no longer rests on the closed-world check's coverage alone.
So is the reader's get("version", "1.0") on the three organisation schemes.
3.x prohibits @version there, so the read could only ever see the default.
test_reader_defaults_census counts 9 assumed versions, not 12.
A dangling level, in both readers¶
A hierarchical code naming a level its hierarchy does not define was read
with no level, in 3.x and in 2.1. The reference vanished without a word.
Both readers now go through _common.node_level, which refuses the
hierarchy and names the node and the level.
The rest¶
Each now has a test:
- Versions.
maximalversions everything1.0, which is also the reader's assumption for an unversioned 3.x artefact (#474), so reading@versionand ignoring it agreed. That covered nine artefact kinds. - Messages. A dimension out of position names both dimensions; a
malformed
validFrom/validToon an item map or a representation mapping names the attribute; and so does a malformedstartIndex/endIndex. - What a lenient read records. A refused category scheme map is in
refused, and a registry passed toreadreceives every artefact. - Absent blocks.
- A usage without a relationship, which FMR emits (#421), is per data set with no dimensions.
- A time dimension without a representation has none.
- A DSD or MSD stub without components is empty, not crashed.
- Within-message resolution of a component's codelist.
The 50 that remain are equivalent¶
| class | count | why |
|---|---|---|
| different only on XSD-invalid input | 26 | an empty or missing required element or attribute: a map's Source/Target, FrequencyId, DatePattern, a usage's reference, a dataflow Target's URN, a component's ConceptIdentity, a dimension's @id |
a component list initialised to None |
6 | ItemList(None) is empty |
| the concept index's exact-URN entry | 5 | a fast path, as in installment 4 |
__init__ |
4 | the com prefix no lookup uses; mes, which ReaderHelpers stores and never reads; has_is_final=None, as falsy as False |
the time dimension's @id |
3 | fixed to TIME_PERIOD by the schema |
a date map's zip |
3 | the schema pairs Source and Target, so the lengths agree |
read_int's default under maxOccurs |
2 | reached only when the attribute is present |
| an empty concept role | 1 | "" and "XXXX" are neither a URN |
Installment 7 (#458): the SDMX-ML 3.x writer¶
sdmx_ml/_v3_writer.py, 96 files that parse 3.x, 4 children, mutmut 3.8 |
mutants | killed | survived |
|---|---|---|---|
| baseline | 3,611 | 3,553 | 58 |
| after this installment | — | — | 7 equivalent, plus 1 rewritten away |
98% of mutants were killed before any work. It is the first installment measured on mutmut 3.8, after the warm-up fix below. The run before that fix booked 282 survivors.
One test file is outside the selection.
test_ml3_writer_parity.py::test_every_annotation_site_is_reached counts
write_annotations call sites in the writer's own source. The mutated copy
holds every function a few hundred times over, so it counted 3,768 sites
against 46 markers and failed the stats pass. A test that reads the source of
the module under mutation cannot run against its mutants. The file's other
seven tests went with it.
14 survivors fell to two files the selection had missed, test_links.py and
test_builders_metadata_annotation.py, which hold the metadata back-link
tests. Re-run in fresh processes, they killed those 14.
36 more fell to test_v3_writer_mutant_gaps.py. As with the 2.1 writer,
nearly all of them were what only the document holds:
- The envelope: the namespace prefixes, the header, the
UTF-8declaration, and output on one line unless pretty-printed. 3.0 and 3.1 each. - The metadata back-links (#191). These are
<com:Link rel="metadata">elements: theirrel/url/urn, a base URL's trailing slash, and their place after the annotations and any links held, in order. Also the refusal without a base URL, and that anAvailabilityConstrainthas no identity to link. - An MSD attribute bounded at exactly 2, in 3.0 and 3.1.
- 3.1's availability. An Actual constraint is written as one only when it fits: two regions keep it a data constraint.
| class | count | why |
|---|---|---|
getattr(obj, "valid_from", None)'s default |
4 | every class passed declares the field; the getattr is for the static type, as its comment says |
the class's pretty_print default |
2 | write and write_kept are called only by the 3.0 and 3.1 modules, which always pass it |
link_base = "" |
1 | read only behind if metadata_links and …, which a falsy metadata_links already fails |
| rewritten away | 1 | isinstance(hc.code, Ref), which Ref \| None already guarantees, as in the 2.1 writer |
Installment 8 (#458): sdmx_ml/_common.py, what both SDMX-ML generations share¶
sdmx_ml/_common.py, 106 files that parse or write SDMX-ML, 4 children, mutmut 3.8 |
mutants | killed | survived |
|---|---|---|---|
| baseline | 4,905 | 4,641 (94.6%) | 264 |
| after this installment | — | — | 64 equivalent, plus 50 rewritten away |
No timeouts: the first run after the warm-up fix below to book none.
The selection missed files again. Re-run in fresh processes against the
105 unit files that mention XML, parsing or serialising and were not
selected, 27 more died. Twenty of them were in ref_el_to_urn's @package /
@class fallback (#240), whose tests are in test_ref_optional_package.py.
A file chosen by fixture misses a test that writes its own XML. For a shared
module, start from everything that parses.
123 fell to new tests, in test_ml_common_mutant_gaps.py unless named:
- Refusal messages nobody had read. These cover a wrong-class reference
naming its element, on both paths to the check. They also cover a component
with neither
@idnorConceptIdentity, and a constraintrole/typeoutside the enumeration, which names the attribute and the constraint. Then every malformed-value message: a cascade on a<Value>or<MemberValue>,ReferencePeriod's times,@validFrom, a TextFormat bound as integer and as number,@minOccurs,@seriesCount. Also 3.1's availability constraint without a<CubeRegion>, refused by its position, and 2.1's<DataSet>without a<com:DataProvider>. - What an absent attribute means. A
CubeRegionwithout@includeincludes. A time bound without@isInclusiveis inclusive. A 3.xValueListwithout@versionis1.0. A codelist extension without a selection has none. A dataflow without<Structure>has none, in all three versions. EmptyReleaseCalendarchildren, an emptySimpleDataSourceand an empty data-key<Value>read as"".maximalstates every one of these, so a reader using the wrong default passed. - Resolution. An artefact's maintainer is the agency its scheme describes,
not a bare
Agency(id). A 3.x component's codelist in the message is resolved. A 2.1 core representation without@package/@classis still a codelist. - An availability constraint's annotations. No census reads an availability constraint (installment 4 said so), and the reader could drop them unseen. It now round-trips whole.
- 2.1 metadata constraints attached to a
MetadatafloworMetadataSet, or holding aMetadataTargetRegion. Each is refused, as one attached to an MSD already was (test_element_census.py). - The writers.
- A
QueryableDataSource'sfalseflags. - A final organisation scheme writes no
isFinal="true"in 2.1. - An unwritable version names its artefact.
- A facet bound of
1.5is not rounded, and1e16is written without an exponent. - An
EnumerationFormatdropsdecimalsandisMultiLingual. - A coding format without a type writes no
textType. - A concept with a text format writes no
EnumerationFormat. - A value in several languages is ordered with the untagged one first.
- A 2.1 node reference at
2.0statesmaintainableParentVersion(test_nested_paths.py). At1.0, omitting it reads back the same.
Rewritten away (50).
- The "latest" index keys (16). The readers indexed every artefact and
item a second time, under a version-less URN. Only a 2.1
<URN>written without a version could reach that key, and it then bound to whichever version the message held. #476 says an omitted version never means latest, so such a reference now stays unresolved, and a test says so. names_value_list's<Ref>branch and its 2.1 shortcut (17). 3.x types<Enumeration>as URN text, and the closed-world check refuses a<Ref>child first. A 2.1<Enumeration>has no text, so the text test alone answersFalsethere.- The reader's unused
com,str_andmesattributes, and itsmes:prefix (5). ref_el_to_urn's@urn(3). No version's<Ref>has the attribute, and the closed-world check refuses one that does.write_istring's third branch (9). Every caller passes"Name"or"Description", now aLiteral._write_id_pair_maps' unusedcontainer_tag(2).
| class | count | why |
|---|---|---|
| different only on XSD-invalid input | 40 | a required element (GeoCell, a data set's com:ID, DataURL, a categorisation's Source/Target, an agreement's two references, 3.x's DataStructureComponents, an ISOConceptReference's three children, an extension's Codelist URN); a required @id on a constraint, and an element's namespace, which every element read has; an empty URN, or an empty or whitespace-only period, parent or <Dimension>; a non-boolean xs:boolean; "F" against "f", which differ only for NaN and infinity |
getattr defaults never reached |
8 | an ObjectRefType must state @class and @package; None or "" is ""; every class written declares valid_from / valid_to; is_partial's None and False are both falsy |
SdmxUrn.make's class argument |
4 | a phantom witness, unused at run time |
a "latest" reference written |
4 | serialize refuses a version-less reference before a writer runs |
resolve_ref's isinstance |
2 | Ref.of(urn, None) is unresolved; and the URN's class is checked before the lookup, so the index holds the right type |
| equal by construction | 6 | InternationalString() is _EMPTY_ISTRING; neither "1.0" nor a mangled default is a final version; partial_language's None is falsy; pop of a key the dict always holds; an untagged value sorting as "XXXX" still sorts before a lowercase language tag; a time range with no bounds, which validate() refuses |
Installment 9 (#458): sdmx_json/_structure_ingest.py, and the second spelling of every text¶
sdmx_json/_structure_ingest.py, 103 files that mention JSON, 4 children, mutmut 3.8 |
mutants | killed | survived |
|---|---|---|---|
| baseline | 2,681 | 2,535 (94.6%) | 142, plus 4 booked as timeouts |
| after this installment | — | — | 17 equivalent, plus 16 rewritten away |
The four "timeouts" were not timeouts. The run was paused with kill -STOP partway through, to keep just ci from overlapping it. Four children were stopped mid-test and booked as timeouts. In a fresh process all four survive. They delete an organisation scheme map's uri, links, valid_from and is_partial_language. A paused run's timeouts must be re-run before they are believed.
The selection missed nothing this time. Re-run in fresh processes against the 111 unit files that parse or serialise and were not selected, all 146 survived.
113 fell to test_json_ingest_mutant_gaps.py:
- The singular spelling of every text. SDMX-JSON carries each text twice: a singular
namein one language and anamesmap.ingest_istrfalls back to the singular form when a producer omits the map, which the schema allows. The writer always emits both, so a reader that ignored the singular form read every fixture correctly. About 40 mutants across every artefact kind fell to one test, which strips every map that has its singular beside it. - A census over the whole message. The per-case round-trip guard names its cases, and an organisation scheme map was not one of them. The new test compares every artefact of
maximalread back from JSON, field by field, filtered throughsl.unrepresentable. It allows exactly one other difference: a component written without a representation reads back with its concept's, which isRepresentationMixin's rule, not a loss. - What an absent field means:
- a member value without
cascadeValues; - a cube region without
include; - a time dimension without an id;
- a component that takes its id from its concept (#477);
- a DSD without components;
- a geo-grid stub without
gridDefinition, which the schema allows "unless the artefact is returned as a stub". - Every maintainable reads its own version (
maximalversions everything1.0), and an organisation scheme at another version is refused only because the version is read. - The
primaryMeasuretolerance. It is not in the 2.0 structure schema, but the schema's ownconstructed-sample-full.jsonwrites it. - A queryable data source's
wsdlURL. SDMX-JSON 2.0 puts queryable and simple data sources on a data constraint, as SDMX-ML does, so the reader reads them. 2.1 dropped both, and since"json"output must validate against 2.0 and 2.1 alike, the writer refuses them. The test builds one by hand for that reason. - Refusal messages for
maxOccurs, the time dimension, components by kind, positions, cascades and validity dates.
Rewritten away (16)¶
- A misplaced dimension was named
''. Positions were checked on the stated id, before a dimension without one took its concept's. They are now checked after the dimensions are built. - Three kinds of schema-invalid input were accepted rather than refused. Each is now an
SdmxConformanceErrorrefusing the artefact (#435), as the 3.x reader's 2.1 shapes were (installment 6): - a concept-role URN that would not parse was skipped, and the role lost;
- a hierarchy node's code that was not a code's URN only warned, or without
urn:said nothing, and the node read as placing nothing; - a metadata attribute's
maxOccursaccepted a string numeral such as"3", while a representation'smaxOccursrefused the same input.
The 17 that remain are equivalent¶
| class | count | why |
|---|---|---|
| normalised before construction | 5 | ItemList(None) is empty; groups passes as None when empty; ingest_annotations of a falsy value is empty |
| required by the schema | 5 | a sentinel's value (both anyOf branches require it); a time dimension's representation, which is a format and never an enumeration, so the index and the isinstance guard change nothing; TimeDimension.id defaults to TIME_PERIOD |
a URN has one = |
3 | split("=", 1), split("="), split("=", 2) and rsplit give the same prefix |
zip's strict |
2 | strict=None and an omitted strict both mean False |
resolve_any_urn(None) |
1 | it returns None, as the guard did |
levels_by_id.get(None) |
1 | None, as the guard did |
Installment 10 (#458): sdmx_json/writer.py¶
sdmx_json/writer.py, 217 files that parse or serialise, 4 children, mutmut 3.8 |
mutants | killed | survived |
|---|---|---|---|
| baseline | 163 | 141 (86.5%) | 22 |
| after this installment | — | — | 5 equivalent |
The module is small because it mostly delegates to the DTO layer, which the reader-side runs and the census already exercise. The selection started from every unit file that parses or serialises, as installments 7–9 taught.
All 17 that fell to test_json_writer_mutant_gaps.py were, as with both SDMX-ML writers, what only the document holds:
- The envelope:
meta.idandsender;preparedin UTC, checked against a fixed clock so a non-UTC run is not the only one that would notice. It had no time zone, which both SDMX-JSON schemas require ("ISO 8601 … including time zone"), and the metadata writer had the same defect. The structure test pinned the zoneless value at first. pysdmx with itsjsonextra, validating, warns on it, and that warning, failing the gate, is what showed it. Both writers now appendZ;- compact output unless pretty-printed, with a two-space indent.
- The metadata back-links (#191):
hrefandurn;- one trailing slash trimmed and a leading one kept, including a base ending in
X, whichrstrip("XX/XX")would also strip (the 3.x writer's test had the same case); - the refusal without a base URL.
- An unwritable version names its artefact.
| class | count | why |
|---|---|---|
write_kept's pretty_print default |
1 | serialize and write always pass it |
link_base = "" |
1 | read only behind metadata_links and … |
the back-link condition as or |
1 | link_base is set whenever metadata_links is truthy, and the None branch fails either way |
| a misfit wildcard expanded anyway | 1 | validation refuses the input first: validate refuses a codelist whose wildcards cannot be expanded before a writer runs |
msgspec.json.format without indent |
1 | its default is 2 |
SDMX-JSON data sources are refused rightly. The 2.0 structure schema puts queryable and simple data sources on a data constraint, and the reader reads them. The 2.1 schema dropped both. sdmxlib's "json" output must validate against 2.0 and 2.1 alike, so check_serialisable refuses them and gaps.py declares the loss.
The runner moved to mutmut 3.8¶
Installments 1–6 ran on mutmut 3.7, with tools/mutmut_forksafe.py patching
its runner so the stats, clean and forced-fail phases ran in a forked child
(#314). mutmut 3.8 does that itself with process_isolation = "forkserver",
and renamed the internals the patch reached into. The tool now requires that
mode and keeps two things of its own:
- the import-time credit (#458). 3.8 still credits a collection-time hit to the first test;
- preloading the native extensions. Without it the stats child exits -6
(
SIGABRT) loading pyarrow.
The first port measured nothing at import, and nobody noticed. It kept
3.8's default forkserver_warmup = "collect". The fork server collects the
suite once, before any mutant is chosen, and every child inherits the
imported modules. So code that runs at import was never mutated in a child:
the 3.x readers' and writers' module-level instances, gaps.py's table of
carried predicates.
Its check measured the credit, and not whether the credited mutants were
ever active. _any_node_has was credited to 34 tests, and its mutants still
survived, because none of them ran. The first _v3_writer.py run found it:
282 survivors, including __init__ setting self._helpers = None, which a
fresh process kills outright.
The tool now requires forkserver_warmup = "none". A second fault surfaced
with it: a mutant that breaks an import-time table fails collection, and
pytest exits 4. The fork server turns that exception into exit 255, which
3.8 books as a timeout; 3.7 booked it as a kill. CreditingRunner books it
as a kill again.
gaps.py against test_gaps_mutant_gaps and test_field_coverage, 622
mutants, 4 children:
| runner | killed | survived | timeout | wall |
|---|---|---|---|---|
warm-up collect (the first port) |
317 | 302 | 2 | 79 s |
warm-up none |
490 | 118 | 13 (11 exit 255) | 290 s |
warm-up none, exit 4 a kill |
501 | 118 | 2 | 296 s |
Twelve sampled survivors survive a fresh process too. The two timeouts are
the real infinite loops in _follow: changed = True, and an inverted
membership test.
Installments 1–6 were measured under 3.7 and were not re-run under 3.8.
Installment 11 (#324): _Scope._agg_body_from, and a version aggregate that refused¶
A new baseline, and two ways the runner misled¶
_compile.py has grown since installment 2: 1,718 mutants. #324's resume
command mutates against the whole unit suite, and that no longer runs. The
stats child, about 11,300 traced tests in one process, is SIGKILLed for memory
with no message (ChildProcessError: Child exited with code -9). The run then
reports killed 0 survived 0. Mutated instead against the 116 test files that
exercise the query language, as mutate_area.py recommends:
| mutants | killed | survived | segfault | timeout | |
|---|---|---|---|---|---|
| baseline, 2026-10-19 | 1,718 | 1,510 | 155 | 52 | 1 |
The segfault column is not a kill count (#504). Re-run in a fresh process
(MUTANT_UNDER_TEST=… PYTHONPATH=mutants/src pytest -n 6 <the same files>),
a "segfault" is as likely to be a survivor or an equivalent as a kill. The
child crashing is the fork-safety class (#314, #498), not the mutant. Every
segfault verdict before this one is suspect for the same reason.
Re-checked one by one, against the tests the stats map says cover each function, the 52 were 18 killed and 34 survived. So this baseline's true survivor count is 155 + 34 = 189. The crashes are not deterministic either: the verification run below, same command, crashed on 4.
The 21 in _agg_body_from¶
| mutants | verdict |
|---|---|
36, 38, 40, 41: the "in an aggregate" context of the sort key |
a defect, below |
| 122, 123: the order of a list of several columns | gap: the first column's .sort() orders the struct list, and nothing asserted it did |
56, 61, 62: mean not mapped to avg |
gap: DuckDB accepts mean(), so the rows agree, but avg is the SQL a second backend reads (§4). Asserted on the emitted SQL, as installment 2 did |
66: a plain min/max's binds dropped |
gap: a localised label binds its language through the aggregate |
45: the arg_ form's binds dropped |
dead by declaration: only version sorts on another key, and its key binds nothing. test_version_is_the_only_column_that_sorts_on_another_key fails the day one does |
124: _order_clause's collected value |
equivalent: it matters only to unique(), and a unique() of several columns ordered by anything is refused whatever it is |
59, 78, 116: AVG, distinct |
equivalent: SQL keywords are case-insensitive |
107, 110, 111: zip(strict=…) |
equivalent: refs is built from agg.columns, so the lengths never differ |
11, 133, 134: the closed set's case _ |
unreachable: agg.kind is a closed set |
The defect¶
version is the one column that sorts on something other than itself, so its
min/max are arg_min/arg_max over version_sort (#351). Both callers
handed _agg_body_from references resolved in their own scope, but the key was
resolved from the query root. In the grouped form that is the same scope. In
the correlated one it is not: q.dsd.dataflows.version.max() read
dataflows.spine as crossing the fan-out the aggregate collects over, and
refused it ("cannot use 'dataflows.spine' in an aggregate"). Meanwhile
.list(), .n_unique(), .sort().list() and max() of any other column
answered. Each caller now resolves the key as it resolves the values, and the
message whose four mutants were the trail is gone with it.
test_query_compile_aggregates.py fails on the old code (it raised) and passes
with dataflows at 1.9 and 1.10, where text order would pick 1.9.
Verified¶
The same command after the change (1,732 mutants: the fix added lines), diffing survivors per function against the baseline's 189:
| killed | survived | segfault | timeout | |
|---|---|---|---|---|
| after | 1,546 | 180 | 4 | 2 |
_agg_body_from: 21 → 10. The ten are exactly the equivalents, the unreachablecase _and thearg_binds classified above._grouped_agg_body: 3 → 5. The two new survivors are mutants of the new code: the grouped caller's sort key passescontext, and the line above it references the same column with the same context, which raises first. The three before are unchanged and wait for their own installment.- No other function moved.
- The 4 segfaults were re-run in a fresh process: all 4 killed.
- The 2 timeouts (
compile_page,_after_sql) are genuine infinite loops. A mutant that ignores the keyset cursor pages forever, so a paging test catches each by never ending. That is detection, but a slow one: a page-count bound in that test would fail fast, which is that installment's to add.
Installment 12 (#324): _window, and two dead entries¶
Of the 15 survivors:
| mutants | verdict |
|---|---|
14, 15, 27, 28, 29, 30: n_unique in the DISTINCT set and the name map |
dead code, deleted. A windowed n_unique returns early as two windowed aggregates, so null counts as a value (#326), before arguments or name is read |
| 70: the binds of that early return | gap: a windowed n_unique over a label binds its language |
35, 36, 37, 38: mean not mapped to avg |
gap, as in installment 11: asserted on the emitted SQL |
34, 54: LIST, desc |
equivalent: case |
| 47, 49: the sort fallback | dead, as installment 1 found: order_ref is None only for a window with no source column, and those are framed aggregates, which never reach the fallback. Removing it is a signature change, not a triage |
test_query_compile_windows.py holds the gap tests. Its third test checks the
deletion itself: the French names differ, so their windowed distinct count is
2, not 1.
Verified¶
Against installment 11's baseline (189 true survivors), the same command after
the change (1,712 mutants: the deletion removed some): _window went 15 → 4,
the four equivalents and dead mutants above. This run booked 40 crashes and timeouts. Re-run in a
fresh process, 26 of the 39 crashes were kills and 13 survivors, the same 13
as the baseline's. The one timeout is installment 11's paging hang. With those
counted, the true survivors went 189 → 178, and no function but _window
moved.
What is not measured yet¶
Stated rather than left as an absence, per #218's ruling that the triage is the artifact and the percentage is not:
- Full-scope wall-clock. 2,300 statements against the whole unit suite has not been timed since the scope changed. The old 923-statement figure (~4.5 h) is void — it was dominated by the mis-booked timeouts of #303/#314, both now fixed. Do not quote it.
- Per-module baselines for
_expr.py,_joins.pyand_common.py._compile.pyhas two: 588/1,290 against the three projection test files, and 755/1,290 against the eight predicate files. - The rest of
_compile.py's triage (#324). 189 true survivors at installment 11's baseline (the segfault verdicts re-checked, #504). Installment 11 left 180 and installment 12 178, each verified against that baseline alone; the two together are not yet measured. The next clusters arecompile_page(14),compile_projection(13),_after_sql(12) and_Scope._agg_subquery(11). The yield so far is a real defect or dead code per cluster, and a few missing tests. - Every baseline above installment 3 predates the import-time credit.
_compile.pybuilds little at import, so its numbers likely stand, but none was re-run to check. - Modules beyond #458's scope. Every reader and writer #458 named is
done (installments 3–10). The SDMX-JSON DTO layer (
_structure_dto.py,_dto.py), the metadata readers and writers, and the data readers and writers have not been run. For any of them, select by fixture and then add every unit file that parses or serialises: installments 7 and 8 each found kills outside the fixture selection. - Six test files are outside every #458 run.
test_registry_data_plane,test_registry_lifecycle,test_bundle_relative_data_path,test_bench_bundle_resolution,test_data_store_ducklake_versionandtest_unattached_catalog.- Each loads the DuckLake extension, and a mutmut child that does so is killed by SIGKILL.
- None imports
gaps.pyor a structure reader or writer. One uses the data writers. A store write can still reachgaps.pythroughLocalRegistry, so the loss is small, not zero. It would be large forstorage/.