diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 0dc2e369..b52bf511 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,6 @@ +## 2026-06-21 (cont.³⁵) — run-NaN actor-side half PROVEN green — run_to_absorbing drives a full Rubicon cycle, lance-free + +**Main thread (Opus), self-directed ("what do you choose next").** Chose the highest-value LIGHT move over forcing a disk-heavy lance build: answered the buildable half of the capstone's **run-NaN HYPOTHESIS**. New `lance-graph-supervisor::kanban_actor::run_to_absorbing(actor, max_ticks)` — repeatedly `drive_version_tick` until the owner reports an absorbing column (`Commit`/`Prune`), returning the forward-arc `KanbanMove` trace; `max_ticks` is a defensive non-termination guard (pure forward arc always reaches `Commit`). This is the actor-side, lance-free, symbiont-free analog of `symbiont::kanban_loop::run_to_absorbing`. **+1 test (14 total green):** `run_to_absorbing_drives_a_full_rubicon_cycle_no_nan_no_panic` — a mailbox runs `Planning → CognitiveWork → Evaluation → Commit`, terminates, every move is a legal Rubicon edge, no panic, no spurious `Illegal`, idempotent at rest (second run empty, phase unchanged). The phase/i4 path is integer-only ⇒ **NaN is structurally impossible on this half**, so green IS the actor-side run-NaN answer. clippy + fmt clean; light build, no lance/disk/symbiont gate. **Remaining run-NaN (symbiont/disk-gated):** the cognitive half — instrument `symbiont::kanban_loop::run_to_absorbing` over the energy column for a live-cycle NaN% (other session owns symbiont; coordinate). Plan run-NaN status annotated "actor-side half PROVEN". Rides a PR on jirak. Capstone actor-side substrate now complete: S4 (#576/#577) + S2 (#578) + S3 (#579) + run-to-absorbing (this). ## 2026-06-21 (cont.³⁴) — S3 IN-leg driver SHIPPED (actor-side) — version tick → owner forward-arc advance, no-op suppressed **Main thread (Opus), self-directed ("PR, easy").** Closed the actor-side half of S3 on the same light crate, mirroring the S2 atomic pattern. New in `lance-graph-supervisor::kanban_actor` (feature `supervisor`): (1) `KanbanMsg::Tick { at, reply }` — the **atomic** in-actor realization of the contract's `NextPhaseScheduler`: a substrate version tick advances the owner along the Rubicon forward arc (`phase().next_phases().first()`) in ONE serialized message, reading the phase at the instant of mutation (the codex-#578 atomicity lesson applied to the IN-leg); absorbing column → `None`, **the no-op tick is suppressed** (not an error; forward arc is legal by construction so the infallible `advance_phase` is used). (2) `drive_version_tick(actor, at)` — thin async wrapper. (3) `drive_scheduled_tick(scheduler, view, at, exec, actor)` — generic consumer that drives the EXISTING `VersionScheduler` trait ("propose, don't dispose": scheduler proposes from a view, owner disposes via `Advance`, `None` suppresses), for custom policies (version-delta gating, `Plan`/`Prune`, batching) reading a richer view; documented as advisory (proposal computed outside the owner message → may relay a typed `Illegal` rather than corrupt). **+3 tests (now green):** `version_tick_advances_forward_arc_then_suppresses_at_absorbing` (Planning→CognitiveWork→Evaluation→Commit then suppressed), `concurrent_version_ticks_serialize_along_the_arc` (two ticks chain, no stale-phase collision), `custom_scheduler_proposes_and_owner_disposes` (drives `NextPhaseScheduler` propose→dispose + suppresses an absorbing proposal). `cargo test -p lance-graph-supervisor --features supervisor --lib` = 12 passed/0 failed; clippy clean (no supervisor-crate warnings; pre-existing ontology/callcenter warnings only) + fmt clean; light build, no lance/disk/symbiont gate. **Remaining S3 (lance/disk-gated):** wire the LIVE `LanceVersionScheduler::drive_at_latest` over a real `VersionedGraph::versions()` to feed `at` — the apply + no-op-suppress loop is now done, only the live `versions()` poll remains. OUT-leg actor side now: S4 owner-advance (#576) + delivery edge (#577) + S2 driver (#578) + **S3 driver (this)**. Plan S3 status annotated. Rides a PR on jirak. diff --git a/.claude/plans/capstone-out-leg-wiring-v1.md b/.claude/plans/capstone-out-leg-wiring-v1.md index 1560459a..dcc3968c 100644 --- a/.claude/plans/capstone-out-leg-wiring-v1.md +++ b/.claude/plans/capstone-out-leg-wiring-v1.md @@ -199,7 +199,25 @@ ractor `cast` — **same ownership, same `try_advance_phase`**. No no-owner test ## run-NaN — the measurement (Wave-0's third metric) -**Census state:** HYPOTHESIS. `symbiont::kanban_loop::run_to_absorbing +**Status (2026-06-21): actor-side half PROVEN green** (lance-free, symbiont-free). +`lance-graph-supervisor::kanban_actor::run_to_absorbing(actor, max_ticks)` drives a +mailbox to its absorbing column through the REAL actor messages (the `Tick` arc) +and returns the forward-arc `KanbanMove` trace. Test +`run_to_absorbing_drives_a_full_rubicon_cycle_no_nan_no_panic`: a mailbox runs +`Planning → CognitiveWork → Evaluation → Commit`, terminates within the bound, +every move is a legal Rubicon edge, no panic, no spurious `Illegal`, and the run +is idempotent at rest (a second run is an empty trace, phase unchanged). The +phase/i4 path is integer-only → **NaN is structurally impossible on this half**, +so the green run IS the actor-side run-NaN answer. 14 tests green; clippy + fmt +clean; light build. + +**Remaining (symbiont/disk-gated):** the *cognitive* half — instrument +`symbiont::kanban_loop::run_to_absorbing(&NextPhaseScheduler)` over the energy +column + observable outputs (not just the phase trail) for a live-cycle NaN%. +That harness drives the full domino sweep over a real SoA and is owned by the +cognitive-compilation session — coordinate first. + +**Census state (orig):** HYPOTHESIS. `symbiont::kanban_loop::run_to_absorbing (&NextPhaseScheduler)` is the runnable harness. **Work:** instrument one `run_to_absorbing` cycle; count valid vs diff --git a/crates/lance-graph-supervisor/src/kanban_actor.rs b/crates/lance-graph-supervisor/src/kanban_actor.rs index de315eed..b715822b 100644 --- a/crates/lance-graph-supervisor/src/kanban_actor.rs +++ b/crates/lance-graph-supervisor/src/kanban_actor.rs @@ -347,6 +347,40 @@ where } } +// ─── Capstone run-to-absorbing: drive a mailbox to its terminal column ───────── + +/// Drive a mailbox to its **absorbing column** by repeatedly ticking +/// ([`drive_version_tick`]) until the owner reports no further move +/// (`Commit`/`Prune`). Returns the full forward-arc [`KanbanMove`] trace. +/// +/// This is the actor-side, lance-free analog of the cognitive loop's +/// run-to-absorbing: it proves the OUT/IN-leg substrate carries a mailbox through +/// a complete Rubicon cycle to a terminal state with no panic and no spurious +/// rejection (the integer-only phase/i4 path cannot produce NaN). The live S3 +/// source feeds real `versions()` ticks through the same `drive_version_tick`; +/// here the loop counter stands in for the version stream. +/// +/// `max_ticks` bounds the loop defensively. The pure forward arc always reaches +/// `Commit` (`Planning → CognitiveWork → Evaluation → Commit`), so the bound is a +/// guard against a future non-terminating policy, not a normal exit: exceeding it +/// returns [`KanbanRouteError::Rpc`] with a non-termination note rather than +/// looping forever. +pub async fn run_to_absorbing( + actor: &ActorRef, + max_ticks: usize, +) -> Result, KanbanRouteError> { + let mut trace = Vec::new(); + for tick in 0..max_ticks { + match drive_version_tick(actor, DatasetVersion(tick as u64 + 1)).await? { + Some(mv) => trace.push(mv), + None => return Ok(trace), // absorbing column reached — the cycle ended + } + } + Err(KanbanRouteError::Rpc(format!( + "run_to_absorbing did not reach an absorbing column within {max_ticks} ticks" + ))) +} + #[cfg(test)] mod tests { use super::*; @@ -737,4 +771,57 @@ mod tests { handle.await.expect("actor join"); } } + + #[tokio::test] + async fn run_to_absorbing_drives_a_full_rubicon_cycle_no_nan_no_panic() { + // Capstone run-NaN (actor-side, lance-free): a mailbox driven from + // Planning runs to the absorbing Commit column through the REAL actor + // messages — it terminates, never panics, never emits a spurious Illegal, + // and the trace is the deterministic forward arc. The integer phase/i4 + // path cannot produce NaN, so a green run here IS the actor-side half of + // the loop's run-NaN answer. + let (actor, handle) = Actor::spawn( + None, + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn"); + + let trace = run_to_absorbing(&actor, 16) + .await + .expect("reaches an absorbing column within the bound"); + + // Forward arc: Planning → CognitiveWork → Evaluation → Commit (3 moves). + let arc: Vec<_> = trace.iter().map(|m| m.to).collect(); + assert_eq!( + arc, + vec![ + KanbanColumn::CognitiveWork, + KanbanColumn::Evaluation, + KanbanColumn::Commit, + ] + ); + // Every move en route is a legal Rubicon edge (no corruption). + for m in &trace { + assert!( + m.from.can_transition_to(m.to), + "{:?} -> {:?} must be legal", + m.from, + m.to + ); + } + + // The owner rests in the absorbing column: a further run is empty, and + // the phase is unchanged (idempotent at rest — no spurious advance/error). + let again = run_to_absorbing(&actor, 4) + .await + .expect("idempotent at the absorbing column"); + assert!(again.is_empty(), "absorbing column yields no further moves"); + let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc"); + assert_eq!(phase, KanbanColumn::Commit); + + actor.stop(None); + handle.await.expect("actor join"); + } }