|
| 1 | +//! # `scheduler` — the IN-direction reactive seam (`E-SUBSTRATE-IS-THE-SCHEDULER`). |
| 2 | +//! |
| 3 | +//! The dual of [`crate::soa_view::MailboxSoaOwner`]. The two directions of the |
| 4 | +//! Rubicon kanban over the ONE per-mailbox SoA: |
| 5 | +//! |
| 6 | +//! - **OUT** ([`MailboxSoaOwner::try_advance_phase`](crate::soa_view::MailboxSoaOwner::try_advance_phase)): |
| 7 | +//! the ractor owner advances a phase → that commit becomes a Lance dataset |
| 8 | +//! **version** → a [`KanbanMove`] (`E-VERSION-ARC-IS-THE-KANBAN`). |
| 9 | +//! - **IN** (this module): the reverse subscription — a substrate `LIVE`/scheduled |
| 10 | +//! event over `Dataset::versions()` is **lowered to the next legal** |
| 11 | +//! [`KanbanMove`], which the owner then applies via `try_advance_phase`. |
| 12 | +//! |
| 13 | +//! This collapses "build a transparent view" into "LIVE-subscribe + schedule" — |
| 14 | +//! the same shape as a CI/PR webhook firing the next job (D-MBX-9, |
| 15 | +//! `E-SUBSTRATE-IS-THE-SCHEDULER`). |
| 16 | +//! |
| 17 | +//! ## Why it lives in the zero-dep contract |
| 18 | +//! It composes **only** [`MailboxSoaView`] + [`KanbanColumn`] + [`KanbanMove`] + |
| 19 | +//! [`ExecTarget`] — no `lance`, no `surreal`, no async runtime. The CI-gated core |
| 20 | +//! impl (`D-MBX-9-IN`: a `LanceVersionScheduler` subscribing to |
| 21 | +//! `VersionedGraph::versions()` via the callcenter `LanceVersionWatcher`) lands in |
| 22 | +//! a buildable downstream crate; this trait is its airgap. |
| 23 | +//! |
| 24 | +//! ## Invariant — propose, don't dispose |
| 25 | +//! [`VersionScheduler::on_version`] takes `&V` (never `&mut`): the scheduler only |
| 26 | +//! **proposes** the next move; the [`MailboxSoaOwner`](crate::soa_view::MailboxSoaOwner) |
| 27 | +//! is the sole mutator (R1 "one SoA never transformed"; mirrors the |
| 28 | +//! `MailboxSoaView` / `MailboxSoaOwner` read/write split). |
| 29 | +
|
| 30 | +use crate::kanban::{ExecTarget, KanbanColumn, KanbanMove}; |
| 31 | +use crate::soa_view::MailboxSoaView; |
| 32 | + |
| 33 | +/// A monotonic Lance dataset version — the surreal Timeline tick, i.e. one entry |
| 34 | +/// of `Dataset::versions()`. The IN-direction event carrier. |
| 35 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] |
| 36 | +pub struct DatasetVersion(pub u64); |
| 37 | + |
| 38 | +/// Lower a substrate version event into the **next legal** kanban move for a |
| 39 | +/// mailbox view, or `None` when no advance is due (the mailbox is in an absorbing |
| 40 | +/// column, or the scheduler's policy filters this tick out). |
| 41 | +/// |
| 42 | +/// The dual of [`crate::soa_view::MailboxSoaOwner`]: a `VersionScheduler` is what a |
| 43 | +/// `surreal_container` `LIVE` query (or the callcenter `LanceVersionWatcher`) calls |
| 44 | +/// per `versions()` tick to decide whether — and how — the ractor owner should |
| 45 | +/// advance the mailbox lifecycle. |
| 46 | +pub trait VersionScheduler { |
| 47 | + /// Decide the next move for `view` on observing dataset version `at`. `exec` |
| 48 | + /// selects the backend the precipitated move runs on |
| 49 | + /// ([`ExecTarget::Native`]/[`Jit`](ExecTarget::Jit)/[`SurrealQl`](ExecTarget::SurrealQl)/[`Elixir`](ExecTarget::Elixir)). |
| 50 | + /// Returns `None` to schedule no advance (e.g. `view.phase().is_absorbing()`). |
| 51 | + fn on_version<V: MailboxSoaView>( |
| 52 | + &self, |
| 53 | + view: &V, |
| 54 | + at: DatasetVersion, |
| 55 | + exec: ExecTarget, |
| 56 | + ) -> Option<KanbanMove>; |
| 57 | +} |
| 58 | + |
| 59 | +/// The canonical reference scheduler: on every version, advance the mailbox along |
| 60 | +/// the Rubicon **forward arc** — the first legal successor of its current column — |
| 61 | +/// or yield `None` when the column is absorbing (`Commit`/`Prune`). |
| 62 | +/// |
| 63 | +/// The "forward arc" is [`KanbanColumn::next_phases`]`().first()`: |
| 64 | +/// `Planning → CognitiveWork`, `CognitiveWork → Evaluation`, `Evaluation → Commit`, |
| 65 | +/// `Plan → Planning` (re-deliberate), `Commit`/`Prune` → none. It stamps the Libet |
| 66 | +/// anchor (`-550_000 µs`) on the `Planning → CognitiveWork` Σ-commit crossing and |
| 67 | +/// `0` elsewhere — matching the `MailboxSoaOwner::advance_phase` convention. |
| 68 | +/// |
| 69 | +/// This is the substrate-free reference: real schedulers may gate on the |
| 70 | +/// `DatasetVersion` delta, choose `Plan`/`Prune` over the forward arc, or batch |
| 71 | +/// ticks — they implement [`VersionScheduler`] with their own policy. |
| 72 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 73 | +pub struct NextPhaseScheduler; |
| 74 | + |
| 75 | +impl VersionScheduler for NextPhaseScheduler { |
| 76 | + fn on_version<V: MailboxSoaView>( |
| 77 | + &self, |
| 78 | + view: &V, |
| 79 | + _at: DatasetVersion, |
| 80 | + exec: ExecTarget, |
| 81 | + ) -> Option<KanbanMove> { |
| 82 | + let from = view.phase(); |
| 83 | + // `next_phases()` is empty exactly for the absorbing columns (Commit/Prune): |
| 84 | + // `?` short-circuits to `None`, i.e. "the cycle ended — schedule nothing". |
| 85 | + let to = *from.next_phases().first()?; |
| 86 | + let libet_offset_us = |
| 87 | + if from == KanbanColumn::Planning && to == KanbanColumn::CognitiveWork { |
| 88 | + -550_000 |
| 89 | + } else { |
| 90 | + 0 |
| 91 | + }; |
| 92 | + Some(KanbanMove { |
| 93 | + mailbox: view.mailbox_id(), |
| 94 | + from, |
| 95 | + to, |
| 96 | + // Structural witness position (R4): the monotonic cycle stamp stands in |
| 97 | + // for the chain index until the A3 `witness_arc` column lands. |
| 98 | + witness_chain_position: view.current_cycle(), |
| 99 | + libet_offset_us, |
| 100 | + exec, |
| 101 | + }) |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +#[cfg(test)] |
| 106 | +mod tests { |
| 107 | + use super::*; |
| 108 | + use crate::collapse_gate::MailboxId; |
| 109 | + |
| 110 | + /// Minimal `MailboxSoaView` with a settable phase — proves the scheduler |
| 111 | + /// lowers a version event to the right move without any consumer crate |
| 112 | + /// (same pattern as `soa_view::tests::FakeSoa`). |
| 113 | + struct FakeView { |
| 114 | + id: MailboxId, |
| 115 | + phase: KanbanColumn, |
| 116 | + cycle: u32, |
| 117 | + } |
| 118 | + impl MailboxSoaView for FakeView { |
| 119 | + fn mailbox_id(&self) -> MailboxId { |
| 120 | + self.id |
| 121 | + } |
| 122 | + fn n_rows(&self) -> usize { |
| 123 | + 0 |
| 124 | + } |
| 125 | + fn w_slot(&self) -> u8 { |
| 126 | + (self.id & 0x3F) as u8 |
| 127 | + } |
| 128 | + fn current_cycle(&self) -> u32 { |
| 129 | + self.cycle |
| 130 | + } |
| 131 | + fn phase(&self) -> KanbanColumn { |
| 132 | + self.phase |
| 133 | + } |
| 134 | + fn energy(&self) -> &[f32] { |
| 135 | + &[] |
| 136 | + } |
| 137 | + fn edges_raw(&self) -> &[u64] { |
| 138 | + &[] |
| 139 | + } |
| 140 | + fn meta_raw(&self) -> &[u32] { |
| 141 | + &[] |
| 142 | + } |
| 143 | + fn entity_type(&self) -> &[u16] { |
| 144 | + &[] |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + fn view(phase: KanbanColumn) -> FakeView { |
| 149 | + FakeView { id: 42, phase, cycle: 9 } |
| 150 | + } |
| 151 | + |
| 152 | + #[test] |
| 153 | + fn planning_schedules_cognitive_work_with_libet_anchor() { |
| 154 | + let m = NextPhaseScheduler |
| 155 | + .on_version(&view(KanbanColumn::Planning), DatasetVersion(1), ExecTarget::Native) |
| 156 | + .expect("Planning is not absorbing"); |
| 157 | + assert_eq!(m.from, KanbanColumn::Planning); |
| 158 | + assert_eq!(m.to, KanbanColumn::CognitiveWork); // forward arc, not the Prune veto |
| 159 | + assert_eq!(m.libet_offset_us, -550_000); // the Σ-commit Rubicon crossing |
| 160 | + assert_eq!(m.mailbox, 42); |
| 161 | + assert_eq!(m.witness_chain_position, 9); // current_cycle stamp |
| 162 | + } |
| 163 | + |
| 164 | + #[test] |
| 165 | + fn mid_cycle_advances_carry_no_libet_anchor() { |
| 166 | + let cw = NextPhaseScheduler |
| 167 | + .on_version(&view(KanbanColumn::CognitiveWork), DatasetVersion(2), ExecTarget::Native) |
| 168 | + .unwrap(); |
| 169 | + assert_eq!(cw.to, KanbanColumn::Evaluation); |
| 170 | + assert_eq!(cw.libet_offset_us, 0); |
| 171 | + |
| 172 | + let ev = NextPhaseScheduler |
| 173 | + .on_version(&view(KanbanColumn::Evaluation), DatasetVersion(3), ExecTarget::Native) |
| 174 | + .unwrap(); |
| 175 | + assert_eq!(ev.to, KanbanColumn::Commit); // forward arc = calcify |
| 176 | + assert_eq!(ev.libet_offset_us, 0); |
| 177 | + } |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn plan_re_deliberates_back_to_planning() { |
| 181 | + let m = NextPhaseScheduler |
| 182 | + .on_version(&view(KanbanColumn::Plan), DatasetVersion(4), ExecTarget::Native) |
| 183 | + .unwrap(); |
| 184 | + assert_eq!(m.from, KanbanColumn::Plan); |
| 185 | + assert_eq!(m.to, KanbanColumn::Planning); // re-enter carrying the witness |
| 186 | + } |
| 187 | + |
| 188 | + #[test] |
| 189 | + fn absorbing_columns_schedule_nothing() { |
| 190 | + // Commit + Prune are absorbing: the cycle has ended, no move is due. |
| 191 | + assert!(NextPhaseScheduler |
| 192 | + .on_version(&view(KanbanColumn::Commit), DatasetVersion(5), ExecTarget::Native) |
| 193 | + .is_none()); |
| 194 | + assert!(NextPhaseScheduler |
| 195 | + .on_version(&view(KanbanColumn::Prune), DatasetVersion(6), ExecTarget::Native) |
| 196 | + .is_none()); |
| 197 | + } |
| 198 | + |
| 199 | + #[test] |
| 200 | + fn exec_target_threads_through_to_the_move() { |
| 201 | + // The scheduler carries the backend selection onto the precipitated move |
| 202 | + // (the Native/Jit/SurrealQl/Elixir routing tag for the IN-direction). |
| 203 | + for exec in [ExecTarget::Native, ExecTarget::Jit, ExecTarget::SurrealQl, ExecTarget::Elixir] { |
| 204 | + let m = NextPhaseScheduler |
| 205 | + .on_version(&view(KanbanColumn::Planning), DatasetVersion(7), exec) |
| 206 | + .unwrap(); |
| 207 | + assert_eq!(m.exec, exec); |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + #[test] |
| 212 | + fn scheduled_move_is_a_legal_rubicon_edge() { |
| 213 | + // Whatever the scheduler proposes MUST be a legal transition the owner's |
| 214 | + // `try_advance_phase` will accept (no illegal-edge proposals). |
| 215 | + for phase in [ |
| 216 | + KanbanColumn::Planning, |
| 217 | + KanbanColumn::CognitiveWork, |
| 218 | + KanbanColumn::Evaluation, |
| 219 | + KanbanColumn::Plan, |
| 220 | + ] { |
| 221 | + let m = NextPhaseScheduler |
| 222 | + .on_version(&view(phase), DatasetVersion(8), ExecTarget::Native) |
| 223 | + .unwrap(); |
| 224 | + assert!( |
| 225 | + m.from.can_transition_to(m.to), |
| 226 | + "{:?} -> {:?} must be a legal Rubicon edge", |
| 227 | + m.from, |
| 228 | + m.to |
| 229 | + ); |
| 230 | + } |
| 231 | + } |
| 232 | +} |
0 commit comments