fix(lessons): convergence is measured against each lesson's own register, and its members must resemble each other - a flat band of no-rule lessons names nothing (#5193)
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>
This commit is contained in:
2026-10-06 22:58:39 -04:00
co-authored by Claude Opus 5.5
parent 890c93e126
commit 37ca2ee958
2 changed files with 189 additions and 33 deletions
+88 -14
View File
@@ -53,30 +53,104 @@ def test_one_incident_written_up_three_times_is_not_a_recurring_situation():
assert links_svc.convergence_group(same) is None
def _note(lid, *, title="t", project=None, sources=()):
data = {"what": title, "when_to_apply": "a CI run overran"}
# ── 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, note_type="lesson", body="", data=data,
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():
found = [(0.8, _note(2, sources=[11])), (0.7, _note(3, sources=[12])),
(0.7, _note(4, sources=[13]))]
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \
patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \
patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2})):
assert await _REAL(7, 1) is None # only #2 answered: a pair
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \
patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \
patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2, 4})):
group = await _REAL(7, 1)
assert [m["id"] for m in group["lessons"]] == [1, 2, 4]
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