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