Files
FabledScribe/tests/test_trigger_shape_is_taught.py
T
bvandeusenandClaude Opus 5 8e06cdf749
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m29s
CI & Build / Build & push image (push) Successful in 25s
feat(rules): the trigger contract is shown as a worked contrast, and pinned (#3855)
Follows 8c9f947, which taught the trigger shape on the two update_*
surfaces but left the softer regression unguarded: guidance kept and
abstracted back to "name the moment in session vocabulary" — advice about
being concrete that is not itself concrete, which is the shape that was
already on file while the corpus filled with categories.

Two attempts to detect that in free prose were written and discarded:

- Counting quoted multi-word phrases anywhere in a docstring measured
  ambient quotation rather than demonstrated triggers. It PASSED the
  abstracted version by scoring unrelated prose, and text with an odd
  number of quote characters produced matches spanning the gap BETWEEN two
  unrelated phrases.
- Scoping that count to a window after each trigger mention then FAILED
  create_preference in its CORRECT state, its examples sitting further from
  the first mention than any defensible window reaches.

Both were proxies inferring demonstration from prose. Where a property
cannot be measured, changing the shape of the thing is cheaper than a
cleverer measurement — so all five trigger-writing surfaces now carry a
two-line labelled contrast:

  RETRIEVES: "the migration failed with a check violation on a column we
    just extended"
  COLLAPSES: "when working on migrations"

Unambiguous to parse, free in its wording, and a better teaching form than
the sentences it replaces: the labels name the mechanism, so they do work
for the reader rather than only for the test.

The guard now pins both halves — the field is documented, and the contrast
is present, complete and non-identical. Falsified against three regressions
before committing: the paragraph stripped, the examples abstracted away,
and one half of the pair removed. All three fail; the current tree passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
2026-09-11 11:48:14 -04:00

157 lines
6.7 KiB
Python

"""Every rule-write surface SHOWS what a trigger looks like (#3855).
WHY THIS EXISTS
`when_to_apply` is not documentation. `rule_document()` uses it twice — as
the embedded title's second half and again above the body — so it dominates
the vector, and a rule whose trigger names a CATEGORY rather than a moment
collapses toward its title and never arrives. Measured in #3835 across 113
rules, then again in #3855 on eight preferences.
The corpus damage traced to an uneven contract rather than to careless
authors. `create_rule` had carried the full argument since 2026-08-27, and
the two preferences written that day got good triggers; the six written
before it — "during hard debugging", "when reading any request from the
operator" — got categories. The guidance worked wherever it existed. It
simply did not exist on either `update_*` surface, which is where every
RETROFITTED trigger is written, and retrofitting is most of the work: a
trigger that already reads fine as English is the one nobody rewrites.
WHAT THIS PINS
Two properties, neither of them wording:
1. A tool accepting `when_to_apply` mentions it. Exactly what
`update_preference` failed before #3855 — the parameter existed and
the docstring never said so.
2. It carries a worked CONTRAST: one `RETRIEVES:` example and one
`COLLAPSES:` example, quoted, and different from each other.
WHY A CONVENTION RATHER THAN A PROSE HEURISTIC
The second property guards the softer regression — guidance kept but
abstracted back to "name the moment in session vocabulary", which is advice
about being concrete that is not itself concrete, and is the shape that was
already on file while the corpus filled with categories.
Two attempts to detect that in free prose were written and discarded, and
the reason is worth keeping because it generalises:
- Counting quoted multi-word phrases anywhere in the docstring measured
ambient quotation, not demonstrated triggers. It PASSED the abstracted
version by scoring unrelated prose, and text with an odd number of
quote characters produced matches spanning the gap BETWEEN two
unrelated phrases.
- Scoping that count to a window after each "trigger"/"when_to_apply"
mention then FAILED `create_preference` in its correct state, because
its examples sit further from the first mention than any defensible
window reaches.
Both were proxies trying to infer demonstration from prose. The fix was to
stop inferring: a two-line labelled contrast is unambiguous to parse, free
in its wording, and a better teaching form than the sentences it replaced —
`RETRIEVES:`/`COLLAPSES:` names the mechanism, so the label does work for
the reader instead of only for the test. Where a property cannot be
measured, changing the shape of the thing is cheaper than a cleverer
measurement.
What it still cannot do is judge whether a GOOD example is good. It catches
what actually happened: a write surface shipping with the contrast absent,
half-present, or tidied back into abstract advice.
THE SURFACE LIST IS DERIVED, NEVER HAND-KEPT. It comes from what
`rulebooks.register()` actually hands the server, filtered to signatures
taking `when_to_apply`, so a rule-write tool added later is in scope on the
day it is added. A hand-kept list has to be remembered by the person least
likely to know it exists — the argument `RANKED_SOURCES` makes in
services/rule_usage.py, and the reason #3855 happened at all.
"""
import inspect
import re
import pytest
from tests.helpers import tool_doc as _doc
_MODULE = "scribe.mcp.tools.rulebooks"
# The worked contrast. Quoted so the example's own punctuation stays inside
# it, labelled so no proximity guess is needed to find it.
_RETRIEVES = re.compile(r'RETRIEVES:\s*"([^"]+)"')
_COLLAPSES = re.compile(r'COLLAPSES:\s*"([^"]+)"')
def _registered_tools() -> list:
"""Every function `rulebooks.register()` actually hands to the server.
Collected by handing `register` a stand-in that records what it is given,
rather than by reading the source or repeating the tuple here. The point
is that this cannot drift from what ships.
"""
from scribe.mcp.tools import rulebooks
collected: list = []
class _Collector:
def tool(self, name=None):
def register_one(fn):
collected.append(fn)
return fn
return register_one
rulebooks.register(_Collector())
return collected
def _trigger_writers() -> list[str]:
"""The registered tools that can write a rule's trigger."""
names = [
fn.__name__
for fn in _registered_tools()
if "when_to_apply" in inspect.signature(fn).parameters
]
# An empty list would make every case below vacuous and the guard would
# pass by having nothing to check — absence read as non-existence, the
# #3720 shape. Fail loudly instead.
assert names, "no rule-write surface takes when_to_apply; the guard is blind"
return sorted(names)
@pytest.mark.parametrize("name", _trigger_writers())
def test_a_trigger_writing_surface_documents_the_field(name):
"""A tool that can write a trigger must say what the field is for."""
doc = _doc(_MODULE, name)
assert "when_to_apply" in doc, (
f"{name} accepts when_to_apply but never mentions it in its "
"docstring. The tool docstring is the agent-facing contract "
"(rule 119), and a trigger written against no contract is the #3855 "
"defect: six preferences named a category instead of a moment, and "
"a category is not a thing any session ever types."
)
@pytest.mark.parametrize("name", _trigger_writers())
def test_a_trigger_writing_surface_shows_the_contrast(name):
"""Advice about being concrete has to be concrete itself."""
doc = _doc(_MODULE, name)
retrieves, collapses = _RETRIEVES.findall(doc), _COLLAPSES.findall(doc)
assert retrieves and collapses, (
f"{name} is missing its worked contrast — found "
f"{len(retrieves)} RETRIEVES and {len(collapses)} COLLAPSES example(s), "
"and one of each is required. Telling an author to 'name the moment "
"in session vocabulary' without showing one is the guidance that was "
"already on file while the corpus filled with categories. Add two "
'quoted lines: RETRIEVES: "<a moment a session actually produces — a '
'command, an error, a half-formed ask>" and COLLAPSES: "<the same '
'thing named as a category>".'
)
same = set(retrieves) & set(collapses)
assert not same, (
f"{name} shows the same text as both RETRIEVES and COLLAPSES "
f"({next(iter(same))!r}). The pair teaches by DIFFERING — one moment "
"written the way a session would produce it, and the same moment "
"written as a category. Identical halves demonstrate nothing."
)