From 386b27e422c4232dccf1428520cc9c515970ea54 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 20 Aug 2026 21:36:10 -0400 Subject: [PATCH] =?UTF-8?q?fix(ledger):=20the=20proposer=20prefers=20a=20s?= =?UTF-8?q?ame-project=20canon=20on=20a=20tie=20=E2=80=94=20family=20canon?= =?UTF-8?q?=20elsewhere=20is=20the=20fallback,=20not=20the=20first=20hit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/scribe/services/shape_ledger.py | 33 +++++++++++++++++------------ tests/test_shape_ledger.py | 19 +++++++++++++++-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index f214b1f..49fef0c 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -685,6 +685,7 @@ class Canon(NamedTuple): locations: tuple[tuple[str, str], ...] signature: str code_norm: str + project_id: int = 0 def _norm_text(text: str) -> str: @@ -718,17 +719,20 @@ def text_contains(body: str, code: str) -> bool: def match_canon( kind: str, path: str, symbol: str, signature: str, body: str, - canons: Iterable[Canon], + canons: Iterable[Canon], *, project_id: int = 0, ) -> tuple[int, str, float] | None: """The strongest (snippet_id, basis, score) a shape earns against the canon catalog, by the non-semantic bases — or None. Strongest by - basis order, then by score within the basis.""" - best: dict[str, tuple[float, int]] = {} + basis order, then by score within the basis; on a tie, a canon recorded + in the shape's own project beats family canon from another (the same + helper recorded in two projects is the shape's own first).""" + best: dict[str, tuple[float, bool, int]] = {} - def offer(basis: str, score: float, sid: int) -> None: + def offer(basis: str, score: float, canon: Canon) -> None: cur = best.get(basis) - if cur is None or score > cur[0]: - best[basis] = (score, sid) + same = bool(project_id) and canon.project_id == project_id + if cur is None or (score, same) > (cur[0], cur[1]): + best[basis] = (score, same, canon.snippet_id) norm_sym = _norm_symbol(symbol) for c in canons: @@ -736,19 +740,19 @@ def match_canon( continue if c.symbol and _norm_symbol(c.symbol) == norm_sym: if not any(location_covers(lp, ls, path, symbol) for lp, ls in c.locations): - offer("symbol", 1.0, c.snippet_id) + offer("symbol", 1.0, c) continue # its own location is canonical territory, not a proposal if c.symbol and references_symbol(body, c.symbol, kind): - offer("reference", 0.9, c.snippet_id) + offer("reference", 0.9, c) if c.code_norm and text_contains(body, c.code_norm): - offer("text", 0.95, c.snippet_id) + offer("text", 0.95, c) if kind == "sym" and c.signature: ratio = signature_similarity(signature, symbol, c.signature, c.symbol) if ratio >= _SIGNATURE_FLOOR: - offer("signature", round(ratio, 3), c.snippet_id) + offer("signature", round(ratio, 3), c) for basis in _BASIS_ORDER: if basis in best: - score, sid = best[basis] + score, _same, sid = best[basis] return (sid, basis, score) return None @@ -790,7 +794,7 @@ async def canon_catalog(user_id: int) -> list[Canon]: ((loc.get("path") or ""), (loc.get("symbol") or "")) for loc in fields.get("locations") or [] ), - signature, _norm_text(code), + signature, _norm_text(code), int(note.project_id or 0), )) return out @@ -872,7 +876,10 @@ async def propose_for_repo( continue examined += 1 group = row.proposal_group # derive grouping is reassigned below - hit = match_canon(row.kind, row.path, row.symbol, signature, body, canons) + hit = match_canon( + row.kind, row.path, row.symbol, signature, body, canons, + project_id=project_id, + ) _clear_proposal(row) row.proposal_group = group row.proposed_at = now diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index e98b45d..9c333bc 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -190,9 +190,24 @@ def test_text_containment_is_whitespace_insensitive_with_a_floor(): assert not text_contains("x = 1", "x = 1") # below the substance floor -def _canon(sid, kind="sym", symbol="", locations=(), signature="", code=""): +def _canon(sid, kind="sym", symbol="", locations=(), signature="", code="", project_id=0): from scribe.services.shape_ledger import Canon, _norm_text - return Canon(sid, kind, symbol, tuple(locations), signature, _norm_text(code)) + return Canon(sid, kind, symbol, tuple(locations), signature, _norm_text(code), project_id) + + +def test_match_canon_prefers_the_shapes_own_project_on_a_tie(): + """The same helper recorded in two projects: the shape's own project's + record is its canon; family canon elsewhere is the fallback.""" + from scribe.services.shape_ledger import match_canon + family = _canon(3, "sym", "slugify", [("lib/text.py", "slugify")], project_id=1) + own = _canon(4, "sym", "slugify", [("src/util/text.py", "slugify")], project_id=2) + assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "", + [family, own], project_id=2) == (4, "symbol", 1.0) + assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "", + [family, own], project_id=1) == (3, "symbol", 1.0) + # No project given → first-best stands; nothing breaks. + assert match_canon("sym", "src/other.py", "slugify", "def slugify(t):", "", + [family, own])[1] == "symbol" def test_match_canon_orders_bases_strongest_first_and_respects_kind():