Caught by the 2026-05-03 dry-run distribution review across the
73-question bench set (§10.11.3 step 2):
intensity pre-fix post-fix
SINGULAR 61 (84%) 65 (89%)
MANY 4 ( 5%) 0 ( 0%) ← all 4 were `how many X?`
ALL 1 ( 1%) 1 ( 1%)
COMPREHENSIVE 1 ( 1%) 1 ( 1%)
OPEN_REQUEST 5 ( 7%) 5 ( 7%)
SMALL_NUM 1 ( 1%) 1 ( 1%)
Defect: `how many states are there?` matched the bare `\bmany\b`
pattern in MANY rung — wrong. `how many X?` is a count-question
SHAPE, asking for ONE numeric answer ("50"), not enumeration of
many things. Cap should be 1 (SINGULAR), not 8 (Hermes MANY).
Fix: count-question short-circuit in classify_question_quantifier()
that returns SINGULAR for `^\s*(?:and\s+|but\s+|so\s+)?how (?:many|much)\b`.
Anchored at start so buried `how many` (e.g. "list all the states;
how many are there?") doesn't suppress the rest of the question's
quantifier markers — the leading `list all` still wins.
9 new tests pin: count questions classify SINGULAR, leading
conjunctions don't break the short-circuit, buried `how many` does
NOT short-circuit (verifies anchor is leading-only).
Bonus — Finding 2 from the dry-run review: zero bounded universals
in bench fixture. Adds two:
name all members of the beatles
list all planets in the solar system
Both classify ALL · scope_bound_hint=bounded so the §10.1 bounded-
vs-unbounded distinction has live bench coverage. Without these,
--reject-broad correctness on bounded universals has no automated
test fixture.
915 tests passing (9 new); 36 skipped.
380 lines
13 KiB
Python
380 lines
13 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
|
|
|
|
|
|
# ----------------------------------------------------------- count-question short-circuit
|
|
|
|
# Caught by the 2026-05-03 dry-run distribution review across
|
|
# bench/qa_questions.txt. `how many X` matched the bare `\bmany\b`
|
|
# pattern in MANY rung — wrong: count questions ask for a SINGLE
|
|
# numeric answer, not enumeration. Cap should be 1 (SINGULAR), not
|
|
# 8 (Hermes MANY).
|
|
|
|
@pytest.mark.parametrize("q", [
|
|
"how many states are in the united states?",
|
|
"how many bones are in the adult human body?",
|
|
"how many wives did henry the eighth have?",
|
|
"how many moons does jupiter have?",
|
|
"how many planets are there?",
|
|
"how much does the earth weigh?",
|
|
"how much water is in the ocean?",
|
|
])
|
|
def test_how_many_classifies_singular_not_many(q):
|
|
out = classify_question_quantifier(q)
|
|
assert out["intensity"] == "SINGULAR", f"{q} → {out}"
|
|
assert out["is_broad"] is False
|
|
assert out["matched_token"].lower().startswith("how ")
|
|
|
|
|
|
def test_how_many_with_leading_conjunction_still_classifies_singular():
|
|
"""`and how many X` still a count question — the conjunction
|
|
doesn't change the shape."""
|
|
out = classify_question_quantifier("and how many states are there?")
|
|
assert out["intensity"] == "SINGULAR"
|
|
|
|
|
|
def test_buried_how_many_does_not_short_circuit():
|
|
"""`how many` in the middle of a longer multi-clause question
|
|
is NOT necessarily count-question shape. The short-circuit
|
|
only fires on leading `how many` / `how much`."""
|
|
out = classify_question_quantifier(
|
|
"list all the states; how many are there?"
|
|
)
|
|
# Leading `list all` → ALL fires. Don't short-circuit on the
|
|
# buried "how many".
|
|
assert out["intensity"] == "ALL"
|