fix(shapes): let a measured meaning-miss silence a divergence prompt (#4208)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 28s

#4204 gave the divergence check a structural gate, which silenced one of the
five false prompts it was filed for. The other four are `def` helpers in a
directory whose canon is an `async def` service unit. No refinement of
`shape_form` reaches them: they differ from #2793's acceptance case — a
hand-rolled sync `confirmDanger` where an async confirm helper is canon —
only by the JOB they do, and a signature does not carry a job.

The proposer's semantic arm already reads bodies per symbol, which is the
comparison option 2 asked for and was thought to be missing. What it did not
do was record its MISSES: a hit became `proposal_basis = "semantic"`, a miss
left the row indistinguishable from one nobody had looked at. So
`flag_divergence` could ask the proposer "do you agree this is the canon?"
but never "did you check, and is it not?".

`_semantic_canon` now reports whether an empty answer is evidence, and
`flag_divergence` withholds the prompt when it is.

The whole risk is in the negative, so only a conclusive miss is stored. A
body too thin to embed, a row the per-refresh cap never reached, an arm that
threw, and a result set that came back full — and may therefore have hidden
the canon behind the limit — all stay "cannot tell" and still ask the
question. That is the discipline `FORM_UNKNOWN` already enforces here: not
knowing must make a check quieter, never more confident. `_SEMANTIC_LIMIT` is
named for that reason; the number is load-bearing, not a tuning knob.

No migration: `proposal_basis` is nullable Text with no CHECK constraint
(verified in the model and across alembic/versions), so rule 36 does not
bite. Nothing can mistake the miss for a proposal either — every reader keys
on `proposed_snippet_id` or `proposal_group`, and `confirm_shape_proposals`
requires the id non-NULL before it will confirm anything.

`_PROPOSER_VERSION` 3 -> 4, per its own contract: rows remember the ruleset
they were examined under, and without the bump no already-examined row would
ever acquire a miss.

Option 1 (widening `kind`) stays closed, on the merits rather than on cost:
bucketing density by exact form takes the async canon out of a sync
candidate's denominator and silences #2793's acceptance case by the identical
mechanism, one layer down. The reasoning is on #4208.

Tests: tests/test_divergence_meaning_gate.py pins the report contract, with
the truncation case tested hardest — reading a cut-off as a negative would
weaken the guard in proportion to how many snippets the operator has. The
end-to-end discrimination is in test_integration_shape_classify.py on
deliberately the SAME fixture as #2793's acceptance case, so the two runs
differ in exactly one thing: whether the arm claims to have looked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 20:35:55 -04:00
co-authored by Claude Opus 5
parent d49e4ad106
commit 91cde6c3e4
3 changed files with 353 additions and 4 deletions
+85
View File
@@ -889,6 +889,91 @@ async def test_a_second_confirm_dialog_is_detected_and_named(seeded):
assert total == 0
@pytest.mark.integration
async def test_a_conclusive_meaning_miss_silences_what_the_signature_cannot(seeded):
"""#4208: the four false prompts #4204's form gate provably cannot reach.
THE FIXTURE IS THE ACCEPTANCE CASE ABOVE, DELIBERATELY. That is the whole
difficulty of this issue: a hand-rolled `confirmDanger` beside an async
confirm canon is structurally IDENTICAL to a registry helper beside an
async service canon — same family, same form contradiction, same directory
density. The form gate has to keep asking about both, so nothing derived
from a signature can separate them. The only difference is whether the
shape does the canon's JOB, and the only reading of that the ledger holds
is the proposer's per-symbol body comparison.
So the two runs differ in exactly one thing. In the test above the semantic
arm is quiet — it answers "nothing" without claiming to have looked — and
the prompt is RAISED, which is what milestone #2793 exists to produce. Here
it answers "I compared this body against the canons in its family and it is
none of them", and the prompt is WITHHELD. Holding the fixture identical is
what makes this a test of the meaning gate rather than of the setup.
Asserted on the stored basis as well as the outcome, so that a future
change which silences the prompt for some other reason fails here instead
of reading as a pass.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from scribe.services import shape_ledger
from scribe.services import snippets as snippets_svc
from scribe.services.shape_ledger import (
BASIS_NO_SEMANTIC_MATCH, flag_divergence, live_rows, propose_for_repo,
)
owner, pid = seeded["owner"], seeded["pid"]
canon = await snippets_svc.create_snippet(
owner, name="cls_confirm_factory_meaning",
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")],
)
await sync_repo_shapes(pid, REPO, base, seen_marker="aaa111")
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="bbb222")
def _conclusive_miss(*_args, report=None, **_kw):
"""The arm ran, compared, and found no canon — the one empty answer
that is evidence. `_semantic_canon` itself decides when it may say
this (a result set shorter than the limit); the unit tests for that
judgment are in tests/test_divergence_meaning_gate.py."""
if report is not None:
report["conclusive"] = True
return None
with patch.object(shape_ledger, "_semantic_canon",
AsyncMock(side_effect=_conclusive_miss)):
await propose_for_repo(owner, pid, REPO, later)
rows = await live_rows(pid)
danger = next(r for r in rows if r.symbol == "confirmDanger")
assert danger.proposal_basis == BASIS_NO_SEMANTIC_MATCH
# The miss is not a proposal: nothing may read it as one.
assert danger.proposed_snippet_id is None
assert await flag_divergence(pid, since=previous - timedelta(seconds=1)) == 0
_, total = await list_project_shapes(owner, pid, flag="divergence")
assert total == 0, "a shape the proposer measured as unrelated must not be urged"
@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