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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user