feat(family): the shape ledger judges against family ideas, across languages (milestone 463 step 5, #4991)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 1m27s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 1m27s
- classify_shapes takes idea_id: the shape is judged against the idea's reference in its language, or its first. - A shape classified against a canon idea's reference moves the project's adoption row (instance -> adopted, variant -> variant). Withdrawing the shapes that gave an answer returns it to unassessed. This runs on classify_shapes, the sweep, confirm_proposals and the coverage refresh. - assess and undo refuse an answer the shapes contradict. A conflict resolution re-judges the variant shapes on both sides. - The proposer's family arm offers a canon idea's reference on a shared platform, in any language. It proposes only when the reference is the top hit over every readable snippet and scores at least 0.70. The pairs this was measured on are recorded beside _FAMILY_FLOOR. The proposer is now v6. - The matrix cell shows the code that answers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,8 @@ export interface AdoptionCell {
|
|||||||
/** Answered against a canon version that is not the current one. */
|
/** Answered against a canon version that is not the current one. */
|
||||||
needs_recheck: boolean;
|
needs_recheck: boolean;
|
||||||
owed_task: { id: number; title: string; status: string } | null;
|
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 {
|
export interface MatrixIdea {
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ defineExpose({ load });
|
|||||||
</router-link>
|
</router-link>
|
||||||
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
|
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
|
||||||
</p>
|
</p>
|
||||||
|
<div v-if="selected.shapes.length" class="fam-detail-shapes">
|
||||||
|
<p>In this project's code — the shape ledger classifies:</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="s in selected.shapes" :key="`${s.path}::${s.symbol}`">
|
||||||
|
<code>{{ s.path }} · {{ s.symbol }}</code>
|
||||||
|
<span class="fam-muted">{{ s.status === "variant" ? "variant" : "instance" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<p v-if="selected.assessed_at" class="fam-muted">
|
<p v-if="selected.assessed_at" class="fam-muted">
|
||||||
Answered {{ fmtStamp(selected.assessed_at) }}
|
Answered {{ fmtStamp(selected.assessed_at) }}
|
||||||
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
|
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
|
||||||
@@ -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 p { margin: 0.4rem 0 0; font-size: 0.875rem; }
|
||||||
.fam-detail-reason { color: var(--fs-text-primary); }
|
.fam-detail-reason { color: var(--fs-text-primary); }
|
||||||
.fam-detail-recheck { color: var(--fs-text-secondary); }
|
.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 { 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 summary { cursor: pointer; color: var(--fs-text-secondary); }
|
||||||
.fam-outcomes dl { margin: 0.5rem 0 0; }
|
.fam-outcomes dl { margin: 0.5rem 0 0; }
|
||||||
|
|||||||
@@ -280,6 +280,12 @@ async def assess_family_adoption(
|
|||||||
variant cancels it, owed again reopens it. The same answer given twice
|
variant cancels it, owed again reopens it. The same answer given twice
|
||||||
records nothing the second time.
|
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:
|
Args:
|
||||||
project_id: the project answering.
|
project_id: the project answering.
|
||||||
idea_id: the canon idea.
|
idea_id: the canon idea.
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ async def classify_shapes(
|
|||||||
Args:
|
Args:
|
||||||
project_id: The project whose ledger is being judged.
|
project_id: The project whose ledger is being judged.
|
||||||
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
|
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
|
list_shapes shows it; kind ("sym"/"css") narrows when one file
|
||||||
defines both. snippet_id is required for canonical/instance/
|
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,
|
an OPTIONAL index beside the prose (one of: scoped-css,
|
||||||
one-off-handler, test-helper, convention-plumbing, pure-helper,
|
one-off-handler, test-helper, convention-plumbing, pure-helper,
|
||||||
generated, script, typed-record) so the ledger can be filtered
|
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
|
confirms it. Must be bound to the project. Omit it to judge only
|
||||||
synced rows.
|
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
|
All-or-nothing: a structural error, a missing snippet target, an unbound
|
||||||
repo, or no write access applies NOTHING. Returns {"classified": N,
|
repo, or no write access applies NOTHING. Returns {"classified": N,
|
||||||
"unmatched": [...], "provisional": N?} — unmatched names shapes no live
|
"unmatched": [...], "provisional": N?, "family": [...]?} — unmatched names
|
||||||
ledger row matches (the tree may have moved since you listed; pass `repo`
|
shapes no live ledger row matches (the tree may have moved since you
|
||||||
for a shape you just wrote, or re-run the project's coverage refresh).
|
listed; pass `repo` for a shape you just wrote, or re-run the project's
|
||||||
|
coverage refresh).
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
return await shape_ledger_svc.classify_shapes(
|
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
|
machine thinks are an instance of a snippet: `proposal` carries
|
||||||
snippet_id, basis, score), "derive" (rows that repeat with NO
|
snippet_id, basis, score), "derive" (rows that repeat with NO
|
||||||
canon: `proposal.group` names the family), or one basis
|
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
|
flag: the divergence readout (#2793) — "divergence": shapes new
|
||||||
since the previous refresh in a directory where one canon
|
since the previous refresh in a directory where one canon
|
||||||
dominates the judged siblings and NOT proposed as that 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
|
THE FAST PATH through a big todo is the proposer's queue: every coverage
|
||||||
refresh matches unclassified shapes against canon (strongest basis
|
refresh matches unclassified shapes against canon (strongest basis
|
||||||
first: same symbol elsewhere → textual containment → body references
|
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
|
`proposal` to each row it can speak for. Review `proposal="canon"` by
|
||||||
snippet or directory, then confirm_shape_proposals the ones that hold —
|
snippet or directory, then confirm_shape_proposals the ones that hold —
|
||||||
hundreds at a time — and classify_shapes the rest (variant/exempt, or
|
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).
|
revise a family you judged earlier).
|
||||||
|
|
||||||
One transaction: applies whole or not at all. Returns
|
One transaction: applies whole or not at all. Returns
|
||||||
{"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
|
{"classified": N, "sample": ["path::symbol", ...], "family": [...]?}
|
||||||
so you can see what the rule reached; N = 0 means the rule matched
|
(sample: first 12, sorted) so you can see what the rule reached; N = 0
|
||||||
nothing live and unclassified — widen the pattern or refresh coverage.
|
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()
|
uid = current_user_id()
|
||||||
try:
|
try:
|
||||||
@@ -317,12 +337,14 @@ async def confirm_shape_proposals(
|
|||||||
snippet_id (confirm one canon's whole queue after reading its
|
snippet_id (confirm one canon's whole queue after reading its
|
||||||
`list_shapes(proposal="canon", ...)` page), path (a directory you
|
`list_shapes(proposal="canon", ...)` page), path (a directory you
|
||||||
audited), or basis (e.g. "symbol" and "reference" are near-certain;
|
audited), or basis (e.g. "symbol" and "reference" are near-certain;
|
||||||
"semantic" deserves a look first) is required — a bare confirm-all is
|
"semantic" and "family" deserve a look first) is required — a bare
|
||||||
not a judgment. min_score trims a basis's tail.
|
confirm-all is not a judgment. min_score trims a basis's tail.
|
||||||
|
|
||||||
Proposals you do NOT confirm are judged with classify_shapes (variant,
|
Proposals you do NOT confirm are judged with classify_shapes (variant,
|
||||||
exempt, or instance of a different snippet) — any judgment retires the
|
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()
|
uid = current_user_id()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -879,6 +879,16 @@ async def compute_coverage(
|
|||||||
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
|
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
|
||||||
|
|
||||||
await shape_ledger.mark_canonicals(project_id, recorded)
|
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:
|
try:
|
||||||
await shape_ledger.apply_derive_groups(project_id)
|
await shape_ledger.apply_derive_groups(project_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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
|
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
|
alternative, or the branch for its condition — and is never dropped. The
|
||||||
substance changed, so the version moves.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
|
from scribe.models.code_shape import CodeShape
|
||||||
from scribe.models.family import (
|
from scribe.models.family import (
|
||||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
|
||||||
Platform, ProjectPlatform,
|
Platform, ProjectPlatform,
|
||||||
@@ -281,6 +294,224 @@ async def _project_languages(session, project_id: int) -> list[str]:
|
|||||||
return [r[0] for r in rows]
|
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,
|
async def assessment_precedents(user_id: int, project_id: int, idea_id: int,
|
||||||
limit: int = 5) -> list[dict]:
|
limit: int = 5) -> list[dict]:
|
||||||
"""What an assessment should be consistent with: the latest answer to
|
"""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))
|
select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None))
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
} if task_ids else {}
|
} 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
|
readable = [(pid, title) for pid, title in projects
|
||||||
if await access.can_read_project(user_id, pid)]
|
if await access.can_read_project(user_id, pid)]
|
||||||
@@ -415,6 +660,7 @@ async def adoption_matrix(
|
|||||||
"needs_recheck": bool(row) and needs_recheck(
|
"needs_recheck": bool(row) and needs_recheck(
|
||||||
row.status, row.canon_version, idea.status, idea.canon_version),
|
row.status, row.canon_version, idea.status, idea.canon_version),
|
||||||
"owed_task": None,
|
"owed_task": None,
|
||||||
|
"shapes": evidence.get((pid, iid), []),
|
||||||
}
|
}
|
||||||
task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None
|
task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None
|
||||||
if task is not None:
|
if task is not None:
|
||||||
@@ -643,13 +889,17 @@ def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int,
|
|||||||
async def assess(
|
async def assess(
|
||||||
user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str,
|
user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str,
|
||||||
evidence: list[str] | None = None, precedent_ids: list[int] | None = None,
|
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:
|
) -> dict:
|
||||||
"""Record one project's answer to one canon idea.
|
"""Record one project's answer to one canon idea.
|
||||||
|
|
||||||
Raises ValueError before writing anything when the answer is malformed
|
Raises ValueError before writing anything when the answer is malformed
|
||||||
(assessment_problems), the caller cannot write the project, or the idea
|
(assessment_problems), the caller cannot write the project, the idea
|
||||||
is not canon or does not reach the project.
|
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 —
|
The same answer given again — same outcome, reason and canon version —
|
||||||
records nothing (`changed: False`); it still makes sure an owed answer
|
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":
|
if idea is None or idea.status != "canon":
|
||||||
raise ValueError(f"#{idea_id} is not family canon — only canon is assessed")
|
raise ValueError(f"#{idea_id} is not family canon — only canon is assessed")
|
||||||
row = await _row_for_write(session, project_id, idea_id)
|
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)
|
before = _row_state(row)
|
||||||
after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version}
|
after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version}
|
||||||
changed = before != after
|
changed = before != after
|
||||||
@@ -683,8 +938,9 @@ async def assess(
|
|||||||
decision = family_svc._log(
|
decision = family_svc._log(
|
||||||
session, idea_id=idea_id, project_id=project_id, action="assess",
|
session, idea_id=idea_id, project_id=project_id, action="assess",
|
||||||
reason=reason, before=before, after=_row_state(row),
|
reason=reason, before=before, after=_row_state(row),
|
||||||
evidence={"evidence": evidence}, precedent_ids=precedent_list,
|
evidence=({"evidence": evidence, "source": source, "shapes": shapes or []}
|
||||||
decided_via=decided_via, user_id=user_id,
|
if source else {"evidence": evidence}),
|
||||||
|
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if decision is not None:
|
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")
|
raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone")
|
||||||
before = _row_state(row)
|
before = _row_state(row)
|
||||||
prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None}
|
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.status = prior["status"]
|
||||||
row.reason = (prior.get("reason") or "").strip() or None
|
row.reason = (prior.get("reason") or "").strip() or None
|
||||||
row.canon_version = prior.get("canon_version")
|
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 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
|
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
|
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
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
@@ -915,6 +1178,9 @@ async def resolve_conflict(
|
|||||||
row_ids[pid] = row.id
|
row_ids[pid] = row.id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(revision)
|
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])
|
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])
|
await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2])
|
||||||
return {
|
return {
|
||||||
@@ -923,4 +1189,28 @@ async def resolve_conflict(
|
|||||||
"folded_as": g["fold_as"],
|
"folded_as": g["fold_as"],
|
||||||
"adoptions": await list_adoptions(user_id, idea_id=idea_id),
|
"adoptions": await list_adoptions(user_id, idea_id=idea_id),
|
||||||
"owed_task": task,
|
"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)
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ def validate_classifications(items: list[dict]) -> str | None:
|
|||||||
snippet_id = item.get("snippet_id") or 0
|
snippet_id = item.get("snippet_id") or 0
|
||||||
if status in _NEEDS_TARGET and not snippet_id:
|
if status in _NEEDS_TARGET and not snippet_id:
|
||||||
return (
|
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)"
|
"the snippet this shape is (or departs from)"
|
||||||
)
|
)
|
||||||
if status in ("variant", "exempt") and not (item.get("reason") or "").strip():
|
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:
|
if via not in _CALLER_VIAS:
|
||||||
raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}")
|
raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}")
|
||||||
|
classifications = await _resolve_ideas(user_id, classifications)
|
||||||
error = validate_classifications(classifications)
|
error = validate_classifications(classifications)
|
||||||
if error:
|
if error:
|
||||||
raise ValueError(error)
|
raise ValueError(error)
|
||||||
@@ -602,6 +603,7 @@ async def classify_shapes(
|
|||||||
classified = 0
|
classified = 0
|
||||||
provisional = 0
|
provisional = 0
|
||||||
unmatched: list[dict] = []
|
unmatched: list[dict] = []
|
||||||
|
touched: set[int] = set()
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -639,6 +641,7 @@ async def classify_shapes(
|
|||||||
continue
|
continue
|
||||||
status = item["status"]
|
status = item["status"]
|
||||||
for row in matches:
|
for row in matches:
|
||||||
|
touched.update(x for x in (row.snippet_id, item.get("snippet_id")) if x)
|
||||||
await _judge(
|
await _judge(
|
||||||
session, row, status=status,
|
session, row, status=status,
|
||||||
snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None,
|
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}
|
out = {"classified": classified, "unmatched": unmatched}
|
||||||
if provisional:
|
if provisional:
|
||||||
out["provisional"] = provisional
|
out["provisional"] = provisional
|
||||||
|
moved = await _sync_family(user_id, project_id, touched)
|
||||||
|
if moved:
|
||||||
|
out["family"] = moved
|
||||||
return out
|
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:
|
def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool:
|
||||||
"""Does a ledger row fall under a rule-form classification (#2868)?
|
"""Does a ledger row fall under a rule-form classification (#2868)?
|
||||||
``path`` is a file or a directory (everything beneath it), ``pattern``
|
``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)
|
now = datetime.now(timezone.utc)
|
||||||
judged: list[str] = []
|
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:
|
async with async_session() as session:
|
||||||
conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)]
|
conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)]
|
||||||
if not include_judged:
|
if not include_judged:
|
||||||
@@ -729,6 +776,8 @@ async def classify_shapes_where(
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if not rule_matches(row, path=path, pattern=pattern, kind=kind):
|
if not rule_matches(row, path=path, pattern=pattern, kind=kind):
|
||||||
continue
|
continue
|
||||||
|
if row.snippet_id:
|
||||||
|
touched.add(row.snippet_id)
|
||||||
await _judge(
|
await _judge(
|
||||||
session, row, status=status,
|
session, row, status=status,
|
||||||
snippet_id=int(snippet_id) if status in _NEEDS_TARGET else None,
|
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)
|
await record_uses(session, row, uses, basis=via, evidence=reason)
|
||||||
judged.append(f"{row.path}::{row.symbol}")
|
judged.append(f"{row.path}::{row.symbol}")
|
||||||
await session.commit()
|
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(
|
async def list_project_shapes(
|
||||||
@@ -1611,6 +1664,29 @@ _SEMANTIC_LIMIT = 8
|
|||||||
# against a prose-forward snippet document measures the wrong field (#2518);
|
# 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
|
# no floor separates the two bands. The arm PROPOSES on a hit; its silence
|
||||||
# says nothing.
|
# 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:
|
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),
|
# 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
|
# and the signature floor from 0.8 to 0.75 (#4306), so rows examined and
|
||||||
# found nothing for must be read once more.
|
# 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
|
# Signature resemblance floor, name blanked (difflib ratio) — and a length
|
||||||
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
|
# floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying
|
||||||
# nothing; a family shape has parameters to resemble.
|
# nothing; a family shape has parameters to resemble.
|
||||||
@@ -1883,28 +1961,47 @@ def _substance(text: str) -> int:
|
|||||||
return len("".join((text or "").split()))
|
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(
|
async def _semantic_canon(
|
||||||
user_id: int, body: str, allowed: set[int],
|
user_id: int, body: str, allowed: set[int], family: set[int] | None = None,
|
||||||
) -> tuple[int, float] | None:
|
) -> tuple[int, float, str] | None:
|
||||||
"""The canon this body MEANS, or None. None is "no proposal", never "not
|
"""The canon this body MEANS, as (snippet_id, score, basis), or None.
|
||||||
the canon" — see the note above `_SEMANTIC_LIMIT`."""
|
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.embeddings import semantic_search_notes
|
||||||
from scribe.services.plugin_context import (
|
from scribe.services.plugin_context import (
|
||||||
WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query,
|
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
|
return None
|
||||||
query = concept_query(body) or body
|
query = concept_query(body) or body
|
||||||
hits = await semantic_search_notes(
|
hits = await semantic_search_notes(
|
||||||
user_id, query, limit=_SEMANTIC_LIMIT,
|
user_id, query, limit=_SEMANTIC_LIMIT,
|
||||||
threshold=WRITEPATH_DEFAULT_THRESHOLD,
|
threshold=min(WRITEPATH_DEFAULT_THRESHOLD, _FAMILY_FLOOR),
|
||||||
note_type="snippet", scope="browse",
|
note_type="snippet", scope="browse",
|
||||||
)
|
)
|
||||||
for score, note in hits:
|
return pick_semantic(
|
||||||
if int(note.id) in allowed:
|
[(float(score), int(note.id)) for score, note in hits], allowed, family,
|
||||||
return int(note.id), round(float(score), 3)
|
floor=WRITEPATH_DEFAULT_THRESHOLD,
|
||||||
return None
|
)
|
||||||
|
|
||||||
|
|
||||||
async def propose_for_repo(
|
async def propose_for_repo(
|
||||||
@@ -1933,6 +2030,16 @@ async def propose_for_repo(
|
|||||||
|
|
||||||
def semantic_allowed(path: str) -> set[int]:
|
def semantic_allowed(path: str) -> set[int]:
|
||||||
return {c.snippet_id for c in sym_canons if same_family(path, c.language)}
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
examined = proposed = checked = 0
|
examined = proposed = checked = 0
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -1998,13 +2105,13 @@ async def propose_for_repo(
|
|||||||
continue
|
continue
|
||||||
checked += 1
|
checked += 1
|
||||||
try:
|
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:
|
except Exception:
|
||||||
logger.warning("semantic proposal failed", exc_info=True)
|
logger.warning("semantic proposal failed", exc_info=True)
|
||||||
found = None
|
found = None
|
||||||
if found:
|
if found:
|
||||||
row.proposed_snippet_id, row.proposal_score = found
|
row.proposed_snippet_id, row.proposal_score, row.proposal_basis = found
|
||||||
row.proposal_basis = "semantic"
|
|
||||||
row.proposal_group = None
|
row.proposal_group = None
|
||||||
proposed += 1
|
proposed += 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -2210,11 +2317,13 @@ async def confirm_proposals(
|
|||||||
conds.append(CodeShape.proposal_basis == basis.strip())
|
conds.append(CodeShape.proposal_basis == basis.strip())
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
confirmed = 0
|
confirmed = 0
|
||||||
|
touched: set[int] = set()
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all()
|
rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if (row.proposal_score or 0.0) < min_score:
|
if (row.proposal_score or 0.0) < min_score:
|
||||||
continue
|
continue
|
||||||
|
touched.add(row.proposed_snippet_id)
|
||||||
await _judge(
|
await _judge(
|
||||||
session, row, status="instance", snippet_id=row.proposed_snippet_id,
|
session, row, status="instance", snippet_id=row.proposed_snippet_id,
|
||||||
by="agent", at=now,
|
by="agent", at=now,
|
||||||
@@ -2225,7 +2334,11 @@ async def confirm_proposals(
|
|||||||
)
|
)
|
||||||
confirmed += 1
|
confirmed += 1
|
||||||
await session.commit()
|
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 -------
|
# --- the divergence readout (#2793): button B where button A is canon -------
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ def _patch(mock: AsyncMock):
|
|||||||
|
|
||||||
async def test_a_hit_returns_the_canon() -> None:
|
async def test_a_hit_returns_the_canon() -> None:
|
||||||
with _patch(_hits((0.88, CANON))):
|
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:
|
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
|
"""The scan is over the whole result set, so a disallowed snippet ranking
|
||||||
first does not hide an allowed one behind it."""
|
first does not hide an allowed one behind it."""
|
||||||
with _patch(_hits((0.95, OTHER), (0.83, CANON))):
|
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:
|
async def test_a_miss_is_none_and_nothing_more() -> None:
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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):
|
async def test_the_matrix_hides_what_the_caller_cannot_read(family):
|
||||||
matrix = await adoption_svc.adoption_matrix(family["outsider"])
|
matrix = await adoption_svc.adoption_matrix(family["outsider"])
|
||||||
assert all(i["note_id"] != family["note"] for i in matrix["ideas"])
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user