modified: .gitlab-ci.yml modified: bench/qa_questions.txt modified: bench/qa_sweep.py modified: bench/run.sh modified: docs/TICKETS.md modified: docs/_source/README.md modified: docs/_source/_ext/makefile_targets.py modified: docs/_source/api/cli.rst modified: docs/_source/api/distill.rst modified: docs/_source/api/mesh.rst modified: docs/_source/api/qa.rst modified: docs/_source/api/retrieval.rst modified: docs/_source/api/storage.rst modified: docs/_source/api/substrate.rst modified: docs/_source/concepts.rst modified: docs/_source/conf.py modified: docs/_source/cookbook.rst modified: docs/_source/index.rst modified: docs/_source/license.rst modified: docs/_source/quickstart.rst modified: docs/bench-maxing.md modified: docs/benchmarks.md modified: docs/cti-architecture.md modified: docs/diagrams/aborist-modules.dot modified: docs/diagrams/aborist-modules.svg modified: docs/diagrams/mesh-data-flow.dot modified: docs/diagrams/mesh-epoch-lifecycle.dot modified: docs/diagrams/mesh-epoch-lifecycle.svg modified: docs/diagrams/mesh-group-decisions.dot modified: docs/diagrams/mesh-group-decisions.svg modified: docs/diagrams/mesh-identity-stack.dot modified: docs/diagrams/mesh-secret-envelope.dot modified: docs/mesh.md modified: docs/qa-modes-bench.md modified: docs/seven-point-program.md modified: docs/tickets/ticket-000001-retrieval-keywords-audit-gap.md modified: docs/tickets/ticket-000002-reference-frame-polarity-contract.md modified: docs/tickets/ticket-000003-anchor-class-warrant.md modified: docs/tickets/ticket-000005-label-ladder-migration.md modified: docs/tickets/ticket-000006-bench-emergent-findings.md modified: docs/tickets/ticket-000007-query-layer-hyphen-fold.md modified: docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md modified: docs/tickets/ticket-000009-quantifier-preflight-dag-binding.md modified: docs/tickets/ticket-000010-metacognition-preflight-guard.md modified: docs/tickets/ticket-000011-soft-preflight-hint-sidecar.md modified: scripts/backfill_concepts.py modified: scripts/bench_emergent.py modified: tests/crawler/test_async_web_fetcher.py modified: tests/crawler/test_bridge.py modified: tests/crawler/test_web_fetch.py modified: tests/test_bench_qa_sweep.py modified: tests/test_burn.py modified: tests/test_burn_doc.py modified: tests/test_claim_lattice.py modified: tests/test_cli_render.py modified: tests/test_compress.py modified: tests/test_concepts.py modified: tests/test_dag.py modified: tests/test_directives.py modified: tests/test_distill.py modified: tests/test_distill_recursive.py modified: tests/test_evict.py modified: tests/test_frame.py modified: tests/test_grok_source.py modified: tests/test_html_source.py modified: tests/test_ingest.py modified: tests/test_inspect.py modified: tests/test_journal.py modified: tests/test_keys.py modified: tests/test_llm_context_base.py modified: tests/test_merkle.py modified: tests/test_mesh.py modified: tests/test_mesh_aead.py modified: tests/test_mesh_chain.py modified: tests/test_mesh_cli.py modified: tests/test_mesh_cli_pull.py modified: tests/test_mesh_wire.py modified: tests/test_mesh_wire_e2e.py modified: tests/test_metacognition.py modified: tests/test_migration_audit_mode.py modified: tests/test_providence_source.py modified: tests/test_qa.py modified: tests/test_qa_quality_live.py modified: tests/test_quantifier_caps.py modified: tests/test_quantifier_classifier.py modified: tests/test_quantifier_phase4.py modified: tests/test_quantifier_reminder.py modified: tests/test_query.py modified: tests/test_reclassify.py modified: tests/test_repair.py modified: tests/test_resume.py modified: tests/test_snapshot.py modified: tests/test_soft_preflight.py modified: tests/test_tfidf.py modified: tests/test_vcs_source.py modified: tests/test_verify.py modified: tests/test_verify_json.py modified: tests/test_versioned_ingest.py modified: tests/test_warrant.py modified: tests/test_wikipedia_old.py modified: tests/test_wikipedia_xml.py modified: tests/test_wikitext.py
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 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"
|