From 47a31d3ed0a7a9e6b16744753faa9c3ba84ed51c Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 6 Jun 2026 09:45:10 -0700 Subject: [PATCH] fix(comprehend): relational reader fail-closed on leftover relational structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lookback (adversarial verify) found a comprehension-layer wrong=0 hole: an argument slot only refused on reader._RESERVED tokens, so connective heads leaked through — 'Carol is the sibling of Dan during school.' fabricated sibling_of(carol, dan_during_school) and realized it, while the asymmetric twin '… during the trip.' refused (article leaked). The fabricated compound entity entered realized memory, breaching the never-fabricate floor. Fix: (1) argument slots must be FREE of connective tokens (a leaked connective head = unparsed relational structure -> refuse 'extra_relational_structure'); (2) the copula must sit STRUCTURALLY adjacent to the connective, so a dangling copula ('Monday before Friday is.') and a period-question-as-statement ('Is Monday before Friday.') refuse instead of fabricating a fact. determine stays sound by construction; this closes the reader-layer hole. Bite tests added for both classes + a 'no fabricated entity enters realized memory' assertion. --- generate/meaning_graph/relational.py | 69 +++++++++++++++++++++------- tests/test_relational_reader.py | 54 ++++++++++++++++++++-- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/generate/meaning_graph/relational.py b/generate/meaning_graph/relational.py index 35058115..e9db976e 100644 --- a/generate/meaning_graph/relational.py +++ b/generate/meaning_graph/relational.py @@ -11,12 +11,17 @@ 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. + - a clause is read ONLY when it matches `` is [the] `` with the + copula sitting STRUCTURALLY adjacent to the connective, AND the connective's lemma + is present in the LOADED pack (``pack_lemmas``), AND neither argument slot carries + leftover relational/reserved vocabulary. A non-matching surface, a dangling copula, + a negated form, an argument slot holding a connective token (a trailing qualifier + like "… of Dan during school" — unparsed structure, NOT an entity), 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. + identifiers), grounded downstream by the OOV substrate. A clean multi-word entity + ("north station") canonicalizes per the join contract; an argument that still holds + relational structure refuses rather than fabricate a compound entity. Scope — ground binary relations, DIRECT reading only. NO transitive/symmetric/rule inference: a symmetric lemma (``sibling_of``, ``equal_to``, ``adjacent_to`` …) reads @@ -81,9 +86,19 @@ _CONNECTIVE_TO_LEMMA: dict[tuple[str, ...], str] = { #: 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"}) +#: The article stripped from an argument slot's edges ("the box" -> "box"). The +#: copula ("is") is NOT stripped here — it is enforced STRUCTURALLY (adjacent to the +#: connective) so a dangling copula ("Monday before Friday is.") cannot read as a fact. +_ARTICLE = "the" + +#: Every token that participates in a connective. An argument slot must be FREE of +#: these: leftover relational vocabulary means unparsed structure (a trailing +#: qualifier like "… of Dan DURING school", a second relation), so the honest output +#: is a Refusal — not a fabricated compound entity ("dan_during_school"). Without this +#: the refusal net is accidental (it fires only when the leaked token happens to be in +#: reader._RESERVED), which is a comprehension-layer wrong=0 hole: "… of Bob during the +#: trip" refuses (article leaks) while "… of Dan during school" fabricates. +_CONNECTIVE_TOKENS = frozenset(tok for key in _CONNECTIVE_TO_LEMMA for tok in key) _MAX_CONNECTIVE_LEN = max(len(k) for k in _CONNECTIVE_TO_LEMMA) @@ -95,12 +110,13 @@ def load_relational_pack_lemmas() -> frozenset[str]: 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.""" +def _strip_article_edges(toks: list[str]) -> list[str]: + """Drop a leading/trailing article ("the") from an argument slot — an entity id is + never the bare article. (The copula is handled structurally, not stripped here.)""" out = list(toks) - while out and out[0] in _EDGE_FUNCTION_WORDS: + while out and out[0] == _ARTICLE: out = out[1:] - while out and out[-1] in _EDGE_FUNCTION_WORDS: + while out and out[-1] == _ARTICLE: out = out[:-1] return out @@ -189,9 +205,6 @@ def _read_relational_clause( 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: @@ -204,11 +217,33 @@ def _read_relational_clause( if lemma not in pack_lemmas: raise _Reject("relational_lemma_not_in_pack", lemma) - subject_toks = _strip_edges(left) - object_toks = _strip_edges(right) + # Structural copula: the template is `` is [the] ``. The copula + # must sit ADJACENT to the connective (fact: the tail of the left side; question: + # its head) — not merely present somewhere. This refuses a dangling copula + # ("Monday before Friday is.") and a question-shaped statement ("Is Monday before + # Friday." with a period) rather than fabricating a fact from them. + if question: + if not left or left[0] != "is": + raise _Reject("no_relational_template", clause) + subject_toks = left[1:] + else: + lead = left[:-1] if (left and left[-1] == _ARTICLE) else list(left) + if not lead or lead[-1] != "is": + raise _Reject("no_relational_template", clause) + subject_toks = lead[:-1] + + subject_toks = _strip_article_edges(subject_toks) + object_toks = _strip_article_edges(right) if not subject_toks or not object_toks: raise _Reject("incomplete_relation", clause) + # Fail-closed on leftover relational structure: an argument slot carrying a + # connective token ("… of Dan DURING school", "X is left of Y INSIDE Z") is unparsed + # structure, not an entity. Refuse — never chunk it into a fabricated compound + # entity. (Without this the net is accidental, firing only on reader._RESERVED.) + if any(t in _CONNECTIVE_TOKENS for t in (*subject_toks, *object_toks)): + raise _Reject("extra_relational_structure", 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. diff --git a/tests/test_relational_reader.py b/tests/test_relational_reader.py index bd8a0862..ac4233a7 100644 --- a/tests/test_relational_reader.py +++ b/tests/test_relational_reader.py @@ -126,11 +126,12 @@ def test_no_copula_refuses() -> None: def test_negated_form_refuses() -> None: - # "not" leaks into the subject slot -> reserved-word refusal; negation never reads - # as a positive relation. + # "is not the parent of" breaks the structural copula (the copula is not adjacent + # to the connective) and "not" is reserved — either way it REFUSES; 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"} + assert res.reason in {"no_relational_template", "reserved_word_in_np", "incomplete_relation"} def test_incomplete_relation_refuses() -> None: @@ -138,6 +139,53 @@ def test_incomplete_relation_refuses() -> None: assert isinstance(res, Refusal) and res.reason == "incomplete_relation" +@pytest.mark.parametrize( + "text", + [ + "Carol is the sibling of Dan during school.", # trailing temporal qualifier + "Alice is the parent of Bob during the trip.", # (the asymmetric twin — now same verdict) + "Five is equal to ten during testing.", + "X is left of Y inside Z.", # two relational structures in one clause + "Noon is during overlaps dusk.", + ], +) +def test_trailing_relational_structure_refuses(text) -> None: + # A connective token leaking into an argument slot means unparsed relational + # structure -> REFUSE, never fabricate a compound entity ("dan_during_school"). + # Without the guard the refusal net is accidental (fires only on reader._RESERVED), + # so "… during the trip" refuses while "… during school" fabricates — that + # comprehension-layer wrong=0 hole is what this bites. + res = _read(text) + assert isinstance(res, Refusal) + assert res.reason in {"extra_relational_structure", "reserved_word_in_np"} + + +@pytest.mark.parametrize( + "text", + [ + "Monday before Friday is.", # dangling copula — not a fact + "Is Monday before Friday.", # question-shaped statement (period, not '?') + ], +) +def test_dangling_copula_refuses(text) -> None: + # The copula must sit adjacent to the connective; a stray/dangling "is" must not + # let an ungrammatical surface read as a stored fact. + res = _read(text) + assert isinstance(res, Refusal) and res.reason == "no_relational_template" + + +def test_no_fabricated_entity_enters_realized_memory(vocab_persona) -> None: + # The damage the misread caused was a fabricated entity persisted as realized + # knowledge. Confirm the hazardous input realizes NOTHING. + ctx = _ctx(vocab_persona) + told = realize_comprehension(_read("Alice is the parent of Bob during school."), ctx) + from generate.realize import NotRealized + + assert isinstance(told, NotRealized) and told.reason == "refusal" + # and the fabricated entity is not recallable + assert isinstance(_ask("Is Alice the parent of bob_during_school?", ctx), Undetermined) + + 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.