feat(learning): continuous learning in idle — idle_tick advances the reviewed-learning flywheel

The second lived-spine half: the engine learns WHILE IT LIVES, not only when
prompted. ChatRuntime.idle_tick() advances the contemplation/proposal flywheel
between turns (no user input):

- contemplates the pending discovery backlog (enrichment), then runs the
  replay-gated propose_from_candidate into a persistent, file-backed ProposalLog
  (engine_state/proposals.jsonl) held on the runtime.
- PROPOSAL-ONLY: it never ratifies. Raw cold-start candidates are 'undetermined'
  and the eligibility gate refuses them outright (the engine won't propose what
  it hasn't determined). A determined candidate only reaches 'pending' — moving
  to 'accepted'/corpus-append stays HITL via teaching/review. No idle tick emits
  an accepted / accepted_corpus_append event.
- The proposal log and the candidate backlog both live in the engine-state dir,
  so idle learning persists across reboot and accumulates (CL-2) — building on
  Shape B+ resume + L11 identity continuity.

idle_tick returns IdleTickResult(candidates_contemplated, proposals_created,
pending_proposals). None proposal log under no_load_state (ephemeral runtimes
keep no learning lineage).

5 dedicated tests: no-op on empty backlog, contemplates the backlog, refuses
undetermined (safety), proposes-a-determined-candidate-but-never-ratifies,
idle-learning-persists-across-reboot.
This commit is contained in:
Shay 2026-06-05 14:03:12 -07:00
parent 0986e9461b
commit 1c5bd19a11
2 changed files with 203 additions and 0 deletions

View file

@ -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, ...],

View file

@ -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