diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index fd9dfa3f..31669955 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,6 @@ +## 2026-06-21 (cont.³³) — S2 MUL→phase driver SHIPPED (actor-side) — gate → owner advance + +**Main thread (Opus), self-directed (da-capo).** S2→S4 composition on the same light crate: `drive_mul_advance(actor, qualia, mantissa)` in `lance-graph-supervisor::kanban_actor` reads the owner's phase (`KanbanMsg::Phase`), runs the contract's `mul::i4_eval::gate_decision_i4` → `KanbanColumn::advance_on_gate` (Flow→forward, Block→Prune-where-legal, Hold→None), and on a non-Hold gate `cast`s `KanbanMsg::Advance` to the owning actor (the owner advances ITSELF — the operator model). `mul_target` is the pure lowering. Integer i4 path — no f64/NaN. **+1 test (5 total green):** `s2_driver_gate_advances_then_holds` (Flow qualia+mantissa>0 → Planning→CognitiveWork; neutral+0 → Hold → no advance, phase stays). clippy + fmt clean; light build, no disk/symbiont gate. This is the actor-side S2 consumer (`mul_phase_step` node wrapper stays the single-node convenience). **Remaining S2:** the per-row `cognitive-shader-driver` owner loop over the `qualia` column (needs `MailboxSoaView::qualia()` + the shader-driver build = disk) — heavier, deferred. **OUT-leg now real+tested on the light crate: S4 actor (#576) + delivery edge (#577) + S2 actor-side driver (this).** Only S3 (lance `LanceVersionScheduler` consumer) + run-NaN need the heavier builds. Plan S2 status annotated. Rides a PR on jirak. ## 2026-06-21 (cont.³²) — S4 delivery edge SHIPPED — S4 mechanism now COMPLETE end-to-end **Main thread (Opus), self-directed (da-capo).** Completed S4 on the same light crate: `deliver_kanban_step("kanban..")` in `lance-graph-supervisor::kanban_actor` — `parse_kanban_step` (snake_case phase vocab) → `ractor::registry::where_is(mailbox)` → `cast(KanbanMsg::Advance{to})` → relays the owner's `try_advance_phase` result. Address source = the step's existing string + the actor system's OWN registry (NOT a bespoke bridge registry, NOT a `UnifiedStep` field — exactly the codex-#574-corrected design). `KanbanRouteError`: `BadStepType` / `NoMailbox` (routing miss, NOT a no-owner case — a live mailbox is always owned) / `Illegal` (relayed RubiconTransitionError) / `Rpc`. **+2 tests (4 total green):** `parse_kanban_step_shapes`, `delivery_edge_resolves_via_registry_then_advances` (legal advance via where_is; unknown mailbox → graceful NoMailbox; illegal edge → Illegal; malformed → BadStepType). clippy clean (fixed an `unnecessary_to_owned` on the where_is arg) + fmt; light build, no disk/symbiont gate. **S4 mechanism is now COMPLETE end-to-end** (owner-advance #576 + delivery edge); only the S2/S3 *drivers* that SEND Advance remain, composing on top. Plan S4 status → "COMPLETE". 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 5be12849..3f943eca 100644 --- a/.claude/plans/capstone-out-leg-wiring-v1.md +++ b/.claude/plans/capstone-out-leg-wiring-v1.md @@ -35,6 +35,19 @@ moment a surface frees. ## S2 — MUL→phase seam gets a real owner-side consumer +**Status (2026-06-21): MUL→phase DRIVER shipped** (actor-side path). +`lance-graph-supervisor::kanban_actor::drive_mul_advance(actor, qualia, mantissa)` +reads the owner's phase, runs `gate_decision_i4` → `advance_on_gate`, and on a +non-Hold gate `cast`s `KanbanMsg::Advance` to the owning actor (the S2→S4 +composition; the owner advances itself). `mul_target` is the pure lowering. +Integer i4 gate — no f64/NaN. Test `s2_driver_gate_advances_then_holds` green +(Flow → Planning→CognitiveWork; Hold → no advance). This is the actor-side S2 +consumer the census wanted (the `mul_phase_step` node wrapper stays the +single-node convenience). **Remaining (heavier, deferred):** the per-row owner +loop in `cognitive-shader-driver` that reads the `qualia` column and drives many +rows — needs `MailboxSoaView::qualia()` (the `soa_view.rs:157` deferral) + the +shader-driver build (disk). The actor-side trigger is real, tested code now. + **Census state:** GAP. `NodeRow::mul_phase_step` (gate→phase) is test-only; `sigma-tier-router` consumes `gate_decision_i4` for tier dispatch, not phase. diff --git a/crates/lance-graph-supervisor/src/kanban_actor.rs b/crates/lance-graph-supervisor/src/kanban_actor.rs index 8ddf756d..4fd1636f 100644 --- a/crates/lance-graph-supervisor/src/kanban_actor.rs +++ b/crates/lance-graph-supervisor/src/kanban_actor.rs @@ -25,7 +25,9 @@ //! Rubicon edge is a typed [`RubiconTransitionError`], never silent corruption. use lance_graph_contract::kanban::{KanbanColumn, KanbanMove, RubiconTransitionError}; +use lance_graph_contract::mul::i4_eval::gate_decision_i4; use lance_graph_contract::soa_view::MailboxSoaOwner; +use lance_graph_contract::QualiaI4_16D; use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort}; /// Messages the kanban actor accepts. @@ -39,6 +41,17 @@ pub enum KanbanMsg { }, /// Read the owned mailbox's current Rubicon phase (no mutation). Phase { reply: RpcReplyPort }, + /// **Atomic** S2 step: run the MUL gate (`gate_decision_i4` over `qualia` + + /// `mantissa`) against the owner's CURRENT phase and advance in ONE message. + /// Replies `Ok(Some(move))` on advance, `Ok(None)` on Hold, or the typed + /// error on an illegal edge. Gate-read and transition are serialized with the + /// owner state (one mailbox message), so a concurrent sender cannot make the + /// phase read stale between decision and mutation (codex #578). + MulAdvance { + qualia: QualiaI4_16D, + mantissa: i8, + reply: RpcReplyPort, RubiconTransitionError>>, + }, } /// A ractor actor whose `State` IS a [`MailboxSoaOwner`] — the SoA mailbox and @@ -91,6 +104,21 @@ where KanbanMsg::Phase { reply } => { let _ = reply.send(state.phase()); } + KanbanMsg::MulAdvance { + qualia, + mantissa, + reply, + } => { + // Gate-decision + transition in ONE serialized message: the gate + // reads `state.phase()` at the instant of mutation, so a + // concurrent sender can't make it stale (mailbox-as-owner + // atomicity — codex #578). + let result = match mul_target(state.phase(), &qualia, mantissa) { + None => Ok(None), // Hold + Some(to) => state.try_advance_phase(to).map(Some), // advance + }; + let _ = reply.send(result); + } } Ok(()) } @@ -168,6 +196,49 @@ pub async fn deliver_kanban_step(step_type: &str) -> Result Option { + let gate = gate_decision_i4(qualia, mantissa); + phase.advance_on_gate(&gate) +} + +/// S2 driver: the MUL gate decides, the owner advances ITSELF — in ONE atomic +/// actor message ([`KanbanMsg::MulAdvance`]). Returns the emitted [`KanbanMove`] +/// on advance, `None` on Hold, or [`KanbanRouteError::Illegal`] on an illegal +/// edge. +/// +/// **Atomicity (codex #578):** the gate-read and the transition run inside the +/// SAME serialized mailbox message, so the gate sees the owner's phase at the +/// instant of mutation — two concurrent drivers can't both read a stale +/// `Planning` and collide. (The earlier two-RPC `Phase`-then-`Advance` shape had +/// that race.) `advance_on_gate` only yields a DAG-legal successor, so `Illegal` +/// here would signal a gate/DAG drift bug — surfaced, not panicked. +pub async fn drive_mul_advance( + actor: &ActorRef, + qualia: QualiaI4_16D, + mantissa: i8, +) -> Result, KanbanRouteError> { + let inner = ractor::call!(actor, |reply| KanbanMsg::MulAdvance { + qualia, + mantissa, + reply + }) + .map_err(|e| KanbanRouteError::Rpc(e.to_string()))?; + inner.map_err(|e| KanbanRouteError::Illegal { + from: e.from, + to: e.to, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -345,4 +416,70 @@ mod tests { actor.stop(None); handle.await.expect("actor join"); } + + #[tokio::test] + async fn s2_driver_gate_advances_then_holds() { + let (actor, handle) = Actor::spawn( + None, + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn"); + + // Flow qualia (warmth/groundedness high, low tension, calibrated) + + // mantissa>0 → gate Flow → forward advance Planning → CognitiveWork. + let flow_q = QualiaI4_16D(0).with(3, 4).with(14, 3).with(9, 4).with(1, 2); + let mv = drive_mul_advance(&actor, flow_q, 4) + .await + .expect("driver ok") + .expect("advanced on Flow"); + assert_eq!(mv.from, KanbanColumn::Planning); + assert_eq!(mv.to, KanbanColumn::CognitiveWork); + + // Neutral qualia + mantissa 0 → gate Hold → None (owner stays put). + let held = drive_mul_advance(&actor, QualiaI4_16D(0), 0) + .await + .expect("driver ok"); + assert!(held.is_none(), "Hold must not advance"); + let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc"); + assert_eq!(phase, KanbanColumn::CognitiveWork); + + actor.stop(None); + handle.await.expect("actor join"); + } + + #[tokio::test] + async fn concurrent_mul_drivers_serialize_no_spurious_rejection() { + // codex #578: two concurrent Flow drivers must NOT both read a stale + // `Planning` and collide. The atomic `MulAdvance` serializes gate+advance + // in the owner's mailbox, so they chain Planning→CognitiveWork→Evaluation + // — both succeed, neither is a spurious `Illegal`. + let (actor, handle) = Actor::spawn( + None, + KanbanActor::::default(), + board(KanbanColumn::Planning), + ) + .await + .expect("spawn"); + + let flow = || QualiaI4_16D(0).with(3, 4).with(14, 3).with(9, 4).with(1, 2); + let a1 = actor.clone(); + let a2 = actor.clone(); + let (r1, r2) = tokio::join!( + drive_mul_advance(&a1, flow(), 4), + drive_mul_advance(&a2, flow(), 4), + ); + + // Neither call is a spurious rejection; both advanced along the arc. + assert!(r1.expect("driver1 ok").is_some(), "first advanced"); + assert!(r2.expect("driver2 ok").is_some(), "second advanced"); + + // Serialized chain: Planning → CognitiveWork → Evaluation. + let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc"); + assert_eq!(phase, KanbanColumn::Evaluation); + + actor.stop(None); + handle.await.expect("actor join"); + } }