fix(comprehend): relational reader fail-closed on leftover relational structure
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.
This commit is contained in:
parent
aa66047b35
commit
47a31d3ed0
2 changed files with 103 additions and 20 deletions
|
|
@ -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 ``<A> is [the] <connective> <B>`` 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 ``<A> is [the] <connective> <B>`` 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 ``<A> 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 ``<A> is [the] <connective> <B>``. 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue