* feat(determine): one-hop sound relational entailment — inverse/converse + symmetric Mastery-v2 Step 3 lead capability (core). DETERMINE could perceive relational structure (16 predicates, breadth-complete) but could not derive the simplest entailed relational facts — it read only the stored direction. This adds two SOUND one-hop rules that read a stored edge in its other lawful direction: INVERSE/converse greater_than(a,b) <= told less_than(b,a) SYMMETRIC sibling_of(b,a) <= told sibling_of(a,b) Strictly scoped (per review): OPEN-WORLD (asserts only True, never False — the answer=False path stays unbuilt, INV-30's firewall holds), ONE hop (NO transitive chaining), DECLARED rules only. Ontology/metadata-driven, not prose intuition: - generate/meaning_graph/relational.py: declarative algebra tables — _INVERSE_PAIRS (the converse edges; the pack carries no inverse metadata) + INVERSE_OF (derived, an involution) + SYMMETRIC_PREDICATES (mirrors the pack's graph.edge.symmetric tag). load_relational_pack_symmetric() reads the pack ontology; a test pins the constant equal to it (no silent divergence). - generate/determine/determine.py: _relational_one_hop() between direct entailment and transitive subsumption; new Determined.rule provenance field (direct/inverse/ symmetric/subsumption) — replay-safe (render reads only basis; trace hashes surface). wrong=0 confuser block proves the rule cannot over-fire: less_than is not self-inverse, sibling_of does not imply parent_of, greater_than does not imply equal_to, NO transitive chain (direct or through-inverse), and no answer=False is ever emitted. An obsolete pin (test_symmetric_converse_is_not_faked, which asserted the pre-rule direct-only floor) is updated to the new sound behavior and a preserved asymmetric-converse bite. Tests: test_determine_relational_inference.py (13, the capability contract) + updated test_relational_reader.py. Broad relational/determine/grounding + capability_index baseline sweep: 114 green; baseline digest UNCHANGED (this is a determination capability; the comprehension lanes are untouched). REMAINING for the full lead PR (next): the measurement lane — an independent-oracle evals/relational_inference lane + a capability_index adapter + baseline re-freeze (breadth 9->10, wrong_total still 0), so the capability registers on the yardstick. INTEGRATION NOTE (cross-PR): this adds 2 Determined() construction sites (now 4: direct/inverse/symmetric/subsumption, ALL answer=True). When rebased onto main with INV-30 (PR #770), update INV-30's test_determine_construction_sites_are_visible count 2 -> 4 and confirm all four assert True. Merge order: #770 -> this. * feat(capability-index): relational-inference lane — breadth 9->10, wrong=0 Mastery-v2 Step 3 lead, measurement half. Puts the one-hop relational-inference capability ON the capability index with an independent-oracle lane: - evals/relational_inference/v1/: cases.jsonl (13 positive: 8 inverse + 5 symmetric, authored from the relation algebra INDEPENDENTLY of determine/relational per INV-25/27), refusals.jsonl (8 confusers that MUST refuse), provenance.md (incl. the honest overlaps_event reader-surface coverage gap). - evals/comprehension/relational_inference_runner.py: told fact(s) -> determine(query) vs gold; refusal = coverage miss, disagreement = wrong (structurally 0 on positives). - evals/capability_index/adapters.py: comprehension_relational_inference_result added to ADAPTERS. - baseline.json re-frozen: breadth 9 -> 10, capability_score 0.94403 -> 0.949483, wrong_total still 0, digest deliberately changed (35dea2b2...). - tests/test_relational_inference_lane.py: SHA-pinned gold, positive wrong=0 + coverage>0, and the confuser wrong=0 BITE (every confuser must refuse). Also reconciles INV-30 (now in main via #770): determine.py grew from 2 to 3 Determined() construction sites (direct, the shared relational one-hop constructor, transitive subsumption) — ALL answer=True. test_determine_construction_sites_are_visible updated 2 -> 3 (correcting the prior commit note's overcount of 4; inverse and symmetric share the single _relational_determined constructor, so it is ONE new site). Also fixes a .gitignore gap: ADR-0219 gen-dir checkpoints (engine_state/current, engine_state/gen-*/) were not ignored (only the old flat engine_state/*.json patterns were), so runtime checkpoints could be committed by accident. Verification: new lane 13/0/0 coverage 1.0; capability_baseline digest matches the re-freeze; INV-30 + architectural invariants green; smoke 99/99. * docs+test: reconcile stale relational-inference docstrings; assert rule provenance (review #775) Addresses two review-requested cleanups on #775: 1. Stale docstrings. generate/meaning_graph/relational.py said the symmetric converse is "a sound-but-incomplete refusal at DETERMINE" and there is "NO transitive/ symmetric/rule inference"; generate/determine/determine.py said "symmetric-converse questions are Undetermined", "no transitive/symmetric/rule inference", and "asserts only answer=True on a direct hit". All false after the one-hop relational algebra landed. Updated: the reader does direct-reading only; DETERMINE applies declared one-hop inverse/converse + pack-declared symmetric (plus the existing member/subset subsumption), still open-world / never answer=False; transitive relational closure, negation, and closed-world falsehood remain out of scope. 2. Rule-provenance gold now meaningful. The relational_inference runner compared only (answer, predicate, subject, object), ignoring res.rule though the gold carries "rule": "inverse"/"symmetric". Added res.rule to the got/gold tuple so a "right answer via the wrong rule path" can no longer pass silently. Lane still 13/0/0; baseline counts (and digest) unchanged. Verification: runner 13/0/0; lane + capability_baseline + unit + INV-30 green (22).
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""Relational reader — binary-relation NL → pack-named structure, realized & determined.
|
|
|
|
The reader is the first consumer of ``en_core_relational_predicates_v1``: it maps
|
|
``<A> is [the] <connective> <B>`` 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:
|
|
# "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 {"no_relational_template", "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"
|
|
|
|
|
|
@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.
|
|
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 + DECLARED one-hop relational rules only; no fabrication
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
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_now_soundly_determines(vocab_persona) -> None:
|
|
# sibling_of IS symmetric (pack-declared ``graph.edge.symmetric``): the converse now
|
|
# SOUNDLY determines via the one-hop symmetric rule (mastery-v2 Step 3) — a DECLARED
|
|
# sound rule, not a fake. The stored direction still determines directly. (Was
|
|
# test_symmetric_converse_is_not_faked, which pinned the pre-rule direct-only floor.)
|
|
ctx = _ctx(vocab_persona)
|
|
_tell("Alice is the sibling of Bob.", ctx)
|
|
direct = _ask("Is Alice the sibling of Bob?", ctx)
|
|
assert isinstance(direct, Determined) and direct.rule == "direct" # stored dir
|
|
converse = _ask("Is Bob the sibling of Alice?", ctx)
|
|
assert isinstance(converse, Determined) and converse.answer is True
|
|
assert converse.rule == "symmetric"
|
|
|
|
|
|
def test_asymmetric_converse_is_not_faked(vocab_persona) -> None:
|
|
# The wrong=0 bite, preserved: an ASYMMETRIC predicate's same-predicate converse is
|
|
# NEVER faked. parent_of is not symmetric — parent_of(alice, bob) does NOT entail
|
|
# parent_of(bob, alice). (Its sound converse is the DIFFERENT predicate child_of.)
|
|
ctx = _ctx(vocab_persona)
|
|
_tell("Alice is the parent of Bob.", ctx)
|
|
converse = _ask("Is Bob the parent of Alice?", ctx)
|
|
assert isinstance(converse, Undetermined)
|
|
|
|
|
|
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_subset_supported_other_categoricals_excluded(vocab_persona) -> None:
|
|
# C admits `subset` (direct + transitive subsumption is sound); the OTHER
|
|
# categoricals (disjoint/intersects/some_not) stay EXCLUDED — their truth is not a
|
|
# stored-pair lookup and there is no sound is-a chain for them.
|
|
from generate.meaning_graph.model import Entity, MeaningGraph, MeaningSpan
|
|
from generate.meaning_graph.reader import Query
|
|
|
|
ctx = _ctx(vocab_persona)
|
|
subset_q = comprehend("Are all men mortals?")
|
|
assert isinstance(subset_q, Comprehension) and subset_q.queries[0].predicate == "subset"
|
|
# subset is supported now: nothing realized → ungrounded (NOT unsupported_query)
|
|
assert determine(subset_q, ctx).reason == "ungrounded"
|
|
|
|
# a disjoint query is still refused as out-of-supported-set
|
|
span = MeaningSpan("input", 0, 3, "x y")
|
|
graph = MeaningGraph(
|
|
entities=(Entity("a", "a", span, "class"), Entity("b", "b", span, "class")),
|
|
relations=(),
|
|
)
|
|
disjoint_q = Comprehension(meaning_graph=graph, queries=(Query("disjoint", ("a", "b"), span),))
|
|
assert determine(disjoint_q, ctx).reason == "unsupported_query"
|