Connects ADR-0080's read-only contemplation loop to the existing teaching-pipeline plumbing without forcing a type collapse. The SPECULATIVE-only invariant from #55 is preserved verbatim; what changes is *where the findings flow*. What was wrong with the prior shape ----------------------------------- PR #55 shipped a parallel core/contemplation/ package whose findings were written as one JSON blob per CLI invocation, with no consumer. The SPECULATIVE-only invariant protected a write path that didn't exist. My closed PR #56 (second miner) would have entrenched the duplication. What this PR changes -------------------- 1. Schema (core/contemplation/schema.py) - Adds a BOUNDARY note documenting why EvidencePointer (teaching) and ContemplationEvidenceRef (core) intentionally stay separate: EvidencePointer.source is constrained to {corpus, pack, vault_coherent} — pointers into reviewed in-process memory the runtime trusts. ContemplationEvidenceRef points to external report files that have NOT been reviewed. Converging them would either widen the runtime-grounding enum (losing the "reviewed memory only" guarantee) or force benchmark reports to masquerade as vault_coherent. Both are worse than keeping them separate. - Adds format_contemplation_finding_jsonl(finding) — the canonical JSONL formatter mirroring teaching.discovery.format_candidate_jsonl. 2. Runner (core/contemplation/runner.py) - Both runners gain an optional sink: DiscoveryCandidateSink | None parameter. When supplied, each finding is emitted as one canonical JSONL line via the SHARED protocol — same protocol that backs DiscoveryBufferSink and DiscoveryMonthlyFileSink. - Sink path is additive: the ContemplationRun blob is byte-identical whether or not a sink is supplied (pinned by test). - No sink supplied → existing in-memory behavior preserved exactly. 3. CLI (core/contemplation/__main__.py) - Adds --lane {frontier_compare, contradiction_detection} flag. Default unchanged. - Adds --sink-root <path> flag. When set, instantiates a DiscoveryMonthlyFileSink and findings land at <root>/<YYYY>/<YYYY-MM>.jsonl — the SAME layout discovery candidates use, so operators can grep one stream. 4. Miner (core/contemplation/miners/contradiction_detection.py) - Restored from closed PR #56 under the unified pipeline. - Failure-mode split preserved (missed_contradiction / false_contradiction_flag) with asymmetric repair actions. What this PR does NOT do ------------------------ - Does NOT unify ContemplationFinding with DiscoveryCandidate. DiscoveryCandidate.trigger is Literal[would_have_grounded, successful_comparison, hedge_acknowledged, oov_resolved_via_decomp] — all turn-loop flavored. None describe "I parsed a benchmark report." Forcing a 5th trigger that no turn-loop extractor produces would pollute the turn-loop type for the schema's sake. - Does NOT extend teaching/gaps.py. Gap aggregates DiscoveryCandidate cells by (subject, intent) — domain nouns. ContemplationFinding subjects are namespaced ("contradiction_detection/CON-PUB-002"). Different operator views. A sibling aggregator can come later when an operator actually asks for it. Why this is the right unification point --------------------------------------- The honest convergence is at the *sink* (so all SPECULATIVE evidence lives in one rooted append-only stream), not the *aggregator* (which appropriately produces typed views per evidence family). The boundary doctrine from #55 is preserved; it now connects to existing plumbing instead of writing JSON to disk with no consumer. Tests (tests/test_contemplation_pipeline_convergence.py, 10 cases) ------------------------------------------------------------------ - DiscoveryBufferSink satisfies DiscoveryCandidateSink (shared protocol) - frontier runner emits findings to shared sink - contradiction runner emits findings to shared sink - sink is optional — no-op when absent - emission is canonical JSONL (sorted keys, no newline, deterministic) - DiscoveryMonthlyFileSink persists findings at <root>/<YYYY>/<YYYY-MM>.jsonl - sink emission does not alter the ContemplationRun blob (additive) - contradiction miner predicate split + repair-action asymmetry - config_hash differs between lanes (replay can distinguish) - BOUNDARY doc is present in schema.py (regression guard) - ContemplationEvidenceRef field invariants - format_contemplation_finding_jsonl is deterministic + canonical All 18 tests pass (5 original ADR-0080 + 13 new convergence). Live evidence ------------- $ uv run python -m core.contemplation \ evals/contradiction_detection/results/v1_public_*.json \ --lane contradiction_detection \ --sink-root /tmp/sink_demo /tmp/sink_demo/2026/2026-05.jsonl ← same layout as discovery candidates predicate=missed_contradiction subject=contradiction_detection/CON-PUB-002 predicate=missed_contradiction subject=contradiction_detection/CON-PUB-004 predicate=false_contradiction_flag subject=contradiction_detection/CON-PUB-005 predicate=false_contradiction_flag subject=contradiction_detection/CON-PUB-006
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Tests for the contemplation→teaching pipeline convergence refactor.
|
|
|
|
Pins three properties:
|
|
|
|
1. ContemplationFinding emission flows through the SHARED sink
|
|
protocol from teaching/discovery_sink.py — not a parallel
|
|
bespoke sink.
|
|
2. The contradiction_detection miner survives and works under the
|
|
unified sink pattern (preserves the work from the closed #56
|
|
while routing it through the corrected boundary).
|
|
3. The boundary doc in core/contemplation/schema.py is intact —
|
|
EvidencePointer (teaching) and ContemplationEvidenceRef (core)
|
|
stay separate by design, not by oversight.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from core.contemplation.miners.contradiction_detection import (
|
|
mine_contradiction_detection_report,
|
|
)
|
|
from core.contemplation.runner import (
|
|
contemplate_contradiction_reports,
|
|
contemplate_frontier_reports,
|
|
)
|
|
from core.contemplation.schema import (
|
|
ContemplationEvidenceRef,
|
|
ContemplationFinding,
|
|
FindingKind,
|
|
format_contemplation_finding_jsonl,
|
|
)
|
|
from core.contemplation.snapshot import ContemplationSubstrate
|
|
from teaching.discovery_sink import (
|
|
DiscoveryBufferSink,
|
|
DiscoveryCandidateSink,
|
|
DiscoveryMonthlyFileSink,
|
|
)
|
|
from teaching.epistemic import EpistemicStatus
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures: real-shape sample reports for both lanes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _frontier_report(path: Path) -> None:
|
|
payload = {
|
|
"benchmark_family": "frontier_compare_wave1",
|
|
"suites": [
|
|
{
|
|
"suite": "truth_lock",
|
|
"cases": [
|
|
{
|
|
"suite": "truth_lock",
|
|
"case_id": "known_truth",
|
|
"prompt": "What is truth?",
|
|
"passed": True,
|
|
"failures": [],
|
|
},
|
|
{
|
|
"suite": "truth_lock",
|
|
"case_id": "unknown_relation",
|
|
"prompt": "Why does xylomorphic matter?",
|
|
"passed": False,
|
|
"failures": ["unexpected_grounding_source:vault"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
|
|
|
|
def _contradiction_report(path: Path) -> None:
|
|
payload = {
|
|
"lane": "contradiction_detection",
|
|
"split": "public",
|
|
"version": "v1",
|
|
"cases": [
|
|
{
|
|
"id": "CON-PUB-001",
|
|
"kind": "paired_contradiction",
|
|
"passed": True,
|
|
"flagged": True,
|
|
"contested": False,
|
|
"versor_delta": 3.28677e-07,
|
|
"versor_spike": True,
|
|
},
|
|
{
|
|
"id": "CON-PUB-002",
|
|
"kind": "paired_contradiction",
|
|
"passed": False,
|
|
"flagged": False,
|
|
"contested": False,
|
|
"versor_delta": 0.0,
|
|
"versor_spike": False,
|
|
},
|
|
{
|
|
"id": "CON-PUB-005",
|
|
"kind": "paired_consistent",
|
|
"passed": False,
|
|
"flagged": True,
|
|
"contested": False,
|
|
"versor_delta": 3.24412e-07,
|
|
"versor_spike": True,
|
|
},
|
|
],
|
|
}
|
|
path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Shared sink protocol — the actual integration claim
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sink_is_typed_as_discovery_candidate_sink() -> None:
|
|
"""DiscoveryBufferSink must satisfy DiscoveryCandidateSink — that is
|
|
the shared protocol both pipelines now feed.
|
|
|
|
A static structural check, but if someone widens the protocol in
|
|
teaching/discovery_sink.py without adapting the contemplation
|
|
emitter, this test surfaces the regression at the type level.
|
|
"""
|
|
sink: DiscoveryCandidateSink = DiscoveryBufferSink()
|
|
assert hasattr(sink, "emit")
|
|
sink.emit("{}")
|
|
assert sink.lines == ["{}"]
|
|
|
|
|
|
def test_frontier_runner_emits_findings_to_shared_sink(tmp_path: Path) -> None:
|
|
report = tmp_path / "frontier.json"
|
|
_frontier_report(report)
|
|
sink = DiscoveryBufferSink()
|
|
|
|
run = contemplate_frontier_reports((report,), sink=sink)
|
|
|
|
# One emission per finding, no extra noise (passing cases don't emit).
|
|
assert len(sink.lines) == 1
|
|
assert len(run.findings) == 1
|
|
# The emitted line round-trips to the same payload as the run blob.
|
|
emitted = json.loads(sink.lines[0])
|
|
assert emitted == run.findings[0].as_dict()
|
|
|
|
|
|
def test_contradiction_runner_emits_findings_to_shared_sink(tmp_path: Path) -> None:
|
|
report = tmp_path / "contradiction.json"
|
|
_contradiction_report(report)
|
|
sink = DiscoveryBufferSink()
|
|
|
|
run = contemplate_contradiction_reports((report,), sink=sink)
|
|
|
|
# Two failed cases (1 missed, 1 false-flag); passing case suppressed.
|
|
assert len(sink.lines) == 2
|
|
assert len(run.findings) == 2
|
|
predicates = {json.loads(line)["predicate"] for line in sink.lines}
|
|
assert predicates == {"missed_contradiction", "false_contradiction_flag"}
|
|
|
|
|
|
def test_sink_is_optional_no_op_when_absent(tmp_path: Path) -> None:
|
|
"""Existing in-memory ContemplationRun behavior is preserved when
|
|
no sink is supplied — guarantees the refactor is non-breaking."""
|
|
report = tmp_path / "contradiction.json"
|
|
_contradiction_report(report)
|
|
|
|
run = contemplate_contradiction_reports((report,)) # no sink kwarg
|
|
|
|
assert len(run.findings) == 2
|
|
assert all(
|
|
f.epistemic_status is EpistemicStatus.SPECULATIVE for f in run.findings
|
|
)
|
|
|
|
|
|
def test_emission_is_jsonl_canonical(tmp_path: Path) -> None:
|
|
"""The JSONL line shape must match the discovery_sink convention:
|
|
one canonical-JSON line per finding, sorted keys, no newline,
|
|
deterministic across runs."""
|
|
report = tmp_path / "contradiction.json"
|
|
_contradiction_report(report)
|
|
sink_a = DiscoveryBufferSink()
|
|
sink_b = DiscoveryBufferSink()
|
|
|
|
contemplate_contradiction_reports((report,), sink=sink_a)
|
|
contemplate_contradiction_reports((report,), sink=sink_b)
|
|
|
|
assert sink_a.lines == sink_b.lines
|
|
for line in sink_a.lines:
|
|
assert "\n" not in line
|
|
# Canonical JSON: re-encoding with same options reproduces the line.
|
|
parsed = json.loads(line)
|
|
roundtrip = json.dumps(
|
|
parsed, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
)
|
|
assert line == roundtrip
|
|
|
|
|
|
def test_monthly_file_sink_persists_contemplation_findings(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""End-to-end: contemplation findings land in the SAME monthly JSONL
|
|
layout discovery candidates use — <root>/<YYYY>/<YYYY-MM>.jsonl."""
|
|
report = tmp_path / "contradiction.json"
|
|
_contradiction_report(report)
|
|
sink_root = tmp_path / "discovery_log"
|
|
|
|
with DiscoveryMonthlyFileSink(sink_root) as sink:
|
|
run = contemplate_contradiction_reports((report,), sink=sink)
|
|
assert len(run.findings) == 2
|
|
|
|
# Find the monthly file the sink created.
|
|
written = sorted(sink_root.rglob("*.jsonl"))
|
|
assert len(written) == 1, f"expected one monthly file, got {written}"
|
|
lines = [
|
|
line
|
|
for line in written[0].read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
assert len(lines) == 2
|
|
# Layout matches discovery candidate convention <YYYY>/<YYYY-MM>.jsonl.
|
|
rel = written[0].relative_to(sink_root)
|
|
assert len(rel.parts) == 2 # YYYY / YYYY-MM.jsonl
|
|
assert rel.parts[1].endswith(".jsonl")
|
|
|
|
|
|
def test_sink_emission_does_not_alter_run_blob(tmp_path: Path) -> None:
|
|
"""Emitting to a sink and building the ContemplationRun blob must
|
|
produce IDENTICAL findings — the sink path is additional output,
|
|
not a parallel transformation."""
|
|
report = tmp_path / "frontier.json"
|
|
_frontier_report(report)
|
|
|
|
run_no_sink = contemplate_frontier_reports((report,))
|
|
sink = DiscoveryBufferSink()
|
|
run_with_sink = contemplate_frontier_reports((report,), sink=sink)
|
|
|
|
assert run_no_sink.run_id == run_with_sink.run_id
|
|
assert run_no_sink.as_dict() == run_with_sink.as_dict()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Contradiction miner — preserved from closed #56, now under the
|
|
# unified sink contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_contradiction_miner_predicate_split(tmp_path: Path) -> None:
|
|
report = tmp_path / "contradiction.json"
|
|
_contradiction_report(report)
|
|
substrate = ContemplationSubstrate.from_report_paths((report,))
|
|
|
|
findings = mine_contradiction_detection_report(
|
|
report, substrate_hash=substrate.substrate_hash
|
|
)
|
|
by_predicate = {f.predicate: f for f in findings}
|
|
assert set(by_predicate.keys()) == {
|
|
"missed_contradiction",
|
|
"false_contradiction_flag",
|
|
}
|
|
# Repair-action asymmetry: tightening (missed) vs loosening (false flag).
|
|
assert "tighten" in by_predicate["missed_contradiction"].proposed_action
|
|
assert "loosen" in by_predicate["false_contradiction_flag"].proposed_action
|
|
# All findings emit FindingKind.CONTRADICTION (not BENCHMARK_CASE) —
|
|
# the lane is semantically targeted.
|
|
for finding in findings:
|
|
assert finding.kind is FindingKind.CONTRADICTION
|
|
|
|
|
|
def test_contradiction_config_hash_differs_from_frontier(tmp_path: Path) -> None:
|
|
"""Lane choice must remain load-bearing in config_hash so replay
|
|
can distinguish which lane was contemplated. Regression guard
|
|
against a future refactor that accidentally unifies the runners."""
|
|
report = tmp_path / "shared.json"
|
|
_contradiction_report(report)
|
|
a = contemplate_contradiction_reports((report,))
|
|
b = contemplate_frontier_reports((report,))
|
|
assert a.config_hash != b.config_hash
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Boundary doc — the deliberate non-merger of evidence types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_boundary_doc_present_in_schema_module() -> None:
|
|
"""The BOUNDARY note in core/contemplation/schema.py is the
|
|
durable record of why EvidencePointer and ContemplationEvidenceRef
|
|
stay separate. Pinning its presence prevents a future contributor
|
|
from silently deleting the boundary rationale."""
|
|
schema_text = Path("core/contemplation/schema.py").read_text(encoding="utf-8")
|
|
assert "BOUNDARY" in schema_text
|
|
assert "EvidencePointer" in schema_text
|
|
assert "vault_coherent" in schema_text
|
|
|
|
|
|
def test_contemplation_evidence_ref_rejects_empty_fields() -> None:
|
|
"""The existing schema invariants must survive the refactor."""
|
|
with pytest.raises(ValueError, match="source_type"):
|
|
ContemplationEvidenceRef(source_type="", source_id="x", pointer="y")
|
|
with pytest.raises(ValueError, match="source_id"):
|
|
ContemplationEvidenceRef(source_type="x", source_id="", pointer="y")
|
|
with pytest.raises(ValueError, match="pointer"):
|
|
ContemplationEvidenceRef(source_type="x", source_id="y", pointer="")
|
|
|
|
|
|
def test_format_contemplation_finding_jsonl_is_deterministic() -> None:
|
|
finding = ContemplationFinding(
|
|
kind=FindingKind.CONTRADICTION,
|
|
subject="lane/case_001",
|
|
predicate="missed_contradiction",
|
|
object=None,
|
|
evidence_refs=(
|
|
ContemplationEvidenceRef(
|
|
source_type="t", source_id="i", pointer="p", summary="s"
|
|
),
|
|
),
|
|
proposed_action="review",
|
|
substrate_hash="abc123",
|
|
)
|
|
a = format_contemplation_finding_jsonl(finding)
|
|
b = format_contemplation_finding_jsonl(finding)
|
|
assert a == b
|
|
# Round-trips through json.loads/dumps with same options.
|
|
parsed = json.loads(a)
|
|
assert (
|
|
json.dumps(parsed, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
== a
|
|
)
|