feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment- insensitive body_sha; migration 0080) and the proposer runs inside the coverage refresh, the one moment bodies exist: symbol elsewhere → textual containment → body references the canon → signature resemblance → semantic (capped per refresh, unreached rows stay unexamined for the next). A hit is a proposal on the row (proposed_snippet_id/basis/score), never a classification; rows with no canon hit group by the derive-first rule (identical body in ≥2 places, same name in ≥3 files) as proposal_basis= derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms in batches as agent instances; any classify_shapes/hook stamp retires the proposal. Readout carries proposed + derive_groups (line, payload, card). Plugin 0.1.35 (skill: the machine proposes, judgment classifies). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -351,3 +351,145 @@ async def test_recent_pulls_reads_the_usage_stream(seeded):
|
||||
assert sid in pulls
|
||||
assert sid + 1000 not in pulls
|
||||
assert sid + 2000 not in pulls
|
||||
|
||||
|
||||
# --- #2792: the mechanical proposer against real rows -------------------------
|
||||
|
||||
|
||||
def _quiet_semantic():
|
||||
"""The semantic basis needs the embedder; these tests prove the other
|
||||
bases and the bookkeeping, so it answers "nothing" here."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from scribe.services import shape_ledger
|
||||
return patch.object(shape_ledger, "_semantic_canon", AsyncMock(return_value=None))
|
||||
|
||||
|
||||
def _defs(*items):
|
||||
"""ArchiveShape-like records: (path, kind, name, signature, body_sha, body)."""
|
||||
import hashlib
|
||||
out = []
|
||||
for path, kind, name, signature, body in items:
|
||||
sha = hashlib.sha1(" ".join(body.split()).encode()).hexdigest()[:16]
|
||||
out.append((path, kind, name, signature, sha, body))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_proposer_proposes_and_confirm_classifies(seeded):
|
||||
"""Bodies in hand, the proposer records proposals on unclassified rows —
|
||||
symbol (a second `factory` elsewhere), reference (a call site), and
|
||||
nothing for the unrelated — skips rows whose content it already judged,
|
||||
and a scoped confirm turns proposals into agent instances while a
|
||||
classify on another retires its proposal."""
|
||||
from scribe.services.shape_ledger import (
|
||||
confirm_proposals, propose_for_repo,
|
||||
)
|
||||
|
||||
owner, other, pid, sid = (
|
||||
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
|
||||
)
|
||||
defs = _defs(
|
||||
("src/app.py", "sym", "make_app", "def make_app():", "def make_app():\n app = factory()\n return app"),
|
||||
("src/app.py", "sym", "Config", "class Config:", "class Config:\n debug = False"),
|
||||
("src/util.py", "sym", "helper", "def helper(x):", "def helper(x):\n return x"),
|
||||
("src/dup.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
|
||||
("web/button.css", "css", "btn", ".btn {", ".btn {\n color: red;\n}"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, defs, seen_marker="main")
|
||||
|
||||
with _quiet_semantic():
|
||||
stats = await propose_for_repo(owner, pid, REPO, defs)
|
||||
assert stats == {"examined": 5, "proposed": 2, "semantic_checked": 2}
|
||||
rows, total = await list_project_shapes(owner, pid, proposal="canon")
|
||||
by_symbol = {r.symbol: r for r in rows}
|
||||
assert total == 2
|
||||
assert by_symbol["factory"].proposal == {"basis": "symbol", "score": 1.0, "snippet_id": sid}
|
||||
assert by_symbol["make_app"].proposal == {"basis": "reference", "score": 0.9, "snippet_id": sid}
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="reference")
|
||||
assert [r.symbol for r in rows] == ["make_app"]
|
||||
|
||||
# Same content again → nothing re-examined (the semantic cap would
|
||||
# otherwise be spent on the same rows every refresh). A cap that leaves
|
||||
# rows unreached leaves them UNexamined, so the next refresh gets them.
|
||||
with _quiet_semantic():
|
||||
assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 0
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/util.py", "symbol": "helper", "status": "unclassified"},
|
||||
])
|
||||
assert (await propose_for_repo(owner, pid, REPO, defs, semantic_cap=0))["semantic_checked"] == 0
|
||||
assert (await propose_for_repo(owner, pid, REPO, defs))["examined"] == 1
|
||||
|
||||
# Outsider can't confirm; the owner confirms by snippet, scoped.
|
||||
with pytest.raises(ValueError):
|
||||
await confirm_proposals(other, pid, snippet_id=sid)
|
||||
assert await confirm_proposals(owner, pid, basis="symbol") == {"confirmed": 1}
|
||||
rows, _ = await list_project_shapes(owner, pid, snippet_id=sid)
|
||||
factory = next(r for r in rows if r.symbol == "factory")
|
||||
assert factory.status == "instance" and factory.classified_by == "agent"
|
||||
assert factory.reason == "confirmed symbol proposal (1.00)"
|
||||
assert factory.proposal is None
|
||||
|
||||
# A judgment on a proposed row retires the proposal; withdrawing a
|
||||
# judgment forgets the examination so the next pass proposes afresh.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/app.py", "symbol": "make_app", "status": "exempt", "reason": "bootstrap"},
|
||||
])
|
||||
rows, _ = await list_project_shapes(owner, pid, path="src/app.py")
|
||||
make_app = next(r for r in rows if r.symbol == "make_app")
|
||||
assert make_app.status == "exempt" and make_app.proposal is None
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/app.py", "symbol": "make_app", "status": "unclassified"},
|
||||
])
|
||||
with _quiet_semantic():
|
||||
assert (await propose_for_repo(owner, pid, REPO, defs))["proposed"] == 1
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="canon")
|
||||
assert [r.symbol for r in rows] == ["make_app"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
|
||||
"""Shapes with no canon hit that repeat — identical bodies in two files,
|
||||
the same name in three — carry a derive proposal, and the readout ranks
|
||||
the families. A canon proposal keeps a row out of any derive group."""
|
||||
from scribe.services.shape_ledger import (
|
||||
apply_derive_groups, live_rows, propose_for_repo, proposal_summary,
|
||||
)
|
||||
|
||||
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||
defs = _defs(
|
||||
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||
("b/x.css", "css", "card", ".card {", ".card { padding: 1px }"),
|
||||
("b/y.css", "css", "card", ".card {", ".card { padding: 2px }"),
|
||||
("b/z.css", "css", "card", ".card {", ".card { padding: 3px }"),
|
||||
("c/only.py", "sym", "alone", "def alone():", "def alone():\n return 0"),
|
||||
("c/use.py", "sym", "boot", "def boot():", "def boot():\n return factory()"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, defs, seen_marker="main")
|
||||
with _quiet_semantic():
|
||||
await propose_for_repo(owner, pid, REPO, defs)
|
||||
assert await apply_derive_groups(pid) == 5
|
||||
|
||||
rows, total = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert total == 5
|
||||
groups = {(r.path, r.symbol): r.proposal for r in rows}
|
||||
assert groups[("a/one.py", "slug")]["group"] == groups[("a/two.py", "slug")]["group"]
|
||||
assert groups[("a/one.py", "slug")]["group"].startswith("dup:")
|
||||
assert groups[("b/x.css", "card")] == {"basis": "derive", "score": 3.0, "group": "name:css:card"}
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="any")
|
||||
assert {r.symbol for r in rows} == {"slug", "card", "boot"} # boot: reference proposal
|
||||
|
||||
summary = proposal_summary(await live_rows(pid))
|
||||
assert summary["proposed"] == 1
|
||||
assert [g["group"] for g in summary["derive_groups"]][0] == "name:css:card"
|
||||
assert summary["derive_groups"][0]["label"] == ".card"
|
||||
assert summary["derive_groups"][0]["size"] == 3
|
||||
|
||||
# One of the css copies gets judged → the group shrinks on the next pass.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
||||
])
|
||||
await apply_derive_groups(pid)
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
|
||||
|
||||
Reference in New Issue
Block a user