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
+86 -4
View File
@@ -1521,11 +1521,37 @@ _SEMANTIC_CAP = 150
# "both are about migrations"; first live run paired every alembic
# upgrade()/downgrade() with an unrelated canon at exactly that band.
_SEMANTIC_FLOOR = 0.8
# How many above-floor hits the semantic arm asks for. Named because the
# NUMBER is load-bearing twice over: it caps the work, and a result set that
# came back short of it is a complete picture of what cleared the floor —
# which is what lets a miss be read as evidence rather than as a cut-off
# (`BASIS_NO_SEMANTIC_MATCH`).
_SEMANTIC_LIMIT = 3
# The proposer looked at this body, compared it against every canon in its
# language family, and matched none of them above `_SEMANTIC_FLOOR` (#4208).
#
# This is a NEGATIVE RESULT, and it is stored because it is the only evidence
# in the ledger that speaks to what a shape MEANS rather than what it looks
# like. `proposal_basis` otherwise names how a proposal was arrived at; here
# it records that the arm ran and came back empty, with `proposed_snippet_id`
# left NULL. Every reader keys "is there a proposal" on `proposed_snippet_id`
# or `proposal_group`, never on the basis, so this cannot be mistaken for one:
# `list_shapes(proposal=...)` and `confirm_shape_proposals` both filter on the
# id, and the latter requires it non-NULL before it will confirm anything.
#
# It is deliberately NOT written for the two cases that merely look the same:
# a body too thin to compare (`_substance` below the write-path minimum), and
# a row the per-refresh cap never reached. Those are "I cannot tell", and the
# 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"
# 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
# restricted to the shape's own project (#2871).
_PROPOSER_VERSION = 3
# v4: the semantic arm records its misses as well as its hits (#4208), so
# every already-examined row must be looked at once more to acquire one.
_PROPOSER_VERSION = 4
# Signature resemblance floor, name blanked (difflib ratio) — and a length
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
# nothing; a family shape has parameters to resemble.
@@ -1760,8 +1786,29 @@ def _substance(text: str) -> int:
async def _semantic_canon(
user_id: int, body: str, allowed: set[int]
user_id: int, body: str, allowed: set[int], *, report: dict | None = None
) -> tuple[int, float] | None:
"""The canon this body MEANS, or None.
`report` is an out-param in the style `semantic_search_notes` already
uses, and it carries the one thing the return value cannot: whether a
None is EVIDENCE. `report["conclusive"] = True` says the arm really
compared this body against the allowed canons and none cleared the floor.
It is left unset whenever the arm could not form an opinion — a body with
too little substance to embed, no allowed canon to compare against, or a
result set that came back full and may therefore have been truncated.
The truncation case is why `_SEMANTIC_LIMIT` is named. The search returns
the top N above the floor; if it returns fewer than N, N was not binding
and we have seen everything that cleared the floor, so "no allowed canon
among them" is a fact about the corpus. If it returns exactly N, an
allowed canon could be sitting at N+1 and the same silence means nothing.
Reading the second case as the first is how a cut-off becomes a finding.
Callers must treat a missing key as "cannot tell", never as "no match"
which is also what makes the existing test double, an `AsyncMock` that
returns None and touches no report, stay correct by default.
"""
from scribe.services.embeddings import semantic_search_notes
from scribe.services.plugin_context import (
WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query,
@@ -1771,13 +1818,15 @@ async def _semantic_canon(
return None
query = concept_query(body) or body
hits = await semantic_search_notes(
user_id, query, limit=3,
user_id, query, limit=_SEMANTIC_LIMIT,
threshold=max(WRITEPATH_DEFAULT_THRESHOLD, _SEMANTIC_FLOOR),
note_type="snippet", scope="browse",
)
for score, note in hits:
if int(note.id) in allowed:
return int(note.id), round(float(score), 3)
if report is not None and len(hits) < _SEMANTIC_LIMIT:
report["conclusive"] = True
return None
@@ -1870,16 +1919,29 @@ async def propose_for_repo(
row.proposed_sha = ""
continue
checked += 1
verdict: dict = {}
try:
found = await _semantic_canon(user_id, d[5], semantic_allowed(row.path))
found = await _semantic_canon(
user_id, d[5], semantic_allowed(row.path), report=verdict,
)
except Exception:
logger.warning("semantic proposal failed", exc_info=True)
found = None
# An arm that threw formed no opinion. Clearing this is not
# belt-and-braces: a partially-filled report would record a
# failure as a finding about the code.
verdict = {}
if found:
row.proposed_snippet_id, row.proposal_score = found
row.proposal_basis = "semantic"
row.proposal_group = None
proposed += 1
elif verdict.get("conclusive"):
# No canon, and the arm is sure of it. Kept as the row's basis
# with `proposed_snippet_id` still NULL, so it reads as "asked
# and answered" rather than "not asked" — the distinction
# `flag_divergence` needs and could not previously make.
row.proposal_basis = BASIS_NO_SEMANTIC_MATCH
await session.commit()
return {"examined": examined, "proposed": proposed, "semantic_checked": checked}
@@ -2435,6 +2497,26 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
continue
if r.proposed_snippet_id == dom[0]:
continue # the proposer already says "instance of the canon"
# ...and the converse, which is the only evidence here that
# is about MEANING rather than shape (#4208). The four false
# prompts #4204 left standing are callables in a directory of
# callables: at the signature level they are indistinguishable
# from #2793's acceptance case, a sync `confirmDanger` beside
# an async confirm canon, and no refinement of `shape_form`
# ever separates them — a registry accessor and a service unit
# differ by the JOB they do, which a signature does not carry.
#
# The proposer does read bodies, and when its semantic arm
# compared this one against every canon in its language family
# and matched none of them, that is a positive finding that
# this shape is not the canon's work. Urging the canon anyway
# would be asserting over a measurement we already hold.
#
# 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
# 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