core/tests/test_intent_subject_extraction.py
Shay c8037cfa0d feat(adr-0049): head-noun subject extraction in intent classifier
Add a deterministic, pack-agnostic post-processor in `generate/intent.py`
that runs after the `_RULES` table fires:

- DEFINITION / RECALL / PROCEDURE: strip trailing punctuation + leading
  articles; preserve multi-word noun phrases
- CAUSE / VERIFICATION: additionally strip leading aux verbs; return
  the head noun

Closed-set frozen sets (`_ARTICLES`, `_AUX_VERBS`) make the transform
inspectable. No pack load, no algebra change — touches only
`DialogueIntent.subject`.

Cognition eval (13-case public split):
  surface_groundedness  46.2% → 61.5%  (+15.3 pp)
  term_capture_rate     33.3% → 50.0%  (+16.7 pp)
  intent_accuracy            100.0%        (=)
  versor_closure_rate        100.0%        (=)

Two cases lift through the ADR-0048 pack path
(definition_procedure_023, definition_relation_026 — both
"What is a X?" → subject=X via article stripping). CAUSE / VERIFICATION
subjects are now clean head nouns, foundational for future COMPARISON
pack path / teaching-store inference.

Tests: tests/test_intent_subject_extraction.py (30 tests).
Lanes green: smoke (67), cognition (121), runtime (19), algebra (132),
teaching (17), packs (6).
2026-05-18 06:51:46 -07:00

220 lines
8 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"),
],
)
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"
assert "procedure" in resp.surface