feat(rules): a preference does not speak in a rule's voice (#3849 step 3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 41s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 41s
Two independent axes on one hint line. `kind` decides the head, `seen` decides the tail, and neither reads the other — which is what let a second kind arrive without reopening #3750's repeat question. Whether a record is already on the exclusion ledger has nothing to do with how much force it carries, so the seen branch is shared verbatim. The noun carries the whole visual difference, deliberately. A reader skimming an injected block gets one word to place the register, so the word that moves is the one naming force: "Standing rule" / "Preference". Everything structural after it is identical, so the kinds read as one set rather than two formats. Force is asserted in exactly one other place, and that moves too. A rule's line says to read it BEFORE DECIDING IT DOES NOT APPLY, 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 BEFORE, and following it buys consistency rather than correctness. Guarded on both places at once. Pinning the noun alone would pass a line reading "Preference … before deciding it does not apply" — label swapped, instruction kept — which is worse than not distinguishing them, because it looks handled. And a guard on the independence claim itself, exercising all four combinations: the way this breaks silently is a seen branch that grows a kind test, leaving one combination rendered by nobody's intention. Noted, not fixed: plugin/skills/using-scribe/SKILL.md still says "Standing rules are binding" with no room for a kind that does not. That surface is step 6's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user