core/tests/test_comprehension_reader.py
Shay a733fc5737 feat(comprehend): multi-word NP chunking under a canonicalization contract
Recovers the multi-word-NP cases the reader previously refused, by adopting ONE
principled canonicalization contract (evals/comprehension/CANONICALIZATION.md) that
the reader AND the gold lanes both follow — so a committed answer can only match
gold or refuse, never silently mean something else.

Contract: a noun-phrase slot -> tokens lowercased, joined with "_"; a plural class
slot singularizes its head first ("metal objects"->"metal_object",
"North station"->"north_station", "Level one"->"level_one"). JOIN is chosen over
head-word-only ("metal objects"->"metal") because head-word-only is
information-destroying — it collapses "metal objects" and "metal tools" into one
false identity, itself a wrong=0 hazard.

reader.py: slot-based templates chunk multi-token NPs (_chunk / _chunk_class
replace the single-token _one / _one_class). Reserved-function-word guard fires only
INSIDE a multi-token slot (a lone "A" item is content, not the article). Still
parse-or-refuse: reserved-word leaks ("Compare beta with beta in the same order"),
non-pluralizable class heads (adjectival "trained"), and the ambiguous adjacent
two-NP subset query ("Are all <Xs> <Ys>?") all REFUSE.

gold (the contract update, logic-preserving — only term NAMES change):
  - sy-v1-0008: metal/soft -> metal_object/soft_object (was head-word-only)
  - to-v1-0005: red -> red_rank (was head-word-only)
  - to-v1-0004: prose made internally consistent ("is after", "north station") +
    north -> north_station (original prose used "North station" in the fact but
    "north" in the query — a latent inconsistency)
  - to-v1-0007: already conformed (level_one…), no change
Gold-only integrity runners stay 8/8 both lanes (structure+query+gold consistent).

Scores: set_membership 8/8, syllogism 6/8->7/8, total_ordering 4/8->7/8, all
wrong=0. Capability index re-frozen: score 0.814356 -> 0.917231, breadth 6,
wrong_total 0, digest 13d7db6c…

Tests: reader chunking + refusal tests; multi-word generative round-trip added to
the wrong=0 property suite (verified to BITE under a head-word-only mutation —
collapsed ids produce a wrong verdict the test catches); pinned counts updated.
100 comprehension/capability targeted + 87 smoke green.
2026-06-05 22:47:34 -07:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Phase 2a-r1 — the general comprehension reader (disciplined Path β).
Reads S-P-O structure SYMBOLICALLY from the token sequence via domain-agnostic
templates keyed on FUNCTION WORDS + ORDER (the field provably cannot recover
structure — see the α-falsification in the Phase 2 scope doc). Content is the
filler; structure is the template. Parse-or-refuse → wrong=0 at the comprehension
layer. Each test bites under its named violation.
"""
from __future__ import annotations
from generate.meaning_graph.reader import (
Comprehension,
Query,
Refusal,
comprehend,
)
def _rel(comp: Comprehension, predicate: str) -> tuple:
return tuple(
(r.predicate, r.arguments) for r in comp.meaning_graph.relations if r.predicate == predicate
)
def _entity_kind(comp: Comprehension, entity_id: str) -> str | None:
for e in comp.meaning_graph.entities:
if e.entity_id == entity_id:
return e.kind
return None
# --------------------------------------------------------------------------- #
# Single-clause templates
# --------------------------------------------------------------------------- #
def test_membership_clause() -> None:
comp = comprehend("Rhea is a raven.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "member") == (("member", ("rhea", "raven")),)
assert _entity_kind(comp, "rhea") == "individual"
assert _entity_kind(comp, "raven") == "class"
assert comp.queries == ()
def test_membership_clause_an() -> None:
comp = comprehend("Ada is an engineer.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "member") == (("member", ("ada", "engineer")),)
def test_subsumption_clause_pluralized() -> None:
comp = comprehend("All ravens are birds.")
assert isinstance(comp, Comprehension)
# plural surfaces normalize to the singular class ids the oracle expects.
assert _rel(comp, "subset") == (("subset", ("raven", "bird")),)
assert _entity_kind(comp, "raven") == "class"
assert _entity_kind(comp, "bird") == "class"
def test_query_clause() -> None:
comp = comprehend("Is Rhea a bird?")
assert isinstance(comp, Comprehension)
assert len(comp.queries) == 1
q = comp.queries[0]
assert isinstance(q, Query)
assert q.predicate == "member"
assert q.arguments == ("rhea", "bird")
def test_subset_query_clause() -> None:
comp = comprehend("Are all squares polygons?")
assert isinstance(comp, Comprehension)
assert len(comp.queries) == 1
q = comp.queries[0]
assert q.predicate == "subset"
assert q.arguments == ("square", "polygon")
def test_definite_np_membership_clause() -> None:
comp = comprehend("The mug is a cup.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "member") == (("member", ("mug", "cup")),)
assert _entity_kind(comp, "mug") == "individual"
def test_definite_np_membership_query() -> None:
comp = comprehend("Is the mug a tool?")
assert isinstance(comp, Comprehension)
assert comp.queries[0].predicate == "member"
assert comp.queries[0].arguments == ("mug", "tool")
# --------------------------------------------------------------------------- #
# Full multi-clause problem
# --------------------------------------------------------------------------- #
def test_full_membership_problem() -> None:
comp = comprehend("Rhea is a raven. All ravens are birds. Is Rhea a bird?")
assert isinstance(comp, Comprehension)
assert _rel(comp, "member") == (("member", ("rhea", "raven")),)
assert _rel(comp, "subset") == (("subset", ("raven", "bird")),)
assert len(comp.queries) == 1
assert comp.queries[0].arguments == ("rhea", "bird")
def test_irregular_plural_people_person_is_consistent() -> None:
# The wrong=0 wrinkle: 'people' must normalize to the same id as 'person'.
comp = comprehend("All scientists are people. Is Ada a person?")
assert isinstance(comp, Comprehension)
assert _rel(comp, "subset") == (("subset", ("scientist", "person")),)
assert comp.queries[0].arguments == ("ada", "person")
# --------------------------------------------------------------------------- #
# Generality — SAME template, DISTINCT domains (anti-overfit)
# --------------------------------------------------------------------------- #
def test_membership_template_generalizes_across_domains() -> None:
# One template, three distinct content domains -> all produce member().
for text, ind, cls in [
("Rex is a dog.", "rex", "dog"), # animals
("Ada is a botanist.", "ada", "botanist"), # professions
("Paris is a city.", "paris", "city"), # geography
]:
comp = comprehend(text)
assert isinstance(comp, Comprehension), text
assert _rel(comp, "member") == (("member", (ind, cls)),), text
# --------------------------------------------------------------------------- #
# Parse-or-refuse — refuse, never guess (wrong=0)
# --------------------------------------------------------------------------- #
def test_refuses_unmatched_clause() -> None:
comp = comprehend("The weather changed quickly yesterday.")
assert isinstance(comp, Refusal)
assert comp.reason == "no_template_match"
def test_refuses_when_any_clause_unmatched() -> None:
# If ONE clause cannot be read, the whole problem refuses (no partial guess).
comp = comprehend("Rhea is a raven. Rhea flew over the mountain.")
assert isinstance(comp, Refusal)
def test_refuses_non_identifier_filler() -> None:
# A filler that is not a clean identifier cannot become an entity id -> refuse.
comp = comprehend("Rhea is a co-pilot.")
assert isinstance(comp, Refusal)
def test_empty_input_refuses() -> None:
assert isinstance(comprehend(""), Refusal)
assert isinstance(comprehend(" "), Refusal)
# --------------------------------------------------------------------------- #
# Categorical premises — E / I / O forms (syllogism shapes)
# --------------------------------------------------------------------------- #
def test_categorical_no_is_disjoint() -> None:
comp = comprehend("No reptiles are mammals.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "disjoint") == (("disjoint", ("reptile", "mammal")),)
def test_categorical_some_is_intersects() -> None:
comp = comprehend("Some students are poets.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "intersects") == (("intersects", ("student", "poet")),)
def test_categorical_some_not_is_some_not() -> None:
comp = comprehend("Some pets are not reptiles.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "some_not") == (("some_not", ("pet", "reptile")),)
# --------------------------------------------------------------------------- #
# "Therefore <categorical>" -> conclusion QUERY (same neutral predicates)
# --------------------------------------------------------------------------- #
def test_therefore_conclusion_is_a_query_not_a_fact() -> None:
comp = comprehend("All whales are mammals. Therefore all whales are mammals.")
assert isinstance(comp, Comprehension)
# the premise is a fact; the conclusion is a query of the same predicate.
assert _rel(comp, "subset") == (("subset", ("whale", "mammal")),)
assert len(comp.queries) == 1
assert comp.queries[0].predicate == "subset"
assert comp.queries[0].arguments == ("whale", "mammal")
def test_therefore_maps_each_quantifier_to_its_predicate() -> None:
for tail, predicate in [
("all dogs are animals", "subset"),
("no dogs are cats", "disjoint"),
("some dogs are pets", "intersects"),
("some dogs are not cats", "some_not"),
]:
comp = comprehend(f"Therefore {tail}.")
assert isinstance(comp, Comprehension), tail
assert comp.queries[0].predicate == predicate, tail
# --------------------------------------------------------------------------- #
# Ordering — comparative facts (total_ordering shapes)
# --------------------------------------------------------------------------- #
def test_comparative_less_direction() -> None:
comp = comprehend("Bronze is below silver.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "less") == (("less", ("bronze", "silver")),)
assert _entity_kind(comp, "bronze") == "item"
def test_comparative_greater_direction_reverses() -> None:
# "X is taller than Y" means X > Y, i.e. less(Y, X).
comp = comprehend("Oak is taller than birch.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "less") == (("less", ("birch", "oak")),)
def test_comparative_elided_verb() -> None:
# "Venus closer than Earth" (no copula) still reads.
comp = comprehend("Venus closer than Earth.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "less") == (("less", ("venus", "earth")),)
def test_comparative_clause_splitting_on_comma_and() -> None:
comp = comprehend("A is earlier than B, and B is earlier than C.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "less") == (("less", ("a", "b")), ("less", ("b", "c")))
# --------------------------------------------------------------------------- #
# Ordering — sort / compare QUERIES
# --------------------------------------------------------------------------- #
def test_sort_query_lowest_to_highest_is_ascending() -> None:
comp = comprehend("Sort them from lowest to highest.")
assert isinstance(comp, Comprehension)
assert comp.queries[0].predicate == "sort"
assert comp.queries[0].arguments == ("ascending",)
def test_sort_query_explicit_descending() -> None:
comp = comprehend("Sort descending.")
assert isinstance(comp, Comprehension)
assert comp.queries[0].arguments == ("descending",)
def test_sort_query_order_question_form() -> None:
comp = comprehend("Which is the height order from shortest to tallest?")
assert isinstance(comp, Comprehension)
assert comp.queries[0].predicate == "sort"
assert comp.queries[0].arguments == ("ascending",)
def test_compare_query() -> None:
comp = comprehend("Compare north with south.")
assert isinstance(comp, Comprehension)
assert comp.queries[0].predicate == "compare"
assert comp.queries[0].arguments == ("north", "south")
# --------------------------------------------------------------------------- #
# Generality — SAME comparative template, DISTINCT domains (anti-overfit)
# --------------------------------------------------------------------------- #
def test_comparative_template_generalizes_across_domains() -> None:
for text, lo, hi in [
("Bronze is below silver.", "bronze", "silver"), # metals
("Monday is earlier than tuesday.", "monday", "tuesday"), # time
("Birch is shorter than oak.", "birch", "oak"), # height
]:
comp = comprehend(text)
assert isinstance(comp, Comprehension), text
assert _rel(comp, "less") == (("less", (lo, hi)),), text
# --------------------------------------------------------------------------- #
# Multi-word NP — CHUNK by the canonicalization contract (join tokens with "_")
# --------------------------------------------------------------------------- #
def test_multiword_np_in_categorical_chunks() -> None:
comp = comprehend("No metal objects are soft objects.")
assert isinstance(comp, Comprehension)
# plural class head singularized, then joined: "metal objects" -> "metal_object"
assert _rel(comp, "disjoint") == (("disjoint", ("metal_object", "soft_object")),)
def test_multiword_np_in_comparative_chunks() -> None:
comp = comprehend("North station is below south.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "less") == (("less", ("north_station", "south")),)
assert _entity_kind(comp, "north_station") == "item"
def test_multiword_item_in_compare_query_chunks() -> None:
comp = comprehend("Compare north station with south.")
assert isinstance(comp, Comprehension)
assert comp.queries[0].predicate == "compare"
assert comp.queries[0].arguments == ("north_station", "south")
def test_multiword_individual_in_membership_chunks() -> None:
comp = comprehend("The red car is a vehicle.")
assert isinstance(comp, Comprehension)
assert _rel(comp, "member") == (("member", ("red_car", "vehicle")),)
def test_join_is_information_preserving_distinct_nps_stay_distinct() -> None:
# WHY the contract JOINS instead of keeping the head word: distinct phrases must
# not collapse into a false identity ("metal objects" vs "metal tools" both ->
# "metal" would be a wrong=0 hazard).
comp = comprehend("All metal objects are heavy items. All metal tools are sharp items.")
assert isinstance(comp, Comprehension)
ids = {e.entity_id for e in comp.meaning_graph.entities}
assert {"metal_object", "metal_tool"} <= ids # NOT collapsed to "metal"
# --------------------------------------------------------------------------- #
# Parse-or-refuse — still refuse where no honest reading exists (wrong=0)
# --------------------------------------------------------------------------- #
def test_adjectival_predicate_refuses_via_morphology() -> None:
# "trained" is an adjective, not a pluralizable noun class -> cannot singularize.
comp = comprehend("All pilots are trained.")
assert isinstance(comp, Refusal)
assert comp.reason == "unknown_morphology"
def test_trailing_prepositional_phrase_in_compare_refuses() -> None:
# "...in the same order" leaks reserved words into the NP slot -> refuse, never
# chunk "beta_in_the_same_order".
comp = comprehend("Compare beta with beta in the same order.")
assert isinstance(comp, Refusal)
assert comp.reason == "reserved_word_in_np"
def test_ambiguous_two_np_subset_query_refuses() -> None:
# Two adjacent multi-word class NPs with no separating function word -> the
# boundary is unknown, so refuse rather than guess it.
comp = comprehend("Are all metal objects soft objects?")
assert isinstance(comp, Refusal)
assert comp.reason == "ambiguous_subset_query"