diff --git a/chat/runtime.py b/chat/runtime.py index 777b8d42..fe4692c1 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -38,6 +38,7 @@ from core.epistemic_state import ( epistemic_state_for_grounding_source, normative_detail_from_verdicts, ) +from core.proposal_review.summary import ProposalReviewIdleSummary, idle_summary from core.response_governance import govern_response, shape_surface from chat.telemetry import ( TurnEventSink, @@ -534,7 +535,9 @@ class IdleTickResult: The proposal pass is PROPOSAL-ONLY learning, never ratification (HITL ratifies). The Step D consolidation pass is SESSION-memory learning: ``facts_consolidated`` derived facts were written back into the held self so the next ``determine`` reaches them - directly — still never corpus mutation, never a proposal. + directly — still never corpus mutation, never a proposal. The proposal-review sub-pass + (``proposal_review``) is READ-ONLY: it surfaces pending comprehension-failure proposals for + review and mutates nothing. """ candidates_contemplated: int @@ -543,6 +546,9 @@ class IdleTickResult: #: Step D — derived facts consolidated into the held self this tick (0 unless #: ``config.consolidate_determinations`` and the closure had a new layer to add). facts_consolidated: int = 0 + #: IT — read-only proposal-review summary (None unless ``config.review_pending_proposals``). + #: Surfaces pending comprehension-failure proposals for review; mutates nothing. + proposal_review: ProposalReviewIdleSummary | None = None class ChatRuntime: @@ -919,6 +925,21 @@ class ChatRuntime: facts_consolidated = consolidate_once(self._context).consolidated did_work = True + # 3. Proposal-review sub-pass (IT) — READ-ONLY. Surfaces pending comprehension-failure + # proposals (the contemplation pass's N5 artifacts) for review. It NEVER mutates an + # artifact, NEVER sets ``did_work`` (no state change → no checkpoint), NEVER ratifies + # or mounts. A reporter failure is CAPTURED, not propagated, so it cannot corrupt the + # tick. Gated off by default. + proposal_review = None + if self.config.review_pending_proposals: + try: + proposal_review = idle_summary() + except Exception as exc: # noqa: BLE001 — a reporter failure must not corrupt the tick + proposal_review = ProposalReviewIdleSummary( + safe=False, total=0, review_needed=0, malformed=0, by_family=(), + errors=(f"proposal_review_failed:{type(exc).__name__}",), + ) + # Persist the advanced state once (backlog + lineage +, with # persist_session_state, the consolidated facts). Skipped on a no-op tick so an # idle engine with nothing to learn does not churn the checkpoint. @@ -929,6 +950,7 @@ class ChatRuntime: proposals_created=created, pending_proposals=self._count_pending_proposals(), facts_consolidated=facts_consolidated, + proposal_review=proposal_review, ) def record_recognition_example( diff --git a/core/config.py b/core/config.py index 30b57b41..17190a87 100644 --- a/core/config.py +++ b/core/config.py @@ -316,6 +316,13 @@ class RuntimeConfig: # same _SUBSUMPTION_SUBSET_FACT_BUDGET; converges (a saturated tick is a no-op). consolidate_determinations: bool = False + # IT (proposal-review surface) — when on, idle_tick runs a READ-ONLY sub-pass that scans + # the comprehension-failure proposal sink and surfaces a summary in + # IdleTickResult.proposal_review. It NEVER mutates an artifact, sets did_work, checkpoints, + # ratifies, mounts, or modifies readers; a reporter failure is captured, not propagated. + # OFF by default — idle ticks don't pay for the scan unless a deployment wants the surface. + review_pending_proposals: bool = False + # Step E (ESTIMATION) — when on, a converse query the engine would otherwise REFUSE # (told p(a,b), asked p(b,a)) may be answered with a DISCLOSED [approximate] estimate # IF the predicate-class has earned the SERVE license on the ratified, committed diff --git a/docs/runtime_contracts.md b/docs/runtime_contracts.md index 01b7922f..6614548a 100644 --- a/docs/runtime_contracts.md +++ b/docs/runtime_contracts.md @@ -110,6 +110,26 @@ Contract: `algebra/versor.py` keeps closure. Off by default; the falsification lane is `evals.determination_closure`. +### Idle proposal review (read-only) + +When `review_pending_proposals` is enabled, `ChatRuntime.idle_tick` runs a **read-only** +sub-pass (after the consolidation pass) that scans the comprehension-failure proposal sink +(`teaching/proposals/comprehension_failures/`, the contemplation pass's proposal-only +artifacts) and surfaces a summary in `IdleTickResult.proposal_review` (`safe`, `total`, +`review_needed`, `malformed`, `by_family`, `errors`). + +Contract: + +- **Read-only, no mutation.** The sub-pass scans, reports, and dry-checks; it writes, moves, + and deletes nothing. It does **not** set `did_work`, so it never triggers a checkpoint, and + it never advances learning, ratifies, mounts, or modifies a reader. +- **Failure-isolated.** A reporter exception is **captured, not propagated** — surfaced as + `proposal_review.safe == False` with `errors == ("proposal_review_failed:",)` — so a + malformed sink or filesystem error can never corrupt the idle tick's state or return. +- **Default off, additive.** Disabled, `proposal_review` is `None` and `IdleTickResult` is + unchanged for existing callers. This is **not** a second idle loop and **not** the L10 + always-on heartbeat — it only surfaces existing review obligations. + ### Estimation surface (Step E — ESTIMATION) When `estimation_enabled` and a turn is a **converse query** DETERMINE refused (told diff --git a/tests/test_idle_proposal_review.py b/tests/test_idle_proposal_review.py new file mode 100644 index 00000000..14b18652 --- /dev/null +++ b/tests/test_idle_proposal_review.py @@ -0,0 +1,70 @@ +"""Contract tests for the read-only proposal-review sub-pass of idle_tick (IT-b). + +Pins the runtime contract: default-off preserves the existing IdleTickResult shape/behavior; +enabled surfaces a summary; a reporter exception is CAPTURED (safe=False) and never corrupts +the tick; the read-only sub-pass never sets did_work / checkpoints / mutates / creates proposals. +""" + +from __future__ import annotations + +from pathlib import Path + +import chat.runtime as rtmod +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from core.proposal_review.summary import ProposalReviewIdleSummary + +_OK = ProposalReviewIdleSummary(safe=True, total=0, review_needed=0, malformed=0, by_family=()) + + +def _rt(tmp_path: Path, *, review: bool = False) -> ChatRuntime: + return ChatRuntime( + config=RuntimeConfig(review_pending_proposals=review), engine_state_path=tmp_path + ) + + +def test_default_off_proposal_review_is_none(tmp_path: Path) -> None: + result = _rt(tmp_path).idle_tick() + assert result.proposal_review is None # additive field absent for existing callers + assert result.candidates_contemplated == 0 and result.proposals_created == 0 + + +def test_enabled_surfaces_the_summary(tmp_path: Path, monkeypatch) -> None: + sentinel = ProposalReviewIdleSummary( + safe=True, total=3, review_needed=3, malformed=0, by_family=(("missing_total_count", 3),) + ) + monkeypatch.setattr(rtmod, "idle_summary", lambda *a, **k: sentinel) + assert _rt(tmp_path, review=True).idle_tick().proposal_review == sentinel + + +def test_reporter_exception_is_captured_not_propagated(tmp_path: Path, monkeypatch) -> None: + def _boom(*a, **k): + raise ValueError("sink exploded") + + monkeypatch.setattr(rtmod, "idle_summary", _boom) + result = _rt(tmp_path, review=True).idle_tick() # must NOT raise + assert result.proposal_review is not None + assert result.proposal_review.safe is False + assert result.proposal_review.errors == ("proposal_review_failed:ValueError",) + assert result.candidates_contemplated == 0 and result.proposals_created == 0 # tick intact + + +def test_review_subpass_does_not_checkpoint_or_set_did_work(tmp_path: Path, monkeypatch) -> None: + rt = _rt(tmp_path, review=True) + calls: list[int] = [] + monkeypatch.setattr(rt, "checkpoint_engine_state", lambda: calls.append(1)) + monkeypatch.setattr(rtmod, "idle_summary", lambda *a, **k: _OK) + result = rt.idle_tick() + assert result.proposal_review is not None # the review ran + assert calls == [] # ...but a read-only review never checkpoints (no did_work) + + +def test_enabled_does_not_perturb_other_passes(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(rtmod, "idle_summary", lambda *a, **k: _OK) + on = _rt(tmp_path, review=True).idle_tick() + off = _rt(tmp_path).idle_tick() + assert (on.candidates_contemplated, on.proposals_created, on.facts_consolidated) == ( + off.candidates_contemplated, + off.proposals_created, + off.facts_consolidated, + )