diff --git a/aborist/qa/quantifier.py b/aborist/qa/quantifier.py index a1334ab..2bd3a0f 100644 --- a/aborist/qa/quantifier.py +++ b/aborist/qa/quantifier.py @@ -202,6 +202,22 @@ _PROPORTIONAL_PATTERNS = [ r"\bthe lion's share of\b", ] +# Count-question short-circuit. `how many` / `how much` at the +# start of a question (or after a leading wh-clause like +# "and how many...") is asking for a single numeric answer, not +# enumeration. Without this short-circuit the bare `\bmany\b` +# pattern below misfires. +# +# Anchored so we only short-circuit when the question is a count- +# question SHAPE — `how many` further into the question (e.g. +# "list the states; how many are there?") doesn't take precedence +# over the rest of the question's quantifier markers. +_COUNT_QUESTION_RE = re.compile( + r"^\s*(?:and\s+|but\s+|so\s+)?how (?:many|much)\b", + re.IGNORECASE, +) + + # ABSENT — universal-negation. _ABSENT_PATTERNS = [ r"\bnone\b", @@ -318,6 +334,31 @@ def classify_question_quantifier(question: str) -> dict: "classifier_version": CLASSIFIER_VERSION, } + # Count-question short-circuit (caught by 2026-05-03 dry-run + # review across bench/qa_questions.txt). `how many X?` and + # `how much X?` ask for a SINGLE numeric answer ("50 states", + # "206 bones") — not enumeration. Without this short-circuit, + # the bare `\bmany\b` pattern in _MANY_PATTERNS misfires on + # `how many` and the question lands in MANY rung (cap 8 on + # Hermes), which is wrong: a count question deserves cap 1 + # (SINGULAR), not 8. + # + # The same applies to `how often`, `how long`, `how big` — + # all count/measurement questions with single-fact answers. + # We catch the dominant `how many|much` shape here; the others + # already classify SINGULAR by default. + if _COUNT_QUESTION_RE.search(question): + m = _COUNT_QUESTION_RE.search(question) + return { + "intensity": "SINGULAR", + "matched_token": m.group(0), + "explicit_count": None, + "is_broad": False, + "operational_shape": _OPERATIONAL_SHAPE["SINGULAR"], + "scope_bound_hint": "unknown", + "classifier_version": CLASSIFIER_VERSION, + } + candidates: list[tuple[str, str, int | None]] = [] # Per-rung detection. Earlier rungs run first but rung selection diff --git a/bench/qa_questions.txt b/bench/qa_questions.txt index b9c8092..a7c1154 100644 --- a/bench/qa_questions.txt +++ b/bench/qa_questions.txt @@ -33,6 +33,15 @@ describe the structure of DNA # claim ceiling. winners of all major sports? +# bounded universals — finite, corpus-known answer sets. Ticket +# #000008 §10.1 splits broad universals into bounded vs unbounded. +# These should classify ALL but with `scope_bound_hint: "bounded"`, +# meaning --reject-broad does NOT reject and the cap is the natural +# bound. Without these fixtures, the bounded-vs-unbounded distinction +# has no live bench coverage. +name all members of the beatles +list all planets in the solar system + # entity list — invites lazy-anchor on a magnet chunk what dinosaurs were in the first jurassic park film? who are the members of the beatles? diff --git a/tests/test_quantifier_classifier.py b/tests/test_quantifier_classifier.py index f5ff5b9..5803f69 100644 --- a/tests/test_quantifier_classifier.py +++ b/tests/test_quantifier_classifier.py @@ -335,3 +335,46 @@ 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"