feat(lessons): convergence is named at the write — no-rule lessons that keep landing in one situation suggest a rule (milestone 440 step 5, #4634)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 27s

When a lesson is answered "no rule fits" (create_lesson / update_lesson on
both doors), the response looks for other no-rule lessons it resembles and,
once there are CONVERGENCE_LESSONS (3) of them, carries `convergence`: the
members, their incidents and projects, and a hint to draft the missing rule
with create_rule (operator approval as always) and point each lesson at it —
or to leave them as lessons when no single choice is right every time.

- convergence_group is the pure bar: distinct LESSONS count, incidents never
  stand in for them (one broad lesson cannot trigger it), and a group whose
  sources all point at one incident is one event written up several times.
- convergence_for searches lessons by the new one's claim + trigger
  (trigger_title) at CONVERGENCE_THRESHOLD 0.65 — above the menu's "worth
  showing", below the duplicate gate's "same record" — then keeps the ones
  with a lesson_no_rule answer. Fail-open. No sweep, no timer (#4183).
- Defaults stated as defaults (rules 32, 115).
- Tests: the bar (pure), the search with stubs, the door, and the no-rule
  filter against Postgres; conftest stubs convergence_for for unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:31:15 -04:00
co-authored by Claude Opus 5.5
parent f34249a2d8
commit 6d3dca0af5
6 changed files with 259 additions and 3 deletions
+100
View File
@@ -0,0 +1,100 @@
"""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
def _note(lid, *, title="t", project=None, sources=()):
data = {"what": title, "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,
project_id=project, arose_from_id=None, tags=[],
created_at=None, updated_at=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]
@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