Skip to content

Commit 98d5d2f

Browse files
authored
Merge pull request #579 from AdaWorldAPI/claude/jirak-math-theorems-harvest-rfii13
S3 IN-leg driver — version tick → owner forward-arc advance (no-op suppressed)
2 parents c29b580 + fd1d58f commit 98d5d2f

3 files changed

Lines changed: 289 additions & 4 deletions

File tree

.claude/board/AGENT_LOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 2026-06-21 (cont.³⁴) — S3 IN-leg driver SHIPPED (actor-side) — version tick → owner forward-arc advance, no-op suppressed
2+
3+
**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.
14
## 2026-06-21 (cont.³³) — S2 MUL→phase driver SHIPPED (actor-side) — gate → owner advance
25

36
**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.

.claude/plans/capstone-out-leg-wiring-v1.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,36 @@ advances exactly the expected rows; integer i4 gate ⇒ no NaN.
7373

7474
## S3 — version→move gets the LIVE subscription (not a synthetic tick)
7575

76-
**Census state:** PARTIAL. `symbiont::kanban_loop` exercises `on_version` from a
77-
synthetic `u32` tick (`self.cycle`); the lowering is proven, the live source is
78-
open.
76+
**Status (2026-06-21): apply+suppress DRIVER shipped** (actor-side path).
77+
`lance-graph-supervisor::kanban_actor` (feature `supervisor`) now carries the
78+
IN-leg consumer primitives:
79+
- `KanbanMsg::Tick { at, reply }` — the **atomic** in-actor realization of
80+
`NextPhaseScheduler`: a version tick advances the owner along the forward arc
81+
(`phase().next_phases().first()`) in ONE serialized message, reading the phase
82+
at the instant of mutation (the codex-#578 atomicity lesson applied to the
83+
IN-leg). Absorbing column → `None`: **the no-op tick is suppressed**, not an
84+
error.
85+
- `drive_version_tick(actor, at)` — thin async wrapper over `Tick`.
86+
- `drive_scheduled_tick(scheduler, view, at, exec, actor)` — generic consumer
87+
that drives the EXISTING `VersionScheduler` trait ("propose, don't dispose":
88+
the scheduler proposes from a view, the owner disposes via `Advance`; `None`
89+
suppresses). For custom policies (version-delta gating, `Plan`/`Prune`,
90+
batching) that read a richer view than the owner computes internally.
91+
92+
Tests (light, no lance): forward-arc chain Planning→…→Commit then suppressed at
93+
absorbing; two concurrent ticks serialize along the arc (no stale-phase
94+
collision); the generic consumer drives `NextPhaseScheduler` propose→dispose +
95+
suppresses an absorbing proposal.
96+
97+
**Remaining (lance/disk-gated):** wire the LIVE source —
98+
`lance-graph::graph::scheduler::LanceVersionScheduler::drive_at_latest` over a
99+
real `VersionedGraph::versions()` — to feed `at` into `drive_version_tick` (or a
100+
custom policy into `drive_scheduled_tick`). The apply + no-op-suppress loop is
101+
done; only the live `versions()` poll remains.
102+
103+
**Census state (orig):** PARTIAL. `symbiont::kanban_loop` exercises `on_version`
104+
from a synthetic `u32` tick (`self.cycle`); the lowering is proven, the live
105+
source is open.
79106

80107
**Enabler:** none — and crucially the **live scheduler ALSO already exists**:
81108
`lance-graph::graph::scheduler::LanceVersionScheduler<S = NextPhaseScheduler>`

crates/lance-graph-supervisor/src/kanban_actor.rs

Lines changed: 256 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
2727
use lance_graph_contract::kanban::{KanbanColumn, KanbanMove, RubiconTransitionError};
2828
use lance_graph_contract::mul::i4_eval::gate_decision_i4;
29-
use lance_graph_contract::soa_view::MailboxSoaOwner;
29+
use lance_graph_contract::scheduler::{DatasetVersion, VersionScheduler};
30+
use lance_graph_contract::soa_view::{MailboxSoaOwner, MailboxSoaView};
3031
use lance_graph_contract::QualiaI4_16D;
3132
use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort};
3233

@@ -52,6 +53,21 @@ pub enum KanbanMsg {
5253
mantissa: i8,
5354
reply: RpcReplyPort<Result<Option<KanbanMove>, RubiconTransitionError>>,
5455
},
56+
/// **Atomic** S3 IN-leg step: a substrate version tick (`at`) advances the
57+
/// owner along the Rubicon **forward arc** — `phase().next_phases().first()` —
58+
/// in ONE message, reading the owner's phase at the instant of mutation. This
59+
/// is the in-actor realization of [`scheduler::NextPhaseScheduler`]'s policy
60+
/// (`E-SUBSTRATE-IS-THE-SCHEDULER`): a Lance `versions()` event lowers to the
61+
/// next legal move and the owner applies it. Replies `Some(move)` on advance,
62+
/// or `None` when the owner is in an absorbing column (`Commit`/`Prune`) — a
63+
/// **no-op tick is suppressed**, not an error. No error variant: the forward
64+
/// arc is legal by construction.
65+
///
66+
/// [`scheduler::NextPhaseScheduler`]: lance_graph_contract::scheduler::NextPhaseScheduler
67+
Tick {
68+
at: DatasetVersion,
69+
reply: RpcReplyPort<Option<KanbanMove>>,
70+
},
5571
}
5672

5773
/// A ractor actor whose `State` IS a [`MailboxSoaOwner`] — the SoA mailbox and
@@ -119,6 +135,19 @@ where
119135
};
120136
let _ = reply.send(result);
121137
}
138+
KanbanMsg::Tick { at: _, reply } => {
139+
// Forward-arc advance, atomic against the owner's live phase. The
140+
// first legal successor is empty exactly for absorbing columns
141+
// (`Commit`/`Prune`) → `None` suppresses the no-op tick. The arc
142+
// is legal by construction, so the infallible `advance_phase` is
143+
// correct here (no `try_`/error path).
144+
let from = state.phase();
145+
let moved = from
146+
.next_phases()
147+
.first()
148+
.map(|&to| state.advance_phase(to));
149+
let _ = reply.send(moved);
150+
}
122151
}
123152
Ok(())
124153
}
@@ -239,6 +268,85 @@ pub async fn drive_mul_advance(
239268
})
240269
}
241270

271+
// ─── S3 IN-leg: substrate version tick → owner forward-arc advance ─────────────
272+
273+
/// S3 driver: a substrate version tick advances the owner along the Rubicon
274+
/// forward arc, in ONE atomic actor message ([`KanbanMsg::Tick`]). Returns the
275+
/// emitted [`KanbanMove`] on advance, or `None` when the owner is absorbing
276+
/// (`Commit`/`Prune`) — the **no-op tick is suppressed** (D-MBX-9-IN,
277+
/// `E-SUBSTRATE-IS-THE-SCHEDULER`).
278+
///
279+
/// **Atomicity:** like [`drive_mul_advance`], the next-phase decision and the
280+
/// transition run inside the SAME serialized mailbox message, so concurrent ticks
281+
/// cannot read a stale phase and collide — they chain along the arc instead
282+
/// (codex #578 lesson, applied to the IN-leg). This is the actor-side realization
283+
/// of the contract's [`NextPhaseScheduler`] policy; use [`drive_scheduled_tick`]
284+
/// when a custom [`VersionScheduler`] policy (version-delta gating, `Plan`/`Prune`
285+
/// over the forward arc, batching) reads a richer view.
286+
///
287+
/// [`NextPhaseScheduler`]: lance_graph_contract::scheduler::NextPhaseScheduler
288+
pub async fn drive_version_tick(
289+
actor: &ActorRef<KanbanMsg>,
290+
at: DatasetVersion,
291+
) -> Result<Option<KanbanMove>, KanbanRouteError> {
292+
ractor::call!(actor, |reply| KanbanMsg::Tick { at, reply })
293+
.map_err(|e| KanbanRouteError::Rpc(e.to_string()))
294+
}
295+
296+
/// S3 driver (custom policy): drive an arbitrary [`VersionScheduler`] for one
297+
/// version tick. The scheduler **proposes** the next move from `view`; if it
298+
/// yields `Some`, the owner **disposes** it via [`KanbanMsg::Advance`]; `None`
299+
/// **suppresses the no-op tick** ("propose, don't dispose" — the scheduler reads,
300+
/// the owner is the sole mutator).
301+
///
302+
/// Unlike [`drive_version_tick`], the proposal is computed OUTSIDE the owner's
303+
/// message (from the supplied `view`), so it is **advisory**: if the owner's phase
304+
/// changes between the proposal and the `Advance`, the edge may be rejected
305+
/// ([`KanbanRouteError::Illegal`]) rather than silently corrupting — surfaced, not
306+
/// panicked. The returned move is the owner's (authoritative phase transition,
307+
/// witness position, and libet anchor from the REAL mutation) with the
308+
/// **scheduler's `exec` overlaid** — the backend routing tag is the policy's
309+
/// decision, which the owner (defaulting to `Native`) can't make. For the pure
310+
/// forward-arc policy prefer the atomic [`drive_version_tick`]; reach for this
311+
/// only when the policy needs a richer view than the owner computes internally.
312+
pub async fn drive_scheduled_tick<S, V>(
313+
scheduler: &S,
314+
view: &V,
315+
at: DatasetVersion,
316+
exec: lance_graph_contract::kanban::ExecTarget,
317+
actor: &ActorRef<KanbanMsg>,
318+
) -> Result<Option<KanbanMove>, KanbanRouteError>
319+
where
320+
S: VersionScheduler,
321+
V: MailboxSoaView,
322+
{
323+
// Propose: lower the version event to the next legal move (or `None`).
324+
let Some(proposed) = scheduler.on_version(view, at, exec) else {
325+
return Ok(None); // absorbing / policy-filtered → suppress the no-op tick
326+
};
327+
// Dispose: the owner applies it (checked); relay an illegal edge as typed.
328+
let inner = ractor::call!(actor, |reply| KanbanMsg::Advance {
329+
to: proposed.to,
330+
reply
331+
})
332+
.map_err(|e| KanbanRouteError::Rpc(e.to_string()))?;
333+
match inner {
334+
// The owner's emitted move is authoritative for the phase transition,
335+
// witness position, and libet anchor (from the REAL mutation), but it
336+
// defaults to `ExecTarget::Native` and can't know which backend the policy
337+
// chose — overlay the scheduler's selection so a `Jit`/`SurrealQl`/`Elixir`
338+
// target is not silently reported/routed as Native (codex #579 P2).
339+
Ok(mut emitted) => {
340+
emitted.exec = proposed.exec;
341+
Ok(Some(emitted))
342+
}
343+
Err(e) => Err(KanbanRouteError::Illegal {
344+
from: e.from,
345+
to: e.to,
346+
}),
347+
}
348+
}
349+
242350
#[cfg(test)]
243351
mod tests {
244352
use super::*;
@@ -482,4 +590,151 @@ mod tests {
482590
actor.stop(None);
483591
handle.await.expect("actor join");
484592
}
593+
594+
#[tokio::test]
595+
async fn version_tick_advances_forward_arc_then_suppresses_at_absorbing() {
596+
// S3 IN-leg: a version tick advances along the forward arc; once the owner
597+
// reaches an absorbing column the tick is a suppressed no-op (`None`).
598+
let (actor, handle) = Actor::spawn(
599+
None,
600+
KanbanActor::<TestBoard>::default(),
601+
board(KanbanColumn::Planning),
602+
)
603+
.await
604+
.expect("spawn");
605+
606+
// Planning → CognitiveWork → Evaluation → Commit, one tick per version.
607+
let expected = [
608+
KanbanColumn::CognitiveWork,
609+
KanbanColumn::Evaluation,
610+
KanbanColumn::Commit,
611+
];
612+
for (i, want) in expected.iter().enumerate() {
613+
let mv = drive_version_tick(&actor, DatasetVersion(i as u64 + 1))
614+
.await
615+
.expect("tick ok")
616+
.expect("non-absorbing advances");
617+
assert_eq!(mv.to, *want);
618+
}
619+
620+
// Commit is absorbing: the next tick advances nothing (no-op suppressed).
621+
let noop = drive_version_tick(&actor, DatasetVersion(99))
622+
.await
623+
.expect("tick ok");
624+
assert!(noop.is_none(), "absorbing column must suppress the tick");
625+
let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc");
626+
assert_eq!(phase, KanbanColumn::Commit);
627+
628+
actor.stop(None);
629+
handle.await.expect("actor join");
630+
}
631+
632+
#[tokio::test]
633+
async fn concurrent_version_ticks_serialize_along_the_arc() {
634+
// Two concurrent ticks must NOT both read a stale `Planning`; the atomic
635+
// `Tick` serializes decision+advance in the owner's mailbox, so they chain
636+
// Planning → CognitiveWork → Evaluation (both advance, neither is lost).
637+
let (actor, handle) = Actor::spawn(
638+
None,
639+
KanbanActor::<TestBoard>::default(),
640+
board(KanbanColumn::Planning),
641+
)
642+
.await
643+
.expect("spawn");
644+
645+
let a1 = actor.clone();
646+
let a2 = actor.clone();
647+
let (r1, r2) = tokio::join!(
648+
drive_version_tick(&a1, DatasetVersion(1)),
649+
drive_version_tick(&a2, DatasetVersion(2)),
650+
);
651+
assert!(r1.expect("tick1 ok").is_some(), "first advanced");
652+
assert!(r2.expect("tick2 ok").is_some(), "second advanced");
653+
654+
let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc");
655+
assert_eq!(phase, KanbanColumn::Evaluation);
656+
657+
actor.stop(None);
658+
handle.await.expect("actor join");
659+
}
660+
661+
#[tokio::test]
662+
async fn custom_scheduler_proposes_and_owner_disposes() {
663+
use lance_graph_contract::scheduler::NextPhaseScheduler;
664+
665+
// The generic consumer drives the EXISTING `VersionScheduler` trait: the
666+
// reference `NextPhaseScheduler` proposes from a view, the owner disposes.
667+
let (actor, handle) = Actor::spawn(
668+
None,
669+
KanbanActor::<TestBoard>::default(),
670+
board(KanbanColumn::Planning),
671+
)
672+
.await
673+
.expect("spawn");
674+
675+
// View mirrors the owner's current phase; scheduler proposes CognitiveWork.
676+
let view = board(KanbanColumn::Planning);
677+
let mv = drive_scheduled_tick(
678+
&NextPhaseScheduler,
679+
&view,
680+
DatasetVersion(1),
681+
ExecTarget::Native,
682+
&actor,
683+
)
684+
.await
685+
.expect("scheduled ok")
686+
.expect("forward arc proposed + disposed");
687+
assert_eq!(mv.from, KanbanColumn::Planning);
688+
assert_eq!(mv.to, KanbanColumn::CognitiveWork);
689+
690+
// An absorbing view → scheduler yields `None` → suppressed, no RPC needed.
691+
let absorbing_view = board(KanbanColumn::Commit);
692+
let noop = drive_scheduled_tick(
693+
&NextPhaseScheduler,
694+
&absorbing_view,
695+
DatasetVersion(2),
696+
ExecTarget::Native,
697+
&actor,
698+
)
699+
.await
700+
.expect("scheduled ok");
701+
assert!(noop.is_none(), "absorbing proposal is suppressed");
702+
703+
actor.stop(None);
704+
handle.await.expect("actor join");
705+
}
706+
707+
#[tokio::test]
708+
async fn scheduled_tick_preserves_non_native_exec_target() {
709+
use lance_graph_contract::scheduler::NextPhaseScheduler;
710+
711+
// codex #579 P2: the scheduler selects the backend; the owner defaults to
712+
// `Native`. The returned move must carry the scheduler's exec, NOT be
713+
// flattened to the owner's Native default.
714+
for exec in [ExecTarget::Jit, ExecTarget::SurrealQl, ExecTarget::Elixir] {
715+
// Fresh owner per exec so the phase starts at Planning each iteration.
716+
let (actor, handle) = Actor::spawn(
717+
None,
718+
KanbanActor::<TestBoard>::default(),
719+
board(KanbanColumn::Planning),
720+
)
721+
.await
722+
.expect("spawn");
723+
724+
let view = board(KanbanColumn::Planning);
725+
let mv =
726+
drive_scheduled_tick(&NextPhaseScheduler, &view, DatasetVersion(1), exec, &actor)
727+
.await
728+
.expect("scheduled ok")
729+
.expect("forward arc proposed + disposed");
730+
assert_eq!(mv.to, KanbanColumn::CognitiveWork);
731+
assert_eq!(
732+
mv.exec, exec,
733+
"scheduler's backend must survive, not be overwritten with Native"
734+
);
735+
736+
actor.stop(None);
737+
handle.await.expect("actor join");
738+
}
739+
}
485740
}

0 commit comments

Comments
 (0)