core/tests/test_intent_subject_extraction.py
Shay 7ef4ef4546 fix(intent): widen RECALL trigger to accept `recall alongside remember`
The articulation breadth benchmark surfaced a RECALL intent gap:

  Before (bench output):
    RECALL    UNKNOWN    pack    Pack-resident tokens — pack-grounded
                                 (en_core_cognition_v1): recall ...

The probe prompt ``"Recall truth."`` classified as UNKNOWN and fell
through to the ADR-0086 pack-resident-token surface — a graceful
degradation, not a hard failure, but a real classifier gap.

Root cause: ``generate/intent.py`` ``_RULES`` line 213 only matched
the imperative ``remember``:

    (re.compile(r"remember\s+", re.IGNORECASE), IntentTag.RECALL)

The verb ``recall`` — every bit as natural an imperative — was
missing from the trigger pattern.  ``"Remember truth."`` correctly
routed to RECALL; ``"Recall truth."`` did not.

Fix: widen the alternation to ``(?:remember|recall)\s+``.  One-word
change; ``re.match`` anchoring at the start of the prompt means the
fix only catches the canonical imperative form, leaving downstream
contexts untouched:

  * ``Does memory require recall?``      → VERIFICATION (unchanged;
    earlier rule on the aux-verb pattern fires first)
  * ``What is recall?``                  → DEFINITION   (unchanged;
    ``what\s+is\s+`` fires first)
  * ``Why does recall exist?``           → CAUSE        (unchanged;
    ``why\s+`` fires first)
  * ``I recall.``                        → UNKNOWN      (unchanged;
    no trailing word after ``recall``, ``\s+`` doesn't match)
  * ``Please recall the truth.``         → UNKNOWN      (unchanged
    — symmetric with ``Please remember the truth.`` since rules use
    ``pattern.match`` not ``pattern.search``)

After (bench output):
    RECALL    RECALL    pack    Truth is what is true. pack-grounded
                                (en_core_cognition_v1).

The articulation bench probe now routes correctly and produces a
pack-grounded definition surface — the canonical RECALL output on
a pack-resident lemma.

Tests extended: ``tests/test_intent_subject_extraction.py::
test_recall_strips_articles`` is parametrized with four new
``Recall ...`` cases parallel to the existing ``Remember ...``
cases.  A regression that re-narrows the trigger pattern fails the
gate immediately.

Verified:
  * pytest tests/test_intent_subject_extraction.py            7/7 pass
  * pytest tests/test_register_firing_diagnostic.py           3/3 pass
  * core test --suite smoke                                  67/67 pass
  * core test --suite runtime                                19/19 pass
  * core bench --suite articulation  → RECALL ✓ pack-grounded
2026-05-21 08:26:08 -07:00

232 lines
8.6 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
# ---------------------------------------------------------------------------
# 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()