arborist/tests/test_quantifier_classifier.py
russell@unturf.com d24291bc8b
qa(#000008): classifier fix — count-question short-circuit + bounded fixtures
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.
2026-05-03 08:29:36 -04:00

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"