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.³³) — 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.<mailbox>.<phase>")` 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.
Expand Down
13 changes: 13 additions & 0 deletions .claude/plans/capstone-out-leg-wiring-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
137 changes: 137 additions & 0 deletions crates/lance-graph-supervisor/src/kanban_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,6 +41,17 @@ pub enum KanbanMsg {
},
/// Read the owned mailbox's current Rubicon phase (no mutation).
Phase { reply: RpcReplyPort<KanbanColumn> },
/// **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<Result<Option<KanbanMove>, RubiconTransitionError>>,
},
}

/// A ractor actor whose `State` IS a [`MailboxSoaOwner`] — the SoA mailbox and
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -168,6 +196,49 @@ pub async fn deliver_kanban_step(step_type: &str) -> Result<KanbanMove, KanbanRo
})
}

// ─── S2 driver: MUL gate (`gate_decision_i4`) → owner advance ─────────────────

/// The MUL-gated target phase for `phase` given a node's `qualia` + inference
/// `mantissa`: run the i4 gate ([`gate_decision_i4`]) and lower it to the
/// DAG-legal next phase via [`KanbanColumn::advance_on_gate`] (Flow → forward,
/// Block → Prune-where-legal, Hold → `None`). Pure + integer-only (no f64/NaN).
pub fn mul_target(
phase: KanbanColumn,
qualia: &QualiaI4_16D,
mantissa: i8,
) -> Option<KanbanColumn> {
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<KanbanMsg>,
qualia: QualiaI4_16D,
mantissa: i8,
) -> Result<Option<KanbanMove>, 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::*;
Expand Down Expand Up @@ -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::<TestBoard>::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::<TestBoard>::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");
}
}
Loading