Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/board/AGENT_LOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 19 additions & 1 deletion .claude/plans/capstone-out-leg-wiring-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions crates/lance-graph-supervisor/src/kanban_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<KanbanMsg>,
max_ticks: usize,
) -> Result<Vec<KanbanMove>, 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::*;
Expand Down Expand Up @@ -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::<TestBoard>::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");
}
}
Loading