English · Español
A self-contained document. It is written so that any person or agent arriving with no prior context can run, read and repair the mutation sweep without asking anyone.
- What it is and why:
docs/MUTATION_TESTING.md - Measured results and triage:
docs/MUTATION_BASELINE.md - This document: how it is operated and what to do when it fails.
Every failure in §3 actually happened while building this on 2026-08-01. They are not hypotheticals: they are the record of what broke, how it was diagnosed and how it was fixed.
Nothing to do. .github/workflows/mutation.yml runs by itself every Monday
at 03:00 UTC: one job per module, in parallel.
To read the result: Actions tab → VIGÍA Mutation Testing run. Each job writes its summary:
killed=172 survived=250 total=422 score=40%
Each job also publishes a mutation-<module> artifact with two files:
| File | Contents |
|---|---|
survivors.txt |
Survivors only. This is the work list. |
all.txt |
Every mutant with its status. Use it to re-count. |
To trigger it manually: Actions → VIGÍA Mutation Testing → Run workflow.
The only_mutate field takes one module path (e.g. vigia_scorer.py); leave
it empty to measure all of them.
- Compare the total, not only the percentage. With
mutate_only_covered_lines = true, writing tests enlarges the mutant universe: previously uncovered lines become mutable. In the first baselinecollapse_decision.pywent from4/29to35/35— numerator and denominator moved together. - A survivor is not always a defect. Three classes, and separating them is
the real work: genuine test gap / equivalent mutant (does not alter
observable behaviour) / unreachable defensive branch. See
MUTATION_TESTING.md§7. - A low score does not mean bad code. It means "weak tests in that area".
pip install -r requirements.txt -r requirements-ci.txt
pip install scipy mutmutInstall requirements.txt in full. If the modules importing
vigia/vigia_sift_bridge.py fail to collect with
ModuleNotFoundError: No module named 'mcp' — or worse, with
TypeError: issubclass() arg 1 must be a class — the cause is almost certainly
a missing fastmcp, not an mcp version problem. Installing mcp on its own
and hunting versions is a dead end: several 1.x releases fail on PEP 604
annotations and 2.x dropped mcp.server.fastmcp entirely. fastmcp pulls a
compatible pair.
If pip install fails with Cannot uninstall PyJWT ... RECORD file not found,
the interpreter has PyJWT from the system package manager
(KNOWN_LIMITATIONS.md L-045). Two ways out:
- use a clean virtualenv (recommended), or
pip install --ignore-installed PyJWT fastmcp.
These modules are excluded from the mutation runner anyway, because they cover the MCP surface and not the verdict path — but the full suite needs them to collect, and CI runs the full suite as its green-baseline gate.
python3 -m pytest tests/ vigia/tests/ -q --tb=short --no-covOver a red suite the mutation score means nothing: every mutant is declared
killed by the pre-existing failure and the result comes out at ~100 %. The
workflow has a baseline job that checks this before mutating. Locally it must
be checked by hand.
python3 -m mutmut run --max-children 4 # note: all 8 modules is ~6 hours
python3 -m mutmut results # survivors
python3 -m mutmut results --all true # all, with status
python3 -m mutmut show <mutant_name> # the exact diff of one mutantmutmut run <pattern> does NOT narrow anything — verified: with five
module patterns it ran all 7,043 mutants anyway. The only mechanism that works
is only_mutate in pyproject.toml. The cycle:
- edit
only_mutate, leaving only the desired module; rm -rf mutants && python3 -m mutmut run --max-children 4;- restore
pyproject.tomlas soon as generation finishes — themutants/sandbox already has its own copy and does not read it again.
Step 3 matters: tests/test_mutation_config_contract.py requires
vigia_scorer.py to stay in only_mutate, so a forgotten narrowing leaves the
suite red. That is deliberate — the contract exists so that a temporary
narrowing does not silently become the permanent scope.
mutants/ takes ~300 MB with all 8 modules (137 MB for the mutated scorer
alone: mutmut writes every variant inline). It is in .gitignore. It can be
deleted at any time; the only cost is regenerating it.
What it means. Before mutating, mutmut runs the whole suite to map which
test covers which function. It runs with -x: a single failing test aborts
the entire sweep. The message does not say which one.
How to find the culprit. The log shows the FAILED just above the
traceback. If it does not appear, turn on verbose mode by adding debug = true
to the [tool.mutmut] section of pyproject.toml, re-run, and remove it
afterwards (it prints the entire suite).
Causes seen, in order of frequency:
a) Tests that inspect source text instead of exercising behaviour. Inside
mutants/, the source contains every mutant variant inline
(x_funcname__mutmut_N, literals wrapped as "XXFOOXX"). A test doing regex
over caie.py, inspect.getsource, or rglob of *.md reads harness
artefacts and fails for a reason that is not the logic under test. Four are
already excluded for this (MUTATION_TESTING.md §5). If a new one appears:
exclude it only if it cannot kill any mutant in only_mutate — that is, if
it does not execute that code. If it does, excluding it falsifies the score and
the test must be fixed instead.
b) Missing data files. If the suite fails inside mutants/ with
FileNotFoundError, something is missing from also_copy. Add it. The
dangerous symptom is the opposite one: if this goes unnoticed, every mutant
is declared killed and the score comes out at a false 100 %.
tests/test_mutation_config_contract.py covers this.
c) A failure that only occurs under mutmut. See §3.4.
What it means. pytest exit code 4 = usage error. Almost always: mutmut
passed a test identifier that no longer resolves (ERROR: not found: ...).
Real case and its root cause. It happened because
tests/caie/test_canonical_cases.py passed a generator to
@pytest.mark.parametrize. A generator is exhausted when consumed and the
object is captured in the marker, at module level. mutmut collects several
times in the same process (list_all_tests, stats, clean run), so the
second collection received an empty generator: the 52 canonical cases ceased to
exist and their identifiers did not resolve.
How to diagnose it: debug = true in [tool.mutmut] makes pytest's real
error print, naming the identifier that does not resolve.
Active prevention: tests/test_parametrize_argvalues_are_reiterable.py
closes this class — including an AST sweep against generator expressions used
as argvalues. If that test fails, the defect is back.
Symptom: tests that used to pass start failing without any code being touched.
Cause: mutants/ exists, and some test sweeps the whole tree
(grep -r ., rglob) counting the mutated copy as repository code. It
happened with test_b117_stale_formula_sweep and — more seriously —
test_b224_contradiction_detector_dormancy, whose sole purpose is to assert
that a given rule has no producer anywhere in the repository: it counted
mutants/vigia_agent.py as a new producer.
Fix: add --exclude-dir=mutants to the grep, or "mutants" not in p.parts to the rglob. There are three precedents in the tree.
Diagnostic shortcut: rm -rf mutants and re-run. If it goes green, this
was it.
Before excluding anything, check the five contexts — this is how a harness problem was told apart from a real defect:
| Context | Command |
|---|---|
| Isolated, in the sandbox | cd mutants && pytest <test_path> |
| Isolated, with the stats environment | MUTANT_UNDER_TEST=stats MUTMUT_DEPENDENCY_DEPTH=-1 pytest <path> |
| Full suite, subprocess | cd mutants && pytest <full selection> |
| Full suite, in-process | python3 -c "import os,pytest; os.chdir('mutants'); pytest.main([...])" |
| Real mutmut | python3 -m mutmut run |
The fourth is closest to mutmut: it runs pytest inside the same process, which is where failures from global state that does not survive a second collection appear. If it fails there, it is a real defect of the suite (this was the generator case, §3.2). If it only fails in the fifth, it is an interaction with the harness.
Open anomaly: vigia/tests/test_lr_calibrator_serialization.py fails only
under the stats phase (2 of 2 runs) and passes in the other four contexts.
Undiagnosed. Excluded, with the full reasoning in MUTATION_TESTING.md §5.1.
If someone diagnoses it, update that section and remove the exclusion.
The hard limit for a job on GitHub Actions is 360 minutes; it cannot be raised.
To re-size after adding modules, with the measured rate (19.5 mutants/min with
--max-children 4 on 4 CPU):
mutants ≈ (number of lines containing "__mutmut_" in mutants/<module>) × 0.49
minutes ≈ mutants / 19.5
All 8 modules in a single job is ~359 min: they do not fit. That is why the
workflow uses strategy.matrix with one job per module.
tests/test_mutation_config_contract.py verifies that the timeout stays under
360 and that the matrix covers exactly only_mutate — two lists that must
match drift apart on their own, and a module declared but missing from the
matrix would never be measured without anything failing.
- Raise the module's line coverage first. Mutating untested code produces trivial survivors and only adds triage noise.
- Add it to
only_mutateinpyproject.toml. - Add it to
strategy.matrix.modulein.github/workflows/mutation.yml. The contract fails if this step is forgotten. - Recompute the time with the formula in §3.5.
- Measure, triage, and record the result in
docs/MUTATION_BASELINE.md.
If it is ever decided that it is not worth it, the whole set must go, not just the tool:
[tool.mutmut]inpyproject.toml.github/workflows/mutation.ymltests/test_mutation_config_contract.pydocs/MUTATION_TESTING.md,docs/MUTATION_BASELINE.md, this file, and their_EScounterparts- the
mutants/,.mutmut-cache,mutmut-stats.jsonentries in.gitignore
Do not remove tests/test_parametrize_argvalues_are_reiterable.py,
tests/test_collapse_decision_boundaries.py, or the --exclude-dir=mutants
guards: they are fixes for real repository defects, independent of the tool
that exposed them.