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
+61 -1
View File
@@ -26,7 +26,8 @@ from __future__ import annotations
import pytest
from scribe.services.shape_ledger import (
FORM_UNKNOWN, canon_form, forms_agree, shape_form, signature_in,
FORM_UNKNOWN, canon_form, families_conflict, forms_agree, shape_family,
shape_form, signature_in,
)
@@ -89,6 +90,65 @@ def test_the_five_shapes_that_started_this_are_not_service_functions() -> None:
assert not forms_agree(shape_form(sig, "sym"), canon), sig
def test_how_many_of_the_five_the_divergence_gate_actually_silences() -> None:
"""HONEST ACCOUNTING, and it is deliberately an assertion rather than a
comment so it fails the day the answer changes.
Divergence gates at FAMILY level and only on contradiction, because a form
mismatch is the PREMISE of a divergence prompt, not an objection to it —
see `shape_family`. So of #4204's five false prompts this silences one:
`Point` is a type and the canon is a callable. The four `def` helpers are
callables like the canon and still ask.
That is not a shortcoming of the gate, it is the limit of the signature:
at this level those four are indistinguishable from #2793's acceptance
case, where a sync `confirmDanger` beside an async confirm helper SHOULD
be flagged. Separating them needs #4204 option 2 (widen `kind`) or a
comparison of meaning."""
canon = shape_form("async def create_note(user_id: int, ...):", "sym")
silenced = {
sig: families_conflict(shape_form(sig, "sym"), canon)
for sig in (
"class Point:",
"def _p(source, kind, what, **kw) -> tuple[str, Point]:",
"def get_point(source: str) -> Point | None:",
"def is_registered(source: str) -> bool:",
"def sources_expected_to_emit() -> list[str]:",
)
}
assert silenced["class Point:"] is True
assert sum(silenced.values()) == 1
def test_a_sync_function_beside_an_async_canon_is_still_a_fair_question() -> None:
"""#2793's acceptance case, which the first version of this change broke:
gating divergence on `forms_agree` silenced a hand-rolled sync confirm in
a directory where an async confirm helper is canon — the exact prompt the
milestone exists to produce."""
canon = shape_form("async function onTrash() {", "sym")
mine = shape_form("function confirmDanger() {", "sym")
assert canon == "async-fn" and mine == "fn"
assert not forms_agree(mine, canon) # the STAMP gate would refuse
assert not families_conflict(mine, canon) # the DIVERGENCE gate asks anyway
@pytest.mark.parametrize(("form", "want"), [
("fn", "callable"), ("async-fn", "callable"), ("type", "type"),
("binding", "value"), ("css", "css"), (FORM_UNKNOWN, ""),
])
def test_every_form_has_a_family_and_unknown_has_none(form, want) -> None:
assert shape_family(form) == want
@pytest.mark.parametrize(("a", "b"), [
(FORM_UNKNOWN, "fn"), ("fn", FORM_UNKNOWN), (FORM_UNKNOWN, FORM_UNKNOWN),
])
def test_an_unreadable_form_never_silences_a_divergence_prompt(a, b) -> None:
"""Same direction as everywhere else in this module: not knowing makes the
check quieter about ASSERTING and never quieter about ASKING."""
assert not families_conflict(a, b)
# ── forms_agree: unknown never matches ────────────────────────────────────
def test_two_known_equal_forms_agree() -> None: