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
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:
@@ -1545,6 +1545,25 @@ _SEMANTIC_LIMIT = 3
|
|||||||
# ledger's standing discipline — the one `FORM_UNKNOWN` enforces everywhere
|
# ledger's standing discipline — the one `FORM_UNKNOWN` enforces everywhere
|
||||||
# else — is that not knowing must make a check quieter, never more confident.
|
# else — is that not knowing must make a check quieter, never more confident.
|
||||||
BASIS_NO_SEMANTIC_MATCH = "no-semantic-match"
|
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
|
# Bump when a basis's rule changes: rows remember the (body, ruleset) they
|
||||||
# were examined under, so a tightened rule re-examines everything once.
|
# were examined under, so a tightened rule re-examines everything once.
|
||||||
# v3: language-family gate on the sym bases, reference stoplist, semantic
|
# v3: language-family gate on the sym bases, reference stoplist, semantic
|
||||||
@@ -1911,6 +1930,7 @@ async def propose_for_repo(
|
|||||||
proposed += 1
|
proposed += 1
|
||||||
elif row.kind == "sym":
|
elif row.kind == "sym":
|
||||||
semantic_todo.append((row, d))
|
semantic_todo.append((row, d))
|
||||||
|
semantic_todo.sort(key=lambda item: _semantic_priority(item[0]))
|
||||||
for i, (row, d) in enumerate(semantic_todo):
|
for i, (row, d) in enumerate(semantic_todo):
|
||||||
if i >= semantic_cap:
|
if i >= semantic_cap:
|
||||||
# Not reached this refresh: leave it unexamined so the next
|
# 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
|
"""Flag shapes created after ``since`` (the previous refresh) that sit
|
||||||
where a canon dominates and were not proposed as that canon. With no
|
where a canon dominates and were not proposed as that canon. With no
|
||||||
previous refresh (first seed) nothing is new, nothing is flagged.
|
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:
|
if since is None:
|
||||||
return 0
|
return 0
|
||||||
async with async_session() as session:
|
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:
|
for r in siblings:
|
||||||
if r.status not in _MECHANICAL_TODO:
|
if r.status not in _MECHANICAL_TODO:
|
||||||
continue
|
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:
|
if r.diverges_from is not None:
|
||||||
flagged += 1
|
flagged += 1
|
||||||
continue
|
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
|
# Only the conclusive miss is stored, so an unexamined row and
|
||||||
# a body too thin to embed still ask the question rather than
|
# a body too thin to embed still ask the question rather than
|
||||||
# being quietly excused.
|
# being quietly excused. (Tested at the top of this loop, where
|
||||||
if r.proposal_basis == BASIS_NO_SEMANTIC_MATCH:
|
# it also withdraws a flag raised before the arm got there.)
|
||||||
continue
|
|
||||||
# The same structural test the write-time check applies
|
# The same structural test the write-time check applies
|
||||||
# (#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
|
||||||
|
|||||||
@@ -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
|
filters BY basis, and a collision there would mean confirming a miss as
|
||||||
though it were a match."""
|
though it were a match."""
|
||||||
assert BASIS_NO_SEMANTIC_MATCH != value
|
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"]
|
||||||
|
|||||||
@@ -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"
|
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
|
@pytest.mark.integration
|
||||||
async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(seeded):
|
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 scribe.services.shape_ledger import shape_history
|
||||||
|
|||||||
Reference in New Issue
Block a user