Skip to content

Commit a9ed99c

Browse files
committed
feat(planner): W1b/W1c live — WAL batch writer implemented, 4 probes green; M15 MulGateDecision rename
W1b: BTreeMap board (monotonic CastId = cast order), ack(cast, LanceVersion) with acked_version getter (the WAL<->temporal join, Addendum-8), delegation cache, never-refuses stacking. All 4 probes un-ignored and passing. M15 (blocking-before-W2): planner-local GateDecision{Proceed,Sandbox, Compass} -> MulGateDecision + deprecated alias; contract kanban gate {Flow,Hold,Block} keeps the name; zero internal alias usage. Kills the false friend before any planner->kanban emission. Planner lib 204 green; clippy clean on all six touched files (pre-existing nars_engine.rs:492 deprecated-CausalEdge64 debt noted, untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLBnPuScZy6w9di2QEjsXM
1 parent e9ca00c commit a9ed99c

6 files changed

Lines changed: 101 additions & 67 deletions

File tree

crates/lance-graph-planner/src/api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ use crate::selector::StrategySelector;
7474
use crate::traits::*;
7575

7676
// Re-export key types for ergonomic API
77-
pub use crate::mul::gate::GateDecision as Gate;
77+
pub use crate::mul::gate::MulGateDecision as Gate;
7878
pub use crate::mul::SituationInput;
7979
pub use crate::thinking::style::{FieldModulation, ScanParams, ThinkingCluster, ThinkingStyle};
8080
pub use crate::thinking::ThinkingContext;
Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! W1b ahead-firing batch writer — the kanban board IS the write-ahead log (M24).
22
//!
3-
//! `cast()` records intent moves AHEAD of any storage ack; `ack()` confirms;
3+
//! `cast()` records intent moves AHEAD of any storage ack; `ack()` confirms
4+
//! at the `LanceVersion` the sink assigned (the CastId↔LanceVersion join
5+
//! wiring the WAL into the temporal classifier; see `crate::temporal`);
46
//! `unacked()` is the crash-replay surface. Payload-generic: the writer never
57
//! inspects `P` (DTO purity — ownership rides the cast pairing, never the DTO).
68
//!
@@ -22,13 +24,19 @@
2224
//! mint a parallel `KanbanMove`; see the D-MBX-A6 Outcome adapter context in
2325
//! `crate::strategy::style_strategy`.
2426
25-
use std::collections::HashMap;
27+
use std::collections::{BTreeMap, HashMap};
2628

2729
use lance_graph_contract::collapse_gate::MailboxId;
2830
use lance_graph_contract::kanban::KanbanMove;
2931

32+
use crate::temporal::LanceVersion;
33+
3034
/// Identity of one `cast()` — a write-ahead intent record on the kanban board.
31-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35+
///
36+
/// `next_id` is monotonically increasing, so `CastId`'s derived `Ord` gives
37+
/// insertion order — this is what lets `board`/`acked` use `BTreeMap` and
38+
/// have `unacked()` iterate in cast order for free.
39+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3240
pub struct CastId(pub u64);
3341

3442
/// Ahead-firing batch writer: intent (`cast`) is visible on the board before
@@ -40,10 +48,15 @@ pub struct BatchWriter<P> {
4048
/// Board: intent moves recorded per cast, keyed by `CastId`, alongside the
4149
/// mailbox the cast was recorded on behalf of. Visible between `cast()`
4250
/// and `ack()` (and beyond, for crash-replay via `unacked()`).
43-
board: HashMap<CastId, (MailboxId, Vec<KanbanMove>)>,
44-
/// Casts that have been confirmed (acked). A cast present in `board` but
45-
/// absent from `acked` is the crash-replay surface (`unacked()`).
46-
acked: std::collections::HashSet<CastId>,
51+
/// `BTreeMap` (not `HashMap`) so iteration is in ascending `CastId` order
52+
/// — i.e. cast (insertion) order, matching `unacked()`'s ordering contract.
53+
board: BTreeMap<CastId, (MailboxId, Vec<KanbanMove>)>,
54+
/// Casts that have been confirmed (acked), mapped to the `LanceVersion`
55+
/// the sink assigned when it flushed — the CastId↔LanceVersion join
56+
/// wiring the WAL into the temporal classifier (see `crate::temporal`).
57+
/// A cast present in `board` but absent from `acked` is the crash-replay
58+
/// surface (`unacked()`).
59+
acked: BTreeMap<CastId, LanceVersion>,
4760
/// W1c delegation cache: `on_behalf` mailbox -> resolved owner mailbox.
4861
delegation_cache: HashMap<MailboxId, MailboxId>,
4962
/// Payloads recorded per cast (payload-generic; the writer never inspects `P`).
@@ -61,8 +74,8 @@ impl<P> BatchWriter<P> {
6174
pub fn new() -> Self {
6275
Self {
6376
next_id: 0,
64-
board: HashMap::new(),
65-
acked: std::collections::HashSet::new(),
77+
board: BTreeMap::new(),
78+
acked: BTreeMap::new(),
6679
delegation_cache: HashMap::new(),
6780
pending_payloads: Vec::new(),
6881
}
@@ -71,47 +84,58 @@ impl<P> BatchWriter<P> {
7184
/// AHEAD: records the intent (moves visible on the board) BEFORE any ack.
7285
/// Returns the cast id.
7386
///
74-
/// Probe-first skeleton (D-V3-W1e): body pending — see
75-
/// `tests/w1_probes.rs::probe_ahead_update_ordering` /
76-
/// `probe_kill_after_cast_replay`.
77-
pub fn cast(&mut self, _on_behalf: MailboxId, _moves: Vec<KanbanMove>, _payload: P) -> CastId {
78-
todo!("W1b")
87+
/// "Melden macht frei" (plan Addendum-7): casting is REPORTING, never
88+
/// refused because earlier casts on the same mailbox are still unacked —
89+
/// stacked casts are stacked WAL entries, each with its own `CastId`.
90+
pub fn cast(&mut self, on_behalf: MailboxId, moves: Vec<KanbanMove>, payload: P) -> CastId {
91+
let cast = CastId(self.next_id);
92+
self.next_id += 1;
93+
self.board.insert(cast, (on_behalf, moves));
94+
self.pending_payloads.push((cast, payload));
95+
cast
7996
}
8097

81-
/// Confirmation — marks the cast acked.
82-
///
83-
/// Probe-first skeleton (D-V3-W1e): body pending — see
84-
/// `tests/w1_probes.rs::probe_ahead_update_ordering`.
85-
pub fn ack(&mut self, _cast: CastId) {
86-
todo!("W1b")
98+
/// Confirmation — marks the cast acked at the Lance version the sink
99+
/// assigned (the CastId↔LanceVersion join wiring the WAL into the
100+
/// temporal classifier; see planner `temporal.rs`).
101+
pub fn ack(&mut self, cast: CastId, version: LanceVersion) {
102+
self.acked.insert(cast, version);
87103
}
88104

89-
/// Crash-replay surface (M24): casts recorded but not yet acked.
90-
///
91-
/// Probe-first skeleton (D-V3-W1e): body pending — see
92-
/// `tests/w1_probes.rs::probe_kill_after_cast_replay`.
105+
/// The `LanceVersion` a cast was acked at, if it has been acked.
106+
#[must_use]
107+
pub fn acked_version(&self, cast: CastId) -> Option<LanceVersion> {
108+
self.acked.get(&cast).copied()
109+
}
110+
111+
/// Crash-replay surface (M24): casts recorded but not yet acked, in
112+
/// ascending `CastId` (cast) order.
113+
#[must_use]
93114
pub fn unacked(&self) -> Vec<CastId> {
94-
todo!("W1b")
115+
self.board
116+
.keys()
117+
.filter(|cast| !self.acked.contains_key(cast))
118+
.copied()
119+
.collect()
95120
}
96121

97122
/// Board read: intent moves recorded for a cast (visible between cast and ack).
98-
///
99-
/// Probe-first skeleton (D-V3-W1e): body pending — see
100-
/// `tests/w1_probes.rs::probe_ahead_update_ordering` /
101-
/// `probe_kill_after_cast_replay`.
102-
pub fn intent_moves(&self, _cast: CastId) -> Option<&[KanbanMove]> {
103-
todo!("W1b")
123+
#[must_use]
124+
pub fn intent_moves(&self, cast: CastId) -> Option<&[KanbanMove]> {
125+
self.board.get(&cast).map(|(_, moves)| moves.as_slice())
104126
}
105127

106128
/// W1c delegation cache: resolve an owner once, cache; returns `(owner, was_cache_hit)`.
107-
///
108-
/// Probe-first skeleton (D-V3-W1e): body pending — see
109-
/// `tests/w1_probes.rs::probe_delegation_miss_then_hit`.
110129
pub fn resolve_owner(
111130
&mut self,
112-
_on_behalf: MailboxId,
113-
_resolver: impl FnOnce(MailboxId) -> MailboxId,
131+
on_behalf: MailboxId,
132+
resolver: impl FnOnce(MailboxId) -> MailboxId,
114133
) -> (MailboxId, bool) {
115-
todo!("W1c")
134+
if let Some(&owner) = self.delegation_cache.get(&on_behalf) {
135+
return (owner, true);
136+
}
137+
let owner = resolver(on_behalf);
138+
self.delegation_cache.insert(on_behalf, owner);
139+
(owner, false)
116140
}
117141
}

crates/lance-graph-planner/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ pub mod temporal;
9393
mod orchestration_impl;
9494

9595
use ir::LogicalPlan;
96-
use mul::{GateDecision, MulAssessment};
96+
use mul::{MulAssessment, MulGateDecision};
9797
use selector::StrategySelector;
9898
use thinking::ThinkingContext;
9999
use traits::{PlanContext, PlanStrategy, QueryFeatures};
@@ -181,7 +181,7 @@ impl PlannerAwareness {
181181
let gate = mul::gate_check(&mul_assessment);
182182

183183
match gate {
184-
GateDecision::Proceed { free_will_modifier } => {
184+
MulGateDecision::Proceed { free_will_modifier } => {
185185
// === LAYER 2: THINKING ORCHESTRATION ===
186186
let thinking_ctx =
187187
thinking::orchestrate(query, &mul_assessment, &plan::PlannerConfig::default());
@@ -219,8 +219,8 @@ impl PlannerAwareness {
219219
emitted_edges: Vec::new(),
220220
})
221221
}
222-
GateDecision::Sandbox { reason } => Err(PlanError::GateBlocked { reason }),
223-
GateDecision::Compass => {
222+
MulGateDecision::Sandbox { reason } => Err(PlanError::GateBlocked { reason }),
223+
MulGateDecision::Compass => {
224224
let compass = mul::compass::navigate(query, &mul_assessment);
225225
match compass.decision {
226226
mul::compass::CompassDecision::SurfaceToMeta => Err(PlanError::SurfaceToMeta {

crates/lance-graph-planner/src/mul/gate.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
use super::{dk::DkPosition, homeostasis::FlowState, trust::TrustTexture, MulAssessment};
1111

1212
/// Gate decision.
13+
///
14+
/// Renamed from `GateDecision` (M15) — this is the planner's
15+
/// Meta-Uncertainty-Layer verdict (Proceed/Sandbox/Compass), NOT the
16+
/// contract's kanban gate (`lance_graph_contract::mul::GateDecision
17+
/// {Flow, Hold, Block}`) which `KanbanColumn::advance_on_gate` consumes.
1318
#[derive(Debug, Clone)]
14-
pub enum GateDecision {
19+
pub enum MulGateDecision {
1520
/// All checks pass. Proceed with free will modifier applied.
1621
Proceed { free_will_modifier: f64 },
1722
/// Gate blocked. Need sandbox or human assistance.
@@ -20,36 +25,43 @@ pub enum GateDecision {
2025
Compass,
2126
}
2227

28+
/// Renamed to `MulGateDecision` (M15); the unqualified name collided with
29+
/// `lance_graph_contract::mul::GateDecision`.
30+
#[deprecated(
31+
note = "renamed to MulGateDecision (M15); the unqualified name collided with contract mul::GateDecision"
32+
)]
33+
pub type GateDecision = MulGateDecision;
34+
2335
/// Check the gate conditions.
24-
pub fn check(assessment: &MulAssessment) -> GateDecision {
36+
pub fn check(assessment: &MulAssessment) -> MulGateDecision {
2537
// Check 1: Not Mount Stupid
2638
if assessment.dk_position == DkPosition::MountStupid {
27-
return GateDecision::Sandbox {
39+
return MulGateDecision::Sandbox {
2840
reason: "Dunning-Kruger: Mount Stupid detected. Learn first.".into(),
2941
};
3042
}
3143

3244
// Check 2: Complexity mapped
3345
if !assessment.complexity_mapped {
34-
return GateDecision::Sandbox {
46+
return MulGateDecision::Sandbox {
3547
reason: "Complexity not mapped. Map dimensions before proceeding.".into(),
3648
};
3749
}
3850

3951
// Check 3: Not depleted
4052
if assessment.homeostasis.needs_recovery {
41-
return GateDecision::Sandbox {
53+
return MulGateDecision::Sandbox {
4254
reason: "Depleted. Recover homeostasis first.".into(),
4355
};
4456
}
4557

4658
// Check 4: Trust not murky/dissonant
4759
match assessment.trust.texture {
4860
TrustTexture::Murky => {
49-
return GateDecision::Compass; // Can navigate with compass
61+
return MulGateDecision::Compass; // Can navigate with compass
5062
}
5163
TrustTexture::Dissonant => {
52-
return GateDecision::Sandbox {
64+
return MulGateDecision::Sandbox {
5365
reason: "Trust dissonant. Resolve dissonance before proceeding.".into(),
5466
};
5567
}
@@ -58,11 +70,11 @@ pub fn check(assessment: &MulAssessment) -> GateDecision {
5870

5971
// Check 5: If trust is fuzzy, use compass for navigation
6072
if assessment.trust.texture == TrustTexture::Fuzzy {
61-
return GateDecision::Compass;
73+
return MulGateDecision::Compass;
6274
}
6375

6476
// All checks pass
65-
GateDecision::Proceed {
77+
MulGateDecision::Proceed {
6678
free_will_modifier: assessment.free_will_modifier,
6779
}
6880
}

crates/lance-graph-planner/src/mul/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub mod trust;
2323

2424
pub use compass::{CompassDecision, CompassResult};
2525
pub use dk::{DkDetector, DkPosition};
26-
pub use gate::GateDecision;
26+
pub use gate::MulGateDecision;
2727
pub use homeostasis::{FlowState, Homeostasis};
2828
pub use trust::{TrustQualia, TrustTexture};
2929

@@ -123,7 +123,7 @@ pub fn assess(input: &SituationInput) -> MulAssessment {
123123
}
124124

125125
/// Gate check: determine whether to proceed, sandbox, or use compass.
126-
pub fn gate_check(assessment: &MulAssessment) -> GateDecision {
126+
pub fn gate_check(assessment: &MulAssessment) -> MulGateDecision {
127127
gate::check(assessment)
128128
}
129129

@@ -152,7 +152,7 @@ mod tests {
152152
let gate = gate_check(&assessment);
153153

154154
// Mount Stupid should block
155-
matches!(gate, GateDecision::Sandbox { .. });
155+
matches!(gate, MulGateDecision::Sandbox { .. });
156156
}
157157

158158
#[test]
@@ -171,6 +171,6 @@ mod tests {
171171
let assessment = assess(&input);
172172
let gate = gate_check(&assessment);
173173

174-
matches!(gate, GateDecision::Proceed { .. });
174+
matches!(gate, MulGateDecision::Proceed { .. });
175175
}
176176
}

crates/lance-graph-planner/tests/w1_probes.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
//! D-V3-W1e probe-first: three failing probes for the W1b ahead-firing batch
2-
//! writer + W1c delegation cache, pinned against
1+
//! D-V3-W1e probe-first: four probes for the W1b ahead-firing batch writer +
2+
//! W1c delegation cache, pinned against
33
//! `lance_graph_planner::batch_writer::BatchWriter`.
44
//!
5-
//! All three are `#[ignore]`d — the writer's methods are `todo!()` stubs.
6-
//! Un-ignore in the W1b implementation commit once the bodies are filled in.
5+
//! Live (W1b implementation landed) — all four run and pass.
76
//!
87
//! Uses the REAL shipped kanban contract types
98
//! (`lance_graph_contract::kanban::{KanbanColumn, KanbanMove, ExecTarget}`,
@@ -29,7 +28,6 @@ fn make_move(mailbox: u32, from: KanbanColumn, to: KanbanColumn, witness: u32) -
2928
/// Probe 1 (W1b): cast() makes intent moves visible on the board AHEAD of any
3029
/// ack; ack() then removes the cast from unacked().
3130
#[test]
32-
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
3331
fn probe_ahead_update_ordering() {
3432
let mut writer: BatchWriter<()> = BatchWriter::new();
3533

@@ -44,16 +42,18 @@ fn probe_ahead_update_ordering() {
4442
// AHEAD: intent is visible on the board BEFORE any ack.
4543
assert_eq!(writer.intent_moves(cast), Some(moves.as_slice()));
4644

47-
writer.ack(cast);
45+
writer.ack(cast, 1);
4846

4947
// After ack, the cast is no longer in the unacked (crash-replay) surface.
5048
assert!(!writer.unacked().contains(&cast));
49+
50+
// The CastId<->LanceVersion join: the ack recorded the assigned version.
51+
assert_eq!(writer.acked_version(cast), Some(1));
5152
}
5253

5354
/// Probe 2 (M24): a cast that is never acked stays on the crash-replay
5455
/// surface (`unacked()`), and its intent moves remain replayable.
5556
#[test]
56-
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
5757
fn probe_kill_after_cast_replay() {
5858
let mut writer: BatchWriter<()> = BatchWriter::new();
5959

@@ -81,7 +81,6 @@ fn probe_kill_after_cast_replay() {
8181
/// a mailbox (cache miss) and skips it on the second lookup for the same
8282
/// mailbox (cache hit), returning the same owner both times.
8383
#[test]
84-
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
8584
fn probe_delegation_miss_then_hit() {
8685
let mut writer: BatchWriter<()> = BatchWriter::new();
8786

@@ -113,7 +112,6 @@ fn probe_delegation_miss_then_hit() {
113112
/// for a row — is sink-side behavior, exercised in the W1b implementation
114113
/// tests, not at this API surface.)
115114
#[test]
116-
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
117115
fn probe_stacked_casts_never_refused() {
118116
let mut writer: BatchWriter<()> = BatchWriter::new();
119117

@@ -135,9 +133,9 @@ fn probe_stacked_casts_never_refused() {
135133
assert!(writer.intent_moves(c3).is_some());
136134

137135
// Acks retire independently and in any order.
138-
writer.ack(c2);
136+
writer.ack(c2, 1);
139137
assert_eq!(writer.unacked(), vec![c1, c3]);
140-
writer.ack(c1);
141-
writer.ack(c3);
138+
writer.ack(c1, 2);
139+
writer.ack(c3, 3);
142140
assert!(writer.unacked().is_empty());
143141
}

0 commit comments

Comments
 (0)