feat(4636): rule pull-through per surface, and per-rule usage with confirmed lessons on list_rules
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s

retrieval_telemetry gains rule_usage.by_source, the twin of usage.by_source:
distinct rules each surface showed, how many an agent opened, and a ratio
that is null on a bulk delivery. list_rules(with_usage=True, days=N) adds
each rule's usage, windowed, and how many lessons are confirmed instances
of it, so linked and unlinked rules can be compared through MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 19:20:32 -04:00
co-authored by Claude Opus 5.5
parent 71260a28e2
commit d05d4f3632
9 changed files with 287 additions and 9 deletions
@@ -93,6 +93,22 @@ async def test_naming_rules_confirms_them_and_both_sides_read_the_link(world):
assert [(l["id"], l["state"]) for l in lessons] == [(world["lesson"], "confirmed")]
async def test_a_rule_list_counts_confirmed_lessons_only(world):
"""The bulk count a rule list reads: a rejected link is not a lesson
proving the rule, a rule with no links reads 0, and a stranger counts
nothing through a lesson they cannot read."""
owner = world["owner"]
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
await links_svc.judge_link(owner, world["lesson"], world["r2"], "reject", "not it")
rules = [world["r1"], world["r2"], world["foreign"]]
assert await links_svc.confirmed_lesson_counts(owner, rules) == {
world["r1"]: 1, world["r2"]: 0, world["foreign"]: 0,
}
assert (await links_svc.confirmed_lesson_counts(world["stranger"], rules))[world["r1"]] == 0
assert await links_svc.confirmed_lesson_counts(owner, []) == {}
async def test_a_rule_left_out_of_the_set_is_rejected_not_forgotten(world):
owner = world["owner"]
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
+33
View File
@@ -573,3 +573,36 @@ def test_rule_outcome_is_registered_and_is_not_read_only():
assert "rule_outcome" in mcp.names
assert "rule_outcome" in _WRITE_TOOLS
assert "rule_outcome" not in _READ_ONLY_TOOLS
@pytest.mark.asyncio
async def test_list_rules_carries_usage_only_when_asked():
"""A listing is for applying rules, so it stays lean; `with_usage` adds
each rule's telemetry and confirmed-lesson count for judging them, with
`days` passed down as a window."""
from grimoire.mcp.tools import rulebooks as tools
from grimoire.services.rule_usage import empty_rule_usage
rows = [fake_rule(id=1, title="dev is home"), fake_rule(id=2, title="ask first")]
seen = {**empty_rule_usage(), "surfaced_count": 4, "pull_count": 1}
usage = AsyncMock(return_value={1: seen})
lessons = AsyncMock(return_value={1: 3})
with patch.object(tools.rulebooks_svc, "list_rules", AsyncMock(return_value=rows)), \
patch.object(tools.rulebooks_svc, "list_rule_moments", AsyncMock(return_value={})), \
patch.object(tools, "usage_for_rules", usage), \
patch.object(tools, "confirmed_lesson_counts", lessons):
lean = await tools.list_rules()
assert all("usage" not in r and "confirmed_lessons" not in r for r in lean["rules"])
usage.assert_not_awaited()
out = await tools.list_rules(with_usage=True, days=14)
by_id = {r["id"]: r for r in out["rules"]}
assert by_id[1]["usage"]["surfaced_count"] == 4
assert by_id[1]["confirmed_lessons"] == 3
# A rule missing from either read is zero-filled, never absent.
assert by_id[2]["usage"] == empty_rule_usage()
assert by_id[2]["confirmed_lessons"] == 0
assert usage.await_args.args[0] == [1, 2]
assert usage.await_args.kwargs["since"] is not None
lessons.assert_awaited_once_with(7, [1, 2])
@@ -618,6 +618,54 @@ async def test_the_preload_lands_in_ambient_and_never_in_the_ratio(_dispose_engi
await cleanup()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_rule_by_source_says_which_arm_earns_its_lines(_dispose_engine):
"""The rule ratio blends every arm. Per surface it can say which one is
working: distinct rules per arm, the agent opens among them, and no ratio
on a bulk delivery."""
from grimoire.services.retrieval_telemetry import retrieval_summary
cleanup = await _rule_events(990016, [
# The write arm chose rule 5301 twice — one rule — and 5302 once.
(5301, "surfaced", "write_path_rule"),
(5301, "surfaced", "write_path_rule"),
(5302, "surfaced", "write_path_rule"),
# The tool arm chose 5303 and nothing came of it.
(5303, "surfaced", "pre_tool_rule"),
# The preload carried 5301 as well.
(5301, "surfaced", "session_start"),
# An agent opened 5301; a person opened 5303, which is not a hint
# landing.
(5301, "pulled", "mcp_get_rule"),
(5303, "pulled", "rest_rule"),
])
try:
ru = (await retrieval_summary(990016, days=30))["rule_usage"]
assert "by_source_failed" not in ru, "the join did not execute"
by_source = ru["by_source"]
wp = by_source["write_path_rule"]
assert (wp["rules_surfaced"], wp["rules_pulled"]) == (2, 1)
assert wp["pull_through"] == 0.5
assert wp["ambient"] is False
tool = by_source["pre_tool_rule"]
assert (tool["rules_surfaced"], tool["rules_pulled"]) == (1, 0)
assert tool["pull_through"] == 0.0
preload = by_source["session_start"]
# The pull is counted against every surface that showed the rule,
# the upper bound the readout states.
assert (preload["rules_surfaced"], preload["rules_pulled"]) == (1, 1)
assert preload["pull_through"] is None
assert preload["ambient"] is True
assert "mcp_get_rule" not in by_source, "a pull is not a surface"
finally:
await cleanup()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_ambient_alone_reports_no_ratio(_dispose_engine):
+41
View File
@@ -198,6 +198,47 @@ async def test_usage_for_rules_aggregates_per_rule(_dispose_engine):
await s.commit()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_usage_for_rules_counts_only_the_window_it_is_given(_dispose_engine):
"""Two windows have to be comparable, so an event before `since` is left
out of every count — and without `since` it is counted."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from grimoire.models import async_session
from grimoire.models.rule_usage import RuleUsageEvent
old = datetime.now(timezone.utc) - timedelta(days=30)
async with async_session() as s:
s.add_all([
RuleUsageEvent(user_id=990022, rule_id=6011, event=SURFACED,
source="write_path_rule", created_at=old),
RuleUsageEvent(user_id=990022, rule_id=6011, event=PULLED,
source="mcp_get_rule", created_at=old),
RuleUsageEvent(user_id=990022, rule_id=6011, event=SURFACED,
source="write_path_rule"),
])
await s.commit()
try:
since = datetime.now(timezone.utc) - timedelta(days=14)
recent = (await rule_usage.usage_for_rules([6011], since=since))[6011]
assert recent["surfaced_count"] == 1
assert recent["pull_count"] == 0
assert recent["last_pulled_at"] is None
everything = (await rule_usage.usage_for_rules([6011]))[6011]
assert everything["surfaced_count"] == 2
assert everything["pull_count"] == 1
finally:
async with async_session() as s:
await s.execute(
delete(RuleUsageEvent).where(RuleUsageEvent.user_id == 990022)
)
await s.commit()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_usage_for_rules_on_an_empty_id_list_asks_the_database_nothing(