From aa66047b353a6b66455446182a2863a771809064 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 6 Jun 2026 09:32:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(comprehend):=20relational=20reader=20?= =?UTF-8?q?=E2=80=94=20binary-relation=20NL=E2=86=92structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First consumer of en_core_relational_predicates_v1: a fail-closed sibling reader that maps ' is [the] ' onto the pack's closed predicate vocabulary (parent_of, less_than, left_of, before_event, ...), producing a standard Comprehension. realize_comprehension consumes it unchanged; determine's guard widens from the single 'member' predicate to a CLOSED direct-entailment set (member + the ground binary relational predicates), keeping categorical/propositional predicates soundly excluded. Direct entailment only — no transitive/symmetric/rule inference (symmetric-converse questions are a sound-but-incomplete refusal, never a faked assertion). Only the predicate is closed-vocabulary; arguments may be OOV. The pack is loaded explicitly, not default-mounted. wrong=0 bites: reader refuses non-template/negated/pack-absent surfaces; determine asserts only on direct entailment and excludes categorical predicates. --- generate/determine/determine.py | 54 ++++--- generate/meaning_graph/relational.py | 223 +++++++++++++++++++++++++++ tests/test_relational_reader.py | 220 ++++++++++++++++++++++++++ 3 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 generate/meaning_graph/relational.py create mode 100644 tests/test_relational_reader.py diff --git a/generate/determine/determine.py b/generate/determine/determine.py index 17cac98b..82fca987 100644 --- a/generate/determine/determine.py +++ b/generate/determine/determine.py @@ -15,10 +15,16 @@ is DIRECTLY entailed by a realized fact. Absence of a fact never refutes it (``Undetermined``). It asserts only ``answer=True`` on a direct hit; it never asserts False. -Slice D0 supports the realized, non-negated ``member`` relation (subsumption / -"Is X a Y?"). Negated questions and other query predicates are an honest -``Undetermined`` until their realized form + entailment predicate land — D0 ships no -entailment path it cannot exercise. +Supported predicates are a CLOSED set for which DIRECT entailment is sound — a +realized ground fact ``p(subject, target)`` answers the asked ``p(subject, target)``: +the ``member`` relation (subsumption / "Is X a Y?") and the binary relational +predicates of ``en_core_relational_predicates_v1`` (``parent_of``, ``less_than``, +``left_of``, ``before_event`` …; see ``generate.meaning_graph.relational``). The +categorical (``subset``/``disjoint`` …) and propositional (``implies``/``or`` …) +predicates are deliberately EXCLUDED — their truth is not a stored-pair lookup, so +admitting them would be unsound. Negated questions, symmetric-converse questions, and +any predicate outside the closed set are an honest ``Undetermined`` — D0 ships no +entailment path it cannot exercise (no transitive/symmetric/rule inference). """ from __future__ import annotations @@ -26,12 +32,16 @@ from __future__ import annotations from dataclasses import dataclass from generate.meaning_graph.reader import Comprehension, Refusal +from generate.meaning_graph.relational import RELATIONAL_PREDICATES from generate.realize import RealizedRecord, recall_realized from session.context import SessionContext from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus -#: The only realized relation D0 can reason over in this slice. -_SUPPORTED_PREDICATE = "member" +#: The CLOSED set of query predicates D0 has a SOUND direct-entailment path for: +#: ``member`` plus the ground binary relational predicates. Direct entailment is +#: "a realized fact ``p(s, t)`` directly answers the asked ``p(s, t)``" — sound for +#: these ground relations, unsound for categorical/propositional predicates (excluded). +_SUPPORTED_PREDICATES = frozenset({"member"}) | RELATIONAL_PREDICATES @dataclass(frozen=True, slots=True) @@ -68,12 +78,13 @@ def _basis(grounds: tuple[RealizedRecord, ...]) -> str: def determine( question: Comprehension | Refusal, ctx: SessionContext ) -> Determined | Undetermined: - """Answer a membership question from realized structure, or refuse. + """Answer a membership-or-relational question from realized structure, or refuse. - Eligibility: a query-bearing ``Comprehension`` with exactly one ``member`` query. - The answer is asserted ONLY on direct structural entailment by a realized - ``member`` fact; everything else is a typed ``Undetermined`` (open-world: absence - never asserts a positive answer). + Eligibility: a query-bearing ``Comprehension`` with exactly one binary, + non-negated query whose predicate is in the closed direct-entailment set + (``member`` or a relational pack predicate). The answer is asserted ONLY on direct + structural entailment by a realized fact of the SAME predicate; everything else is + a typed ``Undetermined`` (open-world: absence never asserts a positive answer). """ if isinstance(question, Refusal): return Undetermined("refusal") @@ -83,8 +94,8 @@ def determine( return Undetermined("not_single_query") # a determination answers one question query = question.queries[0] - if query.predicate != _SUPPORTED_PREDICATE: - return Undetermined("unsupported_query") # honest: only `member` in D0 + if query.predicate not in _SUPPORTED_PREDICATES: + return Undetermined("unsupported_query") # honest: closed direct-entailment set if len(query.arguments) != 2: return Undetermined("malformed_query") # member is binary by construction if query.negated: @@ -94,17 +105,20 @@ def determine( # cannot exercise (it refuses negated membership questions upstream anyway). return Undetermined("negated_query_unsupported") + predicate = query.predicate subject, target = query.arguments[0], query.arguments[1] - # Structural recall: the realized member facts about this subject (R1a). Exact, - # deterministic, versor-collision-irrelevant — never a metric call. - facts = recall_realized(ctx, subject=subject, predicate=_SUPPORTED_PREDICATE) + # Structural recall: the realized facts of this predicate about this subject (R1a). + # Exact, deterministic, versor-collision-irrelevant — never a metric call. + facts = recall_realized(ctx, subject=subject, predicate=predicate) if not facts: return Undetermined("ungrounded") # nothing realized about the subject - # Direct entailment: a realized member(subject, target) holds as-told. Open-world: - # member facts about the subject that do NOT match the asked target never refute - # it, so a miss is a refusal (``not_entailed``), never an asserted False. + # Direct entailment: a realized p(subject, target) holds as-told. Open-world: + # facts about the subject that do NOT match the asked target never refute it, so a + # miss is a refusal (``not_entailed``), never an asserted False. Symmetric relations + # (sibling_of, equal_to …) read only the stored direction here — the converse is a + # sound-but-incomplete ``not_entailed``, never a fabricated assertion. grounding = next((f for f in facts if f.relation_arguments == (subject, target)), None) if grounding is None: return Undetermined("not_entailed") @@ -112,7 +126,7 @@ def determine( return Determined( answer=True, basis=_basis((grounding,)), - predicate=_SUPPORTED_PREDICATE, + predicate=predicate, subject=subject, object=target, grounds=(grounding,), diff --git a/generate/meaning_graph/relational.py b/generate/meaning_graph/relational.py new file mode 100644 index 00000000..35058115 --- /dev/null +++ b/generate/meaning_graph/relational.py @@ -0,0 +1,223 @@ +"""Relational comprehension — the first consumer of ``en_core_relational_predicates_v1``. + +The COMPREHEND organ today mints type-membership and categorical/ordering/propositional +structure (see ``reader.py``). This sibling reader adds the next increment: BINARY +RELATIONS named by the relational-predicates pack's closed vocabulary +(``parent_of``, ``less_than``, ``left_of``, ``before_event`` …). It produces a +STANDARD ``Comprehension`` whose ``Relation.predicate`` IS a pack lemma, so the rest +of the spine consumes it unchanged: ``realize_comprehension`` is predicate-general +(it stores any single non-negated relation), and ``determine`` admits the closed +relational set for DIRECT entailment. + +Fail-closed (wrong=0 at the comprehension layer): + + - a clause is read ONLY when it matches `` is [the] `` AND the + connective's lemma is present in the LOADED pack (``pack_lemmas``). A non-matching + surface, a negated form, a multi-word/reserved filler, or a lemma absent from the + pack all REFUSE — never guess, never field-vote. Deterministic. + - only the PREDICATE is closed-vocabulary; the two arguments may be OOV (arbitrary + identifiers), grounded downstream by the OOV substrate. + +Scope — ground binary relations, DIRECT reading only. NO transitive/symmetric/rule +inference: a symmetric lemma (``sibling_of``, ``equal_to``, ``adjacent_to`` …) reads +ONLY the stated direction; the converse is a sound-but-incomplete refusal at DETERMINE, +never a fabricated assertion. Negation is out of grammar (a negated surface refuses). + +This reader is invoked EXPLICITLY (the pack is loaded by the caller, not default-mounted); +it does not perturb ``comprehend``'s templates or their wrong=0 tests. +""" + +from __future__ import annotations + +from language_packs.compiler import load_pack_entries + +from generate.meaning_graph.model import ( + Entity, + MeaningGraph, + MeaningGraphError, + MeaningSpan, + Relation, +) +from generate.meaning_graph.reader import ( + Comprehension, + Query, + Refusal, + _chunk, + _Reject, + _split_clauses, + _split_sentences, +) + +#: The pack whose closed predicate vocabulary this reader speaks. +RELATIONAL_PACK_ID = "en_core_relational_predicates_v1" + +#: Surface connective (ordered tokens) -> pack predicate lemma. The ONLY surface +#: grammar this reader admits; the lemma must ALSO be present in the loaded pack +#: (fail-closed). Longest connective at a position wins (2-token before 1-token). +_CONNECTIVE_TO_LEMMA: dict[tuple[str, ...], str] = { + # kinship + ("parent", "of"): "parent_of", + ("child", "of"): "child_of", + ("sibling", "of"): "sibling_of", + ("spouse", "of"): "spouse_of", + # ordering / comparison + ("less", "than"): "less_than", + ("greater", "than"): "greater_than", + ("equal", "to"): "equal_to", + ("distinct", "from"): "distinct_from", + # spatial + ("left", "of"): "left_of", + ("right", "of"): "right_of", + ("inside", "of"): "inside_of", + ("adjacent", "to"): "adjacent_to", + # temporal + ("before",): "before_event", + ("after",): "after_event", + ("during",): "during_event", + ("overlaps",): "overlaps_event", +} + +#: The closed set of predicates this reader can mint — the universe DETERMINE admits +#: for direct relational entailment. (The realized fact's predicate is one of these.) +RELATIONAL_PREDICATES = frozenset(_CONNECTIVE_TO_LEMMA.values()) + +#: Structural copula/article tokens stripped from an argument slot's edges. They are +#: scaffolding of the `` is [the] …`` template, never part of an entity id. +_EDGE_FUNCTION_WORDS = frozenset({"is", "the"}) + +_MAX_CONNECTIVE_LEN = max(len(k) for k in _CONNECTIVE_TO_LEMMA) + + +def load_relational_pack_lemmas() -> frozenset[str]: + """The lemma set of the loaded relational-predicates pack — the fail-closed + authority a relational read is gated on. Loading is explicit (NOT a default + mount); ``load_pack_entries`` is cached, so repeated calls are cheap.""" + return frozenset(entry.lemma for entry in load_pack_entries(RELATIONAL_PACK_ID)) + + +def _strip_edges(toks: list[str]) -> list[str]: + """Drop leading/trailing structural copula/article tokens from an argument slot.""" + out = list(toks) + while out and out[0] in _EDGE_FUNCTION_WORDS: + out = out[1:] + while out and out[-1] in _EDGE_FUNCTION_WORDS: + out = out[:-1] + return out + + +def _split_around_connective( + toks: list[str], +) -> tuple[list[str], str, list[str]] | None: + """Find the FIRST connective (longest match at each position) -> (left, lemma, + right). ``None`` when no connective from the closed grammar is present.""" + for i in range(len(toks)): + for length in range(_MAX_CONNECTIVE_LEN, 0, -1): + key = tuple(toks[i : i + length]) + lemma = _CONNECTIVE_TO_LEMMA.get(key) + if lemma is not None: + return toks[:i], lemma, toks[i + length :] + return None + + +def comprehend_relational( + text: str, pack_lemmas: frozenset[str], source_id: str = "input" +) -> Comprehension | Refusal: + """Comprehend binary-relation *text* into a ``Comprehension`` over pack-named + predicates, or a typed ``Refusal``. + + ``pack_lemmas`` is the loaded relational pack's lemma set (see + ``load_relational_pack_lemmas``); a mapped lemma absent from it REFUSES + (fail-closed on the pack, not on the static grammar). + """ + if not text or not text.strip(): + return Refusal("empty") + + sentences = _split_sentences(text) + if not sentences: + return Refusal("empty") + + role_kind: dict[str, str] = {} + span_for: dict[str, MeaningSpan] = {} + relations: list[tuple[str, tuple[str, ...], MeaningSpan, bool]] = [] + queries: list[Query] = [] + + def claim(entity_id: str, span: MeaningSpan) -> None: + role_kind.setdefault(entity_id, "entity") + span_for.setdefault(entity_id, span) + + try: + for body, terminator, start, end in sentences: + span = MeaningSpan( + source_id=source_id, start=start, end=end, text=text[start:end] + ) + is_question = terminator == "?" + for clause in _split_clauses(body): + _read_relational_clause( + clause, is_question, span, pack_lemmas, claim, relations, queries + ) + except _Reject as rej: + return rej.refusal + + try: + entities = tuple( + Entity(entity_id=eid, name=eid, span=span_for[eid], kind=role_kind[eid]) + for eid in sorted(role_kind) + ) + graph = MeaningGraph( + entities=entities, + relations=tuple( + Relation(pred, args, sp, negated) + for pred, args, sp, negated in relations + ), + ) + except MeaningGraphError as exc: # defensive — construction invariants + return Refusal("invalid_graph", repr(exc)) + + return Comprehension(meaning_graph=graph, queries=tuple(queries)) + + +def _read_relational_clause( + clause: str, + question: bool, + span: MeaningSpan, + pack_lemmas: frozenset[str], + claim, + relations: list[tuple[str, tuple[str, ...], MeaningSpan, bool]], + queries: list[Query], +) -> None: + """Read one `` is [the] `` clause; mutate accumulators or REFUSE.""" + toks = clause.strip().lower().split() + if not toks: + raise _Reject("empty_clause", clause) + if "is" not in toks: + # The relational template is copular; without a copula it is not our shape. + raise _Reject("no_relational_template", clause) + + split = _split_around_connective(toks) + if split is None: + raise _Reject("no_relational_template", clause) + left, lemma, right = split + + # Fail-closed on the LOADED pack: the static grammar only maps to pack lemmas, so + # this fires only when the caller passes a pack missing them — and it BITES if a + # future grammar entry outruns the pack. + if lemma not in pack_lemmas: + raise _Reject("relational_lemma_not_in_pack", lemma) + + subject_toks = _strip_edges(left) + object_toks = _strip_edges(right) + if not subject_toks or not object_toks: + raise _Reject("incomplete_relation", clause) + + # ``_chunk`` canonicalizes each slot to a reserved-free identifier (OOV-friendly) + # and REFUSES junk / leaked function words (e.g. a negated "is not …" leaves the + # reserved "not" in the slot -> refuse). Negation thus never reads as a positive. + subject = _chunk(subject_toks, clause) + obj = _chunk(object_toks, clause) + claim(subject, span) + claim(obj, span) + + if question: + queries.append(Query(lemma, (subject, obj), span)) + else: + relations.append((lemma, (subject, obj), span, False)) diff --git a/tests/test_relational_reader.py b/tests/test_relational_reader.py new file mode 100644 index 00000000..bd8a0862 --- /dev/null +++ b/tests/test_relational_reader.py @@ -0,0 +1,220 @@ +"""Relational reader — binary-relation NL → pack-named structure, realized & determined. + +The reader is the first consumer of ``en_core_relational_predicates_v1``: it maps +`` is [the] `` onto the pack's closed predicate vocabulary, FAIL-CLOSED +(a non-template surface, a negated form, or a lemma absent from the loaded pack REFUSES). +Realize/determine consume it unchanged. + +The pinned tests BITE (wrong=0): + - the reader REFUSES a non-template surface and a negated form (no fabricated read); + - the reader REFUSES when the mapped lemma is absent from the passed pack (fail-closed); + - DETERMINE asserts ONLY on direct entailment — a present-but-non-entailing question + and a symmetric CONVERSE both REFUSE (no faked symmetric/transitive inference); + - DETERMINE keeps categorical predicates EXCLUDED (admitting them would be unsound). +""" + +from __future__ import annotations + +import pytest + +from chat.runtime import ChatRuntime +from generate.determine import Determined, Undetermined, determine +from generate.meaning_graph.reader import Comprehension, Refusal, comprehend +from generate.meaning_graph.relational import ( + RELATIONAL_PREDICATES, + comprehend_relational, + load_relational_pack_lemmas, +) +from generate.realize import Realized, realize_comprehension +from session.context import SessionContext + +_HIGH_INTERVAL = 10**9 +_PACK_LEMMAS = load_relational_pack_lemmas() + + +@pytest.fixture(scope="module") +def vocab_persona(): + rt = ChatRuntime(no_load_state=True) + return rt._context.vocab, rt._context.persona + + +def _ctx(vocab_persona) -> SessionContext: + vocab, persona = vocab_persona + return SessionContext(vocab=vocab, persona=persona, vault_reproject_interval=_HIGH_INTERVAL) + + +def _read(text: str): + return comprehend_relational(text, _PACK_LEMMAS) + + +def _tell(text: str, ctx: SessionContext): + return realize_comprehension(_read(text), ctx) + + +def _ask(text: str, ctx: SessionContext): + return determine(_read(text), ctx) + + +def _only_relation(text: str): + comp = _read(text) + assert isinstance(comp, Comprehension), comp + assert len(comp.meaning_graph.relations) == 1 and not comp.queries + return comp.meaning_graph.relations[0] + + +# --------------------------------------------------------------------------- # +# Reader: each relation family reads onto the right pack lemma +# --------------------------------------------------------------------------- # + + +def test_pack_lemmas_match_grammar() -> None: + # The static grammar and the shipped pack agree — no grammar entry outruns the pack. + assert RELATIONAL_PREDICATES == _PACK_LEMMAS + + +@pytest.mark.parametrize( + "text,predicate,args", + [ + ("Alice is the parent of Bob.", "parent_of", ("alice", "bob")), + ("Bob is the child of Alice.", "child_of", ("bob", "alice")), + ("Three is less than five.", "less_than", ("three", "five")), + ("Nine is greater than two.", "greater_than", ("nine", "two")), + ("Five is equal to five.", "equal_to", ("five", "five")), + ("The box is left of the cup.", "left_of", ("box", "cup")), + ("North station is adjacent to south station.", "adjacent_to", + ("north_station", "south_station")), + ("Monday is before Friday.", "before_event", ("monday", "friday")), + ("Lunch is after breakfast.", "after_event", ("lunch", "breakfast")), + ], +) +def test_relation_reads_onto_pack_lemma(text, predicate, args) -> None: + rel = _only_relation(text) + assert rel.predicate == predicate + assert rel.arguments == args + assert rel.negated is False + + +def test_oov_arguments_are_read() -> None: + # Only the PREDICATE is closed-vocabulary; the arguments may be arbitrary (OOV). + rel = _only_relation("Zorptak is the parent of Quxley.") + assert rel.predicate == "parent_of" + assert rel.arguments == ("zorptak", "quxley") + + +def test_question_form_reads_a_query_not_a_fact() -> None: + comp = _read("Is Alice the parent of Bob?") + assert isinstance(comp, Comprehension) + assert not comp.meaning_graph.relations + assert len(comp.queries) == 1 + q = comp.queries[0] + assert q.predicate == "parent_of" and q.arguments == ("alice", "bob") and not q.negated + + +# --------------------------------------------------------------------------- # +# Reader: fail-closed — never guess (wrong=0 at the comprehension layer) +# --------------------------------------------------------------------------- # + + +def test_no_connective_refuses() -> None: + res = _read("Alice greets Bob.") + assert isinstance(res, Refusal) and res.reason == "no_relational_template" + + +def test_no_copula_refuses() -> None: + res = _read("Alice parent of Bob.") + assert isinstance(res, Refusal) and res.reason == "no_relational_template" + + +def test_negated_form_refuses() -> None: + # "not" leaks into the subject slot -> reserved-word refusal; negation never reads + # as a positive relation. + res = _read("Alice is not the parent of Bob.") + assert isinstance(res, Refusal) + assert res.reason in {"reserved_word_in_np", "incomplete_relation"} + + +def test_incomplete_relation_refuses() -> None: + res = _read("Alice is the parent of.") + assert isinstance(res, Refusal) and res.reason == "incomplete_relation" + + +def test_fail_closed_on_pack_membership_bites() -> None: + # The fail-closed gate: a grammar lemma absent from the PASSED pack must refuse, + # even though the static grammar maps it. Break the pack -> the read must refuse. + starved_pack = _PACK_LEMMAS - {"parent_of"} + res = comprehend_relational("Alice is the parent of Bob.", starved_pack) + assert isinstance(res, Refusal) and res.reason == "relational_lemma_not_in_pack" + # a different relation still reads through the same starved pack (only parent_of gone) + ok = comprehend_relational("Three is less than five.", starved_pack) + assert isinstance(ok, Comprehension) + + +# --------------------------------------------------------------------------- # +# End-to-end: realize a relational fact, then determine it (as-told, never verified) +# --------------------------------------------------------------------------- # + + +def test_realize_then_determine_relation(vocab_persona) -> None: + ctx = _ctx(vocab_persona) + told = _tell("Alice is the parent of Bob.", ctx) + assert isinstance(told, Realized) and told.created + res = _ask("Is Alice the parent of Bob?", ctx) + assert isinstance(res, Determined) + assert res.answer is True + assert res.basis == "as_told" # SPECULATIVE grounds — never "verified" + assert res.predicate == "parent_of" + assert res.subject == "alice" and res.object == "bob" + + +def test_distinct_relations_about_one_subject_stay_distinct(vocab_persona) -> None: + # R1 relation-space recall: two facts about "alice" collide on the field versor but + # are keyed apart structurally; each determines independently. + ctx = _ctx(vocab_persona) + _tell("Alice is the parent of Bob.", ctx) + _tell("Alice is the sibling of Carol.", ctx) + assert isinstance(_ask("Is Alice the parent of Bob?", ctx), Determined) + assert isinstance(_ask("Is Alice the sibling of Carol?", ctx), Determined) + + +# --------------------------------------------------------------------------- # +# wrong=0 BITE — direct entailment only; no fabricated inference +# --------------------------------------------------------------------------- # + + +def test_present_but_non_entailing_refuses(vocab_persona) -> None: + # A record about "alice" exists, but not the asked pair -> REFUSE, never assert. + ctx = _ctx(vocab_persona) + _tell("Alice is the parent of Bob.", ctx) + res = _ask("Is Alice the parent of Carol?", ctx) + assert isinstance(res, Undetermined) and res.reason == "not_entailed" + + +def test_symmetric_converse_is_not_faked(vocab_persona) -> None: + # sibling_of is symmetric in the world, but D0 does DIRECT reading only: the stored + # direction determines; the converse is a sound-but-incomplete refusal, NOT a faked + # assertion. (If this flips to Determined, symmetric inference was smuggled in.) + ctx = _ctx(vocab_persona) + _tell("Alice is the sibling of Bob.", ctx) + assert isinstance(_ask("Is Alice the sibling of Bob?", ctx), Determined) # stored dir + converse = _ask("Is Bob the sibling of Alice?", ctx) + # The bite is that the converse does NOT assert. Its reason is "ungrounded" (no + # sibling_of fact has "bob" as subject) rather than "not_entailed" — either way it + # is a refusal, proving symmetric inference was not smuggled in. + assert isinstance(converse, Undetermined) + assert converse.reason in {"ungrounded", "not_entailed"} + + +def test_ungrounded_relation_refuses(vocab_persona) -> None: + ctx = _ctx(vocab_persona) + res = _ask("Is Alice the parent of Bob?", ctx) + assert isinstance(res, Undetermined) and res.reason == "ungrounded" + + +def test_categorical_predicate_stays_excluded(vocab_persona) -> None: + # DETERMINE must NOT admit categorical predicates (subset/disjoint/…): their truth + # is not a stored-pair lookup, so a direct-entailment answer would be unsound. + ctx = _ctx(vocab_persona) + subset_q = comprehend("Are all men mortals?") + assert isinstance(subset_q, Comprehension) and subset_q.queries[0].predicate == "subset" + res = determine(subset_q, ctx) + assert isinstance(res, Undetermined) and res.reason == "unsupported_query"