Merge dev: family canon, milestone 463 steps 1-6 #206
@@ -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