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 & 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:
@@ -987,6 +987,59 @@ def forms_conflict(a: str, b: str) -> bool:
|
|||||||
return bool(a) and bool(b) and a != b
|
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:
|
def canon_form(rows: Iterable, snippet_id: int) -> str:
|
||||||
"""The form a canon's own judged rows agree on, or FORM_UNKNOWN."""
|
"""The form a canon's own judged rows agree on, or FORM_UNKNOWN."""
|
||||||
forms: dict[str, int] = {}
|
forms: dict[str, int] = {}
|
||||||
@@ -1996,18 +2049,22 @@ async def write_time_divergence(
|
|||||||
row.status != "unclassified" or row.proposed_snippet_id == sid
|
row.status != "unclassified" or row.proposed_snippet_id == sid
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
# DOES THIS SHAPE EVEN RESEMBLE THE CANON? (#4204) Without this the
|
# IS THIS EVEN THE SAME CATEGORY OF THING AS THE CANON? (#4204)
|
||||||
# line was a pure base rate: "most things here are X, so be X", with
|
# Without this the line was a pure base rate: "most things here are X,
|
||||||
# the candidate never examined. It told a frozen dataclass and three
|
# so be X", with the candidate never examined at all. It told a frozen
|
||||||
# pure predicates to build from the `async_session` service canon.
|
# 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
|
# Signature from the payload first — a shape being written now may
|
||||||
# have no row yet, and `forms_agree` needs both sides known, so an
|
# have no row yet — and an unreadable one produces a fair question
|
||||||
# unreadable one produces silence rather than a guess.
|
# rather than a guess, because `families_conflict` needs both sides.
|
||||||
mine = shape_form(
|
mine = shape_form(
|
||||||
signature_in(code, name, kind) or getattr(row, "signature", "") or "", kind
|
signature_in(code, name, kind) or getattr(row, "signature", "") or "", kind
|
||||||
)
|
)
|
||||||
if not forms_agree(mine, cform):
|
if families_conflict(mine, cform):
|
||||||
continue
|
continue
|
||||||
out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid,
|
out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid,
|
||||||
"instances": n, "judged": judged})
|
"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
|
# (#4204). The sweep and the hook must agree about what counts
|
||||||
# as divergence, or an audit contradicts the line the writer
|
# as divergence, or an audit contradicts the line the writer
|
||||||
# was shown at the keyboard.
|
# 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
|
continue
|
||||||
r.diverges_from = dom[0]
|
r.diverges_from = dom[0]
|
||||||
flagged += 1
|
flagged += 1
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scribe.services.shape_ledger import (
|
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
|
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 ────────────────────────────────────
|
# ── forms_agree: unknown never matches ────────────────────────────────────
|
||||||
|
|
||||||
def test_two_known_equal_forms_agree() -> None:
|
def test_two_known_equal_forms_agree() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user