Preferences — a rule kind that Scribe keeps up to date (#3849 steps 1–3) #150

Merged
bvandeusen merged 5 commits from dev into main 2026-09-10 23:34:16 -04:00
2 changed files with 120 additions and 4 deletions
Showing only changes of commit 26e0dff706 - Show all commits
+33 -4
View File
@@ -859,7 +859,13 @@ async def get_writepath_config(user_id: int) -> dict:
} }
def _rule_hint_line(rule, *, where: str, seen: bool) -> str: def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
"""One rule hint line — both arms, both tails (#3750). """One rule hint line — both arms, both tails, both kinds (#3750, #3849).
TWO INDEPENDENT AXES. `kind` decides the head, `seen` decides the tail,
and neither reads the other. A preference and a rule differ in force; a
repeat and a first surfacing differ in whether the session already holds
the line. Those are unrelated facts, and keeping them unrelated in the
code is what stopped the second kind from reopening the repeat question.
ONE FUNCTION BECAUSE THE TAILS MUST NOT DRIFT. The two arms phrase their ONE FUNCTION BECAUSE THE TAILS MUST NOT DRIFT. The two arms phrase their
heads differently ("may apply here" vs "may apply to this Bash call") and heads differently ("may apply here" vs "may apply to this Bash call") and
@@ -891,15 +897,38 @@ def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
managing it. managing it.
""" """
trigger = (rule.when_to_apply or "").strip() trigger = (rule.when_to_apply or "").strip()
preference = rule.kind == "preference"
# KIND CHANGES THE HEAD; `seen` CHANGES THE TAIL. The two axes are
# independent and stay that way, which is what lets the repeat logic above
# survive a second kind without being reasoned about again: whether a
# record is already on the ledger has nothing to do with how much force it
# carries, so the seen-branch is shared verbatim.
#
# The noun is the whole of the visual difference, and that is deliberate.
# A reader skimming an injected block gets one word to place the register \u2014
# so it is the SECOND word that moves, and it is the word naming force.
# Everything structural after it is identical, so the three kinds read as
# one set rather than three formats (milestone 385's step 5 writes the
# lesson voice against these two; they are one paragraph, not three).
noun = "Preference" if preference else "Standing rule"
# The only other place force is asserted. A rule's line tells the reader
# not to dismiss it unread, because dismissing a rule unread is how the
# thing it prevents happens. A preference makes no such claim: it says
# where to find how this has been done, and following it is what keeps
# things consistent rather than what keeps them correct.
reason = (
"for how this has been done before" if preference
else "before deciding it does not apply"
)
tail = ( tail = (
f"You saw it earlier this session; pull it with get_rule({rule.id}) " f"You saw it earlier this session; pull it with get_rule({rule.id}) "
"if you no longer hold it." "if you no longer hold it."
if seen else if seen else
f"Read it with get_rule({rule.id}) before deciding it does not " f"Read it with get_rule({rule.id}) {reason}; it is not in this "
"apply; it is not in this session's loaded set." "session's loaded set."
) )
return ( return (
f"Standing rule that may apply {where} \u2014 \u201c{rule.title}\u201d" f"{noun} that may apply {where} \u2014 \u201c{rule.title}\u201d"
+ (f" ({trigger})" if trigger else "") + (f" ({trigger})" if trigger else "")
+ f". {tail}" + f". {tail}"
) )
+87
View File
@@ -1006,6 +1006,93 @@ async def test_the_two_tails_are_distinguishable_and_say_the_true_one(source, ru
assert fresh_ctx != seen_ctx, "the two tails collapsed into one" assert fresh_ctx != seen_ctx, "the two tails collapsed into one"
# ── the third axis: KIND (milestone 399) ────────────────────────────────
#
# A preference is a rule row that does not bind, so it rides the same arms and
# the same line. What must differ is the REGISTER: a rule's line tells the
# reader not to dismiss it unread, because dismissing a rule unread is how the
# thing it prevents happens. A preference makes no such claim — it says where
# to find how this has been done, and following it buys consistency rather
# than correctness.
#
# Rendered in the rule's voice, a preference becomes the thing milestone 399
# exists to avoid: a rule with a different column.
_PREF = fake_rule(
id=177,
kind="preference",
title="Pace hard debugging one step at a time",
statement="Advance one investigative step per turn.",
when_to_apply="during hard debugging",
)
_RULE_FORCE = "before deciding it does not apply"
_PREF_FORCE = "for how this has been done before"
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio
async def test_a_preference_does_not_speak_in_the_rules_voice(source, run):
"""The register, pinned on the two places force is actually asserted.
On BOTH the noun and the clause, because either alone is weak. A line
reading "Preference … before deciding it does not apply" has swapped the
label and kept the instruction, which is worse than not distinguishing
them at all: it looks handled.
"""
ctx = (await run([(0.81, _PREF)], MagicMock()))["context"]
assert "Preference that may apply" in ctx, (
f"{source} announced a preference as something else. The noun is the "
f"one word a skimming reader gets to place the register, so it is the "
f"word that has to move. Context was: {ctx!r}"
)
assert _PREF_FORCE in ctx and _RULE_FORCE not in ctx, (
f"{source} told the session to read a PREFERENCE before deciding it "
f"does not apply. That is a rule's claim: it is the sentence that "
f"makes a line bind, and a preference does not."
)
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio
async def test_kind_and_seen_do_not_read_each_other(source, run):
"""The structural claim the design rests on: two INDEPENDENT axes.
Whether a record is already on the exclusion ledger has nothing to do with
how much force it carries. Keeping the two unrelated in the code is what
let a second kind arrive without reopening #3750's repeat question — and
the way that silently breaks is a `seen` branch that grows a kind test,
or a kind branch that grows a `seen` test, leaving one of the four
combinations rendered by nobody's intention.
So: all four are exercised, and the seen tail must come out identical for
both kinds.
"""
pref_fresh = (await run([(0.81, _PREF)], MagicMock()))["context"]
pref_seen = (await run([(0.81, _PREF)], MagicMock(),
exclude_rule_ids=[177]))["context"]
rule_seen = (await run([(0.81, _HELD)], MagicMock(),
exclude_rule_ids=[156]))["context"]
assert _SEEN_TAIL in pref_seen and _FRESH_TAIL in pref_fresh, (
f"{source}: the seen/fresh split stopped working once kind was added — "
f"the tail axis is now reading the head axis"
)
# The tail is about the LEDGER, so it is shared verbatim. Compared as the
# tail alone rather than the whole line, since the heads differ by design.
assert _SEEN_TAIL in rule_seen, "the rule's seen tail changed"
assert pref_seen.count(_SEEN_TAIL) == rule_seen.count(_SEEN_TAIL) == 1, (
f"{source} rendered the repeat clause a different number of times for "
f"the two kinds; the tail is about the ledger and does not vary by force"
)
# And the head still differs in the seen case — a repeat of a preference
# is still a preference.
assert "Preference that may apply" in pref_seen, (
f"{source} lost the preference register on a repeat, so a preference "
f"seen twice reads as a rule the second time"
)
@pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"]) @pytest.mark.parametrize(("source", "run"), _ARMS, ids=["write_path", "pre_tool"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_neither_tail_injects_the_rule_statement(source, run): async def test_neither_tail_injects_the_rule_statement(source, run):