fix(shapes): the capped meaning pass reads new shapes first, and its miss withdraws an early flag (#4208)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Failing after 51s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 21s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-22 08:05:49 -04:00
co-authored by Claude Opus 5
parent cef91f4cd4
commit 19438cc894
3 changed files with 169 additions and 4 deletions
+36 -4
View File
@@ -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
+30
View File
@@ -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"]
+103
View File
@@ -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<boolean> {\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