diff --git a/core/contemplation/__init__.py b/core/contemplation/__init__.py index 3016454e..e7d439fb 100644 --- a/core/contemplation/__init__.py +++ b/core/contemplation/__init__.py @@ -4,7 +4,7 @@ ADR-0080: contemplation can emit speculative findings about current substrate/report evidence, but it cannot ratify, promote, or mutate packs. """ -from .runner import contemplate_frontier_reports +from .runner import contemplate_frontier_reports, run_contemplation from .schema import ( ContemplationEvidenceRef, ContemplationFinding, @@ -20,4 +20,5 @@ __all__ = [ "ContemplationSubstrate", "FindingKind", "contemplate_frontier_reports", + "run_contemplation", ] diff --git a/core/contemplation/runner.py b/core/contemplation/runner.py index 584312c0..3148fbdf 100644 --- a/core/contemplation/runner.py +++ b/core/contemplation/runner.py @@ -104,6 +104,33 @@ def contemplate_frontier_reports( ) +def run_contemplation( + report_paths: Iterable[str | Path] | None = None, + *, + pack_ids: Iterable[str] = (), + notes: Iterable[str] = (), +) -> ContemplationRun: + """Run ADR-0080 Phase 1 over frontier-compare reports. + + This is the stable operator-facing entry point for Phase 1. If no + explicit paths are supplied it reads the checked-in + ``evals/frontier_compare/results/*.json`` reports in deterministic + path order. It never writes packs, teaching examples, proposal logs, + or discovery sinks. + """ + if report_paths is None: + root = Path(__file__).resolve().parents[2] + paths = tuple(sorted(root.glob("evals/frontier_compare/results/*.json"))) + else: + paths = tuple(Path(p) for p in report_paths) + return contemplate_frontier_reports( + paths, + pack_ids=pack_ids, + notes=notes, + sink=None, + ) + + def contemplate_contradiction_reports( report_paths: Iterable[str | Path], *, @@ -161,5 +188,6 @@ def write_contemplation_run(run: ContemplationRun, path: str | Path) -> None: __all__ = [ "contemplate_contradiction_reports", "contemplate_frontier_reports", + "run_contemplation", "write_contemplation_run", ] diff --git a/core/contemplation/schema.py b/core/contemplation/schema.py index a8f26263..fbfcc3a9 100644 --- a/core/contemplation/schema.py +++ b/core/contemplation/schema.py @@ -151,6 +151,28 @@ class ContemplationFinding: payload["finding_id"] = self.finding_id return payload + @property + def confidence(self) -> EpistemicStatus: + """ADR-0080 Phase 1 confidence alias. + + Phase 1 deliberately reuses the repository's epistemic lattice: + every contemplation confidence is SPECULATIVE by construction. + """ + return self.epistemic_status + + @property + def evidence(self) -> tuple[ContemplationEvidenceRef, ...]: + """ADR-0080 Phase 1 evidence alias for ``evidence_refs``.""" + return self.evidence_refs + + @property + def provenance(self) -> dict[str, str]: + """Deterministic provenance for the finding's read-only substrate.""" + return { + "source": "contemplation", + "substrate_hash": self.substrate_hash, + } + @dataclass(frozen=True, slots=True) class ContemplationRun: diff --git a/docs/decisions/ADR-0080-contemplation-loop.md b/docs/decisions/ADR-0080-contemplation-loop.md index 0bbb6165..473b1ca1 100644 --- a/docs/decisions/ADR-0080-contemplation-loop.md +++ b/docs/decisions/ADR-0080-contemplation-loop.md @@ -1,7 +1,8 @@ # ADR-0080 — Contemplation Loop: self-interrogation without self-ratification -**Status:** Proposed +**Status:** Accepted **Date:** 2026-05-20 +**Accepted:** 2026-05-22 **Author:** Shay **Builds on:** [ADR-0021](./ADR-0021-epistemic-grade-policy.md) (epistemic grade policy), [ADR-0055](./ADR-0055-teaching-corpus-audit.md) (reviewed teaching corpus), [ADR-0078](./ADR-0078-composer-graph-atom-equivalence.md) (composer/graph atom telemetry) **Series:** L0 — learning safety / self-contemplation boundary diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d6a3b226..a272b2db 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -10,6 +10,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt | ADR | Title | Status | |---|---|---| +| [ADR-0080](ADR-0080-contemplation-loop.md) | Contemplation Loop | Accepted (2026-05-22) | | [ADR-0091](ADR-0091-domain-pack-contract-v1.md) | Domain Pack Contract v1 | Accepted (2026-05-22) | | [ADR-0092](ADR-0092-reviewer-registry-v1.md) | Reviewer Registry v1 | Accepted (2026-05-22) | | [ADR-0093](ADR-0093-domain-pack-contract-v1-implementation.md) | Domain Pack Contract v1 implementation | Accepted (2026-05-22) | @@ -53,6 +54,8 @@ The ADR-0091..0105 slate is fully accepted and mechanically evidenced: - Curriculum-Sourced Teaching Proposals — ADR-0104 - Sealed Holdout Encryption via age — ADR-0105 +ADR-0080 has also landed: Contemplation Loop Phase 1 adds a read-only frontier-compare miner that emits `SPECULATIVE` findings only. + Seven lanes are SHA-pinned in `scripts/verify_lane_shas.py` and gated by the `lane-shas` GitHub Actions workflow: - `reviewer_registry` @@ -67,11 +70,10 @@ Seven lanes are SHA-pinned in `scripts/verify_lane_shas.py` and gated by the `la Sequencing per ADR-0108. Listed in priority order: -1. **[ADR-0080](ADR-0080-contemplation-loop.md) — Contemplation Loop.** Sandboxed, read-only Phase 1 self-interrogation; emits `SPECULATIVE` findings from `frontier_compare` reports. Converts gap-finding from human-driven to system-emitted-and-reviewed. -2. **[ADR-0084](ADR-0084-definitional-layer.md) — Definitional Layer for Lexicon Packs.** Optional per-entry definitional block. Deferred — value surfaces during a worked expert promotion that needs definitional depth. -3. **[ADR-0087](ADR-0087-rhetorical-style-axis.md) — Rhetorical Style Axis.** A third substantive selection axis sibling to anchor-lens. Lowest current priority — no active downstream consumer; register + anchor-lens already demonstrate the orthogonality pattern. +1. **[ADR-0084](ADR-0084-definitional-layer.md) — Definitional Layer for Lexicon Packs.** Optional per-entry definitional block. Deferred — value surfaces during a worked expert promotion that needs definitional depth. +2. **[ADR-0087](ADR-0087-rhetorical-style-axis.md) — Rhetorical Style Axis.** A third substantive selection axis sibling to anchor-lens. Lowest current priority — no active downstream consumer; register + anchor-lens already demonstrate the orthogonality pattern. -ADR-0110 has landed — `mathematics_logic` is the first domain at `expert_demo=true`. The remaining three ratified domains (`physics`, `systems_software`, `hebrew_greek_textual_reasoning`) need their own promotion ADRs. +ADR-0080 (Contemplation Loop, Phase 1) and ADR-0110 (math expert-demo) have both landed — `mathematics_logic` is the first domain at `expert_demo=true`; the contemplation loop emits read-only `SPECULATIVE` findings from `frontier_compare` reports. The remaining three ratified domains (`physics`, `systems_software`, `hebrew_greek_textual_reasoning`) need their own promotion ADRs. ### Open candidate directions (no ADR yet) diff --git a/evals/frontier_compare/results/adr_0080_failed_case.json b/evals/frontier_compare/results/adr_0080_failed_case.json new file mode 100644 index 00000000..ab6e19c8 --- /dev/null +++ b/evals/frontier_compare/results/adr_0080_failed_case.json @@ -0,0 +1,42 @@ +{ + "benchmark_family": "frontier_compare_adr_0080_phase1", + "mode": "core", + "model": "core-native", + "suites": [ + { + "case_count": 1, + "cases": [ + { + "case_id": "planner_relation_gap", + "details": { + "observation": { + "error_message": "", + "error_type": "", + "model": "core-native", + "prompt": "Explain how reviewed correction changes future recall.", + "provider": "core", + "surface": "reviewed correction changes future recall" + } + }, + "elapsed_ms": 1.0, + "failures": [ + "missing_reviewed_teaching_path_explanation" + ], + "passed": false, + "prompt": "Explain how reviewed correction changes future recall.", + "score": 0.0, + "suite": "contemplation_phase1" + } + ], + "passed": false, + "primary_score": 0.0, + "suite": "contemplation_phase1" + } + ], + "summary": { + "case_count": 1, + "passed": false, + "primary_score": 0.0, + "suite_count": 1 + } +} diff --git a/teaching/source.py b/teaching/source.py index 197e4e37..a7cff216 100644 --- a/teaching/source.py +++ b/teaching/source.py @@ -1,9 +1,10 @@ -"""Sealed :class:`ProposalSource` provenance type (ADR-0094/ADR-0104). +"""Sealed :class:`ProposalSource` provenance type (ADR-0094/ADR-0104/ADR-0080). Widens :class:`teaching.proposals.TeachingChainProposal` and :class:`teaching.store.PackMutationProposal` with a typed source field. Operator and miner provenance landed in ADR-0094/ADR-0095; curriculum -source activation is governed by ADR-0104. +source activation is governed by ADR-0104. ADR-0080 activates the +reserved contemplation source kind for read-only, SPECULATIVE findings. The kind field is a sealed :data:`ProposalKind` literal. Adding a new kind requires a new ADR adding a branch to every consumer. @@ -19,6 +20,8 @@ Consumers must branch on :attr:`ProposalSource.kind` using exhaustive ... case "curriculum": ... + case "contemplation": + ... case _: # pragma: no cover - exhaustiveness assert_never(proposal.source.kind) """ @@ -29,7 +32,7 @@ from dataclasses import dataclass from typing import Any, Literal, Mapping, get_args -ProposalKind = Literal["operator", "miner", "curriculum"] +ProposalKind = Literal["operator", "miner", "curriculum", "contemplation"] ALLOWED_KINDS: frozenset[str] = frozenset(get_args(ProposalKind)) @@ -42,8 +45,9 @@ class ProposalSource: """Typed provenance for one proposal. :param kind: - One of ``"operator"``, ``"miner"``, ``"curriculum"``. The set is - sealed; adding a new kind requires a new ADR. + One of ``"operator"``, ``"miner"``, ``"curriculum"``, or + ``"contemplation"``. The set is sealed; adding a new kind + requires a new ADR. :param source_id: Empty for ``kind="operator"``. For other kinds, the originating miner id or curriculum course id. @@ -85,6 +89,8 @@ class ProposalSource: -> ``"miner:articulation_quality"`` - ``ProposalSource("curriculum", "math_logic_v1", "")`` -> ``"curriculum:math_logic_v1"`` + - ``ProposalSource("contemplation", "frontier_compare", "")`` + -> ``"contemplation:frontier_compare"`` """ if not self.source_id: return self.kind diff --git a/tests/test_contemplation_loop.py b/tests/test_contemplation_loop.py index 61040567..3d85a7f8 100644 --- a/tests/test_contemplation_loop.py +++ b/tests/test_contemplation_loop.py @@ -7,7 +7,11 @@ import pytest from core.contemplation.__main__ import main from core.contemplation.miners.frontier_compare import mine_frontier_compare_report -from core.contemplation.runner import contemplate_frontier_reports, write_contemplation_run +from core.contemplation.runner import ( + contemplate_frontier_reports, + run_contemplation, + write_contemplation_run, +) from core.contemplation.schema import ( ContemplationEvidenceRef, ContemplationFinding, @@ -15,6 +19,7 @@ from core.contemplation.schema import ( ) from core.contemplation.snapshot import ContemplationSubstrate from teaching.epistemic import EpistemicStatus +from teaching.source import ProposalSource def _sample_frontier_report(path: Path) -> None: @@ -104,6 +109,9 @@ def test_frontier_report_miner_emits_failed_cases_only(tmp_path: Path) -> None: assert finding.predicate == "failed_case" assert finding.object == "Why does xylomorphic matter?" assert finding.epistemic_status is EpistemicStatus.SPECULATIVE + assert finding.confidence is EpistemicStatus.SPECULATIVE + assert finding.evidence == finding.evidence_refs + assert finding.provenance["source"] == "contemplation" assert finding.evidence_refs[0].summary == "unexpected_grounding_source:vault" @@ -142,6 +150,43 @@ def test_contemplation_runner_does_not_mutate_pack_tree(tmp_path: Path) -> None: assert before == after +def test_contemplation_runner_does_not_write_teaching_store_without_review( + tmp_path: Path, +) -> None: + report = tmp_path / "frontier_wave1.json" + _sample_frontier_report(report) + proposal_store = Path("teaching/proposals/proposals.jsonl") + before = proposal_store.read_bytes() if proposal_store.exists() else b"" + + run = contemplate_frontier_reports((report,)) + + after = proposal_store.read_bytes() if proposal_store.exists() else b"" + assert run.findings + assert before == after + + +def test_run_contemplation_default_entrypoint_is_deterministic() -> None: + first = run_contemplation() + second = run_contemplation() + + assert first.as_dict() == second.as_dict() + assert all( + finding.confidence is EpistemicStatus.SPECULATIVE + for finding in first.findings + ) + + +def test_contemplation_proposal_source_parses() -> None: + source = ProposalSource( + kind="contemplation", + source_id="frontier_compare", + emitted_at_revision="abc123", + ) + + assert source.serialize() == "contemplation:frontier_compare" + assert ProposalSource.from_dict(source.as_dict()) == source + + def test_contemplation_write_and_cli(tmp_path: Path, capsys) -> None: source_report = tmp_path / "frontier_wave1.json" output_report = tmp_path / "contemplation.json" diff --git a/tests/test_proposal_source.py b/tests/test_proposal_source.py index cfc4ee55..70458f13 100644 --- a/tests/test_proposal_source.py +++ b/tests/test_proposal_source.py @@ -35,6 +35,10 @@ class TestProposalSourceConstruction: with pytest.raises(ProposalSourceError, match="non-empty source_id"): ProposalSource(kind="curriculum", source_id="", emitted_at_revision="abc") + def test_contemplation_requires_source_id(self) -> None: + with pytest.raises(ProposalSourceError, match="non-empty source_id"): + ProposalSource(kind="contemplation", source_id="", emitted_at_revision="abc") + def test_operator_rejects_source_id(self) -> None: with pytest.raises(ProposalSourceError, match="empty source_id"): ProposalSource(kind="operator", source_id="something", emitted_at_revision="abc") @@ -78,6 +82,14 @@ class TestSerialization: ) assert src.serialize() == "curriculum:math_logic_v1" + def test_contemplation_serializes_kind_and_id(self) -> None: + src = ProposalSource( + kind="contemplation", + source_id="frontier_compare", + emitted_at_revision="abc", + ) + assert src.serialize() == "contemplation:frontier_compare" + def test_round_trip_operator(self) -> None: src = ProposalSource.operator(emitted_at_revision="abc") roundtrip = ProposalSource.from_dict(src.as_dict()) @@ -125,6 +137,8 @@ class TestExhaustiveMatchPattern: return f"m:{src.source_id}" case "curriculum": return f"c:{src.source_id}" + case "contemplation": + return f"q:{src.source_id}" case _: # pragma: no cover - exhaustiveness assert_never(src.kind) @@ -139,8 +153,18 @@ class TestExhaustiveMatchPattern: src = ProposalSource(kind="curriculum", source_id="cur", emitted_at_revision="x") assert self._describe(src) == "c:cur" - def test_kinds_sealed_at_three(self) -> None: - assert ALLOWED_KINDS == frozenset({"operator", "miner", "curriculum"}) + def test_covers_contemplation(self) -> None: + src = ProposalSource( + kind="contemplation", + source_id="frontier_compare", + emitted_at_revision="x", + ) + assert self._describe(src) == "q:frontier_compare" + + def test_kinds_sealed_at_four(self) -> None: + assert ALLOWED_KINDS == frozenset( + {"operator", "miner", "curriculum", "contemplation"} + ) class TestMigrationDeterminism: