Files
FabledGrimoire/tests/test_family_shapes.py
bvandeusenandClaude Opus 5.5 0f5ef0be63
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
rename(547): the server is grimoire — package, image, DB defaults, backups
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>
2026-10-22 10:50:45 -04:00

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")