CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.6 KiB
Python
142 lines
5.6 KiB
Python
"""The shape ledger judging against family ideas, without a database.
|
|
|
|
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, 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 grimoire.services.family_adoption import (
|
|
_contradiction, path_speaks, shape_outcome, shapes_say,
|
|
)
|
|
from grimoire.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", [
|
|
# The measured 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("grimoire.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("grimoire.mcp.tools.shapes", "classify_shapes")
|
|
assert "idea_id" in classify and "shapes contradict" in classify
|
|
assert "family" in tool_doc("grimoire.mcp.tools.shapes", "list_shapes")
|
|
assert '"family"' in tool_doc("grimoire.mcp.tools.shapes", "confirm_shape_proposals")
|
|
assert "classify_shapes" in tool_doc("grimoire.mcp.tools.family", "assess_family_adoption")
|