Skip to content

Commit 6301bc8

Browse files
committed
feat(planner): D-V3-W1e probe-first — batch_writer skeleton + 3 red probes
WAL-shaped BatchWriter<P> skeleton (M24: cast = intent record, ack = confirmation, unacked = replay surface, resolve_owner = W1c delegation cache) with todo! bodies. Three #[ignore]d probes fail as designed (ahead-update ordering, kill-after-cast replay, delegation miss-then-hit) — the W1b implementation commit un-ignores them. Uses the SHIPPED contract types (KanbanMove kanban.rs:151, KanbanColumn, MailboxId collapse_gate.rs:121) — no mint needed; the brief's conditional mint did not fire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLBnPuScZy6w9di2QEjsXM
1 parent adcc084 commit 6301bc8

3 files changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! W1b ahead-firing batch writer — the kanban board IS the write-ahead log (M24).
2+
//!
3+
//! `cast()` records intent moves AHEAD of any storage ack; `ack()` confirms;
4+
//! `unacked()` is the crash-replay surface. Payload-generic: the writer never
5+
//! inspects `P` (DTO purity — ownership rides the cast pairing, never the DTO).
6+
//!
7+
//! Uses the REAL shipped kanban contract types
8+
//! ([`lance_graph_contract::kanban::KanbanMove`],
9+
//! [`lance_graph_contract::kanban::KanbanColumn`],
10+
//! [`lance_graph_contract::collapse_gate::MailboxId`]) — this module does not
11+
//! mint a parallel `KanbanMove`; see the D-MBX-A6 Outcome adapter context in
12+
//! `crate::strategy::style_strategy`.
13+
14+
use std::collections::HashMap;
15+
16+
use lance_graph_contract::collapse_gate::MailboxId;
17+
use lance_graph_contract::kanban::KanbanMove;
18+
19+
/// Identity of one `cast()` — a write-ahead intent record on the kanban board.
20+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21+
pub struct CastId(pub u64);
22+
23+
/// Ahead-firing batch writer: intent (`cast`) is visible on the board before
24+
/// any ack; `unacked()` is the M24 crash-replay surface; `resolve_owner` is
25+
/// the W1c delegation cache (resolve-once, cache-hit thereafter).
26+
pub struct BatchWriter<P> {
27+
/// Monotonic id generator for the next cast.
28+
next_id: u64,
29+
/// Board: intent moves recorded per cast, keyed by `CastId`, alongside the
30+
/// mailbox the cast was recorded on behalf of. Visible between `cast()`
31+
/// and `ack()` (and beyond, for crash-replay via `unacked()`).
32+
board: HashMap<CastId, (MailboxId, Vec<KanbanMove>)>,
33+
/// Casts that have been confirmed (acked). A cast present in `board` but
34+
/// absent from `acked` is the crash-replay surface (`unacked()`).
35+
acked: std::collections::HashSet<CastId>,
36+
/// W1c delegation cache: `on_behalf` mailbox -> resolved owner mailbox.
37+
delegation_cache: HashMap<MailboxId, MailboxId>,
38+
/// Payloads recorded per cast (payload-generic; the writer never inspects `P`).
39+
pending_payloads: Vec<(CastId, P)>,
40+
}
41+
42+
impl<P> Default for BatchWriter<P> {
43+
fn default() -> Self {
44+
Self::new()
45+
}
46+
}
47+
48+
impl<P> BatchWriter<P> {
49+
/// Construct an empty batch writer.
50+
pub fn new() -> Self {
51+
Self {
52+
next_id: 0,
53+
board: HashMap::new(),
54+
acked: std::collections::HashSet::new(),
55+
delegation_cache: HashMap::new(),
56+
pending_payloads: Vec::new(),
57+
}
58+
}
59+
60+
/// AHEAD: records the intent (moves visible on the board) BEFORE any ack.
61+
/// Returns the cast id.
62+
///
63+
/// Probe-first skeleton (D-V3-W1e): body pending — see
64+
/// `tests/w1_probes.rs::probe_ahead_update_ordering` /
65+
/// `probe_kill_after_cast_replay`.
66+
pub fn cast(&mut self, _on_behalf: MailboxId, _moves: Vec<KanbanMove>, _payload: P) -> CastId {
67+
todo!("W1b")
68+
}
69+
70+
/// Confirmation — marks the cast acked.
71+
///
72+
/// Probe-first skeleton (D-V3-W1e): body pending — see
73+
/// `tests/w1_probes.rs::probe_ahead_update_ordering`.
74+
pub fn ack(&mut self, _cast: CastId) {
75+
todo!("W1b")
76+
}
77+
78+
/// Crash-replay surface (M24): casts recorded but not yet acked.
79+
///
80+
/// Probe-first skeleton (D-V3-W1e): body pending — see
81+
/// `tests/w1_probes.rs::probe_kill_after_cast_replay`.
82+
pub fn unacked(&self) -> Vec<CastId> {
83+
todo!("W1b")
84+
}
85+
86+
/// Board read: intent moves recorded for a cast (visible between cast and ack).
87+
///
88+
/// Probe-first skeleton (D-V3-W1e): body pending — see
89+
/// `tests/w1_probes.rs::probe_ahead_update_ordering` /
90+
/// `probe_kill_after_cast_replay`.
91+
pub fn intent_moves(&self, _cast: CastId) -> Option<&[KanbanMove]> {
92+
todo!("W1b")
93+
}
94+
95+
/// W1c delegation cache: resolve an owner once, cache; returns `(owner, was_cache_hit)`.
96+
///
97+
/// Probe-first skeleton (D-V3-W1e): body pending — see
98+
/// `tests/w1_probes.rs::probe_delegation_miss_then_hit`.
99+
pub fn resolve_owner(
100+
&mut self,
101+
_on_behalf: MailboxId,
102+
_resolver: impl FnOnce(MailboxId) -> MailboxId,
103+
) -> (MailboxId, bool) {
104+
todo!("W1c")
105+
}
106+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ pub mod pipeline;
7272
// === Autocomplete Cache (VSA superposition KV-cache) ===
7373
pub mod cache;
7474

75+
// === W1b ahead-firing batch writer (D-V3-W1e probe-first skeleton) ===
76+
pub mod batch_writer;
77+
7578
// === Internal API (same-binary, zero-serde) ===
7679
pub mod api;
7780

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//! D-V3-W1e probe-first: three failing probes for the W1b ahead-firing batch
2+
//! writer + W1c delegation cache, pinned against
3+
//! `lance_graph_planner::batch_writer::BatchWriter`.
4+
//!
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.
7+
//!
8+
//! Uses the REAL shipped kanban contract types
9+
//! (`lance_graph_contract::kanban::{KanbanColumn, KanbanMove, ExecTarget}`,
10+
//! `lance_graph_contract::collapse_gate::MailboxId`) — no hand-rolled
11+
//! composites (F12).
12+
13+
use lance_graph_contract::kanban::{ExecTarget, KanbanColumn, KanbanMove};
14+
use lance_graph_planner::batch_writer::BatchWriter;
15+
16+
/// Build a `KanbanMove` from a `mailbox`/`from`/`to` triple using only public
17+
/// constructors/fields on the shipped contract type — no hand-rolled bit math.
18+
fn make_move(mailbox: u32, from: KanbanColumn, to: KanbanColumn, witness: u32) -> KanbanMove {
19+
KanbanMove {
20+
mailbox,
21+
from,
22+
to,
23+
witness_chain_position: witness,
24+
libet_offset_us: 0,
25+
exec: ExecTarget::Native,
26+
}
27+
}
28+
29+
/// Probe 1 (W1b): cast() makes intent moves visible on the board AHEAD of any
30+
/// ack; ack() then removes the cast from unacked().
31+
#[test]
32+
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
33+
fn probe_ahead_update_ordering() {
34+
let mut writer: BatchWriter<()> = BatchWriter::new();
35+
36+
let moves = vec![
37+
make_move(7, KanbanColumn::Planning, KanbanColumn::CognitiveWork, 0),
38+
make_move(7, KanbanColumn::CognitiveWork, KanbanColumn::Evaluation, 1),
39+
make_move(7, KanbanColumn::Evaluation, KanbanColumn::Commit, 2),
40+
];
41+
42+
let cast = writer.cast(7, moves.clone(), ());
43+
44+
// AHEAD: intent is visible on the board BEFORE any ack.
45+
assert_eq!(writer.intent_moves(cast), Some(moves.as_slice()));
46+
47+
writer.ack(cast);
48+
49+
// After ack, the cast is no longer in the unacked (crash-replay) surface.
50+
assert!(!writer.unacked().contains(&cast));
51+
}
52+
53+
/// Probe 2 (M24): a cast that is never acked stays on the crash-replay
54+
/// surface (`unacked()`), and its intent moves remain replayable.
55+
#[test]
56+
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
57+
fn probe_kill_after_cast_replay() {
58+
let mut writer: BatchWriter<()> = BatchWriter::new();
59+
60+
let moves = vec![make_move(
61+
11,
62+
KanbanColumn::Planning,
63+
KanbanColumn::CognitiveWork,
64+
0,
65+
)];
66+
67+
let cast = writer.cast(11, moves.clone(), ());
68+
// Deliberately no ack() — simulates a crash between cast and ack.
69+
70+
let unacked = writer.unacked();
71+
assert_eq!(unacked, vec![cast]);
72+
73+
let replayed = writer
74+
.intent_moves(cast)
75+
.expect("unacked cast must still have replayable intent moves");
76+
assert!(!replayed.is_empty());
77+
assert_eq!(replayed, moves.as_slice());
78+
}
79+
80+
/// Probe 3 (W1c): resolve_owner() calls the resolver on the first lookup for
81+
/// a mailbox (cache miss) and skips it on the second lookup for the same
82+
/// mailbox (cache hit), returning the same owner both times.
83+
#[test]
84+
#[ignore = "probe-first: W1b mechanism pending — un-ignore in the W1b implementation commit"]
85+
fn probe_delegation_miss_then_hit() {
86+
let mut writer: BatchWriter<()> = BatchWriter::new();
87+
88+
let on_behalf: u32 = 3;
89+
let mut resolver_calls = 0u32;
90+
91+
let (owner_first, was_hit_first) = writer.resolve_owner(on_behalf, |mailbox| {
92+
resolver_calls += 1;
93+
mailbox + 1000 // arbitrary deterministic "resolved owner" transform
94+
});
95+
assert!(!was_hit_first, "first resolve for a mailbox must be a cache miss");
96+
assert_eq!(resolver_calls, 1);
97+
98+
let (owner_second, was_hit_second) = writer.resolve_owner(on_behalf, |mailbox| {
99+
resolver_calls += 1;
100+
mailbox + 1000
101+
});
102+
assert!(was_hit_second, "second resolve for the same mailbox must be a cache hit");
103+
assert_eq!(resolver_calls, 1, "resolver must not be called again on cache hit");
104+
assert_eq!(owner_first, owner_second);
105+
}

0 commit comments

Comments
 (0)