From 19438cc89429a324488ae1150dac2ea8f15a9b4c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 22 Sep 2026 08:05:49 -0400 Subject: [PATCH 1/2] fix(shapes): the capped meaning pass reads new shapes first, and its miss withdraws an early flag (#4208) Measured on the first live refresh after 91cde6c deployed: the version bump queued 1,533 rows for the semantic arm, the cap read 150 of them in row order, and all six shapes new since the previous refresh - the only rows flag_divergence acts on, and the highest ids - were flagged before the arm reached them. The gate silenced nothing because it never got to look, and since a flag persists until judged, a later conclusive miss could not take it back. - propose_for_repo sorts the semantic todo by _semantic_priority: unclassified before scoped, newest first. - flag_divergence withdraws a standing flag when the row now carries a conclusive miss; that evidence alone withdraws one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- src/scribe/services/shape_ledger.py | 40 ++++++++- tests/test_divergence_meaning_gate.py | 30 +++++++ tests/test_integration_shape_classify.py | 103 +++++++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index bff44ae..fb2c05d 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -1545,6 +1545,25 @@ _SEMANTIC_LIMIT = 3 # ledger's standing discipline — the one `FORM_UNKNOWN` enforces everywhere # else — is that not knowing must make a check quieter, never more confident. BASIS_NO_SEMANTIC_MATCH = "no-semantic-match" + + +def _semantic_priority(row) -> tuple: + """Order for the capped semantic pass: the rows its verdict can still act on + come first (#4208, measured on the first live refresh after it shipped). + + The pass is capped (`_SEMANTIC_CAP`), and `flag_divergence` acts only on + shapes NEW since the previous refresh. In plain row order those are the + highest ids — the back of the queue — so whenever the backlog exceeds the + cap (every `_PROPOSER_VERSION` bump queues the whole todo at once), the + cap is spent on old rows and the new ones are flagged as "cannot tell" + before the arm ever reads them. That is what happened: 1,533 rows queued, + 150 checked, and all six new shapes flagged unexamined. + + So: the human todo (`unclassified`) before `scoped`, newest first within + each. A row with no creation time sorts last in its group. + """ + created = row.created_at.timestamp() if row.created_at is not None else float("-inf") + return (row.status != "unclassified", -created) # Bump when a basis's rule changes: rows remember the (body, ruleset) they # were examined under, so a tightened rule re-examines everything once. # v3: language-family gate on the sym bases, reference stoplist, semantic @@ -1911,6 +1930,7 @@ async def propose_for_repo( proposed += 1 elif row.kind == "sym": semantic_todo.append((row, d)) + semantic_todo.sort(key=lambda item: _semantic_priority(item[0])) for i, (row, d) in enumerate(semantic_todo): if i >= semantic_cap: # Not reached this refresh: leave it unexamined so the next @@ -2466,7 +2486,9 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int: """Flag shapes created after ``since`` (the previous refresh) that sit where a canon dominates and were not proposed as that canon. With no previous refresh (first seed) nothing is new, nothing is flagged. - Standing flags persist until judged. Returns how many are flagged.""" + Standing flags persist until judged — or until the proposer's semantic + arm reports a conclusive miss for the row (#4208). Returns how many are + flagged.""" if since is None: return 0 async with async_session() as session: @@ -2490,6 +2512,17 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int: for r in siblings: if r.status not in _MECHANICAL_TODO: continue + # A conclusive miss outranks a standing flag, and is checked + # before it. A flag raised on a row the semantic arm had not + # yet reached was raised on "cannot tell" — the arm is capped, + # so that is routine — and once the arm has read the body and + # found it is none of the canons, keeping the prompt would be + # asserting over a measurement we now hold. Only this evidence + # withdraws a flag; nothing else changes "persist until judged". + if r.proposal_basis == BASIS_NO_SEMANTIC_MATCH: + if r.diverges_from is not None: + r.diverges_from = None + continue if r.diverges_from is not None: flagged += 1 continue @@ -2514,9 +2547,8 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int: # # Only the conclusive miss is stored, so an unexamined row and # a body too thin to embed still ask the question rather than - # being quietly excused. - if r.proposal_basis == BASIS_NO_SEMANTIC_MATCH: - continue + # being quietly excused. (Tested at the top of this loop, where + # it also withdraws a flag raised before the arm got there.) # The same structural test the write-time check applies # (#4204). The sweep and the hook must agree about what counts # as divergence, or an audit contradicts the line the writer diff --git a/tests/test_divergence_meaning_gate.py b/tests/test_divergence_meaning_gate.py index 0e18e28..4f172a5 100644 --- a/tests/test_divergence_meaning_gate.py +++ b/tests/test_divergence_meaning_gate.py @@ -180,3 +180,33 @@ def test_the_miss_basis_is_not_one_of_the_proposal_bases(value: str) -> None: filters BY basis, and a collision there would mean confirming a miss as though it were a match.""" assert BASIS_NO_SEMANTIC_MATCH != value + + +# ── the cap spends itself on the rows its verdict can act on ───────────── + + +class _AgedRow: + def __init__(self, name: str, status: str, created) -> None: + self.name, self.status, self.created_at = name, status, created + + +def test_the_capped_pass_reads_new_shapes_first() -> None: + """Measured on the first live refresh after the gate shipped: a version + bump queued 1,533 rows, the cap read 150 of them in row order, and every + shape NEW since the previous refresh — the only rows `flag_divergence` + acts on, and the highest ids — was flagged before the arm reached it. The + gate silenced nothing because it never got to look.""" + from datetime import datetime, timedelta, timezone + + from scribe.services.shape_ledger import _semantic_priority + + t = datetime(2026, 9, 22, tzinfo=timezone.utc) + rows = [ + _AgedRow("scoped_new", "scoped", t), + _AgedRow("old", "unclassified", t - timedelta(days=30)), + _AgedRow("undated", "unclassified", None), + _AgedRow("new", "unclassified", t), + _AgedRow("scoped_old", "scoped", t - timedelta(days=30)), + ] + order = [r.name for r in sorted(rows, key=_semantic_priority)] + assert order == ["new", "old", "undated", "scoped_new", "scoped_old"] diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index d7943c6..eb4b62e 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -974,6 +974,109 @@ async def test_a_conclusive_meaning_miss_silences_what_the_signature_cannot(seed assert total == 0, "a shape the proposer measured as unrelated must not be urged" +async def _two_generations(seeded, tag: str): + """A canon-dense directory, an OLD unjudged helper, then one NEW shape. + + The same density as the acceptance fixture above, with an extra unjudged + row from the first sync so there is an old row competing with the new one + for the capped semantic pass. + """ + from datetime import datetime, timezone + + from scribe.services import snippets as snippets_svc + + owner, pid = seeded["owner"], seeded["pid"] + canon = await snippets_svc.create_snippet( + owner, name=f"cls_confirm_factory_{tag}", + code="export async function factory(): Promise {\n return true;\n}\n", + language="typescript", repo="Widget", + path="frontend/src/composables/useConfirm.ts", symbol="factory", + project_id=pid, + ) + sid = int(canon.id) + comp = "frontend/src/components" + base = _defs( + *[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{", + f"async function on{n}() {{\n const ok = await factory();\n if (!ok) return;\n}}") + for n in ("Trash", "Delete", "Remove", "Restore")], + (f"{comp}/Old.vue", "sym", "oldHelper", "function oldHelper() {", + "function oldHelper() {\n return document.title.length > 0;\n}"), + ) + await sync_repo_shapes(pid, REPO, base, seen_marker=f"{tag}-1") + await classify_shapes(owner, pid, [ + {"path": f"{comp}/{n}.vue", "symbol": f"on{n}", "status": "instance", "snippet_id": sid} + for n in ("Trash", "Delete", "Remove", "Restore") + ], via="audit") + previous = datetime.now(timezone.utc) + later = base + _defs( + (f"{comp}/Danger.vue", "sym", "confirmDanger", "function confirmDanger() {", + "function confirmDanger() {\n return window.confirm('Really?');\n}"), + ) + await sync_repo_shapes(pid, REPO, later, seen_marker=f"{tag}-2") + return owner, pid, later, previous + + +def _conclusive(*_a, report=None, **_k): + if report is not None: + report["conclusive"] = True + return None + + +@pytest.mark.integration +async def test_the_capped_semantic_pass_reaches_the_new_shape_first(seeded): + """Measured live: after the #4208 version bump, 1,533 rows competed for 150 + semantic checks in row order and the six new shapes — the only ones the + divergence check acts on — were all left unread and flagged. With room for + ONE check, the new shape must be the one that gets it.""" + from unittest.mock import AsyncMock, patch + + from scribe.services import shape_ledger + from scribe.services.shape_ledger import ( + BASIS_NO_SEMANTIC_MATCH, live_rows, propose_for_repo, + ) + + owner, pid, later, _previous = await _two_generations(seeded, "cap") + with patch.object(shape_ledger, "_semantic_canon", + AsyncMock(side_effect=_conclusive)): + stats = await propose_for_repo(owner, pid, REPO, later, semantic_cap=1) + assert stats["semantic_checked"] == 1 + + rows = {r.symbol: r for r in await live_rows(pid)} + assert rows["confirmDanger"].proposal_basis == BASIS_NO_SEMANTIC_MATCH + # The old row is left for the next refresh, unexamined — never stamped. + assert rows["oldHelper"].proposal_basis is None + assert rows["oldHelper"].proposed_sha == "" + + +@pytest.mark.integration +async def test_a_later_conclusive_miss_withdraws_a_flag_raised_before_it(seeded): + """A flag raised while the arm had not yet read the row was raised on + "cannot tell". When a later refresh reads the body and finds it is none of + the canons, the flag goes — and only that evidence withdraws one.""" + from datetime import timedelta + from unittest.mock import AsyncMock, patch + + from scribe.services import shape_ledger + from scribe.services.shape_ledger import flag_divergence, propose_for_repo + + owner, pid, later, previous = await _two_generations(seeded, "withdraw") + since = previous - timedelta(seconds=1) + + # First refresh: the arm is quiet (formed no opinion), so the prompt is raised. + with patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)): + await propose_for_repo(owner, pid, REPO, later) + assert await flag_divergence(pid, since=since) == 1 + + # A later refresh re-reads the body (a ruleset bump forces it) and is sure. + with patch.object(shape_ledger, "_PROPOSER_VERSION", 10_000), \ + patch.object(shape_ledger, "_semantic_canon", + AsyncMock(side_effect=_conclusive)): + await propose_for_repo(owner, pid, REPO, later) + assert await flag_divergence(pid, since=since) == 0 + _, total = await list_project_shapes(owner, pid, flag="divergence") + assert total == 0 + + @pytest.mark.integration async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded): from scribe.services.shape_ledger import shape_history From e2197ac799587bad3abf93563295145dd543b070 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 22 Sep 2026 08:09:52 -0400 Subject: [PATCH 2/2] fix(tests): assert the withdrawn flag on its row, not on a count the skew margin inflates (#4208) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- tests/test_integration_shape_classify.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index eb4b62e..74e2578 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -1057,24 +1057,36 @@ async def test_a_later_conclusive_miss_withdraws_a_flag_raised_before_it(seeded) from unittest.mock import AsyncMock, patch from scribe.services import shape_ledger - from scribe.services.shape_ledger import flag_divergence, propose_for_repo + from scribe.services.shape_ledger import ( + BASIS_NO_SEMANTIC_MATCH, flag_divergence, live_rows, propose_for_repo, + ) owner, pid, later, previous = await _two_generations(seeded, "withdraw") since = previous - timedelta(seconds=1) + async def _danger(): + return next(r for r in await live_rows(pid) if r.symbol == "confirmDanger") + + # Asserted on the ROW, not on a flag count. `since` sits a second before + # the second sync to absorb clock skew, which also admits the fixture's + # old helper as "new" — how many rows get flagged is a property of that + # margin, and this test is about one row's flag coming off. + # First refresh: the arm is quiet (formed no opinion), so the prompt is raised. with patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None)): await propose_for_repo(owner, pid, REPO, later) - assert await flag_divergence(pid, since=since) == 1 + await flag_divergence(pid, since=since) + assert (await _danger()).diverges_from is not None # A later refresh re-reads the body (a ruleset bump forces it) and is sure. with patch.object(shape_ledger, "_PROPOSER_VERSION", 10_000), \ patch.object(shape_ledger, "_semantic_canon", AsyncMock(side_effect=_conclusive)): await propose_for_repo(owner, pid, REPO, later) - assert await flag_divergence(pid, since=since) == 0 - _, total = await list_project_shapes(owner, pid, flag="divergence") - assert total == 0 + await flag_divergence(pid, since=since) + danger = await _danger() + assert danger.proposal_basis == BASIS_NO_SEMANTIC_MATCH + assert danger.diverges_from is None @pytest.mark.integration