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