arborist/tests/test_quantifier_classifier.py
russell@unturf.com 926b05ed97
qa(#000008): Phase 1 — pure quantifier classifier (dry-run wired)
Lands aborist/qa/quantifier.py with classify_question_quantifier(),
a pure function mapping a question string onto the ten-rung
intensity ladder (ticket #000008 §2):

  ABSENT < SINGULAR < PROPORTIONAL < SMALL_NUM_EXPLICIT
       < COMPARATIVE_BOUND < FEW < MANY < ABSENT < ALL
       < OPEN_REQUEST < COMPREHENSIVE

Returns intensity, matched_token, explicit_count, is_broad,
operational_shape, scope_bound_hint, classifier_version. Pure: no
I/O, no model call, no retrieval call.

Highest-intensity-wins arbitration: COMPREHENSIVE strictly stronger
than OPEN_REQUEST (both > ALL). Catches "tell me everything about
all wars" → COMPREHENSIVE rather than dropping to one of the softer
shape detectors.

Scope_bound_hint heuristic (§10.1): bounded vs unbounded universals.
"All members of the Beatles" → bounded (corpus-known finite set).
"Winners of all major sports" → unbounded (scope undefined). Year-
anchored questions ("…in 2024") bound the universe to one event.
Heuristic only — corpus-arity check left for future refinement.

Wired into query() right after policy resolution. Both miss and
cache-hit paths surface quantifier_intensity, quantifier_matched
_token, scope_bound_hint, quantifier_explicit_count on the result
dict. claim_cap_applied is None until Phase 2 lands the cap-
application gate (default-off per §10.11.3 dry-run discipline).

61 new tests cover every rung, scope-bound detection (bounded /
unbounded / unknown), highest-wins arbitration, and regression
fixtures (factoid/wh-questions don't over-classify as broad).
2026-05-03 07:23:44 -04:00

337 lines
12 KiB
Python

"""Pure quantifier classifier (Ticket #000008 Phase 1).
Tests pin the ten-rung intensity ladder defined in
``docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md`` §2
plus the bounded/unbounded scope hint from §10.1.
Highest-intensity-wins arbitration: when a question matches multiple
rungs (e.g. "tell me about all the planets"), the later rung in
``_RUNG_PRIORITY`` wins. The classifier is purely lexical — no I/O,
no model call, no retrieval call.
"""
from __future__ import annotations
import pytest
from aborist.qa.quantifier import (
CLASSIFIER_VERSION,
classify_question_quantifier,
)
# ----------------------------------------------------------- empty / SINGULAR
def test_empty_question_classifies_singular():
out = classify_question_quantifier("")
assert out["intensity"] == "SINGULAR"
assert out["matched_token"] is None
assert out["is_broad"] is False
assert out["scope_bound_hint"] == "unknown"
def test_whitespace_only_classifies_singular():
out = classify_question_quantifier(" \n\t ")
assert out["intensity"] == "SINGULAR"
def test_definite_singular_question():
out = classify_question_quantifier("who painted the mona lisa?")
assert out["intensity"] == "SINGULAR"
assert out["matched_token"] is None # no marker fired
assert out["is_broad"] is False
def test_factoid_singular_question():
out = classify_question_quantifier("what is the capital of france?")
assert out["intensity"] == "SINGULAR"
# ----------------------------------------------------------- ABSENT
@pytest.mark.parametrize("q", [
"which states do not border texas?",
"none of the planets have rings except saturn?",
"nothing about the soviet union",
"no one survived the eruption?",
"never was there a stronger king",
])
def test_absent_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "ABSENT", f"{q}{out}"
assert out["matched_token"] is not None
# ----------------------------------------------------------- PROPORTIONAL
@pytest.mark.parametrize("q", [
"most cats are mammals",
"the majority of voters supported the bill",
"half the planets have moons",
"a third of the population voted",
"the bulk of the work was done by volunteers",
])
def test_proportional_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "PROPORTIONAL", f"{q}{out}"
# ----------------------------------------------------------- SMALL_NUM_EXPLICIT
def test_top_n_digit():
out = classify_question_quantifier("top 3 winners of the open?")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 3
def test_n_biggest():
out = classify_question_quantifier("five biggest cities in europe?")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 5
def test_seven_x():
out = classify_question_quantifier("the original seven mercury astronauts")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 7
def test_dozen_maps_to_twelve():
out = classify_question_quantifier("name a dozen examples?")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 12
def test_pair_of_maps_to_two():
out = classify_question_quantifier("a pair of dice rolls?")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 2
# ----------------------------------------------------------- COMPARATIVE_BOUND
def test_at_least_n():
out = classify_question_quantifier("at least 5 examples please")
assert out["intensity"] == "COMPARATIVE_BOUND"
assert out["explicit_count"] == 5
def test_more_than_n():
out = classify_question_quantifier("more than 10 winners?")
assert out["intensity"] == "COMPARATIVE_BOUND"
assert out["explicit_count"] == 10
def test_between_a_and_b():
"""Range — use the upper bound as the cap."""
out = classify_question_quantifier("between 3 and 7 things")
assert out["intensity"] == "COMPARATIVE_BOUND"
assert out["explicit_count"] == 7
def test_under_n():
out = classify_question_quantifier("under 100 species?")
assert out["intensity"] == "COMPARATIVE_BOUND"
assert out["explicit_count"] == 100
# ----------------------------------------------------------- FEW
@pytest.mark.parametrize("q", [
"list a few examples of supernovas",
"name several philosophers",
"show some constellations",
"list a couple of methods",
])
def test_few_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "FEW", f"{q}{out}"
# ----------------------------------------------------------- MANY
@pytest.mark.parametrize("q", [
"list many examples",
"show various flora",
"what are multiple causes of plague?",
"numerous battles in WWII",
"lots of birds in the amazon",
])
def test_many_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "MANY", f"{q}{out}"
# ----------------------------------------------------------- ALL
@pytest.mark.parametrize("q", [
"list all winners",
"every president of the US",
"each member of the cabinet",
])
def test_all_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "ALL", f"{q}{out}"
assert out["is_broad"] is True
def test_all_alone_classifies_all():
out = classify_question_quantifier("Winners of all major sports?")
assert out["intensity"] == "ALL"
assert out["matched_token"].lower() == "all"
assert out["is_broad"] is True
# ----------------------------------------------------------- COMPREHENSIVE
@pytest.mark.parametrize("q", [
"give me a complete list of supernovas",
"comprehensive overview of the soviet union",
"tell me everything about the civil war",
"everything you know about the boson",
"exhaustive treatment of biology",
"the whole story behind the suez crisis",
])
def test_comprehensive_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "COMPREHENSIVE", f"{q}{out}"
assert out["is_broad"] is True
# ----------------------------------------------------------- OPEN_REQUEST
@pytest.mark.parametrize("q", [
"describe the structure of DNA",
"explain quantum mechanics",
"summarize world war I",
"give me an overview of cubism",
"walk me through the krebs cycle",
"tell me about connecticut",
])
def test_open_request_questions(q):
out = classify_question_quantifier(q)
assert out["intensity"] == "OPEN_REQUEST", f"{q}{out}"
assert out["is_broad"] is True
# ----------------------------------------------------------- highest-wins
def test_open_request_beats_all():
"""`tell me about all the planets` matches BOTH OPEN_REQUEST and
ALL. Highest-priority rung wins (OPEN_REQUEST is later in
_RUNG_PRIORITY → higher intensity → cap is the broader bucket)."""
out = classify_question_quantifier("tell me about all the planets")
assert out["intensity"] == "OPEN_REQUEST"
def test_comprehensive_beats_open_request_and_all():
"""`tell me everything about all wars` matches three rungs:
OPEN_REQUEST (`tell me about`), COMPREHENSIVE (`tell me
everything`), and ALL (`all`). COMPREHENSIVE wins per the
"strictly stronger than ALL" precedence in §2.2 — it's the
explicit exhaustive-request marker, while the others are softer
shape-detectors that happen to overlap."""
out = classify_question_quantifier("tell me everything about all wars")
assert out["intensity"] == "COMPREHENSIVE"
def test_explicit_count_overrides_few_when_present():
"""`name three examples` carries an explicit count — that should
not silently fall through to FEW. SMALL_NUM_EXPLICIT carries the
digit-bound cap."""
out = classify_question_quantifier("name three examples")
assert out["intensity"] == "SMALL_NUM_EXPLICIT"
assert out["explicit_count"] == 3
# ----------------------------------------------------------- scope_bound_hint
def test_scope_bound_unknown_for_singular():
out = classify_question_quantifier("who painted the mona lisa?")
assert out["scope_bound_hint"] == "unknown"
def test_scope_bound_unbounded_for_naked_all():
out = classify_question_quantifier("Winners of all major sports?")
assert out["scope_bound_hint"] == "unbounded"
assert out["is_broad"] is True
def test_scope_bound_bounded_for_beatles_universal():
"""`all members of the Beatles` is a bounded universal — the set
is corpus-known and finite. Should NOT be treated like the
unbounded `winners of all major sports` case."""
out = classify_question_quantifier("name all members of the Beatles")
assert out["intensity"] == "ALL"
assert out["scope_bound_hint"] == "bounded"
def test_scope_bound_bounded_for_planet_universal():
out = classify_question_quantifier("list all planets in the solar system")
assert out["intensity"] == "ALL"
assert out["scope_bound_hint"] == "bounded"
def test_scope_bound_bounded_with_year_anchor():
"""A year anchor bounds the universe to a single event/season."""
out = classify_question_quantifier(
"winners of all major sports in 2024"
)
assert out["intensity"] == "ALL"
assert out["scope_bound_hint"] == "bounded"
# ----------------------------------------------------------- shape mnemonic
def test_operational_shape_present_for_every_rung():
"""Every classification result must carry a non-None
operational_shape so downstream policy can pattern-match by shape
without re-deriving it from intensity."""
examples = [
"what is X?", # SINGULAR
"all X", # ALL
"tell me about X", # OPEN_REQUEST
"complete list of X", # COMPREHENSIVE
"many X", # MANY
"a few X", # FEW
"most X", # PROPORTIONAL
"top 3 X", # SMALL_NUM_EXPLICIT
"at least 5 X", # COMPARATIVE_BOUND
"no X", # ABSENT
]
for q in examples:
out = classify_question_quantifier(q)
assert out["operational_shape"], f"{q}{out}"
# ----------------------------------------------------------- versioning
def test_classifier_version_stamped():
"""Every result carries CLASSIFIER_VERSION so post-hoc bench
analyses can identify which classifier version produced a
label."""
out = classify_question_quantifier("anything?")
assert out["classifier_version"] == CLASSIFIER_VERSION
def test_classifier_version_is_pinned():
"""A version bump invalidates governance_policy_hash via the
Phase 2 _VERIFIER_POLICY_FIELDS extension. Pin the value here so
accidental reformatting doesn't bump it silently."""
assert CLASSIFIER_VERSION == "quantifier-v0.1"
# ----------------------------------------------------------- regression: don't over-classify
def test_simple_who_question_not_open_request():
"""`who is X` shouldn't fire any quantifier rung. Bare wh-questions
are SINGULAR by default."""
out = classify_question_quantifier("who is bilbo baggins's nephew?")
assert out["intensity"] == "SINGULAR"
assert out["is_broad"] is False
def test_simple_what_question_not_open_request():
out = classify_question_quantifier("what dinosaurs were in the first jurassic park film?")
assert out["intensity"] == "SINGULAR"
assert out["is_broad"] is False