diff --git a/.gitignore b/.gitignore index 4c7868be..ca9f9217 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ engine_state/recognizers.jsonl engine_state/discovery_candidates.jsonl engine_state/lived_life.json engine_state/always_on.lock +# per-life idle frontier-contemplation runs — any engine-state root (incl. CORE_ENGINE_STATE_DIR). +contemplation_runs/ # Private outreach / personal packet — never tracked 01_Anthropic/ diff --git a/chat/always_on.py b/chat/always_on.py index 3f1bca00..d5b6ff31 100644 --- a/chat/always_on.py +++ b/chat/always_on.py @@ -89,6 +89,7 @@ class HeartbeatRecord: proposals_created: int # reviewable proposals emitted this beat (proposal-only) pending_proposals: int did_work: bool + frontier_findings: int = 0 # ADR-0080 SPECULATIVE findings mined this beat (0 unless enabled) @dataclass(frozen=True, slots=True) @@ -227,6 +228,7 @@ def run_continuous( result.facts_consolidated > 0 or result.proposals_created > 0 or result.candidates_contemplated > 0 + or getattr(result, "frontier_findings", 0) > 0 ) record = HeartbeatRecord( tick=tick, @@ -236,6 +238,7 @@ def run_continuous( proposals_created=result.proposals_created, pending_proposals=result.pending_proposals, did_work=did_work, + frontier_findings=getattr(result, "frontier_findings", 0), ) records.append(record) if on_heartbeat is not None: diff --git a/chat/runtime.py b/chat/runtime.py index fe4692c1..9dbdbfd7 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -546,6 +546,10 @@ 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 + #: ADR-0080 — SPECULATIVE frontier findings autonomously mined + persisted this tick + #: (0 unless ``config.contemplate_frontier_during_idle`` AND a NOT-yet-mined frontier; + #: a mined frontier converges to 0). Reviewable via the HITL path; never ratified here. + frontier_findings: 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 @@ -718,6 +722,10 @@ class ChatRuntime: None if no_load_state else EngineStateStore(engine_state_path) ) self._recognizer_registry: RecognizerRegistry = RecognizerRegistry() + # Memoized frontier mine (ADR-0080 idle pass): the frontier reports are static within + # a session, so a converged life mines once instead of re-reading every idle beat. A + # reboot re-mines (a fresh runtime), picking up any frontier change across downtime. + self._frontier_run_cache: Any = None self._turn_count: int = 0 self._pending_candidates: list[DiscoveryCandidate] = [] self._pending_recognizer_examples: list[ @@ -925,6 +933,33 @@ class ChatRuntime: facts_consolidated = consolidate_once(self._context).consolidated did_work = True + # 2b. Frontier-contemplation pass (ADR-0080 into the idle loop) — autonomously MINE + # the frontier-compare reports into persisted SPECULATIVE reviewable findings, so + # the always-on life proposes its OWN frontier with no user turn. Idempotent per + # frontier (an already-mined frontier is NOT re-persisted → the life converges, no + # churn); SPECULATIVE-only (ADR-0080: never COHERENT/ratified → the HITL path is + # untouched). Persists per-life under the engine-state dir. + frontier_findings = 0 + if ( + self.config.contemplate_frontier_during_idle + and self._engine_state_store is not None + ): + from core.contemplation.runner import run_contemplation, write_contemplation_run + + # Mine once per session (the frontier is static within a run); a reboot re-mines. + if self._frontier_run_cache is None: + self._frontier_run_cache = run_contemplation() + run = self._frontier_run_cache + target = ( + self._engine_state_store.path + / "contemplation_runs" + / f"idle_{run.substrate_hash}.json" + ) + if run.findings and not target.exists(): + write_contemplation_run(run, target) + frontier_findings = len(run.findings) + 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 @@ -950,6 +985,7 @@ class ChatRuntime: proposals_created=created, pending_proposals=self._count_pending_proposals(), facts_consolidated=facts_consolidated, + frontier_findings=frontier_findings, proposal_review=proposal_review, ) diff --git a/core/cli.py b/core/cli.py index 492341bd..8685ee27 100644 --- a/core/cli.py +++ b/core/cli.py @@ -353,9 +353,11 @@ def cmd_always_on(args: argparse.Namespace) -> int: return vc = "—" if record.versor_condition is None else f"{record.versor_condition:.2e}" if record.did_work: + findings = getattr(record, "frontier_findings", 0) + findings_part = f" +{findings} findings" if findings else "" print( f"[beat {record.tick}] learned +{record.facts_consolidated} facts " - f"+{record.proposals_created} proposals · closure {vc}", + f"+{record.proposals_created} proposals{findings_part} · closure {vc}", file=sys.stderr, ) elif record.tick % _ALWAYS_ON_ALIVE_EVERY == 0: @@ -369,9 +371,18 @@ def cmd_always_on(args: argparse.Namespace) -> int: f"core always-on: continuous-life heartbeat · interval {interval}s · {bound}", file=sys.stderr, ) + daemon_config = _runtime_config_from_args(args) + if getattr(args, "contemplate_frontier", False): + import dataclasses + + # A learning daemon: autonomously mine the frontier into reviewable findings each + # idle beat (ADR-0080, SPECULATIVE + HITL). Opt-in — the default daemon stays idle. + daemon_config = dataclasses.replace( + daemon_config, contemplate_frontier_during_idle=True + ) try: result = run_daemon( - config=_runtime_config_from_args(args), + config=daemon_config, interval=interval, max_beats=max_beats, no_load_state=bool(getattr(args, "no_load_state", False)), @@ -4260,6 +4271,16 @@ def build_parser() -> argparse.ArgumentParser: default=False, help="suppress per-beat logging; print only the final summary", ) + always_on.add_argument( + "--contemplate-frontier", + action="store_true", + default=False, + dest="contemplate_frontier", + help=( + "learning daemon: autonomously mine the frontier into reviewable SPECULATIVE " + "findings each idle beat (ADR-0080; never ratified, HITL-reviewed). Default off." + ), + ) always_on.set_defaults(func=cmd_always_on) test = subparsers.add_parser("test", help="run pytest with curated suite aliases or direct passthrough") diff --git a/core/config.py b/core/config.py index 7d1ff3ba..36ae9d9e 100644 --- a/core/config.py +++ b/core/config.py @@ -323,6 +323,14 @@ class RuntimeConfig: # OFF by default — idle ticks don't pay for the scan unless a deployment wants the surface. review_pending_proposals: bool = False + # Autonomous frontier contemplation (ADR-0080 into the idle loop) — when on, idle_tick + # autonomously MINES its frontier-compare reports into persisted SPECULATIVE reviewable + # findings (core.contemplation), so the always-on life proposes its own frontier without + # a user turn. Idempotent per frontier (a mined frontier converges → no churn); the + # findings are SPECULATIVE-only (ADR-0080: never COHERENT, never self-ratify) and reviewed + # via the existing HITL path. OFF by default — a deliberate learning-daemon mode. + contemplate_frontier_during_idle: 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/core/contemplation/runner.py b/core/contemplation/runner.py index 3148fbdf..dec35f87 100644 --- a/core/contemplation/runner.py +++ b/core/contemplation/runner.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import json +import os from pathlib import Path from typing import Iterable @@ -177,12 +178,16 @@ def contemplate_contradiction_reports( def write_contemplation_run(run: ContemplationRun, path: str | Path) -> None: + # Atomic write (temp + os.replace): the always-on life is an indefinite-uptime process + # where a SIGKILL/crash mid-write is expected, and the idle-pass skip-guard would NEVER + # repair a torn canonical file. A crash leaves only the orphan .tmp; the canonical path + # is either absent (re-mined next boot) or complete — never a half-written artifact. target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) - target.write_text( - json.dumps(run.as_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) + payload = json.dumps(run.as_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + tmp = target.with_name(f".{target.name}.{os.getpid()}.tmp") + tmp.write_text(payload, encoding="utf-8") + os.replace(tmp, target) __all__ = [ diff --git a/docs/analysis/afferent-field-ingest-scope-2026-06-15.md b/docs/analysis/afferent-field-ingest-scope-2026-06-15.md new file mode 100644 index 00000000..cb34390d --- /dev/null +++ b/docs/analysis/afferent-field-ingest-scope-2026-06-15.md @@ -0,0 +1,100 @@ +# Afferent → Field Ingest Seam — Scope & Decision Brief + +**Status:** scope (decision-gated; field-touching, CLAUDE.md sanctioned-site implication) · +**Context:** frontier survey move #2, after the always-on process (#747/#748/#752) + its +long-horizon soak (#757) closed the *idle* lived-spine. This is the proposed next edge: +give the always-on life a **stream of experience** by folding compiled afferent +observations into the live cognitive field. + +## The gap it would close + +The always-on heartbeat lives, holds closure, and resumes — but it is a continuously +**idling** life: `idle_tick` drains a finite backlog + saturates deductive closure, then +does no work forever (proven by the convergence gate H3 in `evals/l10_always_on`). There is +**no afferent input channel** into the idle loop. Separately, the sensorium is fully built +as a compiler/frame/merge substrate but is **never wired to the field** — ADR-0208's own +pipeline arrow (`ObservationFrame → … → field / recall / cognition`) stops at the merge. +This seam is the missing edge in `listen → comprehend → … → replay`. + +## What is confirmed (geometric feasibility) + +- **Units carry a field-shaped target.** `CompilationUnitLike` (sensorium/compiler/protocol.py:28) + exposes `versor: np.ndarray` — a real CL(4,1) multivector — alongside `versor_condition`. + A live `ObservationFrame`'s units therefore expose targets in the field's algebra. +- **The sanctioned pull pattern applies directly, closure-preserving BY CONSTRUCTION.** The + exemplar is `session/context.py::_session_anchor_pull`: + ``` + R = word_transition_rotor(field_state.F, target) # rotor toward target + R_step = rotor_power(R, α) # fractional, stays on Spin manifold + pulled = versor_apply(R_step, field_state.F) # apply — no post-hoc unitize + ``` + It explicitly *replaced* the rejected `_slerp_toward` (which broke closure and needed a + forbidden post-hoc `unitize_versor`). An afferent pull toward `unit.versor` is structurally + identical → preserves `versor_condition < 1e-6` by construction, adds no normalization site. +- **Home:** `session/context.py` (the already-sanctioned site that owns the field), a new + opt-in `observe(frame)` that pulls the field toward each unit's versor by a fractional + step. Default off → serving + existing behavior byte-unchanged. + +## The load-bearing wrinkle (the decision) + +**Closure-preservation is necessary but NOT sufficient.** The sanctioned anchor-pull moves +the field toward a **session concept-attractor** — a target in the *cognitive* semantic +frame, carrying meaning ("the field anchors toward the session's concept"). An afferent +unit's versor is in the *same algebra* (CL(4,1)) but: + +> **Is the sensorium's CGA embedding semantically COMMENSURABLE with the cognitive field — +> or merely same-algebra, different meaning?** + +- If **commensurable**: pulling the field toward `unit.versor` is genuine perception → + cognitive state (decoding doctrine: the observation is real, the field moves toward it). + The seam is clean and meaningful. +- If **not** (same algebra, unrelated embedding): the pull is geometrically valid but + **semantically empty** — the field drifts toward an arbitrary point. That is exactly the + *degenerate fit* the field-as-standing-hand doctrine forbids ("geometrically-realizable + structure only; never force a degenerate fit"). + +This question is unestablished in the code: the sensorium compiles units into CL(4,1) but +nothing today asserts that embedding shares the cognitive field's semantic frame (they were +built as **disjoint** tracks — CLAUDE.md: the afferent arc "is disjoint from the GSM8K +serving path"). Wiring afferent → field is the *first* connection of the two. + +## Decisions for ratification (Shay reviews the design) + +1. **Commensurability ruling (the crux).** Is the sensorium CGA embedding intended to be + commensurable with the cognitive field? Three honest paths: + - **(a) Commensurable by construction** — if the modality packs project into the *same* + concept manifold the cognition field uses, the pull is meaningful → build the seam. + (Needs: evidence/assertion that the projection shares the frame, e.g. a shared + concept-attractor basis, before any pull is wired.) + - **(b) Bridge required** — define an explicit, falsifiable map from a unit's versor to a + cognitive-field target (a grounded embedding), so the pull is toward a *meant* point. + Larger; an ADR + a commensurability gate. + - **(c) Not yet** — keep the tracks disjoint; the always-on life stays idle-converging for + now; the stream-of-experience comes from a different intake (the autonomous + determination-frontier — roadmap Step 5 — which feeds *symbolic* new questions, not + afferent versors). Lower geometric risk, advances the telos via comprehension. + +2. **Sanctioned-site clause.** If (a)/(b): extend `session/context.py`'s CLAUDE.md clause to + cover afferent-observation pull (closure-preserving by construction AND semantic = + perception→field), with the same bright-line justification. A CLAUDE.md amendment. + +3. **ADR.** Completing ADR-0208's arrow (`ObservationFrame → field`) is the first + afferent→cognitive-core connection; it likely warrants an ADR (extend 0208 or new), + given the tracks were deliberately disjoint. + +4. **Falsifiable gate (regardless of path).** Any pull that "preserves closure" is only + proven if a test fails when it doesn't: a soak guard (sibling to `evals/l10_always_on`) + asserting (i) `versor_condition < 1e-6` every observed beat, (ii) the same frame sequence + → byte-identical field digest (determinism), (iii) a *commensurability* assertion — the + field moves **toward** the observation (`dist(F, target)` decreases), not arbitrarily. + +## Recommendation + +The geometry is clean; the **semantics are the gate**. Recommend **ruling on decision 1 +first** — do not wire a field pull until the commensurability question is answered, or the +seam risks a closure-valid-but-meaningless fit. If commensurability isn't yet established +(likely), the higher-leverage telos move is path **(c)**: the autonomous +determination-frontier intake (feed the idle heartbeat *symbolic* new questions it can +`determine` + commit wrong=0 / propose HITL) — which needs no field-embedding ruling and +directly converts the idle life into a learning one. The afferent→field seam then follows +once the embedding frame is established. diff --git a/tests/test_idle_frontier_contemplation.py b/tests/test_idle_frontier_contemplation.py new file mode 100644 index 00000000..10ac1644 --- /dev/null +++ b/tests/test_idle_frontier_contemplation.py @@ -0,0 +1,95 @@ +"""ADR-0080 idle frontier-contemplation — the always-on life autonomously mines its +frontier into reviewable SPECULATIVE findings, with NO user turn. + +Gated (``contemplate_frontier_during_idle``, default off → no behavior change); idempotent +per frontier (an already-mined frontier converges → no churn); SPECULATIVE-only (ADR-0080: +never COHERENT, never ratified here — the HITL path is untouched). +""" + +from __future__ import annotations + +import dataclasses +import json + +from chat.always_on import run_continuous +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig + + +def _runtime(tmp_path, *, frontier: bool) -> ChatRuntime: + config = dataclasses.replace( + RuntimeConfig(), contemplate_frontier_during_idle=frontier + ) + return ChatRuntime(config=config, engine_state_path=tmp_path / "es") + + +def _run_files(tmp_path) -> list: + d = tmp_path / "es" / "contemplation_runs" + return sorted(d.glob("idle_*.json")) if d.exists() else [] + + +def test_idle_frontier_off_by_default(tmp_path) -> None: + # Default config (flag off): no mining, no run persisted — behavior unchanged. + result = _runtime(tmp_path, frontier=False).idle_tick() + assert result.frontier_findings == 0 + assert _run_files(tmp_path) == [] + + +def test_idle_frontier_mines_when_enabled(tmp_path) -> None: + result = _runtime(tmp_path, frontier=True).idle_tick() + assert result.frontier_findings >= 1 # autonomously mined a finding, no user turn + assert len(_run_files(tmp_path)) == 1 # persisted one reviewable run + # Atomic write: a complete artifact, no orphan temp left at the canonical path. + runs_dir = tmp_path / "es" / "contemplation_runs" + assert not list(runs_dir.glob(".*.tmp")) + json.loads(_run_files(tmp_path)[0].read_text(encoding="utf-8")) # valid (not torn) + + +def test_idle_frontier_mines_once_per_session(tmp_path, monkeypatch) -> None: + # A converged life must not re-read/parse/hash the frontier every beat (indefinite + # uptime): the static frontier is mined ONCE per session and memoized. + import core.contemplation.runner as runner_mod + + real = runner_mod.run_contemplation + calls = {"n": 0} + + def _spy(): + calls["n"] += 1 + return real() + + monkeypatch.setattr(runner_mod, "run_contemplation", _spy) + rt = _runtime(tmp_path, frontier=True) + rt.idle_tick() + rt.idle_tick() + rt.idle_tick() + assert calls["n"] == 1 + + +def test_idle_frontier_findings_are_speculative(tmp_path) -> None: + # wrong=0 / ADR-0080: an autonomously-mined finding is SPECULATIVE, never COHERENT. + _runtime(tmp_path, frontier=True).idle_tick() + run = json.loads(_run_files(tmp_path)[0].read_text(encoding="utf-8")) + findings = run["findings"] + assert findings + assert all(f["epistemic_status"] == "speculative" for f in findings) + + +def test_idle_frontier_converges_idempotent(tmp_path) -> None: + # A mined frontier is not re-mined — the life converges (no churn), the wrong=0 invariant + # for a continuous life: an idle beat over an exhausted frontier does no work. + rt = _runtime(tmp_path, frontier=True) + first = rt.idle_tick() + second = rt.idle_tick() + assert first.frontier_findings >= 1 + assert second.frontier_findings == 0 # already mined this frontier + assert len(_run_files(tmp_path)) == 1 # no duplicate run persisted + + +def test_heartbeat_mines_then_converges(tmp_path) -> None: + # The always-on heartbeat over a frontier-contemplating life: beat 0 mines (did_work via + # frontier_findings), then the saturated life settles to rest. + rt = _runtime(tmp_path, frontier=True) + report = run_continuous(rt, heartbeats=4) + assert report.records[0].did_work is True # beat 0 mined the frontier + assert report.records[-1].did_work is False # converged to rest + assert len(_run_files(tmp_path)) == 1