core/tests/test_intent_subject_extraction.py
Shay c945b9a045 fix(intent): widen CORRECTION to catch fully-spoken `that is/was ...` forms
Follow-on to the word-boundary fix (commit 0dd30b8).  After tightening
``\bno\b`` etc. with word boundaries, an audit surfaced a separate
pre-existing gap in the CORRECTION trigger: the contracted-only
``that'?s\s+(?:not|wrong)`` slot silently dropped every fully-spoken
copula form to UNKNOWN.

Concrete gap (every one previously UNKNOWN):

  "That is not right."        → UNKNOWN
  "That is wrong."            → UNKNOWN
  "That was wrong."           → UNKNOWN
  "That is incorrect."        → UNKNOWN
  "That is false."            → UNKNOWN
  "That was not right."       → UNKNOWN
  "that is mistaken."         → UNKNOWN
  "That was incorrect."       → UNKNOWN

Root cause: the slot ``that'?s\s+(?:not|wrong)`` matches only

    that's  /  thats

— ``'?s`` makes the apostrophe optional but the literal ``s`` is
mandatory.  ``that is`` (full word ``is``) and ``that was`` (full
word ``was``) had no path.  And the predicate alternation only
accepted ``not`` or ``wrong``; ``incorrect``, ``false``, and
``mistaken`` were also missing.

Fix: widen both slots in one pattern revision.

    Before:
      that'?s\s+(?:not|wrong)
    After:
      that(?:'?s|\s+(?:is|was))\s+(?:not|wrong|incorrect|false|mistaken)

The full pattern now reads:

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

Boundary discipline holds: the outer ``\b...\b`` still prevents the
predicate alternation from eating into longer words.  Verified:

  "That is correct."          → UNKNOWN (right NOT in predicate set)
  "That is right."            → UNKNOWN (right NOT in predicate set)
  "That is true."             → UNKNOWN (true NOT in predicate set)
  "That works."               → UNKNOWN
  "That is interesting."      → UNKNOWN
  "That is falsifiable."      → UNKNOWN (``false`` + ``i`` is word→word
                                         so ``\b`` after ``false`` fails)
  "That was wrongly accused." → UNKNOWN (same logic for ``wrong``+``ly``)

Tests extended:
  * ``test_correction_canonical_forms_still_route`` — 8 new parametrize
    cases for the fully-spoken copula forms
  * ``test_correction_does_not_eat_no_prefixed_words`` — 9 new
    parametrize cases for the affirmative ``That is/was ...`` shape
    AND the boundary-trap cases ``falsifiable`` / ``wrongly accused``

Verified:
  pytest tests/test_intent_subject_extraction.py         33/33 pass
  full intent + register-diagnostic + proposition graph  77/77 pass
  core test --suite smoke                                67/67 pass
  core test --suite runtime                              19/19 pass
2026-05-21 08:36:33 -07:00

322 lines
12 KiB
Python

"""ADR-0049 — intent classifier subject extraction tests.
Contract pinned here:
- Articles ("a", "an", "the") are stripped from the subject phrase
for every intent that runs through the rule table.
- For CAUSE and VERIFICATION intents, the subject is reduced to the
head noun: leading auxiliary verbs ("does", "is", "can", ...) are
stripped, then the first remaining token is returned.
- For DEFINITION / RECALL / PROCEDURE intents, multi-word noun
phrases are preserved (only articles + trailing punctuation are
stripped) so that proper noun phrases like "artificial
intelligence" survive.
- Trailing punctuation (``?``, ``.``, ``!``) is removed.
- Empty / all-stopword inputs fall back to the original cleaned
phrase rather than producing an empty subject.
- The normalizer is pack-agnostic: no pack loading, no pack-keyed
lookup; this is a pure syntactic transform.
These tests are intentionally narrow and pin only the post-processor
behaviour. Downstream tests (``test_pack_grounding``) cover the
end-to-end lift from this change reaching the pack-grounded surface.
"""
from __future__ import annotations
import pytest
from generate.intent import (
DialogueIntent,
IntentTag,
classify_intent,
)
# ---------------------------------------------------------------------------
# DEFINITION — article stripping
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,expected_subject",
[
("What is a procedure?", "procedure"),
("What is a relation?", "relation"),
("What is an answer?", "answer"),
("What is the truth?", "truth"),
("What is light?", "light"), # already single-word, no change
("What is artificial intelligence?", "artificial intelligence"), # multi-word noun phrase preserved
],
)
def test_definition_strips_articles(prompt: str, expected_subject: str) -> None:
intent = classify_intent(prompt)
assert intent.tag is IntentTag.DEFINITION
assert intent.subject == expected_subject
# ---------------------------------------------------------------------------
# CAUSE — head-noun extraction past leading aux verb
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,expected_subject",
[
("Why does light exist?", "light"),
("Why does knowledge require evidence?", "knowledge"),
("Why is memory important?", "memory"),
("Why are categories useful?", "categories"),
("Why can a procedure fail?", "procedure"), # aux 'can' then article 'a'
],
)
def test_cause_extracts_head_noun(prompt: str, expected_subject: str) -> None:
intent = classify_intent(prompt)
assert intent.tag is IntentTag.CAUSE
assert intent.subject == expected_subject
# ---------------------------------------------------------------------------
# VERIFICATION — head-noun extraction past leading aux verb
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,expected_subject",
[
("Does memory require recall?", "memory"),
("Is light a wave?", "light"),
("Can a procedure fail?", "procedure"),
("Are categories useful?", "categories"),
("Has truth been defined?", "truth"),
],
)
def test_verification_extracts_head_noun(prompt: str, expected_subject: str) -> None:
intent = classify_intent(prompt)
assert intent.tag is IntentTag.VERIFICATION
assert intent.subject == expected_subject
# ---------------------------------------------------------------------------
# RECALL — already minimal, articles still stripped
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,expected_subject",
[
("Remember light", "light"),
("Remember the truth", "truth"),
("Remember a procedure", "procedure"),
# ``recall`` is a synonym imperative of ``remember`` and must
# route identically. The articulation breadth benchmark probe
# ``"Recall truth."`` was misclassified as UNKNOWN until the
# trigger pattern in ``_RULES`` was widened to ``(?:remember|
# recall)\s+`` — without this case the regression silently
# returns.
("Recall light", "light"),
("Recall the truth", "truth"),
("Recall a procedure", "procedure"),
("Recall truth.", "truth"),
],
)
def test_recall_strips_articles(prompt: str, expected_subject: str) -> None:
intent = classify_intent(prompt)
assert intent.tag is IntentTag.RECALL
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",
[
# Pre-existing canonical forms (contracted ``that's``)
"No, that's wrong.",
"No.",
"No way.",
"no, knowledge is wrong.",
"Incorrect.",
"Actually, that's false.",
"Correction: memory is not storage.",
"That's wrong.",
# Fully-spoken copula forms — added when the contracted-only
# ``that'?s\s+(?:not|wrong)`` slot was widened to also accept
# ``that\s+(?:is|was)`` and the predicate alternation grew
# ``incorrect|false|mistaken``. Every one of these used to
# silently fall through to UNKNOWN.
"That is not right.",
"That is wrong.",
"That was wrong.",
"That is incorrect.",
"That is false.",
"That was not right.",
"that is mistaken.",
"That was incorrect.",
],
)
def test_correction_canonical_forms_still_route(prompt: str) -> None:
"""Legitimate CORRECTION pragmas must classify after the
word-boundary fix narrowed the alternation, AND the
fully-spoken copula variants must route too (previously UNKNOWN)."""
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.",
# Affirmatives that share the ``That is/was ...`` shape — the
# predicate alternation (``not|wrong|incorrect|false|mistaken``)
# must not over-match. ``That is correct/right/true`` are NOT
# corrections; ``falsifiable`` / ``wrongly accused`` carry the
# trigger root but extend past the boundary.
"That is correct.",
"That is right.",
"That is true.",
"That works.",
"That is interesting.",
"That is a fact.",
"That was a good question.",
"That is falsifiable.",
"That was wrongly accused.",
],
)
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
# ---------------------------------------------------------------------------
def test_definition_with_only_article_falls_back() -> None:
"""``What is the?`` is malformed; the normalizer must not empty the
subject — it falls back to the cleaned original."""
intent = classify_intent("What is the?")
assert intent.tag is IntentTag.DEFINITION
assert intent.subject != ""
def test_verification_with_only_aux_falls_back() -> None:
"""``Is is?`` is degenerate; the normalizer must not empty the subject."""
# The rule table will match this as VERIFICATION; head-noun extraction
# would strip all tokens, so the fallback path kicks in.
intent = classify_intent("Is is is?")
assert intent.tag is IntentTag.VERIFICATION
assert intent.subject != ""
def test_empty_prompt_returns_unknown_with_empty_subject() -> None:
intent = classify_intent("")
assert intent.tag is IntentTag.UNKNOWN
assert intent.subject == ""
def test_unknown_intent_preserves_raw_subject() -> None:
"""UNKNOWN-tag prompts bypass the normalizer entirely so the raw
input survives for debugging / future-pattern detection."""
intent = classify_intent("light logos")
assert intent.tag is IntentTag.UNKNOWN
assert intent.subject == "light logos"
# ---------------------------------------------------------------------------
# Trailing punctuation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt",
[
"What is light?",
"What is light.",
"What is light!",
"What is light",
],
)
def test_trailing_punctuation_does_not_affect_subject(prompt: str) -> None:
intent = classify_intent(prompt)
assert intent.tag is IntentTag.DEFINITION
assert intent.subject == "light"
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
def test_normalization_is_deterministic() -> None:
"""Same prompt must produce byte-identical DialogueIntent on repeat
classification — no randomness, no state."""
prompt = "Why does memory require recall?"
seen: set[DialogueIntent] = set()
for _ in range(5):
seen.add(classify_intent(prompt))
assert len(seen) == 1
# ---------------------------------------------------------------------------
# Existing intent-test contract still holds (loose ``in subject.lower()``)
# ---------------------------------------------------------------------------
def test_legacy_loose_contract_still_holds() -> None:
"""Pre-ADR-0049 tests assert ``"field" in intent.subject.lower()``
for ``"Why does the field diverge?"`` — ADR-0049 tightens the
subject to ``"field"``, which still satisfies the substring check."""
intent = classify_intent("Why does the field diverge?")
assert intent.tag is IntentTag.CAUSE
assert "field" in intent.subject.lower()
assert intent.subject == "field"
# ---------------------------------------------------------------------------
# Pack-grounded path end-to-end — ADR-0049 unblocks ADR-0048 cases
# ---------------------------------------------------------------------------
def test_pack_grounded_surface_lifts_with_article_stripped() -> None:
"""``What is a procedure?`` was previously routed to the universal
disclosure because the subject ``"a procedure"`` did not match the
pack lemma index. Post-ADR-0049 the article is stripped and the
pack-grounded surface engages."""
from chat.runtime import ChatRuntime
rt = ChatRuntime()
resp = rt.chat("What is a procedure?")
assert resp.grounding_source == "pack"
# Case-insensitive: gloss-backed surfaces capitalize the lemma
# at sentence start (Procedure is ...).
assert "procedure" in resp.surface.lower()