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
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:
@@ -37,6 +37,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import statistics
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, delete, exists, func, not_, or_, select
|
from sqlalchemy import and_, delete, exists, func, not_, or_, select
|
||||||
@@ -735,17 +736,47 @@ async def confirmed_rules_in_scope(
|
|||||||
# answered, and never by a sweep or a timer (#4183): the reader is in the
|
# answered, and never by a sweep or a timer (#4183): the reader is in the
|
||||||
# situation then, and a nudge arriving anywhere else is one nobody acts on.
|
# situation then, and a nudge arriving anywhere else is one nobody acts on.
|
||||||
#
|
#
|
||||||
|
# WHAT "RESEMBLE" MEANS (#5193). Not a fixed similarity. Lessons are written
|
||||||
|
# in one register — a sentence of advice and the moment it applies — so an
|
||||||
|
# embedder scores any two of them well above two unrelated texts, and a
|
||||||
|
# constant bar sits inside that band: every no-rule lesson then "resembles"
|
||||||
|
# most of the others, and the group grows with the pool rather than with any
|
||||||
|
# situation recurring. The bar is instead relative to each lesson's own
|
||||||
|
# background: how far a neighbour stands above the typical score that lesson
|
||||||
|
# gets against every lesson in its register, measured in that register's own
|
||||||
|
# spread (median and MAD, so the near neighbours being judged do not move the
|
||||||
|
# yardstick). An install with a different embedder, or lessons in a different
|
||||||
|
# style, gets a different band and the same bar.
|
||||||
|
#
|
||||||
|
# And resemblance must be MUTUAL, among every member, not just to the lesson
|
||||||
|
# being answered. A lesson broad enough to stand near many others is a hub,
|
||||||
|
# and hub-and-spoke is how three unrelated lessons used to make a "group"
|
||||||
|
# through one general one. A clique is the shape of one situation met again.
|
||||||
|
#
|
||||||
# DEFAULTS, stated as defaults (rules 32, 115). Three lessons — the new one
|
# DEFAULTS, stated as defaults (rules 32, 115). Three lessons — the new one
|
||||||
# and two it resembles — is the smallest group that is a pattern rather than
|
# and two it resembles — is the smallest group that is a pattern rather than
|
||||||
# a pair. The similarity bar sits above the notes menu's ("worth showing")
|
# a pair. One spread above the background is a modest bar for one direction
|
||||||
# and below the duplicate gate's ("the same record"): these lessons should be
|
# alone; the strictness comes from requiring it of every pair in both
|
||||||
# about one situation without being one lesson written twice, which the
|
# directions, which unrelated lessons rarely all clear. Raise it if groups
|
||||||
# duplicate gate already catches.
|
# name lessons a reader would not call one situation; lower it if lessons a
|
||||||
|
# reader would group never meet.
|
||||||
CONVERGENCE_LESSONS = 3
|
CONVERGENCE_LESSONS = 3
|
||||||
CONVERGENCE_THRESHOLD = 0.65
|
CONVERGENCE_STANDOUT = 1.0
|
||||||
# Candidates fetched before keeping the no-rule ones; most lessons near a
|
# Below this many other lessons a median and its spread describe too little
|
||||||
# situation may well have rules.
|
# to say what stands out, so nothing is named.
|
||||||
_CONVERGENCE_FETCH = 20
|
CONVERGENCE_MIN_BACKGROUND = 8
|
||||||
|
# How many of a lesson's register the background is read from. A register
|
||||||
|
# larger than this is measured on its nearest part, which reads the
|
||||||
|
# background HIGH — the bar errs strict, toward naming nothing, never toward
|
||||||
|
# a group that is only the pool's size.
|
||||||
|
_CONVERGENCE_BACKGROUND = 200
|
||||||
|
# Neighbours checked for mutual resemblance, nearest first. Each costs one
|
||||||
|
# more search at the write, and a group large enough to need more is named
|
||||||
|
# just as well by its nearest members.
|
||||||
|
_CONVERGENCE_CANDIDATES = 8
|
||||||
|
# The consistency constant that makes a MAD estimate a standard deviation's
|
||||||
|
# scale on normal data, so CONVERGENCE_STANDOUT reads as "spreads".
|
||||||
|
_MAD_SCALE = 1.4826
|
||||||
|
|
||||||
|
|
||||||
def convergence_group(members: list[dict]) -> dict | None:
|
def convergence_group(members: list[dict]) -> dict | None:
|
||||||
@@ -812,6 +843,38 @@ async def convergence_for(user_id: int, lesson_id: int) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def standout(scores: dict[int, float]) -> dict[int, float]:
|
||||||
|
"""How far each lesson stands above this one's background, in spreads.
|
||||||
|
|
||||||
|
`scores` is one lesson's similarity to every other lesson in its register,
|
||||||
|
itself left out. Pure, so the bar is testable. Empty when there is too
|
||||||
|
little background to measure, or none of it varies.
|
||||||
|
"""
|
||||||
|
if len(scores) < CONVERGENCE_MIN_BACKGROUND:
|
||||||
|
return {}
|
||||||
|
middle = statistics.median(scores.values())
|
||||||
|
spread = statistics.median(abs(v - middle) for v in scores.values()) * _MAD_SCALE
|
||||||
|
if spread <= 0:
|
||||||
|
return {}
|
||||||
|
return {i: (v - middle) / spread for i, v in scores.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def converging(lesson_id: int, standouts: dict[int, dict[int, float]],
|
||||||
|
candidates: list[int]) -> list[int]:
|
||||||
|
"""The lesson, then each candidate that stands out to EVERY member so far
|
||||||
|
and every member to it. Candidates are taken nearest first, so the clique
|
||||||
|
grows around the closest situation rather than the first id. Pure."""
|
||||||
|
def near(a: int, b: int) -> bool:
|
||||||
|
return (standouts.get(a, {}).get(b, float("-inf")) >= CONVERGENCE_STANDOUT
|
||||||
|
and standouts.get(b, {}).get(a, float("-inf")) >= CONVERGENCE_STANDOUT)
|
||||||
|
|
||||||
|
group = [lesson_id]
|
||||||
|
for c in candidates:
|
||||||
|
if c not in group and all(near(c, m) for m in group):
|
||||||
|
group.append(c)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
|
async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
|
||||||
from scribe.services import lessons as lessons_svc
|
from scribe.services import lessons as lessons_svc
|
||||||
from scribe.services.embeddings import semantic_search_notes, trigger_title
|
from scribe.services.embeddings import semantic_search_notes, trigger_title
|
||||||
@@ -819,14 +882,34 @@ async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
|
|||||||
lesson = await lessons_svc.get_lesson(user_id, lesson_id)
|
lesson = await lessons_svc.get_lesson(user_id, lesson_id)
|
||||||
if lesson is None:
|
if lesson is None:
|
||||||
return None
|
return None
|
||||||
data = lesson.data if isinstance(lesson.data, dict) else {}
|
|
||||||
query = trigger_title(data.get("what") or lesson.title, lessons_svc.lesson_trigger(lesson))
|
async def background_of(note) -> list[tuple[float, Note]]:
|
||||||
found = await semantic_search_notes(
|
"""`note`'s similarity to every other lesson it can reach — the
|
||||||
user_id, query, exclude_ids={int(lesson.id)}, limit=_CONVERGENCE_FETCH,
|
background it is measured against. No threshold: the bar is relative,
|
||||||
threshold=CONVERGENCE_THRESHOLD, note_type=(lessons_svc.LESSON_NOTE_TYPE,),
|
and needs the scores a fixed bar would have turned away. No
|
||||||
include_global_kinds=True, scope="browse",
|
supersession penalty either: it would shift some scores and not
|
||||||
)
|
others, and the background is a measure of resemblance alone."""
|
||||||
answered = await _no_rule_ids([int(n.id) for _s, n in found])
|
data = note.data if isinstance(note.data, dict) else {}
|
||||||
|
query = trigger_title(data.get("what") or note.title, lessons_svc.lesson_trigger(note))
|
||||||
|
return await semantic_search_notes(
|
||||||
|
user_id, query, exclude_ids={int(note.id)}, limit=_CONVERGENCE_BACKGROUND,
|
||||||
|
threshold=-1.0, note_type=(lessons_svc.LESSON_NOTE_TYPE,),
|
||||||
|
include_global_kinds=True, scope="browse", demote_superseded=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
lid = int(lesson.id)
|
||||||
|
found = await background_of(lesson)
|
||||||
|
notes = {int(n.id): n for _s, n in found}
|
||||||
|
standouts = {lid: standout({int(n.id): s for s, n in found})}
|
||||||
|
standing = [i for i, z in standouts[lid].items() if z >= CONVERGENCE_STANDOUT]
|
||||||
|
answered = await _no_rule_ids(standing)
|
||||||
|
candidates = sorted(answered, key=lambda i: (-standouts[lid][i], i))[:_CONVERGENCE_CANDIDATES]
|
||||||
|
# Too few stand out from here for any clique to reach the bar — skip the
|
||||||
|
# searches the mutual check would cost.
|
||||||
|
if len(candidates) < CONVERGENCE_LESSONS - 1:
|
||||||
|
return None
|
||||||
|
for c in candidates:
|
||||||
|
standouts[c] = standout({int(n.id): s for s, n in await background_of(notes[c])})
|
||||||
|
|
||||||
def member(note) -> dict:
|
def member(note) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -834,6 +917,5 @@ async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
|
|||||||
"sources": lessons_svc.lesson_sources(note), "project_id": note.project_id,
|
"sources": lessons_svc.lesson_sources(note), "project_id": note.project_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
return convergence_group(
|
group = converging(lid, standouts, candidates)
|
||||||
[member(lesson)] + [member(n) for _s, n in found if int(n.id) in answered]
|
return convergence_group([member(lesson)] + [member(notes[i]) for i in group[1:]])
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
assert links_svc.convergence_group(same) is None
|
||||||
|
|
||||||
|
|
||||||
def _note(lid, *, title="t", project=None, sources=()):
|
# ── The bar is relative to each lesson's own register (#5193) ──────────────
|
||||||
data = {"what": title, "when_to_apply": "a CI run overran"}
|
|
||||||
|
|
||||||
|
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:
|
if sources:
|
||||||
data["taught_by"] = list(sources)
|
data["taught_by"] = list(sources)
|
||||||
return SimpleNamespace(
|
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=[],
|
project_id=project, arose_from_id=None, tags=[],
|
||||||
created_at=None, updated_at=None,
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_only_lessons_answered_no_rule_join_the_group():
|
async def test_only_lessons_answered_no_rule_join_the_group():
|
||||||
found = [(0.8, _note(2, sources=[11])), (0.7, _note(3, sources=[12])),
|
a, b, c = _run(no_rule={1, 2}) # #3 has a rule: a pair is no group
|
||||||
(0.7, _note(4, sources=[13]))]
|
with a, b, c:
|
||||||
with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \
|
assert await _REAL(7, 1) is None
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user