core/tests/test_derived_close_proposals.py
Claude b318501c55
fix(tests): stop a test writing proposals into the live in-repo sink
Found by running the full tree: every full-suite run left an untracked
speculative proposal artifact in teaching/proposals/derived_close_facts/.

test_runtime_flag_on_emits_after_consolidation passes engine_state_path=tmp_path,
but that does not redirect the proposal sink — the runtime calls
emit_derived_close_proposals(ctx) with no sink argument, so it resolves
DEFAULT_SINK: the real, repo-relative directory. Fixed by monkeypatching the
module-level default the runtime resolves through.

Its neighbour was defending against exactly this with:

    assert not any(sink.glob("*.json")) or True   # may have pre-existing from other tests

which is vacuously true, and whose comment was describing the leak it was
papering over rather than the contract it claimed to check. With the flag-ON
test no longer writing there, an honest assertion is possible: snapshot the
live sink BEFORE the tick and require it unchanged after. (First attempt at
this took the snapshot after idle_tick(), which compares the sink to itself —
corrected here.)

Only visible now that the parents[3] DEFAULT_SINK bug is fixed and the sink
resolves inside the repo; before 2026-07-24 these artifacts were landing
outside the tree entirely, which is why nobody saw them.

[Verification]: 28 passed across test_derived_close_proposals,
test_proposal_queue, test_idle_proposal_review; working tree clean afterward,
no residue.
NON-CANONICAL: Python 3.12.11, not the pinned 3.12.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FduW6Krm3PPQv3P5iwBYtx
2026-07-25 13:36:22 +00:00

288 lines
No EOL
10 KiB
Python

"""PR-2 tests for the derived CLOSE proposal bridge.
Covers:
- Direct emission of reviewable artifacts for member/subset and relational-transitive derived facts.
- Stable dedupe across ticks/runs.
- Safe skipping of non-derived, malformed, and unsupported predicates.
- No status upgrade (remain SPECULATIVE / proposal_only).
- Determinism and no corpus/pack side-effects.
- Runtime flag wiring (off by default; on emits after consolidation).
"""
from __future__ import annotations
import json
from pathlib import Path
from dataclasses import replace
import pytest
from chat.runtime import ChatRuntime
from core.config import DEFAULT_CONFIG, RuntimeConfig
from generate.determine import consolidate_once
from generate.determine.derived_close_proposals import (
emit_derived_close_proposals,
DEFAULT_SINK,
)
from generate.meaning_graph.reader import comprehend
from generate.meaning_graph.relational import (
comprehend_relational,
load_relational_pack_lemmas,
)
from generate.realize import realize_comprehension, 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
@pytest.fixture(scope="module")
def rel_pack():
return load_relational_pack_lemmas()
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 _tell_rel(text: str, ctx: SessionContext, pack) -> None:
realize_comprehension(comprehend_relational(text, pack), ctx)
def _members(ctx: SessionContext, subject: str) -> set[str]:
return {
f.relation_arguments[1]
for f in recall_realized(ctx, subject=subject, predicate="member")
}
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)
}
# --------------------------------------------------------------------------- #
# Core emitter tests (direct, with temp sink)
# --------------------------------------------------------------------------- #
def test_emit_flag_off_null_effect(vocab_persona, tmp_path: Path):
"""When the runtime flag is off, the emitter is not called from idle_tick
and consolidation behavior is unchanged."""
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
before = len(_members(ctx, "socrates"))
res = consolidate_once(ctx)
assert res.consolidated >= 1
assert "mortal" in _members(ctx, "socrates")
# Direct call with custom sink should still work, but the point is the
# runtime path (tested below) is gated.
sink = tmp_path / "derived_close"
counts = emit_derived_close_proposals(ctx, sink=sink)
assert counts["emitted"] >= 1 # the bridge itself emits when called
def test_emits_for_derived_member_subset(vocab_persona, tmp_path: Path):
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
consolidate_once(ctx)
assert "mortal" in _members(ctx, "socrates")
sink = tmp_path / "derived_close"
counts = emit_derived_close_proposals(ctx, sink=sink)
assert counts["emitted"] >= 1
# Find the artifact
arts = list(sink.glob("*.json"))
assert arts
art = json.loads(arts[0].read_text())
assert art["source"] == "derived_close_fact"
assert art["predicate"] == "member"
assert art["subject"] == "socrates"
assert art["object"] == "mortal"
assert art["derivation"]["rule"] == "member_subset"
assert art["derivation"]["verdict"] == "entailed"
assert "structure_key" in art
assert art["status"] == "proposal_only"
assert art["requires_review"] is True
assert art["mounted"] is False
def test_emits_for_derived_relational_transitive(vocab_persona, rel_pack, tmp_path: Path):
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)
consolidate_once(ctx)
assert "c" in _rel_facts(ctx, "less_than", "a")
sink = tmp_path / "derived_close"
counts = emit_derived_close_proposals(ctx, sink=sink)
assert counts["emitted"] >= 1
arts = list(sink.glob("*.json"))
assert any(
json.loads(p.read_text())["predicate"] == "less_than"
and json.loads(p.read_text())["object"] == "c"
for p in arts
)
def test_dedupe_stable_across_runs(vocab_persona, tmp_path: Path):
ctx = _ctx(vocab_persona)
_tell("Rex is a dog.", ctx)
_tell("All dogs are mammals.", ctx)
consolidate_once(ctx)
sink = tmp_path / "derived_close"
r1 = emit_derived_close_proposals(ctx, sink=sink)
assert r1["emitted"] >= 1
r2 = emit_derived_close_proposals(ctx, sink=sink)
assert r2["emitted"] == 0
assert r2["duplicate"] >= 1
def test_skips_non_derived_direct_facts(vocab_persona, tmp_path: Path):
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx) # direct, not derived
sink = tmp_path / "derived_close"
counts = emit_derived_close_proposals(ctx, sink=sink)
# No derived member for socrates->man from this tell alone
assert counts["emitted"] == 0
def test_skips_malformed_and_unsupported(vocab_persona, tmp_path: Path, monkeypatch):
# We can't easily inject a bad record, so we test the predicate filter
# and the fact that only eligible predicates are considered.
ctx = _ctx(vocab_persona)
# Tell something that produces a derived for a supported pred, then
# manually verify unsupported are not emitted by the filter.
_tell("X is parent of Y.", ctx)
_tell("Y is parent of Z.", ctx)
# parent_of is unsupported for CLOSE derivation; consolidate will not derive it
# (the test here just confirms the emitter would skip even if a record existed).
sink = tmp_path / "derived_close"
counts = emit_derived_close_proposals(ctx, sink=sink)
# Nothing eligible for parent_of
assert "parent_of" not in {p.stem for p in sink.glob("*.json")} or True # defensive
def test_no_status_upgrade_or_corpus_mutation(vocab_persona, tmp_path: Path):
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
consolidate_once(ctx)
sink = tmp_path / "derived_close"
emit_derived_close_proposals(ctx, sink=sink)
# Derived record in vault is still speculative
recs = [
r
for r in recall_realized(ctx, subject="socrates", predicate="member")
if r.derived
]
assert recs
assert recs[0].epistemic_status == "speculative"
# No pack files touched (proposals live in teaching/proposals, not packs/)
# (simple existence check that we didn't accidentally write elsewhere)
assert not any(
"derived_close" in str(p) for p in Path("packs").rglob("*") if p.is_file()
)
# --------------------------------------------------------------------------- #
# Runtime integration (flag wiring)
# --------------------------------------------------------------------------- #
def test_runtime_flag_off_does_not_emit(vocab_persona, tmp_path: Path):
cfg = replace(DEFAULT_CONFIG, review_derived_close_proposals=False)
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
ctx = rt._context
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
# The proposal sink is repo-relative even when engine_state is a tmp_path,
# so the flag-off contract is checkable directly: snapshot the live sink
# BEFORE the tick, and require it unchanged after.
#
# This assertion used to read:
# assert not any(sink.glob("*.json")) or True
# which is vacuously true, and whose comment ("may have pre-existing from
# other tests") was describing the very leak it papered over — the flag-ON
# test below wrote into the real in-repo sink on every full-suite run.
# That test now redirects its sink, so an honest check is possible here.
sink = Path("teaching") / "proposals" / "derived_close_facts"
before = set(sink.glob("*.json")) if sink.exists() else set()
res = rt.idle_tick() # consolidation happens (if flag on), proposal bridge must not
assert res.derived_close_proposals_emitted == 0
after = set(sink.glob("*.json")) if sink.exists() else set()
assert after == before, f"flag-off path wrote to the live sink: {after - before}"
def test_runtime_flag_on_emits_after_consolidation(
vocab_persona, tmp_path: Path, monkeypatch
):
# ``engine_state_path=tmp_path`` does NOT redirect the proposal sink: the
# runtime calls ``emit_derived_close_proposals(ctx)`` with no ``sink``, so
# it lands on ``DEFAULT_SINK`` — the real, in-repo
# ``teaching/proposals/derived_close_facts/``. Before this monkeypatch,
# every full-suite run left a committed-looking speculative artifact in the
# working tree, which is how it was found (2026-07-25). Redirect the
# module-level default the runtime resolves through.
import generate.determine.derived_close_proposals as dcp
monkeypatch.setattr(dcp, "DEFAULT_SINK", tmp_path / "derived_close_facts")
cfg = replace(
DEFAULT_CONFIG,
consolidate_determinations=True,
review_derived_close_proposals=True,
)
rt = ChatRuntime(config=cfg, engine_state_path=tmp_path)
ctx = rt._context
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
res = rt.idle_tick()
assert res.facts_consolidated >= 1
# The emitter was invoked (after consolidation); result accepted the new field.
assert hasattr(res, "derived_close_proposals_emitted")
def test_write_failure_increments_skipped(vocab_persona, tmp_path: Path, monkeypatch):
"""Verify that I/O failure on write_text is caught locally, increments skipped,
does not emit, and does not abort the emitter (best-effort per contract)."""
ctx = _ctx(vocab_persona)
_tell("Socrates is a man.", ctx)
_tell("All men are mortals.", ctx)
consolidate_once(ctx)
sink = tmp_path / "derived_close"
def always_fail_write(self, *a, **k):
raise OSError("simulated I/O failure for test")
monkeypatch.setattr(Path, "write_text", always_fail_write)
counts = emit_derived_close_proposals(ctx, sink=sink)
assert counts["emitted"] == 0
assert counts["skipped"] >= 1 # write failure counted here (plus any ineligible)
# sink should have no files created
assert len(list(sink.glob("*.json"))) == 0