diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts index a26b1c9e..5162b578 100644 --- a/frontend/src/api/family.ts +++ b/frontend/src/api/family.ts @@ -126,6 +126,8 @@ export interface AdoptionCell { /** Answered against a canon version that is not the current one. */ needs_recheck: boolean; owed_task: { id: number; title: string; status: string } | null; + /** Live shapes in the project classified against one of the idea's references. */ + shapes: { path: string; symbol: string; status: "canonical" | "instance" | "variant" }[]; } export interface MatrixIdea { diff --git a/frontend/src/components/FamilyAdoptionMatrix.vue b/frontend/src/components/FamilyAdoptionMatrix.vue index e897f778..8adfd707 100644 --- a/frontend/src/components/FamilyAdoptionMatrix.vue +++ b/frontend/src/components/FamilyAdoptionMatrix.vue @@ -218,6 +218,15 @@ defineExpose({ load }); ({{ selected.owed_task.status.replace("_", " ") }})

+
+

In this project's code — the shape ledger classifies:

+ +

Answered {{ fmtStamp(selected.assessed_at) }} @@ -320,6 +329,9 @@ button.fam-cell:hover { background: var(--fs-surface-hover); } .fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; } .fam-detail-reason { color: var(--fs-text-primary); } .fam-detail-recheck { color: var(--fs-text-secondary); } +.fam-detail-shapes ul { margin: 0.25rem 0 0; padding-left: 1.1rem; font-size: 0.85rem; } +.fam-detail-shapes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; } +.fam-detail-shapes code { font-family: var(--fs-font-mono); } .fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); } .fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); } .fam-outcomes dl { margin: 0.5rem 0 0; } diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py index 206ad917..da157c85 100644 --- a/src/scribe/mcp/tools/family.py +++ b/src/scribe/mcp/tools/family.py @@ -280,6 +280,12 @@ async def assess_family_adoption( variant cancels it, owed again reopens it. The same answer given twice records nothing the second time. + When the project's code is in the shape ledger, prefer answering THERE: + classify_shapes the shape against the idea (idea_id=…) as an instance or + a variant, and this answer moves with it. An answer the shapes already + give otherwise is refused — the two ledgers never disagree; reclassify + the shapes if they are wrong. + Args: project_id: the project answering. idea_id: the canon idea. diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index cab11589..f2bd4c77 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -37,10 +37,15 @@ async def classify_shapes( Args: project_id: The project whose ledger is being judged. classifications: Objects of {path, symbol, status, kind?, snippet_id?, - reason?, reason_code?}. path+symbol name the shape exactly as + idea_id?, reason?, reason_code?}. path+symbol name the shape exactly as list_shapes shows it; kind ("sym"/"css") narrows when one file defines both. snippet_id is required for canonical/instance/ - variant; reason is required for variant/exempt. reason_code is + variant — or idea_id, a family idea: the shape is then judged + against that idea's reference implementation in the shape's + language, or its first reference when none is in that language + (an idea is shared across languages: a Python shape can be an + instance of an idea whose reference is Go). reason is required + for variant/exempt. reason_code is an OPTIONAL index beside the prose (one of: scoped-css, one-off-handler, test-helper, convention-plumbing, pure-helper, generated, script, typed-record) so the ledger can be filtered @@ -60,11 +65,20 @@ async def classify_shapes( confirms it. Must be bound to the project. Omit it to judge only synced rows. + FAMILY CANON FOLLOWS. A shape judged against a canon family idea's + reference IS the project's answer to that idea: an instance answers it + `adopted`, a variant `variant` (with the shape's reason), and + withdrawing the shapes that gave an answer returns it to `unassessed`. + The adoption ledger is moved for you — `family` in the result lists the + answers that moved — and assess_family_adoption refuses an answer the + shapes contradict. + All-or-nothing: a structural error, a missing snippet target, an unbound repo, or no write access applies NOTHING. Returns {"classified": N, - "unmatched": [...], "provisional": N?} — unmatched names shapes no live - ledger row matches (the tree may have moved since you listed; pass `repo` - for a shape you just wrote, or re-run the project's coverage refresh). + "unmatched": [...], "provisional": N?, "family": [...]?} — unmatched names + shapes no live ledger row matches (the tree may have moved since you + listed; pass `repo` for a shape you just wrote, or re-run the project's + coverage refresh). """ uid = current_user_id() return await shape_ledger_svc.classify_shapes( @@ -116,7 +130,10 @@ async def list_shapes( machine thinks are an instance of a snippet: `proposal` carries snippet_id, basis, score), "derive" (rows that repeat with NO canon: `proposal.group` names the family), or one basis - (symbol/text/reference/signature/semantic). + (symbol/text/reference/signature/semantic/family). "family" is + a canon family idea's reference — another project's, often + another language's — that the body means: the top match over + every snippet you can read, on a platform the project shares. flag: the divergence readout (#2793) — "divergence": shapes new since the previous refresh in a directory where one canon dominates the judged siblings and NOT proposed as that canon @@ -145,7 +162,8 @@ async def list_shapes( THE FAST PATH through a big todo is the proposer's queue: every coverage refresh matches unclassified shapes against canon (strongest basis first: same symbol elsewhere → textual containment → body references - the canon → signature resemblance → semantic) and attaches a + the canon → signature resemblance → semantic, and a family idea's + reference in any language) and attaches a `proposal` to each row it can speak for. Review `proposal="canon"` by snippet or directory, then confirm_shape_proposals the ones that hold — hundreds at a time — and classify_shapes the rest (variant/exempt, or @@ -214,9 +232,11 @@ async def classify_shapes_by_rule( revise a family you judged earlier). One transaction: applies whole or not at all. Returns - {"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted) - so you can see what the rule reached; N = 0 means the rule matched - nothing live and unclassified — widen the pattern or refresh coverage. + {"classified": N, "sample": ["path::symbol", ...], "family": [...]?} + (sample: first 12, sorted) so you can see what the rule reached; N = 0 + means the rule matched nothing live and unclassified — widen the pattern + or refresh coverage. As with classify_shapes, a family idea's reference + judged here moves the project's answer to that idea (`family`). """ uid = current_user_id() try: @@ -317,12 +337,14 @@ async def confirm_shape_proposals( snippet_id (confirm one canon's whole queue after reading its `list_shapes(proposal="canon", ...)` page), path (a directory you audited), or basis (e.g. "symbol" and "reference" are near-certain; - "semantic" deserves a look first) is required — a bare confirm-all is - not a judgment. min_score trims a basis's tail. + "semantic" and "family" deserve a look first) is required — a bare + confirm-all is not a judgment. min_score trims a basis's tail. Proposals you do NOT confirm are judged with classify_shapes (variant, exempt, or instance of a different snippet) — any judgment retires the - proposal. Requires write access. Returns {"confirmed": N}. + proposal. A confirmed family reference answers that idea `adopted` in + the project (`family` lists the answers that moved). Requires write + access. Returns {"confirmed": N, "family": [...]?}. """ uid = current_user_id() try: diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 7f8e3b28..36bd0656 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -879,6 +879,16 @@ async def compute_coverage( logger.warning("platform detection failed for project %s", project_id, exc_info=True) await shape_ledger.mark_canonicals(project_id, recorded) + # The adoption ledger follows the shapes (milestone 463 step 5). The + # judge paths sync the ideas they touch; the refresh is the one place + # that sees canonical stamps land and shapes vanish, so it answers every + # canon idea reaching the project. It must not fail the refresh. + try: + from scribe.services import family_adoption + + await family_adoption.sync_from_shapes(user_id, project_id) + except Exception: + logger.warning("family adoption sync failed for project %s", project_id, exc_info=True) try: await shape_ledger.apply_derive_groups(project_id) except Exception: diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index 493e9656..6d3f9103 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -43,13 +43,26 @@ applies wins, and every ground above it must be said not to: The losing side's reasoning is folded into the idea's note — as a trap, an alternative, or the branch for its condition — and is never dropped. The substance changed, so the version moves. + +SHAPES (step 5). The shape ledger answers too: a shape classified against an +idea's reference — in any language — is the project's code saying `adopted` +(an instance) or `variant` (with the shape's reason). The two ledgers never +disagree: a classification moves the row, an assessment or undo the shapes +contradict is refused, an answer the shapes gave is withdrawn with them, and +a conflict resolution re-judges the variant shapes on both sides. The shape +proposer offers an idea's reference across projects and languages by +meaning; its rule and the measurement behind it sit above +shape_ledger._FAMILY_FLOOR. """ from __future__ import annotations import logging +from typing import Iterable + from sqlalchemy import func, select from scribe.models import async_session +from scribe.models.code_shape import CodeShape from scribe.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, Platform, ProjectPlatform, @@ -281,6 +294,224 @@ async def _project_languages(session, project_id: int) -> list[str]: return [r[0] for r in rows] +# --- the shape ledger as evidence (milestone 463 step 5) ---------------------------- +# +# A shape classified against an idea's reference IS an answer to the idea: an +# `instance` — or the reference's own `canonical` location — says the project +# does it; a `variant` says it departs, for the reason the shape carries. The +# two ledgers must never disagree, and the shapes are the stronger evidence +# (they name the code), so: +# +# - classifying a shape moves the adoption row to what the shapes say, +# decided_via "system", the shapes as evidence; +# - an assessment or an undo that would contradict the shapes is refused — +# reclassify the shapes if they are wrong; +# - an answer the shape ledger gave is withdrawn to `unassessed` when the +# shapes that gave it are withdrawn. An answer given on other evidence is +# left alone: no shape is not the same as `owed`. + +SHAPE_SOURCE = "shape_ledger" +_SHAPE_ADOPTS = ("canonical", "instance") + +# Which reference a shape is judged against when it is classified against an +# idea: the one in the shape's language when there is one. +_EXTS_BY_LANGUAGE = { + "python": (".py", ".pyi"), "go": (".go",), "kotlin": (".kt", ".kts"), + "java": (".java",), "dart": (".dart",), "swift": (".swift",), "rust": (".rs",), + "typescript": (".ts", ".tsx"), "javascript": (".js", ".jsx", ".mjs", ".cjs"), + "vue": (".vue",), "svelte": (".svelte",), "css": (".css",), "scss": (".scss",), + "bash": (".sh", ".bash"), "sh": (".sh",), "sql": (".sql",), +} + + +def path_speaks(path: str, language: str) -> bool: + """Is a file at ``path`` written in ``language`` (a snippet's recorded one)?""" + exts = _EXTS_BY_LANGUAGE.get((language or "").strip().lower()) + return bool(exts) and (path or "").strip().lower().endswith(exts) + + +def shape_outcome(statuses: Iterable[str]) -> str | None: + """What a project's shapes say about an idea: `adopted` when any shape is + an instance of (or is) a reference, `variant` when the only ones are + variants, None when no shape speaks. None is silence, never `owed`.""" + seen = set(statuses) + if seen & set(_SHAPE_ADOPTS): + return "adopted" + if "variant" in seen: + return "variant" + return None + + +def shapes_say(rows) -> dict | None: + """The answer a project's evidence rows give — outcome, reason, evidence + lines and the shapes themselves — or None when no shape speaks. The + `adopted` reason is fixed text, so classifying one more instance does not + log a new decision; a `variant` carries the first variant shape's why.""" + outcome = shape_outcome(r.status for r in rows) + if outcome is None: + return None + speaking = [r for r in rows if (r.status in _SHAPE_ADOPTS) == (outcome == "adopted")] + if outcome == "adopted": + reason = "the shape ledger classifies this project's code as the idea's reference implementation" + else: + first = speaking[0] + reason = (f"the shape ledger classifies {first.path}::{first.symbol} as a variant " + f"of #{first.snippet_id}: {(first.reason or '').strip()}") + return { + "outcome": outcome, + "reason": reason, + "evidence": [f"{r.path}::{r.symbol}" for r in speaking], + "shapes": [{"id": r.id, "path": r.path, "symbol": r.symbol, "status": r.status, + "snippet_id": r.snippet_id} for r in speaking], + } + + +async def _shape_evidence(session, project_id: int, idea_id: int) -> list[CodeShape]: + """The project's live shapes judged against any of the idea's references.""" + refs = select(FamilyIdeaReference.snippet_id).where(FamilyIdeaReference.idea_id == idea_id) + return list((await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.vanished_at.is_(None), + CodeShape.snippet_id.in_(refs), + CodeShape.status.in_(_SHAPE_ADOPTS + ("variant",)), + ).order_by(CodeShape.path, CodeShape.symbol) + )).scalars().all()) + + +def _contradiction(outcome: str, said: dict | None, idea_id: int) -> str | None: + if said is None or outcome == said["outcome"]: + return None + named = ", ".join(said["evidence"][:3]) + (" …" if len(said["evidence"]) > 3 else "") + return ( + f"the shape ledger already answers #{idea_id} in this project as {said['outcome']} " + f"({named}). The two ledgers never disagree: reclassify those shapes " + "(classify_shapes) if they are wrong, and the answer follows") + + +async def reference_for(user_id: int, idea_id: int, path: str) -> int: + """The reference snippet a shape at ``path`` is judged against when it is + classified against a family idea: the one in the path's language when + there is one, else the first. An idea is shared across languages, so a + Python shape can be an instance of an idea whose only reference is Go. + Raises ValueError when the idea is not a family idea the caller can read + or has no reference they can read.""" + if not await access.can_read_note(user_id, idea_id): + raise ValueError(f"#{idea_id} is not a family idea you can read") + async with async_session() as session: + if await session.get(FamilyIdea, idea_id) is None: + raise ValueError(f"#{idea_id} is not a family idea") + refs = await _references(session, idea_id) + readable = [r for r in refs if await access.can_read_note(user_id, r["id"])] + if not readable: + raise ValueError( + f"family idea #{idea_id} has no reference implementation you can read — " + "set_family_references first, or classify against a snippet_id") + for r in readable: + if path_speaks(path, r["language"]): + return r["id"] + return readable[0]["id"] + + +async def family_reference_ids(project_id: int) -> set[int]: + """The reference snippets of every canon idea that reaches the project — + what the shape proposer may match across projects and languages.""" + async with async_session() as session: + return set((await session.execute( + select(FamilyIdeaReference.snippet_id) + .join(FamilyIdea, FamilyIdea.note_id == FamilyIdeaReference.idea_id) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.note_id == FamilyIdea.note_id) + .join(ProjectPlatform, ProjectPlatform.platform_id == FamilyIdeaPlatform.platform_id) + .where( + FamilyIdea.status == "canon", + ProjectPlatform.project_id == project_id, + ProjectPlatform.state.in_(family_svc.MEMBER_STATES), + ) + )).scalars().all()) + + +async def _latest_source(session, project_id: int, idea_id: int) -> str: + latest = (await session.execute( + select(FamilyDecision).where( + FamilyDecision.idea_id == idea_id, FamilyDecision.project_id == project_id) + .order_by(FamilyDecision.id.desc()).limit(1) + )).scalars().first() + return str(((latest.evidence if latest is not None else None) or {}).get("source") or "") + + +async def _withdraw_shape_answer(user_id: int, project_id: int, idea_id: int) -> FamilyDecision: + reason = ("the shapes that answered this idea were withdrawn from the shape ledger; " + "it is unassessed again") + async with async_session() as session: + row = await _row(session, project_id, idea_id) + before = _row_state(row) + row.status = "unassessed" + row.reason = None + row.canon_version = None + row.assessed_at = None + row.decided_via = None + row.updated_at = family_svc._now() + decision = family_svc._log( + session, idea_id=idea_id, project_id=project_id, action="assess", + reason=reason, before=before, after=_row_state(row), + evidence={"source": SHAPE_SOURCE, "shapes": []}, precedent_ids=None, + decided_via="system", user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + row_id = row.id + await _sync_owed_task(user_id, row_id, reason=reason) + return decision + + +async def sync_from_shapes(user_id: int, project_id: int, + snippet_ids: Iterable[int] | None = None) -> list[dict]: + """Bring the project's adoption rows into line with its shape ledger, for + the canon ideas whose references are among ``snippet_ids`` — or every + canon idea that reaches the project when None (the coverage refresh, + which also sees shapes vanish). + + A row that already gives the shapes' outcome at the current version is + left alone, whatever evidence it was given on. Returns one entry per + answer that moved: {idea_id, status, decision_id}.""" + query = select(FamilyIdea.note_id).where(FamilyIdea.status == "canon") + if snippet_ids is not None: + wanted = {int(s) for s in snippet_ids if s} + if not wanted: + return [] + query = query.where(FamilyIdea.note_id.in_( + select(FamilyIdeaReference.idea_id).where(FamilyIdeaReference.snippet_id.in_(wanted)))) + async with async_session() as session: + idea_ids = [i for i in (await session.execute(query)).scalars().all() + if await _is_member(session, project_id, i)] + moved: list[dict] = [] + for idea_id in sorted(idea_ids): + if not await access.can_read_note(user_id, idea_id): + continue + async with async_session() as session: + idea = await session.get(FamilyIdea, idea_id) + said = shapes_say(await _shape_evidence(session, project_id, idea_id)) + row = await _row(session, project_id, idea_id) + current = _row_state(row) if row is not None else None + source = await _latest_source(session, project_id, idea_id) + if said is not None: + if current and current["status"] == said["outcome"] \ + and current["canon_version"] == idea.canon_version: + continue + result = await assess( + user_id, project_id, idea_id, outcome=said["outcome"], reason=said["reason"], + evidence=said["evidence"], decided_via="system", source=SHAPE_SOURCE, + shapes=said["shapes"], + ) + if result["changed"]: + moved.append({"idea_id": idea_id, "status": said["outcome"], + "decision_id": result["decision"]["id"]}) + elif current and current["status"] in ("adopted", "variant") and source == SHAPE_SOURCE: + decision = await _withdraw_shape_answer(user_id, project_id, idea_id) + moved.append({"idea_id": idea_id, "status": "unassessed", "decision_id": decision.id}) + return moved + + async def assessment_precedents(user_id: int, project_id: int, idea_id: int, limit: int = 5) -> list[dict]: """What an assessment should be consistent with: the latest answer to @@ -388,6 +619,20 @@ async def adoption_matrix( select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None)) )).scalars().all() } if task_ids else {} + # The code that answers each cell (step 5): live shapes classified + # against one of the idea's references. + evidence: dict[tuple[int, int], list[dict]] = {} + for iid, shape in (await session.execute( + select(FamilyIdeaReference.idea_id, CodeShape) + .join(CodeShape, CodeShape.snippet_id == FamilyIdeaReference.snippet_id) + .where(FamilyIdeaReference.idea_id.in_(idea_ids), + CodeShape.project_id.in_(list(project_ids)), + CodeShape.vanished_at.is_(None), + CodeShape.status.in_(_SHAPE_ADOPTS + ("variant",))) + .order_by(CodeShape.path, CodeShape.symbol) + )).all() if idea_ids and project_ids else []: + evidence.setdefault((shape.project_id, iid), []).append( + {"path": shape.path, "symbol": shape.symbol, "status": shape.status}) readable = [(pid, title) for pid, title in projects if await access.can_read_project(user_id, pid)] @@ -415,6 +660,7 @@ async def adoption_matrix( "needs_recheck": bool(row) and needs_recheck( row.status, row.canon_version, idea.status, idea.canon_version), "owed_task": None, + "shapes": evidence.get((pid, iid), []), } task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None if task is not None: @@ -643,13 +889,17 @@ def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int, async def assess( user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str, evidence: list[str] | None = None, precedent_ids: list[int] | None = None, - decided_via: str = "agent", + decided_via: str = "agent", source: str = "", shapes: list[dict] | None = None, ) -> dict: """Record one project's answer to one canon idea. Raises ValueError before writing anything when the answer is malformed - (assessment_problems), the caller cannot write the project, or the idea - is not canon or does not reach the project. + (assessment_problems), the caller cannot write the project, the idea + is not canon or does not reach the project, or the project's shape + ledger already answers the idea otherwise (the two never disagree). + + ``source``/``shapes`` are the shape ledger's own door (sync_from_shapes): + the decision's evidence then names the shapes it was read from. The same answer given again — same outcome, reason and canon version — records nothing (`changed: False`); it still makes sure an owed answer @@ -673,6 +923,11 @@ async def assess( if idea is None or idea.status != "canon": raise ValueError(f"#{idea_id} is not family canon — only canon is assessed") row = await _row_for_write(session, project_id, idea_id) + if source != SHAPE_SOURCE: + refused = _contradiction( + outcome, shapes_say(await _shape_evidence(session, project_id, idea_id)), idea_id) + if refused: + raise ValueError(refused) before = _row_state(row) after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version} changed = before != after @@ -683,8 +938,9 @@ async def assess( decision = family_svc._log( session, idea_id=idea_id, project_id=project_id, action="assess", reason=reason, before=before, after=_row_state(row), - evidence={"evidence": evidence}, precedent_ids=precedent_list, - decided_via=decided_via, user_id=user_id, + evidence=({"evidence": evidence, "source": source, "shapes": shapes or []} + if source else {"evidence": evidence}), + precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, ) await session.commit() if decision is not None: @@ -722,6 +978,11 @@ async def undo_assessment(user_id: int, target: FamilyDecision, *, reason: str, raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone") before = _row_state(row) prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None} + refused = _contradiction( + prior["status"], shapes_say(await _shape_evidence(session, target.project_id, target.idea_id)), + target.idea_id) + if refused: + raise ValueError(f"decision {target.id} cannot be undone: {refused}") row.status = prior["status"] row.reason = (prior.get("reason") or "").strip() or None row.canon_version = prior.get("canon_version") @@ -821,7 +1082,9 @@ async def resolve_conflict( the grounds checked above it); the canon side is answered `adopted`, and the other side `owed` (with a task filed) or, in a split, `adopted` under its condition. Each answer is its own `assess` decision, naming the - revision as its precedent. + revision as its precedent. Last, either side's shapes classified as a + variant of the idea are re-judged to agree with its new answer + (`shapes_rejudged`), so the shape ledger and this one do not disagree. """ from scribe.services import notes as notes_svc @@ -915,6 +1178,9 @@ async def resolve_conflict( row_ids[pid] = row.id await session.commit() await session.refresh(revision) + # The shapes follow the resolution, so the two ledgers still agree. + rejudged = {pid: await _shapes_follow(user_id, pid, idea_id, outcome, why) + for pid, outcome, why in answers} task = await _sync_owed_task(user_id, row_ids[other_project_id], reason=answers[1][2]) await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2]) return { @@ -923,4 +1189,28 @@ async def resolve_conflict( "folded_as": g["fold_as"], "adoptions": await list_adoptions(user_id, idea_id=idea_id), "owed_task": task, + "shapes_rejudged": {str(pid): n for pid, n in rejudged.items() if n}, } + + +async def _shapes_follow(user_id: int, project_id: int, idea_id: int, outcome: str, + why: str) -> int: + """Re-judge a project's variant shapes of the idea to agree with the + answer a conflict resolution just gave it: `adopted` makes them instances + of the reference they departed from (in a split, their condition is now + canon); `owed` returns them to the shape todo, to be rebuilt to the + canon the owed task names. Returns how many rows were re-judged.""" + from scribe.services import shape_ledger + + async with async_session() as session: + rows = [r for r in await _shape_evidence(session, project_id, idea_id) + if r.status == "variant"] + if not rows: + return 0 + status = "instance" if outcome == "adopted" else "unclassified" + result = await shape_ledger.classify_shapes(user_id, project_id, [ + {"path": r.path, "symbol": r.symbol, "kind": r.kind, "status": status, + "snippet_id": r.snippet_id, "reason": why} + for r in rows + ], via="agent") + return int(result.get("classified") or 0) diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index b86368d3..9a7f5285 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -514,7 +514,7 @@ def validate_classifications(items: list[dict]) -> str | None: snippet_id = item.get("snippet_id") or 0 if status in _NEEDS_TARGET and not snippet_id: return ( - f"classifications[{i}]: status {status!r} needs snippet_id — " + f"classifications[{i}]: status {status!r} needs snippet_id (or idea_id) — " "the snippet this shape is (or departs from)" ) if status in ("variant", "exempt") and not (item.get("reason") or "").strip(): @@ -570,6 +570,7 @@ async def classify_shapes( if via not in _CALLER_VIAS: raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}") + classifications = await _resolve_ideas(user_id, classifications) error = validate_classifications(classifications) if error: raise ValueError(error) @@ -602,6 +603,7 @@ async def classify_shapes( classified = 0 provisional = 0 unmatched: list[dict] = [] + touched: set[int] = set() async with async_session() as session: rows = ( await session.execute( @@ -639,6 +641,7 @@ async def classify_shapes( continue status = item["status"] for row in matches: + touched.update(x for x in (row.snippet_id, item.get("snippet_id")) if x) await _judge( session, row, status=status, snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None, @@ -653,9 +656,52 @@ async def classify_shapes( out = {"classified": classified, "unmatched": unmatched} if provisional: out["provisional"] = provisional + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved return out +async def _resolve_ideas(user_id: int, items: list[dict]) -> list[dict]: + """An item may name a family idea (`idea_id`) in place of a snippet + (milestone 463 step 5): it is judged against the idea's reference in the + shape's language, or its first when there is none in that language — an + idea is shared across languages. An explicit snippet_id wins.""" + from scribe.services import family_adoption + + out = [] + for i, item in enumerate(items or []): + if (isinstance(item, dict) and item.get("idea_id") and not item.get("snippet_id") + and item.get("status") in _NEEDS_TARGET): + try: + sid = await family_adoption.reference_for( + user_id, int(item["idea_id"]), (item.get("path") or "").strip()) + except (TypeError, ValueError) as exc: + raise ValueError(f"classifications[{i}]: {exc}") from None + item = {**item, "snippet_id": sid} + out.append(item) + return out + + +async def _sync_family(user_id: int, project_id: int, snippet_ids: set[int]) -> list[dict]: + """The adoption ledger follows the shapes (milestone 463 step 5): after a + judgment commits, the canon ideas whose references it touched — as the + new target or the one it replaced — are answered from the shapes. + + A failure is logged and the judgment stands; the next judgment touching + those references, or the next coverage refresh, brings the two back in + line.""" + from scribe.services import family_adoption + + if not snippet_ids: + return [] + try: + return await family_adoption.sync_from_shapes(user_id, project_id, snippet_ids) + except Exception: + logger.warning("family adoption sync failed for project %s", project_id, exc_info=True) + return [] + + def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool: """Does a ledger row fall under a rule-form classification (#2868)? ``path`` is a file or a directory (everything beneath it), ``pattern`` @@ -721,6 +767,7 @@ async def classify_shapes_where( now = datetime.now(timezone.utc) judged: list[str] = [] + touched: set[int] = {int(snippet_id)} if snippet_id and status in _NEEDS_TARGET else set() async with async_session() as session: conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)] if not include_judged: @@ -729,6 +776,8 @@ async def classify_shapes_where( for row in rows: if not rule_matches(row, path=path, pattern=pattern, kind=kind): continue + if row.snippet_id: + touched.add(row.snippet_id) await _judge( session, row, status=status, snippet_id=int(snippet_id) if status in _NEEDS_TARGET else None, @@ -738,7 +787,11 @@ async def classify_shapes_where( await record_uses(session, row, uses, basis=via, evidence=reason) judged.append(f"{row.path}::{row.symbol}") await session.commit() - return {"classified": len(judged), "sample": sorted(judged)[:12]} + out = {"classified": len(judged), "sample": sorted(judged)[:12]} + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved + return out async def list_project_shapes( @@ -1611,6 +1664,29 @@ _SEMANTIC_LIMIT = 8 # against a prose-forward snippet document measures the wrong field (#2518); # no floor separates the two bands. The arm PROPOSES on a hit; its silence # says nothing. +# +# THE FAMILY ARM (milestone 463 step 5). The arm above is held to the shape's +# own project and language family, because across projects it was pure noise +# (#2871). A family idea is the exception the hold was waiting for: an idea +# shared across projects ON PURPOSE, whose reference may be in another +# language (a Python throttle is an instance of a Go one). So the arm also +# proposes the reference of a canon idea that reaches the shape's project +# through a shared platform — any language, any project — under a stricter +# rule of its own, measured 2026-10-06 (#4991) on cross-project pairs: +# +# true pairs A py→go 0.728 (next 0.703) B py→go 0.715 (next 0.675) +# D kt→kt 0.816 (next 0.734) E go→go 0.610 (next 0.604) +# no reference N1 0.666 N2 0.623 N3 0.626 N4 0.715 N5 0.614 (best hit, +# none of them a family reference); unrelated hits to 0.774 +# +# No floor separates those bands — the false band reaches 0.715–0.774, a +# true pair sits at 0.610. RANK does: every true pair was the single best +# snippet the user can read. So a family reference is proposed only when it +# is the TOP hit over everything readable (not merely the first allowed one, +# as above) and clears _FAMILY_FLOOR: A, B, D proposed; E missed; no +# negative proposed. A miss is still not evidence — the judge can classify +# against the idea by idea_id. +_FAMILY_FLOOR = 0.70 def _semantic_priority(row) -> tuple: @@ -1640,7 +1716,9 @@ def _semantic_priority(row) -> tuple: # v5: the semantic arm's floor dropped from 0.8 to the write-path floor (#4208), # and the signature floor from 0.8 to 0.75 (#4306), so rows examined and # found nothing for must be read once more. -_PROPOSER_VERSION = 5 +# v6: the family arm (milestone 463 step 5) — a canon idea's reference on a +# shared platform, any language, as the top hit at _FAMILY_FLOOR. +_PROPOSER_VERSION = 6 # 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. @@ -1883,28 +1961,47 @@ def _substance(text: str) -> int: return len("".join((text or "").split())) +def pick_semantic( + hits: list[tuple[float, int]], allowed: set[int], family: set[int], *, floor: float, +) -> tuple[int, float, str] | None: + """Which ranked hit the semantic arm proposes, as (snippet_id, score, + basis). Pure; ``hits`` are (score, snippet_id) over everything the user + can read, best first. The TOP hit may be a family reference (basis + "family", at _FAMILY_FLOOR); any hit at ``floor`` may be an own-project + canon (basis "semantic").""" + for rank, (score, sid) in enumerate(hits): + if rank == 0 and sid in family and score >= _FAMILY_FLOOR: + return sid, round(float(score), 3), "family" + if sid in allowed and score >= floor: + return sid, round(float(score), 3), "semantic" + return None + + async def _semantic_canon( - user_id: int, body: str, allowed: set[int], -) -> tuple[int, float] | None: - """The canon this body MEANS, or None. None is "no proposal", never "not - the canon" — see the note above `_SEMANTIC_LIMIT`.""" + user_id: int, body: str, allowed: set[int], family: set[int] | None = None, +) -> tuple[int, float, str] | None: + """The canon this body MEANS, as (snippet_id, score, basis), or None. + None is "no proposal", never "not the canon" — see the note above + `_SEMANTIC_LIMIT`; the family arm's rule is the note above + `_FAMILY_FLOOR`.""" from scribe.services.embeddings import semantic_search_notes from scribe.services.plugin_context import ( WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query, ) - if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not allowed: + family = family or set() + if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not (allowed or family): return None query = concept_query(body) or body hits = await semantic_search_notes( user_id, query, limit=_SEMANTIC_LIMIT, - threshold=WRITEPATH_DEFAULT_THRESHOLD, + threshold=min(WRITEPATH_DEFAULT_THRESHOLD, _FAMILY_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) - return None + return pick_semantic( + [(float(score), int(note.id)) for score, note in hits], allowed, family, + floor=WRITEPATH_DEFAULT_THRESHOLD, + ) async def propose_for_repo( @@ -1933,6 +2030,16 @@ async def propose_for_repo( def semantic_allowed(path: str) -> set[int]: return {c.snippet_id for c in sym_canons if same_family(path, c.language)} + # The family arm: references of canon ideas on a platform this project + # shares, in any language (see _FAMILY_FLOOR). A project on no shared + # platform gets none, so nothing is proposed across to it. + try: + from scribe.services import family_adoption + + family_refs = await family_adoption.family_reference_ids(project_id) + except Exception: + logger.warning("family references unreadable for project %s", project_id, exc_info=True) + family_refs = set() now = datetime.now(timezone.utc) examined = proposed = checked = 0 async with async_session() as session: @@ -1998,13 +2105,13 @@ async def propose_for_repo( continue checked += 1 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), family_refs) except Exception: logger.warning("semantic proposal failed", exc_info=True) found = None if found: - row.proposed_snippet_id, row.proposal_score = found - row.proposal_basis = "semantic" + row.proposed_snippet_id, row.proposal_score, row.proposal_basis = found row.proposal_group = None proposed += 1 await session.commit() @@ -2210,11 +2317,13 @@ async def confirm_proposals( conds.append(CodeShape.proposal_basis == basis.strip()) now = datetime.now(timezone.utc) confirmed = 0 + touched: set[int] = set() async with async_session() as session: rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all() for row in rows: if (row.proposal_score or 0.0) < min_score: continue + touched.add(row.proposed_snippet_id) await _judge( session, row, status="instance", snippet_id=row.proposed_snippet_id, by="agent", at=now, @@ -2225,7 +2334,11 @@ async def confirm_proposals( ) confirmed += 1 await session.commit() - return {"confirmed": confirmed} + out = {"confirmed": confirmed} + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved + return out # --- the divergence readout (#2793): button B where button A is canon ------- diff --git a/tests/test_divergence_meaning_gate.py b/tests/test_divergence_meaning_gate.py index 864d00c7..adf37819 100644 --- a/tests/test_divergence_meaning_gate.py +++ b/tests/test_divergence_meaning_gate.py @@ -64,14 +64,14 @@ def _patch(mock: AsyncMock): async def test_a_hit_returns_the_canon() -> None: with _patch(_hits((0.88, CANON))): - assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88) + assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88, "semantic") async def test_an_allowed_canon_below_the_top_hit_still_wins() -> None: """The scan is over the whole result set, so a disallowed snippet ranking first does not hide an allowed one behind it.""" with _patch(_hits((0.95, OTHER), (0.83, CANON))): - assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83) + assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83, "semantic") async def test_a_miss_is_none_and_nothing_more() -> None: diff --git a/tests/test_family_shapes.py b/tests/test_family_shapes.py new file mode 100644 index 00000000..baa2090b --- /dev/null +++ b/tests/test_family_shapes.py @@ -0,0 +1,142 @@ +"""The shape ledger judging against family ideas, without a database +(milestone 463 step 5). + +Pinned here: what a project's shapes say about an idea, when an answer +contradicts them, which reference a shape in a given language is judged +against, and the family arm's rule — measured on 2026-10-06 (#4991), and +replayed below as the table it was chosen from. The two ledgers moving +together against Postgres are in tests/test_integration_family_adoption.py. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from scribe.services.family_adoption import ( + _contradiction, path_speaks, shape_outcome, shapes_say, +) +from scribe.services.shape_ledger import _FAMILY_FLOOR, _semantic_canon, pick_semantic +from tests.helpers import tool_doc + + +def _shape(status: str, symbol: str = "f", reason: str = "", snippet_id: int = 7): + return SimpleNamespace(id=1, path=f"src/{symbol}.py", symbol=symbol, status=status, + reason=reason, snippet_id=snippet_id) + + +# --- what the shapes say --------------------------------------------------------- + +@pytest.mark.parametrize("statuses,expected", [ + (["instance"], "adopted"), + (["canonical"], "adopted"), + (["variant", "instance"], "adopted"), + (["variant"], "variant"), + ([], None), +]) +def test_shape_outcome(statuses, expected): + assert shape_outcome(statuses) == expected + + +def test_no_shape_is_silence_not_owed(): + assert shapes_say([]) is None + + +def test_an_adopted_reason_is_fixed_text_so_another_instance_logs_nothing(): + one = shapes_say([_shape("instance", "a")]) + two = shapes_say([_shape("instance", "a"), _shape("instance", "b")]) + assert one["reason"] == two["reason"] + assert two["evidence"] == ["src/a.py::a", "src/b.py::b"] + + +def test_a_variant_carries_the_shapes_why_and_only_variants_are_its_evidence(): + said = shapes_say([_shape("variant", "k", reason="a kiosk installs it")]) + assert said["outcome"] == "variant" and "a kiosk installs it" in said["reason"] + mixed = shapes_say([_shape("variant", "k", reason="x"), _shape("instance", "i")]) + assert mixed["outcome"] == "adopted" and mixed["evidence"] == ["src/i.py::i"] + + +# --- the two ledgers never disagree ----------------------------------------------- + +def test_an_answer_the_shapes_contradict_names_them_and_the_way_out(): + said = shapes_say([_shape("instance", "a")]) + refused = _contradiction("owed", said, idea_id=5) + assert "src/a.py::a" in refused and "classify_shapes" in refused and "#5" in refused + + +@pytest.mark.parametrize("outcome,said", [ + ("adopted", shapes_say([_shape("instance")])), + ("owed", None), + ("exempt", None), +]) +def test_an_agreeing_answer_or_silent_shapes_refuse_nothing(outcome, said): + assert _contradiction(outcome, said, idea_id=5) is None + + +# --- which reference a shape is judged against -------------------------------------- + +@pytest.mark.parametrize("path,language,expected", [ + ("tools/release.py", "python", True), + ("app/Updater.kt", "kotlin", True), + ("cmd/main.go", "go", True), + ("cmd/main.go", "kotlin", False), + ("web/App.vue", "vue", True), + ("README", "python", False), + ("x.py", "", False), +]) +def test_path_speaks(path, language, expected): + assert path_speaks(path, language) is expected + + +# --- the family arm, and the measurement it was chosen from ----------------------- + +OWN, REF, OTHER = 1, 2, 3 + + +@pytest.mark.parametrize("name,hits,expected", [ + # #4991's pairs, the reference as REF and the best other hit as OTHER. + ("A py->go", [(0.728, REF), (0.703, OTHER)], "family"), + ("B py->go", [(0.715, REF), (0.675, OTHER)], "family"), + ("D kt->kt", [(0.816, REF), (0.734, OTHER)], "family"), + ("E go->go", [(0.610, REF), (0.604, OTHER)], None), + # No reference exists: the best hit is unrelated, the reference trails. + ("N4", [(0.715, OTHER), (0.706, REF)], None), + ("N1", [(0.666, OTHER)], None), +]) +def test_the_family_arm_replays_its_measurement(name, hits, expected): + found = pick_semantic(hits, allowed=set(), family={REF}, floor=0.68) + assert (found[2] if found else None) == expected, name + + +def test_a_family_reference_must_be_the_top_hit_not_merely_the_first_allowed(): + assert pick_semantic([(0.80, OTHER), (0.79, REF)], set(), {REF}, floor=0.68) is None + + +def test_the_own_project_arm_still_looks_past_disallowed_hits(): + assert pick_semantic([(0.80, OTHER), (0.70, OWN)], {OWN}, {REF}, floor=0.68) == ( + OWN, 0.7, "semantic") + + +def test_the_family_floor_sits_between_the_bands_it_was_measured_on(): + assert 0.610 < _FAMILY_FLOOR <= 0.715 + + +async def test_a_project_on_a_shared_platform_is_searched_with_no_own_canon(): + """The own-project allowed set is often empty (the language gate); the + family references alone are reason to search.""" + body = "def throttle(key: str) -> bool:\n return attempts[key] < LIMIT and not locked(key)\n" + hit = SimpleNamespace(id=REF) + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.72, hit)])): + assert await _semantic_canon(1, body, set(), {REF}) == (REF, 0.72, "family") + + +# --- the agent-facing contract ----------------------------------------------------- + +def test_the_tools_teach_idea_id_and_the_family_basis(): + classify = tool_doc("scribe.mcp.tools.shapes", "classify_shapes") + assert "idea_id" in classify and "shapes contradict" in classify + assert "family" in tool_doc("scribe.mcp.tools.shapes", "list_shapes") + assert '"family"' in tool_doc("scribe.mcp.tools.shapes", "confirm_shape_proposals") + assert "classify_shapes" in tool_doc("scribe.mcp.tools.family", "assess_family_adoption") diff --git a/tests/test_integration_family_adoption.py b/tests/test_integration_family_adoption.py index bb75a100..8c911784 100644 --- a/tests/test_integration_family_adoption.py +++ b/tests/test_integration_family_adoption.py @@ -387,3 +387,191 @@ async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family): async def test_the_matrix_hides_what_the_caller_cannot_read(family): matrix = await adoption_svc.adoption_matrix(family["outsider"]) assert all(i["note_id"] != family["note"] for i in matrix["ideas"]) + + +# --- the shape ledger as evidence (step 5) ------------------------------------------- + +REPO = "git.example/android-two" +KOTLIN_BODY = ( + "fun installUpdate(context: Context, apk: File) {\n" + " val installer = context.packageManager.packageInstaller\n" + " val session = installer.openSession(installer.createSession(params()))\n" + " apk.inputStream().use { src -> session.openWrite(\"apk\", 0, apk.length()).use(src::copyTo) }\n" + "}\n" +) +B_SHAPES = [ + ("app/src/main/kotlin/Updater.kt", "sym", "selfUpdate"), + ("tools/release.py", "sym", "push_update"), +] + + +async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate") -> int: + from scribe.services import snippets as snippets_svc + + snippet = await snippets_svc.create_snippet( + f["owner"], name=f"{name} ({language})", code=KOTLIN_BODY, language=language, + project_id=f["a"], + ) + return int(snippet.id) + + +@pytest_asyncio.fixture +async def shapes(family): + """Android two's ledger has two live shapes, and the idea has a Kotlin + reference in android one.""" + from scribe.services.shape_ledger import sync_repo_shapes + + await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") + family["kotlin"] = await _reference(family) + await adoption_svc.set_references(family["owner"], family["note"], [family["kotlin"]]) + return family + + +async def _classify(f, project: str, symbol: str, status: str, **item): + from scribe.services import shape_ledger + + path = next(p for p, _, s in B_SHAPES if s == symbol) + return await shape_ledger.classify_shapes( + f["owner"], f[project], [{"path": path, "symbol": symbol, "status": status, **item}]) + + +async def test_a_shape_classified_against_the_idea_answers_it_adopted(shapes): + """Across languages: a Python shape is an instance of an idea whose only + reference is Kotlin, and the project's answer moves with it.""" + out = await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + assert out["classified"] == 1 + assert [m["status"] for m in out["family"]] == ["adopted"] + row = await _row(shapes, "b") + assert (row.status, row.decided_via, row.canon_version) == ("adopted", "system", 1) + [decision] = await _decisions(shapes, "b") + assert decision.evidence["source"] == adoption_svc.SHAPE_SOURCE + assert decision.evidence["evidence"] == ["tools/release.py::push_update"] + assert decision.evidence["shapes"][0]["snippet_id"] == shapes["kotlin"] + # The matrix shows the code that answers the cell. + matrix = await adoption_svc.adoption_matrix(shapes["owner"], project_id=shapes["b"]) + [cell] = [c for c in matrix["cells"] if c["idea_id"] == shapes["note"]] + assert cell["shapes"] == [ + {"path": "tools/release.py", "symbol": "push_update", "status": "instance"}] + # A second instance agrees with the standing answer: nothing more is logged. + await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"]) + assert len(await _decisions(shapes, "b")) == 1 + + +async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes): + from scribe.services.shape_ledger import live_rows + + python_ref = await _reference(shapes, language="python", name="install_update") + await adoption_svc.set_references(shapes["owner"], shapes["note"], + [shapes["kotlin"], python_ref]) + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"]) + by_symbol = {r.symbol: r.snippet_id for r in await live_rows(shapes["b"])} + assert by_symbol == {"push_update": python_ref, "selfUpdate": shapes["kotlin"]} + + +async def test_an_idea_with_no_reference_is_refused_and_nothing_applies(family): + from scribe.services.shape_ledger import live_rows, sync_repo_shapes + + await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") + with pytest.raises(ValueError, match="no reference implementation"): + await _classify(family, "b", "push_update", "instance", idea_id=family["note"]) + assert {r.status for r in await live_rows(family["b"])} == {"unclassified"} + + +async def test_a_variant_shape_answers_variant_and_withdrawing_it_unassesses(shapes): + await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"], + reason="installs through the device-owner API: it is a kiosk") + row = await _row(shapes, "b") + assert row.status == "variant" and "device-owner API" in row.reason + out = await _classify(shapes, "b", "selfUpdate", "unclassified") + assert [(m["idea_id"], m["status"]) for m in out["family"]] == [ + (shapes["note"], "unassessed")] + row = await _row(shapes, "b") + assert (row.status, row.canon_version, row.decided_via) == ("unassessed", None, None) + assert (await _decisions(shapes, "b"))[-1].evidence["source"] == adoption_svc.SHAPE_SOURCE + + +async def test_the_shapes_close_an_owed_task_as_done(shapes): + owed = await _assess(shapes, "b", "owed", reason="no in-place update yet") + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + assert (await _task(owed["owed_task"]["id"])).status == "done" + + +async def test_an_answer_the_shapes_contradict_is_refused(shapes): + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + with pytest.raises(ValueError, match="never disagree"): + await _assess(shapes, "b", "owed", reason="the agent thinks otherwise") + # The agreeing answer is not refused. + await _assess(shapes, "b", "adopted", reason="release.py does it", + evidence=["tools/release.py"]) + + +async def test_an_undo_that_would_contradict_the_shapes_is_refused(shapes): + await _assess(shapes, "b", "owed", reason="not yet") + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + latest = (await _decisions(shapes, "b"))[-1] + with pytest.raises(ValueError, match="never disagree"): + await family_svc.undo(shapes["owner"], latest.id, reason="go back") + + +async def test_an_answer_given_on_other_evidence_survives_the_shapes_leaving(shapes): + """No shape is not the same as owed: withdrawing a shape only withdraws + an answer the shape ledger gave.""" + await _assess(shapes, "b", "adopted", reason="CI proves it", + evidence=["ci run 12"]) + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + out = await _classify(shapes, "b", "push_update", "unclassified") + assert "family" not in out + assert (await _row(shapes, "b")).status == "adopted" + + +async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platform(shapes): + """The Go service shares no platform with the idea: its shape judged + against the reference is plain canon use, and the proposer never offers + the reference to it.""" + from scribe.services import shape_ledger + + await shape_ledger.sync_repo_shapes(shapes["c"], "git.example/go", [ + ("cmd/update.go", "sym", "PushUpdate")], seen_marker="main") + out = await shape_ledger.classify_shapes(shapes["owner"], shapes["c"], [ + {"path": "cmd/update.go", "symbol": "PushUpdate", "status": "instance", + "snippet_id": shapes["kotlin"]}]) + assert out["classified"] == 1 and "family" not in out + async with async_session() as s: + assert (await s.execute(select(FamilyAdoption).where( + FamilyAdoption.project_id == shapes["c"]))).first() is None + assert await adoption_svc.family_reference_ids(shapes["c"]) == set() + assert await adoption_svc.family_reference_ids(shapes["b"]) == {shapes["kotlin"]} + + +async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes): + from scribe.services import shape_ledger + + body = ("def push_update(apk_path: str) -> None:\n" + " session = installer.open_session(installer.create_session())\n" + " session.write_stream('apk', open(apk_path, 'rb'))\n" + " session.commit()\n") + defs = [("tools/release.py", "sym", "push_update", "def push_update(apk_path: str) -> None:", + "sha-push", body)] + ref = type("Hit", (), {"id": shapes["kotlin"]})() + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.73, ref)])): + await shape_ledger.propose_for_repo(shapes["owner"], shapes["b"], REPO, defs) + row = next(r for r in await shape_ledger.live_rows(shapes["b"]) if r.symbol == "push_update") + assert (row.proposed_snippet_id, row.proposal_basis) == (shapes["kotlin"], "family") + # Confirming it answers the idea. + out = await shape_ledger.confirm_proposals(shapes["owner"], shapes["b"], basis="family") + assert out["confirmed"] == 1 and out["family"][0]["status"] == "adopted" + + +async def test_a_conflict_brings_the_losing_sides_variant_shapes_back_to_the_todo(shapes): + from scribe.services.shape_ledger import live_rows + + await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"], + reason="signs in CI with a throwaway key") + out = await _resolve(shapes, "covers_failure") + assert out["shapes_rejudged"] == {str(shapes["b"]): 1} + b = await _row(shapes, "b") + assert b.status == "owed" + row = next(r for r in await live_rows(shapes["b"]) if r.symbol == "selfUpdate") + assert row.status == "unclassified"