fix(ledger): divergence asks at family level — the first gate was inverted (#4204)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 56s
CI & Build / Python tests (push) Successful in 1m29s
CI & Build / Build & push image (push) Successful in 28s

CI run 7090 caught this; the unit suite could not. Integration job 25763 —
`test_a_second_confirm_dialog_is_detected_and_named`, the acceptance case of
milestone #2793.

WHAT I GOT WRONG. The previous commit gated BOTH halves of the ledger on
`forms_agree`. That is right for stamping and backwards for divergence,
because the two assert opposite things:

  STAMPING says "this IS that canon". Agreement in form is evidence FOR the
  claim, so demanding it is correct.

  DIVERGENCE says "this is NOT the canon that dominates here — did you mean
  to?" A form MISMATCH is the PREMISE of that prompt. Requiring the candidate
  to match the canon silences the check precisely where it belongs.

So #2793's case stopped firing: a hand-rolled sync `confirmDanger` in a
directory where an async confirm helper is canon read as `fn` against
`async-fn`, disagreed, and was dropped. `flag_divergence` returned 0 where the
test demands 1, and the write-time check returned nothing where it must name
the canon. That is a real flag the milestone exists to produce, and my change
removed it.

THE FIX. Divergence now gates at FAMILY level — callable {fn, async-fn},
type, value, css — and only on contradiction. A sync function beside an async
one is still a fair question. A frozen dataclass told to build from an async
service function is not a question at all.

WHAT THIS DOES NOT FIX, asserted rather than commented so it fails the day it
changes (`test_how_many_of_the_five_the_divergence_gate_actually_silences`):
of #4204's five false prompts this silences ONE. `Point` is a type against a
callable canon. `_p`, `get_point`, `is_registered` and
`sources_expected_to_emit` are callables like the canon and still ask — and
at the signature level they are indistinguishable from the #2793 case above,
so nothing readable here can separate them. That needs #4204 option 2 (widen
`kind` past `css | sym`) or a comparison of meaning rather than form.

The stamping half — `_RESEMBLE_MIN` 0.80 and the graded burden — is unchanged
and unaffected by this failure. It is also the half that matters more: loose
stamping is what manufactures the density the divergence check reads as
authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-20 21:26:12 -04:00
co-authored by Claude Opus 5
parent 947203fa44
commit a4883c8ac1
2 changed files with 126 additions and 9 deletions
+65 -8
View File
@@ -987,6 +987,59 @@ def forms_conflict(a: str, b: str) -> bool:
return bool(a) and bool(b) and a != b
def shape_family(form: str) -> str:
"""The coarse category a form belongs to, or "" when the form is unknown.
WHY THIS IS SEPARATE FROM `shape_form`, and it is the correction to the
first version of this change. The two checks in this module ask opposite
questions, so they cannot share a burden:
STAMPING asserts "this IS that canon". Agreement in form is evidence
FOR the claim, so `forms_agree` / `forms_conflict` — the precise level,
where `fn` and `async-fn` are different — is right.
DIVERGENCE asserts "this is NOT the canon that dominates here — did you
mean to?" A form MISMATCH is the premise of that prompt, not an
objection to it. Gating it on `forms_agree` inverted the check: it went
silent on exactly the mismatches it exists to catch, and the acceptance
case of #2793 — a hand-rolled sync `confirmDanger` in a directory where
an async confirm helper is canon — stopped being flagged.
So divergence gates at FAMILY level and only on contradiction. A sync
function beside an async one is still a fair question. A frozen dataclass
told to build from an async service function is not a question at all,
and that is the #4204 prompt this removes.
This does NOT remove every false prompt #4204 recorded. Of the five, it
silences `Point` (a type, against a callable canon); `_p`, `get_point`,
`is_registered` and `sources_expected_to_emit` are callables like the
canon and still ask. At the signature level they are indistinguishable
from the #2793 case above, so nothing readable here can separate them —
only widening `kind` past `css | sym` (#4204 option 2) or comparing
meaning rather than form can. Stated here so the next reader does not
assume the gap is an oversight.
"""
if form in ("fn", "async-fn"):
return "callable"
if form == "type":
return "type"
if form == "binding":
return "value"
if form == "css":
return "css"
return ""
def families_conflict(a: str, b: str) -> bool:
"""Do two forms belong to categorically different KNOWN families?
Like `forms_conflict`, both sides must be readable: an unknown form makes
this quieter, never more confident.
"""
fa, fb = shape_family(a), shape_family(b)
return bool(fa) and bool(fb) and fa != fb
def canon_form(rows: Iterable, snippet_id: int) -> str:
"""The form a canon's own judged rows agree on, or FORM_UNKNOWN."""
forms: dict[str, int] = {}
@@ -1996,18 +2049,22 @@ async def write_time_divergence(
row.status != "unclassified" or row.proposed_snippet_id == sid
):
continue
# DOES THIS SHAPE EVEN RESEMBLE THE CANON? (#4204) Without this the
# line was a pure base rate: "most things here are X, so be X", with
# the candidate never examined. It told a frozen dataclass and three
# pure predicates to build from the `async_session` service canon.
# IS THIS EVEN THE SAME CATEGORY OF THING AS THE CANON? (#4204)
# Without this the line was a pure base rate: "most things here are X,
# so be X", with the candidate never examined at all. It told a frozen
# dataclass to build from the `async_session` service canon.
#
# FAMILY, not form, and only on contradiction — see `shape_family`. A
# divergence prompt is ABOUT a mismatch, so requiring the candidate to
# match would silence the check precisely where it belongs.
#
# Signature from the payload first — a shape being written now may
# have no row yet, and `forms_agree` needs both sides known, so an
# unreadable one produces silence rather than a guess.
# have no row yet and an unreadable one produces a fair question
# rather than a guess, because `families_conflict` needs both sides.
mine = shape_form(
signature_in(code, name, kind) or getattr(row, "signature", "") or "", kind
)
if not forms_agree(mine, cform):
if families_conflict(mine, cform):
continue
out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid,
"instances": n, "judged": judged})
@@ -2129,7 +2186,7 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
# (#4204). The sweep and the hook must agree about what counts
# as divergence, or an audit contradicts the line the writer
# was shown at the keyboard.
if not forms_agree(shape_form(r.signature or "", r.kind), cform):
if families_conflict(shape_form(r.signature or "", r.kind), cform):
continue
r.diverges_from = dom[0]
flagged += 1