CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Python tests (push) Successful in 2m23s
CI & Build / Build & push image (push) Successful in 52s
Lessons share one register, so any two score well above unrelated text and a fixed similarity bar sat inside that band: every no-rule lesson "resembled" most of the others and the named group grew with the pool. A neighbour now counts only when it stands above the lesson's own background (median and MAD of its similarity to every lesson it can reach, in that register's spread), and a group is a clique: every pair clears the bar from both sides, so one broad lesson cannot join unrelated ones. The bar, the minimum background and the fetch sizes are stated as defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
6.7 KiB
Python
175 lines
6.7 KiB
Python
"""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
|