Skip to content

S4: Guard audit — mutation-test the new guards against both tests and code #89

Closed

Description

@Polichinel

Epic: #85 · Order: Story 4 of 8 · Depends on: S2, S3

Background

S1–S3 add guards. This story asks the question those guards cannot ask about themselves: would
they actually catch anything?

The motivation is not hypothetical. This repo already had 248 passing tests, a golden byte-identity
suite, a pandas-free subprocess tripwire and three strict-xfail ratchets while every production
model was dead. A test count is not evidence. The specific failure was a guard whose fixture had
drifted from the contract it claimed to guard — and nothing in the suite could detect that.

Work

Run /falsify in guard mode against the guards added in S1–S3, plus the gate. Guard mode
mutates production code and asks which mutations leave the guards green.

  1. Mutate views_baseline/model/catalog.py and confirm each is caught:
    • self.targets = list(config["regression_targets"]) → config.get("regression_targets", [])
      (does anything fail on an empty target list, or do we emit a zero-target frame?)
    • → config["classification_targets"]
    • drop the list(...) (aliasing the config's list into the model — mutation leak)
    • swap two _get_* builders' targets= for a hardcoded []
  2. Mutate views_baseline/infrastructure/reproducibility_gate.py:
    • remove regression_targets from CORE_GENOME — S2's tests must fail
    • remove level — likewise
    • reorder the four checks in audit_manifest so the None check precedes the presence check
    • make audit_manifest return early instead of raising
  3. Mutate the S3 conformance fixture — change regression_targets to targets in the checked-in
    config. The conformance test must fail; if it passes, the fixture is not being read the way the
    test claims.
  4. For every mutation that survives, do not patch it inline. Record it via /register-risk
    with the mutation as the trigger, and note it here. A surviving mutant is a finding about the
    guard, and inventing a test to kill one specific mutant usually produces a worse suite.
  5. Report the kill rate per file in this issue.

Acceptance criteria

  • Every mutation listed above has a recorded verdict (killed / survived) with the failing test named
  • Reverting S1 alone fails at least S3's conformance test
  • Reverting S2 alone fails at least two gate tests
  • Any surviving mutant is registered, not silently fixed
  • Kill rates for catalog.py and reproducibility_gate.py reported in a comment
  • No production code changed by this story unless a mutation exposed a genuine defect (say so if it did)

Validation

# per mutation, by hand or via /falsify guard mode:
conda run -n views_pipeline python -m pytest -q   # must go RED for a killed mutant
git checkout views_baseline/                       # restore between mutations

Notes — why this story exists

  • Nygard, Release It Ch5: integration testing verifies only that the system works when its
    dependencies work correctly; a mock constrained to the assumed interface cannot reveal that the
    real interface moved.
  • Martin, Clean Architecture Ch28: the Fragile Tests Problem — tests coupled to structure
    break on trivial change, so developers stop making changes. The inverse failure is this one:
    tests coupled to a fixture that no longer matches reality never break at all.
  • Sculley et al. 2015, Hidden Technical Debt in ML Systems §4.4: configuration debt can exceed
    code debt; each configuration line is a potential mistake, and configurations are "ephemeral and
    less well tested." This outage was one config key.

Labels: story, testing

Activity

  1. added
    storyA single story within an epic
    testingTest authoring / validation work
    on Sep 9, 2026
  2. Polichinel commented on Sep 9, 2026

    @Polichinel
    ContributorAuthor

    Guard audit complete. 10 mutations, 8 killed, 1 real gap found and closed, 1 equivalent mutant.

    Results

    # Mutation Verdict Evidence
    M1 list(config["regression_targets"]) → config.get("regression_targets", []) killed 7 failed
    M2 read classification_targets instead killed 59 failed
    M3 drop the list() copy (alias the config's list) killed 7 failed
    M4 hardcode targets=[] in two builders killed 8 failed
    M5 delete regression_targets from CORE_GENOME SURVIVED → now killed 277 passed → 2 failed
    M6 delete level from CORE_GENOME SURVIVED → now killed 277 passed → 2 failed
    M7 audit_manifest logs but does not raise on missing core killed 2 failed
    M8 remove the None-value rejection (check 4) killed 1 failed
    M9 conformance fixture carries the retired targets key killed 5 failed
    M10 exempt a key from check 1 (if k not in config and k != "level") equivalent see below
    — revert S1's catalog fix entirely killed 15 of 29 conformance tests failed

    The real finding: M5/M6

    Deleting either newly-promoted key from CORE_GENOME left all 277 tests green. S2 shipped
    without a guard on its own change: its tests assert that a config missing a key raises, but with
    the key removed from the genome, every test config still has the key, so nothing fails. I had
    verified the contract in a shell one-liner and never encoded it.

    Closed with two tests (tests/test_reproducibility_gate.py), and the shape matters:

    • test_core_genome_declares_every_unconditionally_read_key — asserts the five keys by name,
      each annotated with where it is dereferenced. Deliberately not parametrized over CORE_GENOME: a
      test that iterates the genome cannot catch a key being removed from it, because the
      parametrize list just shrinks and the case stops running. That is precisely how M5/M6 hid.
    • test_each_core_key_is_enforced_not_merely_listed — deletes each key in turn and requires a loud
      rejection, so a key can't be listed-but-unaudited.

    Both mutations now fail 2 tests each.

    The equivalent mutant: M10

    Exempting a key from check 1 leaves the suite green — but this is not a gap. Checks 1 and 4 of
    audit_manifest overlap: a missing key reaches check 4 as config.get(k) is None, so it is still
    rejected, just with a different message:

    REPRODUCIBILITY CONTRACT VIOLATED: Mandatory parameters set to None: ['level']
    

    The contract holds; only the diagnostic text changes. Recorded in the new test's docstring so the
    next reader does not mistake that test for proof that check 1 works.

    Worth noting as a design observation, not a defect: check 1 is largely redundant with check 4.
    Its distinct value is the clearer message. Not changing it — that is exactly the kind of
    consolidation that trades a real message for a theoretical tidiness.

    Notes

    • No production code was changed by this story. The two additions are tests.
    • git status verified clean of mutation residue; views_baseline/ __pycache__ cleared and the
      suite re-run to confirm no stale bytecode influenced a verdict.
    • Final: 283 passed, 1 xfailed, ruff check . clean.
    • Nothing registered — the one gap found was closed inside the epic rather than deferred, which is
      the outcome the story wanted. Nothing survived that needed carrying.
  3. Polichinel commented on Sep 9, 2026

    @Polichinel
    ContributorAuthor

    Closed by #94, merged to development (2b3d3b4). CI green: check (3.11), lint, build, check-branch all pass — the first green Lint and Test since 2026-08-02. Suite 248 → 295 passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    storyA single story within an epictestingTest authoring / validation work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions