"""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 arborist.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"