diff --git a/chat/runtime.py b/chat/runtime.py index 63142997..2f098a25 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -505,6 +505,15 @@ class ChatResponse: dispatch_trace: DispatchTrace | None = None +@dataclass(frozen=True, slots=True) +class IdleTickResult: + """Outcome of one ``idle_tick`` — proposal-only learning, never ratification.""" + + candidates_contemplated: int + proposals_created: int + pending_proposals: int + + class ChatRuntime: def __init__( self, @@ -683,6 +692,17 @@ class ChatRuntime: self.config, get_git_revision() ) self._loaded_engine_identity: str = "" + # CL — the persistent reviewed-learning proposal log. ``idle_tick()`` + # advances it during idle (proposal-only); it lives alongside the engine + # state so the learning backlog survives reboot. None for no_load_state + # (ephemeral runtimes don't accumulate a learning lineage). + self._proposal_log: Any | None = None + if self._engine_state_store is not None: + from teaching.proposals import ProposalLog + + self._proposal_log = ProposalLog( + path=self._engine_state_store.path / "proposals.jsonl" + ) # L11 — set True on reboot when the stamped checkpoint identity differs # from the recomputed identity (the ratified substrate changed while down). self.identity_continuity_break: bool = False @@ -783,6 +803,75 @@ class ChatRuntime: ) self._loaded_engine_identity = self._engine_identity + def _count_pending_proposals(self) -> int: + if self._proposal_log is None: + return 0 + return sum( + 1 + for entry in self._proposal_log.current_state().values() + if entry.get("state") == "pending" + ) + + def idle_tick(self) -> "IdleTickResult": + """Advance the reviewed-learning flywheel during idle (NO user turn). + + This is how the engine "learns while it lives": between turns it turns its + lived experience (the pending discovery backlog) into reviewable teaching + proposals. It contemplates each pending candidate (enrichment) and runs + the replay-gated ``propose_from_candidate``, which leaves a PROPOSAL-ONLY + ``pending`` entry in the persistent proposal log. + + Teaching safety (CLAUDE.md): an idle tick NEVER ratifies. Ratification — + moving a proposal to ``accepted`` and appending to the corpus — stays + HITL via ``teaching/review``. The tick only *proposes*; the reviewed loop + is not bypassed or duplicated. + + The proposal log and the candidate backlog both live in the engine-state + dir, so this learning progress persists across reboot (CL-2). + """ + if self._proposal_log is None or not self._pending_candidates: + return IdleTickResult(0, 0, self._count_pending_proposals()) + + from teaching.contemplation import contemplate + from teaching.proposals import ( + ProposalError, + TeachingChainProposal, + _current_revision, + propose_from_candidate, + ) + from teaching.source import ProposalSource + + vault_probe = ( + _vault_probe_for_context(self._context) if self._context else None + ) + contemplated = [ + contemplate(candidate, vault_probe=vault_probe) + for candidate in self._pending_candidates + ] + created = 0 + for candidate in contemplated: + source = ProposalSource( + kind="contemplation", + source_id=candidate.candidate_id, + emitted_at_revision=_current_revision(), + ) + try: + result = propose_from_candidate( + candidate, log=self._proposal_log, source=source + ) + except ProposalError: + continue + if isinstance(result, TeachingChainProposal): + created += 1 + # Persist the advanced backlog (candidates + lineage); the proposal log + # is already file-backed. + self.checkpoint_engine_state() + return IdleTickResult( + candidates_contemplated=len(contemplated), + proposals_created=created, + pending_proposals=self._count_pending_proposals(), + ) + def record_recognition_example( self, tokens: tuple[str, ...], diff --git a/tests/test_continuous_learning_idle.py b/tests/test_continuous_learning_idle.py new file mode 100644 index 00000000..372371d2 --- /dev/null +++ b/tests/test_continuous_learning_idle.py @@ -0,0 +1,114 @@ +"""Continuous learning in idle time — CL-1/2/3. + +``ChatRuntime.idle_tick()`` advances the reviewed-learning flywheel BETWEEN turns +(no user input): it contemplates the pending discovery backlog and runs the +replay-gated, PROPOSAL-ONLY ``propose_from_candidate`` into a persistent proposal +log. The engine "learns while it lives", and that progress survives reboot. + +Teaching safety (CLAUDE.md): an idle tick never ratifies. Raw cold-start +candidates are ``undetermined`` and the eligibility gate refuses them outright; +even a determined candidate only reaches ``pending`` — moving to ``accepted`` and +appending to the corpus stays HITL via ``teaching/review``. No idle tick ever +emits an ``accepted`` / ``accepted_corpus_append`` event. +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from chat.runtime import ChatRuntime +from core.cognition.pipeline import CognitiveTurnPipeline +from core.config import RuntimeConfig +from teaching.discovery import EvidencePointer + + +def _drive_backlog(state_dir: Path) -> ChatRuntime: + """Run cold-cause turns that emit (undetermined) discovery candidates.""" + runtime = ChatRuntime(config=RuntimeConfig(), engine_state_path=state_dir) + pipe = CognitiveTurnPipeline(runtime=runtime) + for subject in ("principle", "narrative", "judgment"): + pipe.run(f"What causes {subject}?") + return runtime + + +def _make_determined(candidate): + """Promote a real discovery candidate to a fully-eligible, determined one.""" + chain = dict(candidate.proposed_chain) + chain.update({"connective": "because", "object": "light"}) + return replace( + candidate, + proposed_chain=chain, + polarity="affirms", + boundary_clean=True, + claim_domain="factual", + evidence=( + EvidencePointer( + source="corpus", + ref="en_core_cognition_v1:principle", + polarity="affirms", + epistemic_status="coherent", + ), + ), + ) + + +def test_idle_tick_is_noop_on_empty_backlog(tmp_path: Path) -> None: + rt = ChatRuntime(config=RuntimeConfig(), engine_state_path=tmp_path / "es") + result = rt.idle_tick() + assert result.candidates_contemplated == 0 + assert result.proposals_created == 0 + assert result.pending_proposals == 0 + + +def test_idle_tick_contemplates_the_pending_backlog(tmp_path: Path) -> None: + rt = _drive_backlog(tmp_path / "es") + n_backlog = len(rt._pending_candidates) + assert n_backlog >= 1 # cold-cause turns produced a backlog + result = rt.idle_tick() + assert result.candidates_contemplated == n_backlog + + +def test_idle_tick_refuses_undetermined_candidates(tmp_path: Path) -> None: + # Cold-start candidates are undetermined: the engine has not decided whether + # they are true, so it must NOT propose them. + rt = _drive_backlog(tmp_path / "es") + assert all(c.polarity == "undetermined" for c in rt._pending_candidates) + result = rt.idle_tick() + assert result.proposals_created == 0 + + +def test_idle_tick_proposes_determined_candidate_but_never_ratifies( + tmp_path: Path, +) -> None: + rt = _drive_backlog(tmp_path / "es") + # Inject a fully-determined, eligible candidate (as if the engine had + # determined polarity through grounding/review). + rt._pending_candidates = [_make_determined(rt._pending_candidates[0])] + rt.idle_tick() + + assert rt._proposal_log is not None + events = rt._proposal_log.events() + # The proposal path was exercised, but NOTHING was ratified. + assert not any( + ev.get("event") == "transition" and ev.get("to") == "accepted" for ev in events + ) + assert not any(ev.get("event") == "accepted_corpus_append" for ev in events) + # Any proposal that exists is pending (HITL review not bypassed). + state = rt._proposal_log.current_state() + assert all(v.get("state") == "pending" for v in state.values()) + + +def test_idle_learning_persists_across_reboot(tmp_path: Path) -> None: + state_dir = tmp_path / "es" + rt_a = _drive_backlog(state_dir) + rt_a._pending_candidates = [_make_determined(rt_a._pending_candidates[0])] + rt_a.idle_tick() + pending_before = rt_a._count_pending_proposals() + backlog_before = len(rt_a._pending_candidates) + + # Reboot: a fresh runtime over the same engine-state dir. + rt_b = ChatRuntime(config=RuntimeConfig(), engine_state_path=state_dir) + # The proposal log and the candidate backlog both survived. + assert rt_b._count_pending_proposals() == pending_before + assert len(rt_b._pending_candidates) == backlog_before