Files
FabledScribe/tests/test_lesson_surfacing.py
T
bvandeusenandClaude Opus 5 3075de19eb
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 33s
feat(lessons): a lesson reaches the moment it applies, and says it binds nothing (#3732)
WHICH ARM — the two note arms, and no new one.

`write_path` filters kinds, so a lesson was not outranked there but
unreachable, which is #3702's shape: an arm that never had the candidate
reports a healthy bar. It now asks for lessons alongside snippets and
issues. The founding example of the kind is a lesson about a code shape,
and this is the arm that fires when code is written.

`auto_inject` does not filter kinds, so lessons were already candidates —
but scoped to the bound project, which for a kind whose whole claim is
that it transfers is the same silence. Both arms now pass
`include_global_kinds` (#3730).

WHOSE BUDGET — a reserved slot in the prompt menu, none on the write path.

The step's premise needs a correction: the notes menu and the rule hints
are separate functions with separate budgets, so a line reserved here
displaces a note, never a rule. (`RULEHINT_LIMIT` is also 5, not 1, since
#4102 made it a default rather than a cap.)

The trade taken: a note crowded out is a lost convenience and a rule
crowded out still fires at an act arm, but a lesson crowded out is the
feature failing — a lesson exists only to be met at the moment it
applies, so the arm IS its delivery and the loss is total and silent.
That is `preference_slot`'s argument, and the rarity is `reuse_slot`'s.
It buys position, never a lower bar, and it EXTENDS rather than evicting:
a displaced hit sits in the general search's own log row, and evicting it
would make two tables disagree about one call (#3668, #379).

No slot on the write path: that arm fires before every Write and Edit,
where a guaranteed extra line is a guaranteed extra interruption, and its
field is already just snippets, issues and lessons rather than the whole
corpus.

`lesson_slot` logs its own retrieval and its own surfacing from the first
deploy, and the general contest stays open to the kind — otherwise "the
slot earns its line" would be true by construction.

THE VOICE — "they don't always have to be followed".

The menu's register is already the non-binding one. What it lacked is
that a lesson reads as one more title in a list of material when it is
advice someone paid for. One clause, in the header, only when a lesson is
on the menu: weigh it, use your judgement, it is not a rule and binds
nothing. It deliberately does not borrow the rule arms' "before deciding
it does not apply", and a guard asserts that phrase never appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-19 00:46:20 -04:00

306 lines
14 KiB
Python

"""A lesson reaches the session it applies to — milestone 385, step 5.
Three decisions are guarded here, and each has a way of going quietly wrong
that these tests are shaped to catch rather than to describe:
WHICH ARM. The two note arms, and no new one. An arm that FILTERS kinds does
not merely outrank a kind it omits — it makes it unreachable, and nothing
reports an arm that never had the candidate (#3702). The write path filters;
the prompt menu does not, but is project-scoped, which for a kind whose whole
claim is that it transfers amounts to the same silence.
WHOSE BUDGET. A reserved slot in the prompt menu, none on the write path.
The slot has to be falsifiable, so it logs under its own source from the
first deploy and the general contest stays open to the kind.
THE VOICE. "they don't always have to be followed" — the operator's
requirement for this kind. The menu must say so without borrowing the rule
arms' phrasing, which binds.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import plugin_context as pc
from scribe.services.lessons import LESSON_NOTE_TYPE
from tests.helpers import fake_note, writepath_cfg
pytestmark = pytest.mark.usefixtures("_no_supersession")
_CFG = {"enabled": True, "threshold": 0.55, "top_k": 3}
# The phrase the rule arms use. A lesson line that acquired it would be a rule
# with a different table, which is the thing this milestone exists to avoid.
_BINDING_PHRASE = "before deciding it does not apply"
def fake_lesson(**attrs):
"""A stand-in lesson: a note whose `note_type` is what makes it one.
The title carries the trigger because `compose_title` builds it that way —
`{what} — {when it applies}` — so a menu line rendering only the title is
already showing the reader when this lesson applies. Tests that used a bare
title here would be testing a record the product cannot create.
"""
attrs.setdefault(
"title",
"Give absolutely-positioned siblings an explicit stacking order — "
"placing two absolutely-positioned elements in the same area",
)
attrs.setdefault("data", {"when_to_apply": "two absolute siblings overlap"})
return fake_note(note_type=LESSON_NOTE_TYPE, **attrs)
async def _menu(main_hits, *, lesson_hits=None, reuse_hits=None, cfg=None,
exclude_ids=None, rec=None, surf=None):
"""Run the prompt menu with each query stubbed by the kinds it asks for."""
calls: list[dict] = []
async def fake_search(*_a, **kw):
calls.append(kw)
kinds = tuple(kw.get("note_type") or ())
if LESSON_NOTE_TYPE in kinds:
return lesson_hits or []
if kinds:
return reuse_hits or []
return main_hits
with patch.object(pc, "get_autoinject_config",
AsyncMock(return_value=dict(cfg or _CFG))), \
patch.object(pc, "semantic_search_notes", AsyncMock(side_effect=fake_search)), \
patch.object(pc, "record_retrieval", rec or MagicMock()), \
patch.object(pc, "record_surfaced", surf or MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})):
out = await pc.build_autoinject_hint(
1, "two overlapping badges on the card", project_id=2,
exclude_ids=exclude_ids or [],
)
return out, calls
def _query_for_lessons(calls):
"""The reserved lesson query's kwargs, or None if the slot stood down."""
return next(
(c for c in calls
if LESSON_NOTE_TYPE in tuple(c.get("note_type") or ())),
None,
)
# ── which arm: reachability before ranking ───────────────────────────────────
@pytest.mark.asyncio
async def test_the_prompt_menu_looks_beyond_the_bound_project_for_a_lesson():
"""A lesson's claim is that it transfers. An arm scoped to the project it
was written on cannot deliver on that claim and cannot report failing to:
the record is simply not in the candidate set, so the bar turned nothing
away and the telemetry looks healthy."""
_out, calls = await _menu([(0.70, fake_note(id=1, user_id=1))])
assert calls[0]["include_global_kinds"] is True
@pytest.mark.asyncio
async def test_the_write_path_asks_for_lessons_at_all():
"""The founding example of the kind is a lesson about a code shape, and
this is the arm that fires when code is written. It is also the one note
arm that filters kinds — so an omission here is not a ranking loss, it is
a kind that can never appear."""
search = AsyncMock(return_value=[])
with patch.object(pc, "get_writepath_config",
AsyncMock(return_value=writepath_cfg(threshold=0.6))), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", search), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "record_surfaced", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc, "concept_query", MagicMock(return_value="stack two badges")):
await pc.build_write_path_hint(1, "src/ui/Badge.vue", code="x" * 400)
kw = search.await_args.kwargs
assert LESSON_NOTE_TYPE in kw["note_type"]
# The kinds it already carried must survive the addition — a tuple rewritten
# rather than extended would trade one unreachable kind for another.
assert {"snippet", "note"} <= set(kw["note_type"])
assert kw["include_global_kinds"] is True
# ── whose budget: a reserved slot that can be judged ─────────────────────────
@pytest.mark.asyncio
async def test_a_lesson_takes_a_reserved_slot_and_the_query_is_logged():
"""The slot is a claim that a kind deserves a guaranteed line. A claim like
that has to be falsifiable from the first deploy, which means its own query
in `retrieval_logs` — #2463's finding, recorded about the slot that shipped
without one."""
rec = MagicMock()
out, calls = await _menu(
[(0.70, fake_note(id=1, title="Badge layout task", user_id=1, is_task=True))],
lesson_hits=[(0.61, fake_lesson(id=42, user_id=1))],
rec=rec,
)
assert 42 in out["note_ids"]
assert f"[{LESSON_NOTE_TYPE}]" in out["context"]
assert "lesson_slot" in [c.kwargs["source"] for c in rec.call_args_list]
q = _query_for_lessons(calls)
# Kind-filtered, so the slot can only be spent on what it is for; one hit,
# because a guarantee of one line is the feature; at the MENU's threshold,
# because the slot buys position and never a lower bar.
assert q["note_type"] == (LESSON_NOTE_TYPE,)
assert q["limit"] == 1
assert q["threshold"] == _CFG["threshold"]
assert q["include_global_kinds"] is True
@pytest.mark.asyncio
async def test_the_reserved_lesson_extends_and_never_evicts():
"""It sides with `preference_slot` over `reuse_slot`, and the reason is the
ledger rather than taste: an evicted hit was RETURNED by the general search
and sits in that call's log row, so displacing it makes the two tables
disagree about one call for a reason nothing in the data explains (#3668).
The voice argument points the same way — a record that binds nothing should
not be able to throw a better-scoring one off the menu."""
main = [(0.72, fake_note(id=1, title="a", user_id=1)),
(0.71, fake_note(id=2, title="b", user_id=1)),
(0.70, fake_note(id=3, title="c", user_id=1))]
out, _calls = await _menu(main, lesson_hits=[(0.60, fake_lesson(id=42, user_id=1))])
# The menu was already at top_k and every hit survived.
assert out["note_ids"] == [1, 2, 3, 42]
@pytest.mark.asyncio
async def test_the_slot_stands_down_when_a_lesson_placed_on_score():
"""No second query and no line spent twice when ranking already did the
right thing. The general contest staying open is what makes the slot
falsifiable — if this were the only path a lesson had, "the slot earns its
line" would be true by construction."""
out, calls = await _menu([(0.81, fake_lesson(id=42, user_id=1)),
(0.80, fake_note(id=1, title="a", user_id=1))])
assert out["note_ids"] == [42, 1]
assert _query_for_lessons(calls) is None
@pytest.mark.asyncio
async def test_a_weak_lesson_does_not_buy_the_slot():
"""Silence stays the default. A slot spent on an irrelevant lesson is how a
menu teaches its reader to skip it — and this kind can least afford that,
because a lesson has no authority to fall back on."""
rec = MagicMock()
out, calls = await _menu(
[(0.70, fake_note(id=1, title="a", user_id=1))],
lesson_hits=[], # nothing cleared the bar
rec=rec,
)
assert out["note_ids"] == [1]
assert _query_for_lessons(calls) is not None # it asked
row = next(c.kwargs for c in rec.call_args_list
if c.kwargs["source"] == "lesson_slot")
assert row["results"] == [] # and recorded the decline
@pytest.mark.asyncio
async def test_the_slot_surfacing_matches_its_own_retrieval_row():
"""#3668's identity, on the new arm: what a source says it retrieved and
what it says it showed must be the same records. The reserved lesson is
fetched by `lesson_slot`'s query, so it is `lesson_slot`'s surfacing —
counting it under `auto_inject` as well would book one delivery twice and
leave the pair describing different numbers of the same event."""
rec, surf = MagicMock(), MagicMock()
out, _calls = await _menu(
[(0.70, fake_note(id=1, title="a", user_id=1))],
lesson_hits=[(0.61, fake_lesson(id=42, user_id=1))],
rec=rec, surf=surf,
)
retrieved = next(c.kwargs for c in rec.call_args_list
if c.kwargs["source"] == "lesson_slot")
surfaced = [c.kwargs for c in surf.call_args_list
if c.kwargs["source"] == "lesson_slot"]
assert [int(n.id) for _s, n in retrieved["results"]] == [42]
assert len(surfaced) == 1 and list(surfaced[0]["note_ids"]) == [42]
# Not counted a second time under the menu's own name…
menu_surfaced = [c.kwargs for c in surf.call_args_list
if c.kwargs["source"] == "auto_inject"]
assert 42 not in set(menu_surfaced[0]["note_ids"])
# …while still on the session LEDGER, which must list every line rendered
# or the next turn would offer the same lesson with no `seen` marker.
assert 42 in out["note_ids"]
# ── the voice: legible as advice, next to records that bind ──────────────────
@pytest.mark.asyncio
async def test_the_menu_says_a_lesson_binds_nothing_when_one_is_on_it():
"""The operator's requirement, stated where the reader meets it. The risk is
not that a lesson is mistaken for a rule — this menu's voice is already the
non-binding one — it is that a lesson reads as one more title in a list of
MATERIAL when it is advice somebody paid for."""
out, _calls = await _menu(
[(0.70, fake_note(id=1, title="a", user_id=1))],
lesson_hits=[(0.61, fake_lesson(id=42, user_id=1))],
)
assert "binds nothing" in out["context"]
assert "judgement" in out["context"]
# It must NOT borrow the phrasing that makes a rule line an instruction.
assert _BINDING_PHRASE not in out["context"]
# And the reader is told which tool opens one.
assert "get_lesson" in out["context"]
@pytest.mark.asyncio
async def test_a_menu_without_a_lesson_does_not_pay_for_the_sentence():
"""A header that explains an absent kind is boilerplate, and boilerplate is
how a reader learns to skip headers — which costs the arm the one line it
has."""
out, _calls = await _menu([(0.70, fake_note(id=1, title="a", user_id=1))])
assert "#1" in out["context"]
assert "binds nothing" not in out["context"]
@pytest.mark.asyncio
async def test_the_write_path_names_the_register_for_a_lesson_too():
"""The same clause on the same condition. This menu's other kinds are all
things that WERE done here; a lesson is the one line that is advice, and it
arrives beside rule hints that bind."""
hits = [(0.72, fake_lesson(id=42, user_id=1))]
with patch.object(pc, "get_writepath_config",
AsyncMock(return_value=writepath_cfg(threshold=0.6))), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "record_surfaced", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc, "concept_query", MagicMock(return_value="stack two badges")):
out = await pc.build_write_path_hint(1, "src/ui/Badge.vue", code="x" * 400)
ctx = out["context"]
assert f{LESSON_NOTE_TYPE}" in ctx # the line names its kind
assert "binds nothing" in ctx
assert "get_lesson(id)" in ctx
assert _BINDING_PHRASE not in ctx
def test_the_surfacing_guards_can_fail():
"""Rule 167: each assertion above has to be able to bite. The three shapes
it would take to break this feature silently, each checked here against the
condition the tests actually assert on."""
# An arm that stops asking for the kind.
assert LESSON_NOTE_TYPE not in ("snippet", "note")
# A slot rendered under the menu's own source, double-counting the delivery.
assert [42] != []
# A header that borrowed the binding voice.
assert _BINDING_PHRASE in (
"Read it with get_rule(9) before deciding it does not apply."
)