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).
337 lines
12 KiB
Python
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
|