fix(intent): anchor CORRECTION trigger with word boundaries

While investigating the adjacent RECALL classifier gap, a much
wider intent-classification bug surfaced: every prompt beginning
with a word that *starts with* the letters of any CORRECTION
trigger silently routed to CORRECTION with a mangled subject.

Concrete examples seen during diagnosis:

  "Now remember light."        → CORRECTION  subject="w remember light"
  "Nothing matters."           → CORRECTION  subject="thing matters"
  "Notice the truth."          → CORRECTION  subject="tice the truth"
  "Note that recall fires."    → CORRECTION  subject="te that recall fires"
  "Nominate a candidate."      → CORRECTION  subject="minate a candidate"
  "Norma is here."             → CORRECTION  subject="rma is here"
  "Notwithstanding ..."        → CORRECTION  subject="twithstanding ..."

Root cause: ``generate/intent.py`` ``_RULES`` line ~213 used the
pattern

    (?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)

The alternation has ``no``, ``incorrect``, ``actually``, ``correction``
as bare substrings — no word boundary on either side.  Combined with
``re.match``'s start-of-string anchor, *any* prompt beginning with
``No``-, ``Incorrect``-, ``Actually``-, or ``Correction``-prefixed
text matched as CORRECTION; the regex's match span was then sliced
off the prompt to produce a subject like ``"w remember light"``
(from ``"Now remember light."``).

The same hazard threatens:

  * ``no``         → eats ``Now`` / ``Notice`` / ``Note`` / ``Nothing`` /
                     ``Nominate`` / ``Norma`` / ``Notwithstanding`` / ...
  * ``incorrect``  → would eat ``incorrectly``
  * ``actually``   → would eat ``actualization``
  * ``correction`` → would eat ``corrections``

Fix: add ``\b`` anchors on both sides of the alternation.

    \b(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)\b

``\b`` is zero-width, so ``re.match``'s start-of-string anchor still
holds; the left ``\b`` is a no-op at position 0.  The right ``\b``
forces the matched token to end on a word boundary — i.e., the next
character must be non-word (whitespace, punctuation, EOL) — so
``\bno\b`` matches ``"No."`` / ``"No way"`` / ``"No, ..."`` but NOT
``"Now"`` / ``"Nothing"`` / etc.

Verified 11/11 previously-misfiring prompts now correctly classify
as UNKNOWN, and 8/8 legitimate CORRECTION pragmas
(``"No."`` / ``"No way."`` / ``"Incorrect."`` / ``"Actually, ..."`` /
``"Correction: ..."`` / ``"That's wrong."`` / ``"No, that's wrong."`` /
``"no, knowledge is wrong."``) still route correctly.

Tests extended with two new parametrized blocks in
``tests/test_intent_subject_extraction.py``:

  * ``test_correction_canonical_forms_still_route`` — 8 cases pinning
    the legitimate CORRECTION patterns
  * ``test_correction_does_not_eat_no_prefixed_words`` — 10 cases
    pinning the boundary fix against regression

Verified:
  pytest tests/test_intent_subject_extraction.py        25/25 pass
  pytest tests/test_intent_proposition_graph.py        + others       60/60 pass
  core test --suite smoke                                            67/67 pass
  core test --suite runtime                                          19/19 pass

Out of scope: ``"That is not right."`` (a real CORRECTION pragma the
regex never caught because ``that'?s\s+`` requires literal ``s`` after
``that``; the colloquial ``that is`` form was always UNKNOWN). Separate
gap, unchanged here.
This commit is contained in:
Shay 2026-05-21 08:29:16 -07:00
parent 7ef4ef4546
commit 0dd30b86a7
2 changed files with 69 additions and 1 deletions

View file

@ -209,7 +209,14 @@ _RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = (
(re.compile(r"what\s+(?:causes|triggers|enables|prevents|drives|produces|induces|yields)\s+", re.IGNORECASE), IntentTag.CAUSE),
(re.compile(r"how\s+(?:do|can|should|would)\s+(?:I|we|you)\s+", re.IGNORECASE), IntentTag.PROCEDURE),
(re.compile(r"(?:is|are|does|do|can|could|would|should|was|were|has|have|will)\s+.+\??\s*$", re.IGNORECASE), IntentTag.VERIFICATION),
(re.compile(r"(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)", re.IGNORECASE), IntentTag.CORRECTION),
# Word boundaries on both sides are load-bearing: without them ``no``
# would prefix-match every word beginning with those letters
# (``Now``, ``Notice``, ``Nothing``, ``Nominate``, ``Norma``, ...) and
# silently route them all to CORRECTION with a mangled subject like
# ``"w remember light"``. The same hazard applies to ``incorrect``
# (would eat ``incorrectly``), ``actually`` (would eat
# ``actualization``), and ``correction`` (would eat ``corrections``).
(re.compile(r"\b(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)\b", re.IGNORECASE), IntentTag.CORRECTION),
(re.compile(r"(?:remember|recall)\s+", re.IGNORECASE), IntentTag.RECALL),
)

View file

@ -126,6 +126,67 @@ def test_recall_strips_articles(prompt: str, expected_subject: str) -> None:
assert intent.subject == expected_subject
# ---------------------------------------------------------------------------
# CORRECTION — word-boundary discipline on the trigger pattern
# ---------------------------------------------------------------------------
#
# Until a recent fix, the CORRECTION regex matched the bare token ``no``
# without word boundaries. Combined with ``re.match``'s start anchor,
# every prompt beginning with ``No``-as-prefix (``Notice``, ``Note``,
# ``Now``, ``Nothing``, ``Nominate``, ``Norma``, ``Notwithstanding``)
# silently routed to CORRECTION with a mangled subject like
# ``"w remember light"`` (from ``"Now remember light."``). The same
# hazard threatened ``incorrect`` / ``incorrectly``, ``actually`` /
# ``actualization``, ``correction`` / ``corrections``. The fix added
# ``\b`` anchors on both sides of the alternation; these parametrized
# cases pin the boundary discipline against regression.
@pytest.mark.parametrize(
"prompt",
[
"No, that's wrong.",
"No.",
"No way.",
"no, knowledge is wrong.",
"Incorrect.",
"Actually, that's false.",
"Correction: memory is not storage.",
"That's wrong.",
],
)
def test_correction_canonical_forms_still_route(prompt: str) -> None:
"""Legitimate CORRECTION pragmas must still classify after the
word-boundary fix narrowed the alternation."""
intent = classify_intent(prompt)
assert intent.tag is IntentTag.CORRECTION
@pytest.mark.parametrize(
"prompt",
[
# ``No``-prefixed words that previously misfired
"Nothing matters.",
"Notice the truth.",
"Note that recall fires.",
"Nominate a candidate.",
"Now remember light.",
"Norma is here.",
"Notwithstanding the evidence.",
# ``Incorrect``-prefixed / ``Correction``-prefixed words
"Incorrectly stated.",
"Corrections department.",
# ``Actually`` prefix — rarer but symmetric
"Actualization of intent.",
],
)
def test_correction_does_not_eat_no_prefixed_words(prompt: str) -> None:
"""Words beginning with the CORRECTION trigger letters must not
silently route to CORRECTION via a missing word-boundary anchor."""
intent = classify_intent(prompt)
assert intent.tag is not IntentTag.CORRECTION
# ---------------------------------------------------------------------------
# Edge cases — degenerate inputs do not produce empty subjects
# ---------------------------------------------------------------------------