Skip to content

Per-channel run report: make the composition audit a command (#628) - #646

Merged
WilfordGrimley merged 4 commits into
masterfrom
feat/per-channel-run-report
Jul 30, 2026
Merged

Per-channel run report: make the composition audit a command (#628)#646
WilfordGrimley merged 4 commits into
masterfrom
feat/per-channel-run-report

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Closes #628.

docs/reports/2026-07-29-pipeline-coverage-composition-audit.md is a per-channel table across vote channels, Stage C extractors and skip reasons, assembled by hand against production one channel at a time. It found three channels down, one for a reason nobody had recorded. This makes that table a command: manage.py channel_report.

The roster is derived from code

cardpicker/channel_roster.py reads the tree with ast — no imports, no Django, no hand-written list. Same technique and same reasoning as docs_lint.py's calculator/skip-reason tethers: "a second hand-maintained list would just move the stale list from the doc into the check." The scan is recursive (rglob, minus tests/, including migrations/), so PR #588's management-commands hole stays closed.

Derived: vote channels from actual cast sites, the Stage C manifest from image_evidence.py's own extractor_versions[...] stores (the same facts check_extractor_manifest_sync.py tethers), abstention channels from CardScanLog writers, and skip reasons from *_SKIP_REASON declarations.

Chips are counted BY TAG, not by identity

The single most important requirement. local-fallback-v1 derives as seven channels, not one — the audit found border chips healthy while frame-style and bleed-edge sat at zero under that same anonymous_id, and an identity-level count reports the identity as fine while two thirds of it is dead.

Getting this right needed intra-function constant tracing: both cast_border_attribute_vote and cast_frame_style_vote reach Tag.objects.filter(name=tag_name) through a local bound from BORDER_COLOR_TO_TAG / FRAME_STYLE_TO_TAG. Without it both collapse into the bleed channel — the exact merge this instrument exists to prevent. There is a test for that.

Identity-scoped abstentions deliberately do not count as evidence for a tag-grained channel: CardScanLog has no tag column, so letting border abstentions rescue the frame channel would reinstate the same merge.

Five outcomes, one failure

Positive vote / negative vote / abstention by named reason / value written / nothing at all. Only the last gates.

  • A negative vote (is_no_match, is_unknown, polarity=NOT_APPLICABLE) is a conclusion, not an absence.
  • Abstentions are reported by reason — "500 × no-evidence" and "500 × artist-mismatch" are different findings.
  • Stage C success is a populated field, never a manifest key. All eleven extractors report 100% key presence while bleed_diff_mm is NULL on 97.9% of rows. "" does not count as a written value, since compute_card_evidence writes it for "ran, found nothing".
  • Routers that write CardScanLog only (stage-d-slow-path-v1) say so in their row instead of showing zeros that read as failure.

No consensus outcome is read. Machine votes cast are the measure; a report keyed on resolutions would show ~100% failure on a healthy pipeline.

Zero gates unless declared

ZeroDeclaration rejects an empty, token, or unsigned reason at construction — the declaration table cannot be used as a silencer. A declared-silent channel that starts producing is flagged too.

ZERO_DECLARATIONS ships empty on purpose. The audit's real zeros are meant to show up red on the first run rather than be silenced in the same change that built the meter.

Four states, and reachability

NEVER-WIRED / WIRED-NEVER-RUN / RAN / RAN-AND-PURGED, from a module-qualified static call graph plus the ledger. The graph independently reproduces the audit's hand-built findings, and there are tests asserting each:

  • the per-channel CMD column, channel by channel;
  • art-edge-continuity-v1 reachable from no management command — the same shape as extract_card_evidence, whose only non-test callers are a docstring and a comment;
  • the headline that the pooled runner reaches no vote channelrun_image_evidence_cohort reaches exactly one channel, evidence-transfer-v1, which writes no votes.

Limits are stated in the doc rather than hidden: dynamic dispatch is not modelled, so "reachable" is the weak claim and NEVER-WIRED is the strong one.

Generalises build_reconciliation_report, does not duplicate it

The Stage C side calls it once per derived extractor key and inherits its doctrine — "queries the rows directly rather than a separately-maintained counter, so the report can never drift from what was actually persisted" — including its deliberate run_id asymmetry.

Three exit codes

Following soak_gate_report: 0 PASS, 1 FAIL, 2 INSUFFICIENT-DATA when the roster itself cannot be derived. An empty derivation is a hard finding — an instrument that measures nothing must never report all clear.

Verification

35 tests in cardpicker/tests/test_channel_report.py, including the three demonstrations the brief asked for: a silent channel flagged and gating, a fully-producing roster passing, and a mutated derivation whose empty roster is itself the finding (asserted both at the API and through the command's exit code 2).

docs_lint.py --strict clean. No migration. No production write — validated against fixtures only.

Documented in docs/features/stage-e-operations.md (living published doc, per the owner rule), not a dated report.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

WilfordGrimley and others added 2 commits July 30, 2026 09:45
`docs/reports/2026-07-29-pipeline-coverage-composition-audit.md` is a
per-channel table across vote channels, Stage C extractors and skip
reasons, assembled by hand against production one channel at a time. It
found three channels down, one for a reason nobody had recorded. This
makes that table a command, so a failed run produces a LIST rather than
another hand audit.

`cardpicker/channel_roster.py` DERIVES the roster from code, never a
hand-written list - the same technique and the same reasoning as
docs_lint.py's calculator/skip-reason tethers ("a second hand-maintained
list would just move the stale list from the doc into the check"). The
scan is recursive (`rglob`, minus `tests/`, including `migrations/`) so
PR #588's management-commands hole stays closed.

CHIPS ARE COUNTED BY TAG, NOT BY IDENTITY. `local-fallback-v1` derives
as SEVEN channels, not one: the audit found border chips healthy while
frame-style and bleed-edge sat at zero under that same `anonymous_id`,
and an identity-level count reports the identity as fine while two
thirds of it is dead.

FIVE OUTCOMES, ONE FAILURE. Positive vote / negative vote (a conclusion,
not an absence) / abstention by named reason / value written / nothing at
all. Only the last gates. Extractor success is a POPULATED FIELD, never a
manifest key - all eleven extractors report 100% key presence while
`bleed_diff_mm` is NULL on 97.9% of rows.

Nothing here reads a consensus outcome. Machine votes cast are the
measure; `resolve_weighted_consensus`'s human-backed gate means a report
keyed on resolutions would show ~100% failure on a healthy pipeline.

Generalises `image_evidence.build_reconciliation_report` rather than
duplicating it - the Stage C side calls it once per derived extractor key
and inherits its doctrine ("queries the rows directly rather than a
separately-maintained counter, so the report can never drift from what
was actually persisted") and its run_id asymmetry.

Zero gates unless declared: `ZeroDeclaration` rejects an empty, token or
unsigned reason at construction, and a declared-silent channel that
STARTS producing is flagged too. `ZERO_DECLARATIONS` ships EMPTY on
purpose - the audit's real zeros are meant to show up red on the first
run.

Four states kept apart (never-wired / wired-never-run / ran /
ran-and-purged) from a module-qualified static call graph, which
independently reproduces the audit's per-channel CMD column, its
unreachable-channel finding, and its headline that the pooled runner
reaches no vote channel.

Three exit codes, following soak_gate_report: 0 PASS, 1 FAIL, 2
INSUFFICIENT-DATA when the roster itself cannot be derived. An empty
derivation is a hard finding - an instrument that measures nothing must
never report all clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
New behaviour goes in the living published doc, not a dated report (owner
rule: "if we are documenting changes to the way something actually works,
we should be editing existing docs that are wiki/site facing").

Operator-facing: what a channel is and why it is not an `anonymous_id`,
the five outcomes with only "nothing at all" as a failure, the four
why-is-it-silent states, the three exit codes, how to declare a
legitimate zero, and the honest limits of the static reachability
derivation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley and others added 2 commits July 30, 2026 09:51
…ts (#628)

`votes_written` and `counters` are the run's SELF-REPORT. They are printed
next to the row counts this report derives independently, for comparison
only: where the two disagree, the rows are the fact and the counters are
the claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…ped (#628)

`build_reconciliation_report` filters `card_id__in=[...]`. A whole-catalogue
Stage C pass touches ~220,000 cards, so running it per extractor would send
eleven 220,000-element IN clauses at Postgres.

Above the cap it is SKIPPED and the channel's notes say so. The lifetime
field counts the gate actually fires on are unaffected. An omitted
measurement that looks like a clean one is the defect this instrument
exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
@WilfordGrimley
WilfordGrimley merged commit c2c5dc9 into master Jul 30, 2026
13 checks passed
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…mode (#650)

* Record why channel_report is not auto-gating yet (#628)

Owner ruling 2026-07-30: it stays an operator step for now. The decision
belongs in the published doc, not a PR comment.

The command is built to gate, and the roster is KNOWINGLY dirty -
ZERO_DECLARATIONS is empty by design and the coverage audit found three
genuinely silent channels. Auto-gating today means an unrelated command
fails because a channel nobody was touching has been silent for a week.

Sequence recorded: run deliberately -> first real reading -> rule on each
zero -> then wire it in, at which point a new zero genuinely means
something regressed. Also states that the first production reading is
EXPECTED to exit 1, so nobody reads a red first run as a broken tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* docs: record the push-loses-the-race-with-squash-merge failure mode

Second occurrence: PR #646's final commit landed 76 seconds after the
squash-merge fired, so it never reached master. PR #604's 82360a9 was
the first. Nothing in GitHub or CI reports this - the PR reads MERGED
and CI reads green.

Extends the existing squash-merge orphan entry rather than adding a
sibling: same mechanism, narrower window. Adds the mergedAt vs branch-tip
sweep so it can be detected mechanically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* docs: correct recovery PR number to #650

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-channel run measurability harness: every vote channel reports what it produced, per run (priority 1 — gates the monolith)

1 participant