Repository navigation
story/12.1: Nightly symmetry audit — neutrality proven on the real pipeline, not just the formula (closes #74) - #183
Merged
Conversation
RED. The feature file and tests exist; app.neutrality does not, so both files fail at collection with ModuleNotFoundError. The point of this story, stated once so the tests can be read against it: tests/test_alignment_formula_unit.py already proves the FORMULA is antisymmetric (mirror every s_i, a score of x becomes exactly 100-x). It cannot see candidate_positions being computed from votes, the stance direction being applied, tier weights being read, or the badge / dealbreaker / reason assembly. This audit drives that whole pipeline over a mirrored synthetic fixture and compares the real results. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GREEN. 4013 pass, 3 xfailed (none of them the symmetry scenario any more). What it does: writes a deliberately mirrored synthetic fixture into storage, recomputes positions through the real PositionComputer, scores both sides through the real AlignmentService, and compares. Two sign flips are exercised, because a bug in one would be invisible to a fixture that only flipped the other: * the candidate's record (Yea <-> Nay) => scores must SUM to 100 * the voter's stance (direction_code +/-) => scores must be EQUAL It also fails on four things that are not scores — the confidence badge, the evidence count, which interests the reasons name, and which direction each reason moved the score, plus the dealbreaker warning firing on exactly one side. Each is a way to present one direction of the axis as better than the other while the numbers stay symmetric; two of the four are caught at a max_delta of exactly 0.0, which a score-only audit would miss. app/neutrality/audit.py the fixture, auditor, report and gate app/repositories/models.py AuditRun (audit_runs had no model) app/scheduling/jobs.py run_nightly_symmetry_audit, nightly scripts/check_symmetry_audit.py the release gate, self-checking .github/workflows/ci.yml wired in on every push and PR prd.md's Neutrality scenario is no longer pending. Its marker named story 11.1, which was a mis-numbering twice over (that scenario is 12.1; 11.1 is the verdict engine), and its steps now drive the real audit rather than the CandidateBuilder sketch — a PRD scenario asserting on the builders would be asserting the test fixture is symmetric, which was never prd.md's claim. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scripts/verify_symmetry_audit_live.py the real live run
tests/test_symmetry_audit_unit.py the gate's own edges
.github/workflows/scheduled-jobs.yml ZERODB_PROFILE_ROOT_KEY
The added unit tests cover the paths a feature file reads badly for and
that matter most if they are wrong:
* an unscoreable run (inf delta) must still WRITE its audit_runs row.
JSON has no infinity, so a naive float(inf) would fail the write and
lose the record of exactly the run that went wrongest — leaving the
gate to see 'no recent audit' with no trace of why.
* a stored run_at the gate cannot parse must BLOCK, not be treated as
fresh: a corrupted write must not be indistinguishable from a healthy
nightly run.
* the gate must read the NEWEST audit_runs row. The id is an ISO date so
lexicographic order is chronological, sorted locally because story 1.4
found live ZeroDB returns rows in whatever order it likes — a gate that
read an old passing row over a new failing one would ship the skew.
app.neutrality.audit coverage is 93%, above CLAUDE.md's 90% bar for
scoring logic.
The scheduled-jobs workflow needs the profile root key for the new nightly
job, and it must be the SAME key the deployed API uses, not a fresh one:
the keyring derives per-user data keys from it. Documented there, with the
note that story 12.1's 'a failure blocks the next release' is kept TODAY by
the CI step, not by this nightly job.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…heals run() cleans up in a finally, which covers an exception but NOT a SIGKILL. A nightly runner killed by a CI timeout or an OOM mid-fixture leaves rows that the in-process tracking list died with — and those rows are fabricated claims about how a named person voted, which is the one thing this module must never leave in the project. Noticed while watching the live run, which takes several minutes against the ~60 req/min ceiling and is therefore a realistic candidate for exactly that kind of interruption. So cleanup now runs two passes: the tracked list (exact, cheap, the normal case) and then a marker sweep over every table the fixture writes. The next night's run removes an interrupted run's orphans, so the audit self-heals rather than accumulating one interrupted run's worth of fabricated voting record at a time. It also makes verify_symmetry_audit_live.py's 'clean start' check a real assertion rather than a tripwire a human clears by hand. _is_synthetic is scoped to the three id-bearing fields (id, person_id, entity_id), never the whole serialised row: a real stance card or roll-call question containing the words 'symmetry audit' would match a whole-row check, and a sweep that can delete real data is far worse than one that occasionally misses a synthetic row. The next run catches a missed row; nothing brings back a deleted roll call. Pinned by a test. Also: logging.basicConfig in the live verifier. Without it the app's own INFO lines are suppressed and a multi-minute live run is indistinguishable from a hang — which is how I spent the first ten minutes of this one. 4026 pass. app.neutrality.audit coverage 93%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Story 12.1 — Nightly symmetry audit (8 pts, Must, Iteration 7) · closes #74
Feature file:
backend/tests/features/symmetry_audit.featureWhy this is not already done
tests/test_alignment_formula_unit.py::test_the_score_is_antisymmetric_under_flipping_every_position(story 6.4) already proves the formula is antisymmetric: mirror everys_iand a score ofxbecomes exactly100 − x. That test is right, and it is deliberately all it tests — four hand-written numbers per term, no database.It is blind to almost everything a voter's number actually passes through:
positions.compute_positions_value, via vote-type weights + omnibus discountpositions._stance_directionservice._terms_forscoring.badgesscoring.dealbreakersscoring.reasonsSix of those seven are newer than that test. "Nonpartisan by construction" is a claim about the whole pipeline, so this audit drives the whole pipeline: it writes a mirrored synthetic fixture into storage, recomputes positions through the real
PositionComputer, scores both sides through the realAlignmentService, and compares the real results.What "mirrored" means, exactly
Two independent sign flips exist in this pipeline, and the fixture exercises both — a bug in one would be invisible to a fixture that only flipped the other.
mirrored_position): L votes Yea on every roll call R votes Nay on. Same roll calls, samevote_type, sameis_bundled, same topics._vote_directionis the only code readingposition, sos_valuenegates.mirrored_direction_code): the mirrored voter picks the opposite stance card on the same topic (direction_code+1vs−1), at the same tier and intensity._stance_directionis the only code readingdirection_code, sos_valuenegates again.That gives two relationships a nonpartisan pipeline must satisfy, both asserted per pair:
score(voter, L) + score(voter, R) == 100— the end-to-end form of the formula's antisymmetry, now with real stored positions and real published tier weights in the path.score(voter, L) == score(mirrored voter, R)—s_valuedouble-negates. This is prd.md's own BDD scenario, word for word: "each voter's score for their aligned candidate is identical within 0.5 points."Both are measured in points; the worse of the two is the pair's delta, so
max_deltais one number against one threshold rather than two that could disagree.It also fails on four things that are not scores
The acceptance criterion names the scores, and a literal reading would stop there. Each of these is a way to present one direction of the axis as better than the other while the numbers stay perfectly symmetric:
This is not theoretical. I sabotaged four real pipeline components and confirmed each is caught — and two of the four leave
max_deltaat exactly 0.0, so a score-only audit would have passed them:max_deltapositions._vote_direction— Nay weighted 0.8 vs Yea's 1.0service._terms_for— tier weight doubled for positive positionsreasons.top_reasons— direction always"raised"dealbreakers— the check disabled entirelyThe real live run
backend/scripts/verify_symmetry_audit_live.py, against the liveinformedproject, real methodology 2026.3, real published tier weights{primary: 3.0, secondary: 1.5, nice_to_have: 0.5}:No asymmetry bug was found. Three things about that result are worth more than the headline:
L + Rsums to100.0in IEEE754 for all three pairs, including the non-round49.046…/50.954…pair, so the tolerance is headroom and not something the pipeline is currently consuming. The live verifier asserts== 0.0for that reason: a nonzero delta that stayed inside 0.5 would mean something is diverging and happening to hide.weighted-and-mixedscoring49.046…rather than something round is the fixture doing its job: that pair carries all three tiers, mixed Yea/Nay, a procedural vote, a bundled vote and an abstention, so the vote-type weights, the omnibus discount andpresentcontributing nothing are all in the measured path.The run finished green on every check (
all live checks passed). It also confirmed, against the real API:audit_runsaccepts this row shape. Nothing had ever written to that table — its columns existed inTABLE_SCHEMASand no code used one. Live row:{'id': 'aud:2026-10-20', 'passed': True, 'run_at': '2026-10-20T11:30:12Z', 'max_delta': 0.0, 'report_file_id': None, 'methodology_version': '2026.3'}max_deltaround-trips as a realfloatandpassedas a realbool— not strings. This is the round-trip that matters most: a string"false"is truthy, and the release gate reads that field.id, and nostance_id,tier,intensity,interests,vp:orpayload.passed=False:candidate_positions(the set that matters most), and no live row links a synthetic voter to a candidate. Exactly oneaudit_runsrow is left, which is the deliberate keep.The CI gate also ran for real on this branch and behaved as designed — self-check first, then the real check:
Determinism was checked separately over 12 repeat runs: one distinct
max_delta(0.0), bit-identical scores, identical reason orders. No flakiness risk from float ordering."A failure blocks the next release"
backend/scripts/check_symmetry_audit.py, following story 12.5'scheck_panel_gate.pyshape rather than inventing a second release-blocking mechanism, and wired intoci.ymlon every push and PR.One genuine difference from the panel gate, and it is in this gate's favour:
check_panel_gate --fakecan only prove its own logic, because its real question is about deployed data. This gate's question is "is the scoring code symmetric?", and the code in CI is the code that ships — the fixture is synthetic by design, so there is no live data for it to be missing. The in-memory run therefore answers the real question completely.It self-checks its failing path first: an 8-point skew is injected and MUST be caught, or the script exits 1 without reporting a result. A neutrality audit that rubber-stamps a skewed pipeline is worse than no audit, so a broken comparison fails loudly instead of going quietly green.
prd.md's pending Neutrality scenario is retired
tests/features/neutrality.feature's "Symmetry audit" scenario was xfailed against story 11.1, which was a mis-numbering twice over: that scenario is 12.1, and 11.1 is the verdict engine. Its steps now drive the real audit rather thancommon_steps.py'sCandidateBuilder/VoterProfileBuildersketches — a PRD acceptance scenario asserting on the builders would be asserting that the test fixture is symmetric, which was never prd.md's claim.Files
app/neutrality/audit.pyapp/repositories/models.pyAuditRun—audit_runshad no modelapp/scheduling/jobs.pyrun_nightly_symmetry_audit, nightly, preconditioned on the profile root keyscripts/check_symmetry_audit.pyscripts/verify_symmetry_audit_live.py.github/workflows/ci.yml.github/workflows/scheduled-jobs.ymlZERODB_PROFILE_ROOT_KEYfor the new jobtests/features/symmetry_audit.feature+ step defstests/test_symmetry_audit_unit.py4026 tests pass;
app.neutrality.auditcoverage is 93%, above CLAUDE.md's 90% bar for scoring logic.Judgment calls
SYMMETRY-AUDITin its own id and deleted in afinally(including on failure) — a fabricatedcandidate_positionsrow is a claim about how a named person voted, which under "evidence over opinion" is the worst thing this codebase could leave in its database. Theaudit_runsrow is the one deliberate keep.finallycovers an exception but not a SIGKILL, and a multi-minute nightly job against a ~60 req/min ceiling is a realistic candidate for a CI timeout or an OOM. So cleanup does two passes — the tracked list, then a marker sweep over all eleven tables the fixture writes — and the next night's run removes an interrupted run's orphans. The audit self-heals rather than accumulating one interrupted run's worth of fabricated voting record at a time._is_syntheticis scoped to the three id-bearing fields (id,person_id,entity_id), never the whole serialised row: a real stance card or roll-call question containing the words "symmetry audit" would match a whole-row check, and a sweep that can delete real data is far worse than one that occasionally misses a synthetic row — the next run catches a missed row, nothing brings back a deleted roll call.audit_runsis not append-only, unlikeverdictsandcandidate_positions. Those two must stay reproducible because someone can be shown the verdict that was in force when they read it; an audit run measures code, not a person, and datamodel.md'saud:{date}id has no room for a second run per day anyway. CLAUDE.md's append-only rule names three tables specifically and this is none of them.inf, not 0.0 — twoNones comparing equal would report a perfect run over a fixture that produced nothing.direction_code0is refused a mirror. A stance that does not move along its axis is its own reflection andcompute_positiondeclines to score it, so a0pair could never produce two comparable scores.partystring. A differing party is the one thing a nonpartisan scorer must not be able to see; giving the fixture one would make a passing audit less meaningful.skewhook. Its only production value is 0.0 and no non-test caller passes anything else. It exists because otherwise the only way to see this check fail would be to introduce a real partisan bug — and it is applied after the real pipeline, so the sabotage can never be what the pipeline is tested through.audit.pyis 1512 lines / 389 statements, which is heavily documented but in line with this codebase (positions.pyis 975,jobs.py880). The gate is the separable part, andpanel_reviewdoes splitgate.pyfromservice.py— but that gate reads a ledger, where this one reads the report type defined beside it, so splitting would buy a file boundary at the cost of an import that goes straight back. Happy to split it if a reviewer prefers the symmetry with 12.5's layout.Open questions for human review
candidate_positionsdo not exist in the live project at all, becauseroll_calls.topic_idsis[]until story 5.5 classifies roll calls. An audit over the real roster today would score every candidateNoneand report a vacuous pass, which is worse than not running. What this proves is that the pipeline is symmetric, which is what "no party-specific rules" names. Scoring the real roster is a follow-up gated on 5.5.report_file_idis alwaysNone. datamodel.md givesaudit_runsthat field and a stored report file would be the right home for per-pair detail, but writing one needszerodb_upload_fileand a retention decision for audit artefacts that no story has made. Per-pair detail is in the log line and the gate's violations today.ZERODB_PROFILE_ROOT_KEYin theprodEnvironment, and it must be the same key the deployed API uses (the keyring derives per-user data keys from it). Documented inscheduled-jobs.ymlalongside the two existing missing-key gaps. Until it exists the job reportsblocked, notfailed— and note the release-blocking promise is kept today by the CI step, which needs no credentials at all.BUNDLED_WEIGHT_FACTORis still a module constant, not published methodology. Pre-existing debt flagged in story 6.2's own docstring; this audit now depends on it being symmetric (it is — it is a multiplier applied identically to both sides), which is one more reason to move it intomethodology_versions.🤖 Generated with Claude Code