Files
FabledGrimoire/tests/test_lesson_convergence.py
T
bvandeusenandClaude Opus 5.5 914f31359c
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
docs(6209): the remaining test files — lessons, reply moment and shape, retrieval specs and scopes, rule doors and judgments, services, snippets, search scope; five tests renamed to their property
Docstrings, section headers and assertion messages drop the record number,
milestone or step that introduced the behaviour, and incident accounts are
restated as the present-tense failure shape. A dead pointer to a design doc
that is not in the repo is removed.

Renamed to the property they check:
  test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught
  test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld
  test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker
  test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file
  test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with
  test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note

Fixture and example ids stay: "note 5" in a supersession chain, "rule 156"
in an illustrative question, `#2275` as the hex-versus-citation case,
`#3244` as the example of a bare number, fixed dates in date-filter tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:44:58 -04:00

175 lines
6.7 KiB
Python

"""Convergence named at the write.
When a lesson is answered "no rule fits", the response looks for other
no-rule lessons in the same situation and, once there are enough, names the
group and the rule it may be missing. The bar is pinned here as a pure
function; the search around it with the database and embedder stubbed; the
doors by what they return.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools import lessons as lesson_tools
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
# Bound before conftest's autouse stub replaces the module attribute.
_REAL = links_svc.convergence_for
def _m(lid, sources=(), project=None, title=None):
return {"id": lid, "title": title or f"lesson {lid}", "sources": list(sources),
"project_id": project}
def test_below_the_group_size_nothing_is_named():
assert links_svc.convergence_group([_m(1), _m(2)]) is None
def test_a_group_of_distinct_lessons_names_its_members_and_the_next_step():
group = links_svc.convergence_group([_m(1, [10], 2), _m(2, [11], 5), _m(3, [], 2)])
assert [m["id"] for m in group["lessons"]] == [1, 2, 3]
assert group["incidents"] == [10, 11]
assert group["projects"] == [2, 5]
assert "create_rule" in group["hint"] and "update_lesson" in group["hint"]
assert "#1 “lesson 1”" in group["hint"]
def test_one_broad_lesson_cannot_reach_the_bar_alone():
"""Many incidents behind ONE lesson is still one judgment: incidents never
stand in for lessons, and a repeated id is one member."""
broad = _m(1, [10, 11, 12, 13, 14])
assert links_svc.convergence_group([broad]) is None
assert links_svc.convergence_group([broad, broad, broad]) is None
def test_one_incident_written_up_three_times_is_not_a_recurring_situation():
same = [_m(1, [10]), _m(2, [10]), _m(3, [10])]
assert links_svc.convergence_group(same) is None
# ── The bar is relative to each lesson's own register ──────────────────────
def test_too_little_background_says_nothing_stands_out():
few = {i: 0.6 for i in range(links_svc.CONVERGENCE_MIN_BACKGROUND - 1)}
assert links_svc.standout(few) == {}
def test_a_background_that_never_varies_says_nothing_stands_out():
assert links_svc.standout({i: 0.7 for i in range(20)}) == {}
def test_standing_out_is_measured_in_the_registers_own_spread():
"""The same neighbour score stands out in a tight register and not in a
loose one — the bar moves with the corpus, not with a constant."""
tight = {i: 0.70 + 0.01 * (i % 5) for i in range(20)} | {99: 0.80}
loose = {i: 0.60 + 0.06 * (i % 5) for i in range(20)} | {99: 0.80}
assert links_svc.standout(tight)[99] >= links_svc.CONVERGENCE_STANDOUT
assert links_svc.standout(loose)[99] < links_svc.CONVERGENCE_STANDOUT
def test_a_hub_near_two_lessons_that_are_not_near_each_other_is_no_clique():
up = links_svc.CONVERGENCE_STANDOUT + 1
standouts = {1: {2: up, 3: up}, 2: {1: up, 3: 0.0}, 3: {1: up, 2: 0.0}}
assert links_svc.converging(1, standouts, [2, 3]) == [1, 2]
def test_resemblance_must_run_both_ways():
up = links_svc.CONVERGENCE_STANDOUT + 1
standouts = {1: {2: up, 3: up}, 2: {1: 0.0, 3: up}, 3: {1: up, 2: up}}
assert links_svc.converging(1, standouts, [2, 3]) == [1, 3]
def _note(lid, *, title=None, project=None, sources=()):
data = {"what": title or f"lesson {lid}", "when_to_apply": "a CI run overran"}
if sources:
data["taught_by"] = list(sources)
return SimpleNamespace(
id=lid, title=title or f"lesson {lid}", note_type="lesson", body="", data=data,
project_id=project, arose_from_id=None, tags=[],
created_at=None, updated_at=None,
)
# A register: lessons 1-3 resemble each other well above a background of
# filler lessons 10-29, which all sit in one flat band — the shape every
# lesson-to-lesson score has, because lessons share one register.
_TRIO = {1, 2, 3}
_FILLER = range(10, 30)
_NOTES = {i: _note(i, sources=[100 + i]) for i in (*_TRIO, *_FILLER)}
def _sim(a, b):
if a in _TRIO and b in _TRIO:
return 0.82
return 0.66 + 0.01 * ((a + b) % 5)
def _search_over(sim=_sim):
async def search(user_id, query, *, exclude_ids, **_kw):
(me,) = exclude_ids
return sorted(((sim(me, i), n) for i, n in _NOTES.items() if i != me),
key=lambda pair: -pair[0])
return search
def _run(no_rule, sim=_sim):
return (
patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_NOTES[1])),
patch("scribe.services.embeddings.semantic_search_notes", _search_over(sim)),
patch.object(links_svc, "_no_rule_ids",
AsyncMock(side_effect=lambda ids: {i for i in ids if i in no_rule})),
)
@pytest.mark.asyncio
async def test_lessons_that_stand_out_together_are_named():
a, b, c = _run(no_rule=set(_NOTES))
with a, b, c:
group = await _REAL(7, 1)
assert [m["id"] for m in group["lessons"]] == [1, 2, 3]
@pytest.mark.asyncio
async def test_a_large_no_rule_pool_in_one_flat_band_names_nothing():
"""The flat-band failure: every lesson answered "no rule", every score above
a fixed bar, and a group the size of the pool. Nothing stands out of a
flat band, so nothing is named however large the pool grows."""
a, b, c = _run(no_rule=set(_NOTES), sim=lambda x, y: 0.66 + 0.01 * ((x + y) % 5))
with a, b, c:
assert await _REAL(7, 1) is None
@pytest.mark.asyncio
async def test_only_lessons_answered_no_rule_join_the_group():
a, b, c = _run(no_rule={1, 2}) # #3 has a rule: a pair is no group
with a, b, c:
assert await _REAL(7, 1) is None
@pytest.mark.asyncio
async def test_a_failed_search_names_nothing_and_raises_nothing():
with patch.object(lessons_svc, "get_lesson", AsyncMock(side_effect=RuntimeError("db down"))):
assert await _REAL(7, 1) is None
@pytest.mark.asyncio
async def test_the_door_carries_convergence_only_with_a_no_rule_answer():
_user_id_ctx.set(7)
group = {"lessons": [], "incidents": [], "projects": [], "hint": "h"}
named = AsyncMock(return_value=group)
with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_note(41))), \
patch.object(links_svc, "set_no_rule", AsyncMock()), \
patch.object(links_svc, "convergence_for", named):
out = await lesson_tools.update_lesson(lesson_id=41, no_rule="stands alone")
assert out["convergence"] == group
out = await lesson_tools.update_lesson(lesson_id=41, what="reworded")
assert "convergence" not in out
assert named.await_count == 1