core/tests/test_contemplation_pipeline_convergence.py
Shay 1573064349
refactor(contemplation): converge to shared discovery-sink plumbing (#58)
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
2026-05-20 12:32:53 -07:00

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
)