"""Convergence named at the write (milestone 440, #4634). 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 (#5193) ────────────── 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 #5193 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