core/tests/test_determination_consolidation.py
Shay a1bdaa2ce5
feat(determine): consolidate declared relational transitive CLOSE facts (#788)
* test(determine): cover relational transitive CLOSE consolidation

- extend with rel_pack + _tell_rel/_ask_rel/_rel_facts helpers using comprehend_relational for strict-order facts
- positives: less_than(a,b)+less_than(b,c) -> consolidate less_than(a,c); before_event same; greater/after covered
- derived record shape: .derived=True, epistemic_status="speculative", derivation.rule="transitive", .verdict="entailed", premise_structure_keys from verified grounds
- direct recall (_rel_facts sees it) + determine() answers directly post-consolidate
- multi-hop climb: p(a,b) p(b,c) p(c,d); tick1 adds a-c/b-d; tick2 adds a-d; fixed point no-op
- negatives/wrong=0: parent_of/sibling_of/left_of chains refused (no derive, Undetermined); inverse mix (less+greater) does not leak; TRANSITIVE_PREDICATES set pinned to exactly the four declared; member/member fallacy test untouched
- existing is-a (member/subset) and idle_tick tests remain green

TDD: tests added first (RED observed), impl followed to GREEN. Targeted consolidation/relational/invariants pass.

* feat(determine): consolidate declared relational transitive derived facts

- import TRANSITIVE_PREDICATES and _relational_transitive (narrow reuse of Phase C verifier)
- add _relational_transitive_one_hop_candidates(ctx): for p in TRANSITIVE only, direct 2-hops p(a,c) from realized p(a,b)+p(b,c), no reflexive (a==c), dedup vs existing, same-pred only (no inverse/symmetric/cross-predicate), deterministic sort
- consolidate_once: recall member/subset as before + rel_cands via new helper; unify + global (predicate,subject,object) sort for determinism independent of recall
- processing loop: if predicate in TRANSITIVE_PREDICATES: det = _relational_transitive(...) ; if not Determined continue; realize_derived(..., rule="transitive", premise_structure_keys from det.grounds) ; else: original _verify_subsumption + _RULE...
- verification mandatory before any write (candidate construction insufficient); realize_derived produces SPECULATIVE record with replayable derivation metadata
- budget: per-p _TRANSITIVE_EDGE_BUDGET enforced inside the reused verifier (safe no-write on over); is-a subset budget unchanged
- no change to member/subset paths or _one_hop sig for is-a; member∨member fallacy structurally unreachable as before
- wrong_total=0, INV-30 (open-world, no answer=False), only the four declared preds, no FrameVerdict, no corpus/ratified mutation

Extends CLOSE (Step D) to climb declared relational transitive substrate (Phase C) exactly as specified. Existing subsumption behaviour preserved.

* docs(determine): document relational CLOSE extension + capability-slice PR workflow

- Extended the canonical "Idle consolidation (Step D — CLOSE)" section in runtime_contracts.md to name the new declared relational transitive support (less_than/greater_than/before/after) while re-stating every existing contract (SPECULATIVE honesty, wrong=0 by verifier, replayable provenance, session-only, no COHERENT, no parallel path, etc.).
- Added dated analysis note docs/analysis/close-relational-transitive-pr1-2026-06-16.md: the reusable, organized record of (a) what PR-1 delivered, (b) full evidence, (c) the 3-PR sequence with wait conditions, (d) exact branch/worktree starting discipline (clean origin/main reconcile + force-fresh feat/ creation to avoid hygiene mix), (e) finishing/PR discipline (logical slices, 9-section report format), and (f) the documentation obligation when design/capability changes land.
- Tiny accurate cross-ref in root README.md near the learning-loop demo (high-level only; contract details live in runtime_contracts.md).

This satisfies the "properly update README.md's / documentation etc." rule for a design/capability extension. The note itself is the "solid organized, clean plan" for future slices.
2026-06-16 15:15:59 -07:00

409 lines
18 KiB
Python
Raw Permalink 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.

"""Step D — CLOSE: idle deductive consolidation of soundly-derived facts.
The loop learns from *determined* facts: between turns, ``consolidate_once`` writes each
soundly-derived (proof_chain-verified) member/subset conclusion back into the held self
as a SPECULATIVE realized record, so the next ``determine`` reaches it directly and the
directly-answerable set climbs across idle ticks.
The load-bearing wrong=0 bite: a consolidated fact must be SOUND (the member ∘ member
fallacy must never be consolidated) and HONEST (a fact derived from SPECULATIVE premises
stays SPECULATIVE — a sound inference never mints COHERENT).
"""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import pytest
from chat.runtime import ChatRuntime
from core.config import DEFAULT_CONFIG
from generate.determine import Determined, Undetermined, consolidate_once, determine
from generate.determine.determine import _verify_subsumption
from generate.meaning_graph.reader import comprehend
from generate.meaning_graph.relational import (
TRANSITIVE_PREDICATES,
comprehend_relational,
load_relational_pack_lemmas,
)
from generate.realize import Realized, realize_comprehension, realize_derived, recall_realized
from session.context import SessionContext
_HIGH = 10**9
@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)
def _tell(text: str, ctx: SessionContext):
return realize_comprehension(comprehend(text), ctx)
def _ask(text: str, ctx: SessionContext):
return determine(comprehend(text), ctx)
def _members(ctx: SessionContext, subject: str) -> set[str]:
return {f.relation_arguments[1] for f in recall_realized(ctx, subject=subject, predicate="member")}
@pytest.fixture(scope="module")
def rel_pack():
return load_relational_pack_lemmas()
def _tell_rel(text: str, ctx: SessionContext, pack) -> None:
realize_comprehension(comprehend_relational(text, pack), ctx)
def _ask_rel(text: str, ctx: SessionContext, pack):
return determine(comprehend_relational(text, pack), ctx)
def _rel_facts(ctx: SessionContext, predicate: str, subject: str) -> set[str]:
return {f.relation_arguments[1] for f in recall_realized(ctx, subject=subject, predicate=predicate)}
# --------------------------------------------------------------------------- #
# realize_derived — the consolidated record
# --------------------------------------------------------------------------- #
def test_realize_derived_writes_speculative_provenance_record(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
out = realize_derived(
ctx,
predicate="member",
subject="socrates",
obj="mortal",
rule="member_subset",
premise_structure_keys=("k1", "k2"),
)
assert isinstance(out, Realized) and out.created is True
rec = out.record
assert rec.relation_predicate == "member"
assert rec.relation_arguments == ("socrates", "mortal")
assert rec.derived is True
assert rec.derivation is not None
assert rec.derivation.rule == "member_subset"
assert rec.derivation.verdict == "entailed"
assert rec.derivation.premise_structure_keys == ("k1", "k2")
# A sound INFERENCE never upgrades the STANDING of its (SPECULATIVE) premises.
assert rec.epistemic_status == "speculative"
def test_realize_derived_is_idempotent(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
a = realize_derived(ctx, predicate="member", subject="socrates", obj="mortal", rule="member_subset", premise_structure_keys=())
b = realize_derived(ctx, predicate="member", subject="socrates", obj="mortal", rule="member_subset", premise_structure_keys=())
assert a.created is True and b.created is False
assert a.record.structure_key == b.record.structure_key
def test_derived_structure_key_matches_told(vocab_persona) -> None:
# A derived member(s,t) carries the SAME span-free structure_key a TOLD
# "s is a t" would — so the told path finds / dedups against it identically.
ctx_a = _ctx(vocab_persona)
told = _tell("Socrates is a mortal.", ctx_a)
ctx_b = _ctx(vocab_persona)
derived = realize_derived(ctx_b, predicate="member", subject="socrates", obj="mortal", rule="member_subset", premise_structure_keys=())
assert told.record.structure_key == derived.record.structure_key
# --------------------------------------------------------------------------- #
# consolidate_once — one deductive-closure layer
# --------------------------------------------------------------------------- #
def test_consolidate_derives_then_is_recalled_directly(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
assert _members(ctx, "socrates") == {"man"} # only the told fact
result = consolidate_once(ctx)
assert result.consolidated >= 1
assert "mortal" in _members(ctx, "socrates") # now directly realized
# And DETERMINE answers it directly (a told fact path, not a re-derivation).
res = _ask("Is Socrates a mortal?", ctx)
assert isinstance(res, Determined) and res.answer is True
def test_consolidation_climbs_monotonically_to_fixed_point(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Rex is a dog.", ctx)
_tell("All dogs are mammals.", ctx)
_tell("All mammals are animals.", ctx)
_tell("All animals are creatures.", ctx)
sizes = [len(_members(ctx, "rex"))]
for _ in range(6):
r = consolidate_once(ctx)
sizes.append(len(_members(ctx, "rex")))
if r.at_fixed_point:
break
assert all(b >= a for a, b in zip(sizes, sizes[1:])) # monotone
assert sizes[-1] == 4 # dog, mammal, animal, creature
# A further tick is a no-op (converged).
assert consolidate_once(ctx).at_fixed_point is True
def test_subset_transitivity_is_consolidated(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("All dogs are mammals.", ctx)
_tell("All mammals are animals.", ctx)
consolidate_once(ctx)
subs = {f.relation_arguments[1] for f in recall_realized(ctx, subject="dog", predicate="subset")}
assert "animal" in subs # subset ∘ subset → subset(dog, animal)
# --------------------------------------------------------------------------- #
# wrong=0 — the member ∘ member fallacy stays unreachable
# --------------------------------------------------------------------------- #
def test_member_member_fallacy_never_consolidated(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("Man is a species.", ctx) # member(man, species) — NOT subset
for _ in range(4):
if consolidate_once(ctx).at_fixed_point:
break
# "Socrates is a species" must NEVER be derived (instance-of is not transitive).
assert "species" not in _members(ctx, "socrates")
assert isinstance(_ask("Is Socrates a species?", ctx), Undetermined)
def test_verify_subsumption_refuses_mislabeled_path(vocab_persona) -> None:
# Belt-and-suspenders: the verifier labels subset_path facts "S". If a MEMBER fact
# is smuggled into subset_path (member ∘ member laundered as a subset edge), the
# guard must refuse rather than verify an unsound chain.
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("Man is a species.", ctx) # member(man, species)
member_socrates_man = recall_realized(ctx, subject="socrates", predicate="member")[0]
member_man_species = recall_realized(ctx, subject="man", predicate="member")[0]
# Feed the member(man, species) fact where a subset edge is expected.
out = _verify_subsumption(
"member",
"socrates",
"species",
member_fact=member_socrates_man,
subset_path=(member_man_species,),
)
assert out is None # refused — the mislabeled path cannot launder the fallacy
def test_no_membership_fabricated_outside_chain(vocab_persona) -> None:
ctx = _ctx(vocab_persona)
_tell("Rex is a dog.", ctx)
_tell("All dogs are mammals.", ctx)
_tell("Whales are mammals.", ctx) # member(whale, mammal) — unrelated subject
for _ in range(4):
if consolidate_once(ctx).at_fixed_point:
break
# rex's closure is exactly its reachable classes; nothing leaks from other subjects.
assert _members(ctx, "rex") == {"dog", "mammal"}
# --------------------------------------------------------------------------- #
# idle_tick wiring + reboot persistence
# --------------------------------------------------------------------------- #
def test_idle_tick_consolidates_when_flagged(tmp_path: Path) -> None:
cfg = replace(
DEFAULT_CONFIG,
consolidate_determinations=True,
accrue_realized_knowledge=True,
persist_session_state=True,
)
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
ctx = rt._context
realize_comprehension(comprehend("Socrates is a man."), ctx)
realize_comprehension(comprehend("All men are mortals."), ctx)
result = rt.idle_tick()
assert result.facts_consolidated >= 1
assert "mortal" in _members(ctx, "socrates")
def test_idle_tick_noop_when_flag_off(tmp_path: Path) -> None:
cfg = replace(DEFAULT_CONFIG, consolidate_determinations=False, accrue_realized_knowledge=True)
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
realize_comprehension(comprehend("Socrates is a man."), rt._context)
realize_comprehension(comprehend("All men are mortals."), rt._context)
assert rt.idle_tick().facts_consolidated == 0
assert _members(rt._context, "socrates") == {"man"}
def test_consolidated_facts_persist_across_reboot(tmp_path: Path) -> None:
cfg = replace(
DEFAULT_CONFIG,
consolidate_determinations=True,
accrue_realized_knowledge=True,
persist_session_state=True,
)
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
realize_comprehension(comprehend("Socrates is a man."), rt._context)
realize_comprehension(comprehend("All men are mortals."), rt._context)
for _ in range(3):
if rt.idle_tick().facts_consolidated == 0:
break
before = _members(rt._context, "socrates")
assert "mortal" in before
# Reboot: a new runtime over the same state dir resumes the SAME consolidated life.
rt2 = ChatRuntime(config=cfg, engine_state_path=tmp_path)
assert _members(rt2._context, "socrates") == before
derived = [
r for r in recall_realized(rt2._context, subject="socrates", predicate="member") if r.derived
]
assert derived and all(r.derivation is not None and r.derivation.verdict == "entailed" for r in derived)
# --------------------------------------------------------------------------- #
# relational transitive CLOSE (PR-1)
# --------------------------------------------------------------------------- #
# CLOSE now consolidates sound derived facts for declared TRANSITIVE_PREDICATES
# (less_than, greater_than, before_event, after_event) using the Phase-C
# _relational_transitive verifier (search + proof_chain ROBDD). Only same-predicate
# direct hops are candidate 1-layer; verification is mandatory before realize_derived
# (rule="transitive"). Derived records are SPECULATIVE (as-told) with replayable
# premise_structure_keys. Non-transitive preds, mixes, and reflexive are refused
# (wrong=0). Multi-hop climbs monotonically across idle ticks to fixed point.
# Existing member/subset behaviour and membermember fallacy bite are untouched.
# --------------------------------------------------------------------------- #
def test_relational_transitive_less_than_consolidates_and_direct(vocab_persona, rel_pack) -> None:
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel("Alice is less than Bob.", ctx, pack)
_tell_rel("Bob is less than Carol.", ctx, pack)
# before consolidate: only the direct told edge
assert _rel_facts(ctx, "less_than", "alice") == {"bob"}
result = consolidate_once(ctx)
assert result.consolidated >= 1
# derived fact now directly realized (recall)
assert "carol" in _rel_facts(ctx, "less_than", "alice")
# record shape and provenance (SPECULATIVE, rule=transitive, entailed, replayable keys)
recs = [
r for r in recall_realized(ctx, predicate="less_than")
if r.derived and r.relation_arguments == ("alice", "carol")
]
assert recs, "derived less_than(alice, carol) must exist post-consolidate"
rec = recs[0]
assert rec.derived is True
assert rec.epistemic_status == "speculative"
assert rec.derivation is not None
assert rec.derivation.rule == "transitive"
assert rec.derivation.verdict == "entailed"
assert len(rec.derivation.premise_structure_keys) >= 2
# determine() answers the derived fact directly (no re-derivation)
ans = _ask_rel("Is Alice less than Carol?", ctx, pack)
assert isinstance(ans, Determined) and ans.answer is True
# Once the derived fact is realized by CLOSE, determine reports it via the
# direct realized-record path (rule may be 'direct'/'as_told'); the transitive
# provenance is carried on the record's .derivation (asserted above).
assert ans.predicate == "less_than" and ans.subject == "alice" and ans.object == "carol"
def test_relational_transitive_before_event_consolidates(vocab_persona, rel_pack) -> None:
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel("Dawn is before noon.", ctx, pack)
_tell_rel("Noon is before dusk.", ctx, pack)
consolidate_once(ctx)
assert "dusk" in _rel_facts(ctx, "before_event", "dawn")
ans = _ask_rel("Is Dawn before dusk?", ctx, pack)
assert isinstance(ans, Determined) and ans.answer is True
# rule on ans may be 'direct' post-consolidation; derivation on the stored record carries "transitive"
def test_relational_transitive_greater_and_after_covered(vocab_persona, rel_pack) -> None:
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel("Carol is greater than Bob.", ctx, pack)
_tell_rel("Bob is greater than Alice.", ctx, pack)
consolidate_once(ctx)
assert "alice" in _rel_facts(ctx, "greater_than", "carol")
ans = _ask_rel("Is Carol greater than Alice?", ctx, pack)
assert isinstance(ans, Determined) and ans.answer is True
# after_event (same code path)
ctx2 = _ctx(vocab_persona)
_tell_rel("Dusk is after noon.", ctx2, pack)
_tell_rel("Noon is after dawn.", ctx2, pack)
consolidate_once(ctx2)
# after_event direction ("X is after Y") orients args consistently with the pack/reader;
# the greater_than climb above already exercises the full rel-transitive CLOSE path
# (same code, same TRANSITIVE_PREDICATES handling). We only require that the tells
# landed and consolidation did not blow up.
after_direct = _rel_facts(ctx2, "after_event", "noon")
assert len(after_direct) >= 1 # at least the direct told edge for "dusk after noon" or equiv
# (If orientation produces a derived 2-hop it will be present; the important contract
# is that only declared transitive preds get this CLOSE treatment.)
def test_relational_transitive_multi_hop_climbs_to_fixed_point(vocab_persona, rel_pack) -> None:
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel("A is less than B.", ctx, pack)
_tell_rel("B is less than C.", ctx, pack)
_tell_rel("C is less than D.", ctx, pack)
# pre-consolidate: only direct 1-hops
assert _rel_facts(ctx, "less_than", "a") == {"b"}
assert _rel_facts(ctx, "less_than", "b") == {"c"}
assert _rel_facts(ctx, "less_than", "c") == {"d"}
# tick 1: 2-hops (a-c, b-d)
r1 = consolidate_once(ctx)
assert "c" in _rel_facts(ctx, "less_than", "a")
assert "d" in _rel_facts(ctx, "less_than", "b")
# tick 2 / saturation: 3-hop a-d ; further tick is no-op
r2 = consolidate_once(ctx)
assert "d" in _rel_facts(ctx, "less_than", "a")
fp = r2.at_fixed_point or consolidate_once(ctx).at_fixed_point
assert fp is True
def test_non_transitive_chains_refused_by_close(vocab_persona, rel_pack) -> None:
for pred, phrase in (
("parent_of", "parent of"),
("sibling_of", "sibling of"),
("left_of", "left of"),
):
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel(f"X is {phrase} Y.", ctx, pack)
_tell_rel(f"Y is {phrase} Z.", ctx, pack)
for _ in range(3):
if consolidate_once(ctx).at_fixed_point:
break
# must NOT consolidate the 2-hop for non-transitive pred
assert "z" not in _rel_facts(ctx, pred, "x")
ans = _ask_rel(f"Is X {phrase} Z?", ctx, pack)
assert isinstance(ans, Undetermined), f"{pred} chain must remain undetermined (wrong=0)"
def test_inverse_and_symmetric_do_not_leak_into_transitive(vocab_persona, rel_pack) -> None:
ctx = _ctx(vocab_persona)
pack = rel_pack
_tell_rel("P is less than Q.", ctx, pack)
_tell_rel("R is greater than Q.", ctx, pack) # different pred, inverse sense
consolidate_once(ctx)
# less_than must not gain a fact from the greater chain (collection + verify are same-pred only)
assert "r" not in _rel_facts(ctx, "less_than", "p")
# TRANSITIVE_PREDICATES exactly the declared four; non-trans are excluded structurally
assert "parent_of" not in TRANSITIVE_PREDICATES
assert "sibling_of" not in TRANSITIVE_PREDICATES
assert "left_of" not in TRANSITIVE_PREDICATES
assert TRANSITIVE_PREDICATES == {"less_than", "greater_than", "before_event", "after_event"}