feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped

Every judgment now goes through one helper that remembers the fingerprint
judged (classified_sha) and writes a code_shape_events row; the sync writes
vanished / reappeared / drifted events and flags recheck_at when a body
moves under an instance/variant. The refresh flags diverges_from on shapes
new since the previous computation that sit where one canon dominates the
judged siblings of their directory+kind and were not proposed as that canon
(a first seed flags nothing); the write-path hint asks the same question
in-band for the shapes the hook names. list_shapes(flag=divergence|recheck),
shape_history(project_id, path, symbol) (read-only), coverage line/payload/
card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:13:39 -04:00
co-authored by Claude Fable 5
parent 386b27e422
commit d74a244b3a
17 changed files with 880 additions and 62 deletions
+294 -43
View File
@@ -30,7 +30,7 @@ from typing import Iterable, NamedTuple
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
from scribe.models.code_shape import CodeShape, CodeShapeEvent
logger = logging.getLogger(__name__)
@@ -111,11 +111,25 @@ async def sync_repo_shapes(
row.last_seen_commit = seen_marker
if signature:
row.signature = signature
if body_sha:
if body_sha and body_sha != row.body_sha:
judged = row.status in ("instance", "variant")
if judged and not row.classified_sha:
# Judged before fingerprints existed: the first sync
# that sees a body adopts it as the judged content.
row.classified_sha = body_sha
elif judged and row.classified_sha != body_sha and row.recheck_at is None:
# The body moved under a standing judgment: the judgment
# stands, but asks to be confirmed again (#2793).
row.recheck_at = now
session.add(_event(row, "drifted", now, commit=seen_marker))
row.body_sha = body_sha
elif row.status in ("instance", "variant") and not row.classified_sha:
row.classified_sha = row.body_sha
# A shape that vanished and came back is live again — the vanish
# stays visible in history via updated_at, not as a dead flag.
row.vanished_at = None
if row.vanished_at is not None:
row.vanished_at = None
session.add(_event(row, "reappeared", now, commit=seen_marker))
if row.status in _NEEDS_TARGET and row.snippet_id is None:
row.status = "unclassified"
row.classified_by = None
@@ -124,9 +138,50 @@ async def sync_repo_shapes(
for key, row in by_key.items():
if key not in seen and row.vanished_at is None:
row.vanished_at = now
session.add(_event(row, "vanished", now, commit=row.last_seen_commit))
await session.commit()
def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> CodeShapeEvent:
"""A history row for a state change on ``row`` — status/snippet/by/reason
are the row's CURRENT values, which for `classified` is the judgment
just made and for presence events is the standing one."""
return CodeShapeEvent(
shape_id=row.id, project_id=row.project_id,
path=row.path, symbol=row.symbol, kind=row.kind,
event=event, status=row.status, snippet_id=row.snippet_id,
classified_by=row.classified_by, reason=row.reason,
commit=commit or row.last_seen_commit or "", at=at,
)
async def _judge(
session, row: CodeShape, *, status: str, snippet_id: int | None,
by: str | None, reason: str | None, at: datetime,
) -> None:
"""Apply a judgment to a row — the ONE place a status is set — and write
its history. Clears what a judgment settles: the standing proposal, the
recheck ask, the divergence flag; remembers the fingerprint judged.
`unclassified` is the withdrawal: fields clear, the examination is
forgotten so the proposer looks again, and history records the
withdrawal too."""
row.status = status
row.snippet_id = snippet_id if status in _NEEDS_TARGET else None
row.reason = (reason or "").strip() or None
row.classified_by = by if status != "unclassified" else None
row.classified_at = at if status != "unclassified" else None
row.classified_sha = row.body_sha if status != "unclassified" else ""
row.recheck_at = None
row.diverges_from = None
_clear_proposal(row, reexamine=(status == "unclassified"))
if row.id is None:
# A provisional row (hook stamp on a shape not yet synced): flush so
# the event can point at it.
session.add(row)
await session.flush()
session.add(_event(row, "classified", at))
async def mark_canonicals(
project_id: int, recorded: list[tuple[int, str, str]]
) -> None:
@@ -159,19 +214,15 @@ async def mark_canonicals(
None,
)
if covering is not None and row.status == "unclassified":
row.status = "canonical"
row.snippet_id = covering
row.classified_by = "mechanical"
row.classified_at = now
await _judge(session, row, status="canonical", snippet_id=covering,
by="mechanical", reason=None, at=now)
elif (
covering is None
and row.status == "canonical"
and row.classified_by == "mechanical"
):
row.status = "unclassified"
row.snippet_id = None
row.classified_by = None
row.classified_at = None
await _judge(session, row, status="unclassified", snippet_id=None,
by=None, reason=None, at=now)
await session.commit()
@@ -308,23 +359,11 @@ async def classify_shapes(
continue
status = item["status"]
for row in matches:
row.status = status
# The machine proposes, judgment classifies: any judgment
# retires the standing proposal; a withdrawal also forgets
# the examination so the next refresh proposes afresh.
_clear_proposal(row, reexamine=(status == "unclassified"))
if status == "unclassified":
row.snippet_id = None
row.reason = None
row.classified_by = None
row.classified_at = None
else:
row.snippet_id = (
int(item["snippet_id"]) if status in _NEEDS_TARGET else None
)
row.reason = (item.get("reason") or "").strip() or None
row.classified_by = via
row.classified_at = now
await _judge(
session, row, status=status,
snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None,
by=via, reason=item.get("reason"), at=now,
)
classified += 1
await session.commit()
return {"classified": classified, "unmatched": unmatched}
@@ -341,6 +380,7 @@ async def list_project_shapes(
limit: int = 100,
offset: int = 0,
proposal: str = "",
flag: str = "",
) -> tuple[list[CodeShape], int]:
"""A filtered page of a project's ledger, with the unfiltered-match total.
@@ -349,7 +389,9 @@ async def list_project_shapes(
beneath it, mirroring recorded-location semantics. ``proposal`` narrows
to rows the proposer has spoken about: "any", "canon" (an instance-of-#N
suggestion), "derive" (a repeats-with-no-canon group), or one basis
name (symbol/reference/text/signature/semantic).
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
the readout's asks (#2793): "divergence" (new where a canon dominates,
`diverges_from` names it) or "recheck" (a judged shape whose body moved).
"""
from sqlalchemy import func, or_
@@ -380,6 +422,10 @@ async def list_project_shapes(
conds.append(CodeShape.proposal_group.isnot(None))
elif proposal:
conds.append(CodeShape.proposal_basis == proposal)
if flag == "divergence":
conds.append(CodeShape.diverges_from.isnot(None))
elif flag == "recheck":
conds.append(CodeShape.recheck_at.isnot(None))
async with async_session() as session:
total = (
await session.execute(
@@ -615,12 +661,8 @@ async def stamp_write_path_instances(
by_key[(name, kind)] = row
elif not (row.status == "unclassified" or row.classified_by == "hook"):
continue # a judgment — or the canon itself — stands
row.status = "instance"
row.snippet_id = sid
row.reason = why
row.classified_by = "hook"
row.classified_at = now
_clear_proposal(row)
await _judge(session, row, status="instance", snippet_id=sid, by="hook",
reason=why, at=now)
stamped.append({
"path": path, "symbol": name, "kind": kind,
"snippet_id": sid, "reason": why,
@@ -1051,15 +1093,224 @@ async def confirm_proposals(
for row in rows:
if (row.proposal_score or 0.0) < min_score:
continue
row.status = "instance"
row.snippet_id = row.proposed_snippet_id
row.reason = (
f"confirmed {row.proposal_basis} proposal"
f" ({(row.proposal_score or 0.0):.2f})"
await _judge(
session, row, status="instance", snippet_id=row.proposed_snippet_id,
by="agent", at=now,
reason=(
f"confirmed {row.proposal_basis} proposal"
f" ({(row.proposal_score or 0.0):.2f})"
),
)
row.classified_by = "agent"
row.classified_at = now
_clear_proposal(row)
confirmed += 1
await session.commit()
return {"confirmed": confirmed}
# --- the divergence readout (#2793): button B where button A is canon -------
#
# Three answers the ledger can now give mechanically:
# DIVERGENCE a shape NEW since the previous refresh, in a directory+kind
# where one canon dominates the judged siblings, that the
# proposer did not match to that canon → `diverges_from=#N`.
# Read: "button B appeared where button A is canon — divergence
# or variant? classify it." Surfaced in the coverage readout and
# in-band at write time (the prior-art hook names the shapes).
# HISTORY every judgment / vanish / reappearance / drift is an event;
# shape_history answers "what was used here, when, and why".
# RECHECK an instance/variant whose body moved since it was judged is
# flagged recheck_at (sync) — the judgment stands, re-confirm it.
# A canon dominates a directory+kind when at least this many siblings are
# judged (canonical/instance) and this share of them answer to one snippet.
_DENSITY_MIN_JUDGED = 3
_DENSITY_SHARE = 0.6
def dominant_canon(rows: Iterable[CodeShape]) -> tuple[int, int, int] | None:
"""(snippet_id, its_count, judged_count) when one canon dominates these
sibling rows (same directory + kind), else None."""
counts: dict[int, int] = {}
judged = 0
for r in rows:
if r.status in ("canonical", "instance") and r.snippet_id is not None:
judged += 1
counts[r.snippet_id] = counts.get(r.snippet_id, 0) + 1
if judged < _DENSITY_MIN_JUDGED or not counts:
return None
sid, n = max(counts.items(), key=lambda kv: (kv[1], -kv[0]))
if n / judged < _DENSITY_SHARE:
return None
return sid, n, judged
def _dir_of(path: str) -> str:
return path.rsplit("/", 1)[0] if "/" in path else ""
async def canon_density(project_id: int, path: str, kind: str) -> tuple[int, int, int] | None:
"""The dominant canon for the directory ``path`` sits in, for ``kind`` —
the write-time question "is this a canon-dense place?"."""
directory = _dir_of(path)
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.kind == kind,
CodeShape.vanished_at.is_(None),
CodeShape.path.like(directory + "/%") if directory
else CodeShape.path.notlike("%/%"),
)
)
).scalars().all()
siblings = [r for r in rows if _dir_of(r.path) == directory]
return dominant_canon(siblings)
async def write_time_divergence(
project_id: int, path: str, shapes: list[tuple[str, str]], stamped: list[dict],
) -> list[dict]:
"""The in-band check for the shapes the hook named at ``path``: for each
kind whose directory has a dominant canon, the named shapes that are
not (already or just now) that canon's instance/canonical — new or
unclassified rows only; a judged shape is not re-litigated at every
edit. Returns [{symbol, kind, canon_snippet_id, instances, judged}]."""
just_stamped = {(s["symbol"], s["kind"]): s["snippet_id"] for s in stamped}
out: list[dict] = []
kinds = {k for k, _n in shapes}
density = {k: await canon_density(project_id, path, k) for k in kinds}
if not any(density.values()):
return out
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.path == path,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
by_key = {(r.symbol, r.kind): r for r in rows}
for kind, name in shapes:
dom = density.get(kind)
if not dom:
continue
sid, n, judged = dom
if just_stamped.get((name, kind)) == sid:
continue
row = by_key.get((name, kind))
if row is not None and (
row.status != "unclassified" or row.proposed_snippet_id == sid
):
continue
out.append({"symbol": name, "kind": kind, "canon_snippet_id": sid,
"instances": n, "judged": judged})
return out
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
"""Flag shapes created after ``since`` (the previous refresh) that sit
where a canon dominates and were not proposed as that canon. With no
previous refresh (first seed) nothing is new, nothing is flagged.
Standing flags persist until judged. Returns how many are flagged."""
if since is None:
return 0
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
by_dir: dict[tuple[str, str], list[CodeShape]] = {}
for r in rows:
by_dir.setdefault((_dir_of(r.path), r.kind), []).append(r)
flagged = 0
for siblings in by_dir.values():
dom = dominant_canon(siblings)
for r in siblings:
if r.status != "unclassified":
continue
if r.diverges_from is not None:
flagged += 1
continue
if dom is None or r.created_at is None or r.created_at <= since:
continue
if r.proposed_snippet_id == dom[0]:
continue # the proposer already says "instance of the canon"
r.diverges_from = dom[0]
flagged += 1
await session.commit()
return flagged
def divergence_summary(rows: Iterable[CodeShape], *, top: int = 10) -> dict:
"""Readout view: flagged shapes (newest first) and the recheck count."""
flagged = [r for r in rows if r.diverges_from is not None and r.status == "unclassified"]
flagged.sort(key=lambda r: (r.created_at or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
recheck = sum(1 for r in rows if r.recheck_at is not None and r.vanished_at is None)
return {
"divergent": len(flagged),
"divergence": [
{"path": r.path, "symbol": r.symbol, "kind": r.kind,
"canon_snippet_id": r.diverges_from}
for r in flagged[:top]
],
"recheck": recheck,
}
async def shape_history(
user_id: int, project_id: int, path: str, *, symbol: str = "", limit: int = 200
) -> dict:
"""What was used at ``path`` (a file or directory), when, and why: the
current rows plus their events, oldest first. Read-gated like every
other ledger read; {} when the caller cannot read the project."""
from sqlalchemy import or_
from scribe.services import access
if not await access.can_read_project(user_id, project_id):
return {}
clean = (path or "").strip().strip("/")
conds = [CodeShape.project_id == project_id]
if clean:
conds.append(or_(CodeShape.path == clean, CodeShape.path.like(clean + "/%")))
if symbol.strip():
conds.append(CodeShape.symbol == symbol.strip())
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(*conds)
.order_by(CodeShape.path, CodeShape.symbol, CodeShape.kind)
.limit(500)
)
).scalars().all()
ids = [r.id for r in rows]
events = (
await session.execute(
select(CodeShapeEvent).where(CodeShapeEvent.shape_id.in_(ids))
.order_by(CodeShapeEvent.at.asc(), CodeShapeEvent.id.asc())
.limit(max(1, min(limit, 1000)))
)
).scalars().all() if ids else []
return {
"shapes": [
{
"path": r.path, "symbol": r.symbol, "kind": r.kind,
"status": r.status, "snippet_id": r.snippet_id,
"classified_by": r.classified_by, "reason": r.reason,
"first_seen_commit": r.first_seen_commit,
"last_seen_commit": r.last_seen_commit,
"first_seen_at": r.created_at.isoformat() if r.created_at else None,
"vanished_at": r.vanished_at.isoformat() if r.vanished_at else None,
"recheck_at": r.recheck_at.isoformat() if r.recheck_at else None,
"diverges_from": r.diverges_from,
}
for r in rows
],
"events": [e.to_dict() for e in events],
}