Skip to main content

logicaffeine_compile/codegen_sva/
storage_controller_rtl.rs

1//! storage_controller — the deterministic NAND storage controller (the storage analog
2//! of [`super::dram`]).
3//!
4//! The memory controller proves that timing can be *guaranteed*: DRAM timing is
5//! deterministic by spec, so the compiler schedules a command stream, an independent
6//! checker proves it legal, and a device model delivers exactly the promised beat cycles
7//! — zero jitter, refresh co-scheduled and never a surprise. Storage looks impossible at
8//! first because a commodity SSD's FTL runs garbage-collection / wear-leveling / erase
9//! *reactively and hidden* (that is SSD tail latency). The fix is the SAME move as DRAM
10//! refresh: make every reactive background event a SCHEDULED event the compiler owns.
11//! That requires HOST-MANAGED flash (NVMe ZNS / open-channel): append-only zones (no
12//! device-side GC surprise) and an exposed channel×die geometry (schedulable
13//! parallelism), so a host-scheduled block erase is the flash analog of REFpb.
14//!
15//! This module makes that a checked, executable fact:
16//!
17//!   • [`FlashTimings`] — a ZNS-TLC-class timing table (one struct of DATA, so the flash
18//!     architect's corrections drop in as numbers, not code);
19//!   • [`schedule_zoned`] — the compile-time command scheduler: zone/gather/reset
20//!     descriptors → a fully TIMED NAND command stream, die-interleaved and channel-
21//!     striped, with block erases placed into idle die windows (never displacing a read);
22//!   • [`check_legal_flash`] — an INDEPENDENT per-command legality checker (per-die busy
23//!     serialization, per-channel data-bus occupancy, tR / tPROG / tBERS) — the scheduler
24//!     never grades its own homework;
25//!   • [`FlashDeviceModel`] — a die/channel-state device that EXECUTES a timed stream and
26//!     reports every read's completion cycle: the zero-jitter fact is `planned == executed`;
27//!   • [`execute_with_suspend`] / [`black_box_execute`] — the Tier-B (bounded, erase-
28//!     suspend) and Tier-C (black-box reactive FTL) executors that prove the determinism
29//!     ladder: bounded jitter under a budgeted suspend, and the IMPOSSIBILITY of any static
30//!     guarantee once reclamation timing is not host-owned.
31//!
32//! Everything is a pure function of its inputs — deterministic by construction, like the
33//! rest of the stack, and the certified-SAT tR / erase-collision obligations live in the
34//! test battery beside the executor RTL.
35
36/// One NAND operation on a die.
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum FlashOp {
39    /// Page sense (tR) then data-out over the channel.
40    Read,
41    /// Data-in over the channel then page program (tPROG).
42    Program,
43    /// Block erase (tBERS) — the host-scheduled reclamation event (the REFpb analog).
44    Erase,
45}
46
47/// One timed NAND command. `chan` is the shared data-bus this die hangs off; `die` is the
48/// global die index (its own serializing resource).
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub struct FlashCmd {
51    pub op: FlashOp,
52    pub chan: u32,
53    pub die: u32,
54}
55
56/// A command with its planned issue cycle. The PLAN is the artifact: the certified SAT
57/// proves windows of it timing-legal, and the executor RTL runs it in order.
58#[derive(Clone, Copy, Debug)]
59pub struct TimedCmd {
60    pub at: u64,
61    pub cmd: FlashCmd,
62}
63
64/// ZNS-TLC-class NAND timing + geometry, in CONTROLLER CLOCK CYCLES. Engineering-realistic
65/// ratios (read : program : erase ≈ 1 : 10 : 100); the table is DATA for the flash
66/// architect to correct, and every measurement re-derives from it.
67#[derive(Clone, Debug)]
68pub struct FlashTimings {
69    /// Independent data-bus channels.
70    pub channels: u32,
71    /// Dies per channel — the parallelism that lets one die erase while others read.
72    pub dies_per_channel: u32,
73    /// Planes per die (multi-plane program/erase; reads serialize on the die here).
74    pub planes_per_die: u32,
75    /// Pages per erase block.
76    pub pages_per_block: u32,
77    /// Pages per zone (append-only unit; a zone reset erases its blocks).
78    pub zone_pages: u64,
79    /// Page read (sense) latency.
80    pub t_r: u64,
81    /// Page program latency.
82    pub t_prog: u64,
83    /// Block erase latency (~10³× a read — the tail source we schedule away).
84    pub t_bers: u64,
85    /// Channel cycles to move one page of data in/out.
86    pub data_cycles: u64,
87    /// Max cycles a program/erase may be SUSPENDED to let a read through (Tier-B budget).
88    pub suspend_window: u64,
89    /// Bounded worst-case extra latency of a soft-LDPC read-retry (Tier-B envelope).
90    pub ldpc_retry_worst: u64,
91}
92
93impl FlashTimings {
94    /// A host-managed ZNS-TLC-class part: 8 channels × 8 dies (enough die depth to hide the
95    /// sense latency behind the channel data-out), 100× erase.
96    pub fn zns_tlc() -> Self {
97        FlashTimings {
98            channels: 8,
99            dies_per_channel: 8,
100            planes_per_die: 2,
101            pages_per_block: 384,
102            zone_pages: 12288,
103            t_r: 64,
104            t_prog: 640,
105            t_bers: 6400,
106            data_cycles: 8,
107            suspend_window: 96,
108            ldpc_retry_worst: 32,
109        }
110    }
111
112    /// A pSLC fast-path part: faster sense/program/erase, tighter suspend — the crown-tier
113    /// medium where erases fit in per-die idle gaps with room to spare.
114    pub fn pslc_fast() -> Self {
115        FlashTimings {
116            channels: 8,
117            dies_per_channel: 8,
118            planes_per_die: 2,
119            pages_per_block: 256,
120            zone_pages: 8192,
121            t_r: 16,
122            t_prog: 160,
123            t_bers: 1600,
124            data_cycles: 8,
125            suspend_window: 32,
126            ldpc_retry_worst: 8,
127        }
128    }
129
130    /// Total independent dies.
131    pub fn n_dies(&self) -> u32 {
132        self.channels * self.dies_per_channel
133    }
134
135    /// The worst-case read latency the tier promises: sense + data-out, plus (Tier B) a
136    /// budgeted suspend and a bounded LDPC retry. A read never exceeds this.
137    pub fn read_latency_bound(&self) -> u64 {
138        self.t_r + self.data_cycles + self.suspend_window + self.ldpc_retry_worst
139    }
140}
141
142/// Global page → (channel, die): consecutive pages rotate across ALL dies so a sequential
143/// stream interleaves — one die senses while the next streams data, and idle dies are free
144/// to erase. A compile-time-known mapping the schedule depends on.
145pub fn map_page(t: &FlashTimings, page: u64) -> (u32, u32) {
146    let g = (page % t.n_dies() as u64) as u32;
147    let chan = g % t.channels;
148    (chan, g)
149}
150
151/// A compile-time transfer descriptor — the vocabulary the static schedule hands to the
152/// storage edge: `count` consecutive pages from global page `base_page`, read or programmed.
153#[derive(Clone, Debug)]
154pub struct ZoneDesc {
155    pub zone: u64,
156    pub base_page: u64,
157    pub count: u64,
158    pub write: bool,
159}
160
161/// The random tail: explicit page reads (a pointer-chase / index lookup).
162#[derive(Clone, Debug)]
163pub struct Gather {
164    pub pages: Vec<u64>,
165}
166
167/// A host-scheduled zone reset: erase every die's block backing the zone — the reclamation
168/// event, placed into idle windows by the scheduler (the REFpb analog).
169#[derive(Clone, Copy, Debug)]
170pub struct ZoneReset {
171    pub zone: u64,
172}
173
174/// How the access list is ordered into the plan.
175#[derive(Clone, Copy, PartialEq, Eq, Debug)]
176pub enum VisitOrder {
177    /// Descriptors back-to-back in declaration order (single-stream kernels).
178    ProgramOrder,
179    /// Round-robin one page from each stream — the multi-stream interleave.
180    Interleaved,
181}
182
183fn access_list(zones: &[ZoneDesc], gathers: &[Gather], order: VisitOrder) -> Vec<(FlashOp, u64)> {
184    let mut out: Vec<(FlashOp, u64)> = Vec::new();
185    let per_zone: Vec<Vec<(FlashOp, u64)>> = zones
186        .iter()
187        .map(|z| {
188            let op = if z.write {
189                FlashOp::Program
190            } else {
191                FlashOp::Read
192            };
193            (0..z.count).map(|i| (op, z.base_page + i)).collect()
194        })
195        .collect();
196    match order {
197        VisitOrder::ProgramOrder => {
198            for z in &per_zone {
199                out.extend(z.iter().copied());
200            }
201        }
202        VisitOrder::Interleaved => {
203            let mut idx = vec![0usize; per_zone.len()];
204            loop {
205                let mut any = false;
206                for (s, z) in per_zone.iter().enumerate() {
207                    if idx[s] < z.len() {
208                        out.push(z[idx[s]]);
209                        idx[s] += 1;
210                        any = true;
211                    }
212                }
213                if !any {
214                    break;
215                }
216            }
217        }
218    }
219    for g in gathers {
220        for &p in &g.pages {
221            out.push((FlashOp::Read, p));
222        }
223    }
224    out
225}
226
227/// A zone's DIE WINDOW. Real ZNS zones occupy a subset of the dies, so reads on one zone and a
228/// reset on a DISJOINT zone can run CONCURRENTLY without ever colliding — the system-level form
229/// of "GC is free". [`Placement::full`] is the classic all-dies stripe.
230#[derive(Clone, Copy, Debug)]
231pub struct Placement {
232    pub die_base: u32,
233    pub die_count: u32,
234}
235
236impl Placement {
237    pub fn full(t: &FlashTimings) -> Self {
238        Placement {
239            die_base: 0,
240            die_count: t.n_dies(),
241        }
242    }
243    /// Map a zone-local page to (channel, global die) within this window.
244    fn map(&self, t: &FlashTimings, local: u64) -> (u32, u32) {
245        let die = self.die_base + (local % self.die_count.max(1) as u64) as u32;
246        (die % t.channels, die)
247    }
248    fn dies(&self) -> std::ops::Range<u32> {
249        self.die_base..self.die_base + self.die_count
250    }
251}
252
253/// The shared scheduling core: lay a stream of pre-mapped `(op, chan, die)` commands out die-
254/// interleaved + channel-striped (a die senses while its channel-mates stream data), then place
255/// one trailing block erase per entry in `erase_dies` at that die's idle point. Time-sorted.
256fn schedule_ops(
257    t: &FlashTimings,
258    ops: &[(FlashOp, u32, u32)],
259    erase_dies: &[u32],
260) -> Vec<TimedCmd> {
261    let nd = t.n_dies() as usize;
262    let nc = t.channels as usize;
263    let mut die_free = vec![0u64; nd];
264    let mut chan_free = vec![0u64; nc];
265    let mut out: Vec<TimedCmd> = Vec::new();
266
267    for &(op, chan, die) in ops {
268        let (ci, gi) = (chan as usize, die as usize);
269        match op {
270            FlashOp::Read => {
271                // Align sense so the data-out lands exactly when the channel frees.
272                let mut at = die_free[gi];
273                if chan_free[ci] > at + t.t_r {
274                    at = chan_free[ci] - t.t_r;
275                }
276                at = at.max(die_free[gi]);
277                out.push(TimedCmd {
278                    at,
279                    cmd: FlashCmd { op, chan, die },
280                });
281                die_free[gi] = at + t.t_r;
282                chan_free[ci] = at + t.t_r + t.data_cycles;
283            }
284            FlashOp::Program => {
285                // Data-in over the channel first, then program the die.
286                let at = die_free[gi].max(chan_free[ci]);
287                out.push(TimedCmd {
288                    at,
289                    cmd: FlashCmd { op, chan, die },
290                });
291                chan_free[ci] = at + t.data_cycles;
292                die_free[gi] = at + t.data_cycles + t.t_prog;
293            }
294            FlashOp::Erase => unreachable!("erases come from erase_dies"),
295        }
296    }
297
298    // Reclamation: an erase per `erase_dies` entry, placed at that die's idle point AFTER its
299    // own ops — in the plan at a known time, never a surprise. A die is serial, so an erase on a
300    // die can never overlap that die's reads; an erase on a die with NO reads is free from t=0.
301    for &die in erase_dies {
302        let gi = die as usize;
303        let chan = die % t.channels;
304        let at = die_free[gi];
305        out.push(TimedCmd {
306            at,
307            cmd: FlashCmd {
308                op: FlashOp::Erase,
309                chan,
310                die,
311            },
312        });
313        die_free[gi] = at + t.t_bers;
314    }
315
316    out.sort_by(|a, b| a.at.cmp(&b.at));
317    out
318}
319
320/// The compile-time scheduler (full-stripe placement): read/program streams laid down die-
321/// interleaved and channel-striped, then reset erases placed into each die's trailing idle
322/// window. Returns the timed plan, time-sorted.
323pub fn schedule_zoned(
324    t: &FlashTimings,
325    zones: &[ZoneDesc],
326    gathers: &[Gather],
327    resets: &[ZoneReset],
328    order: VisitOrder,
329) -> Vec<TimedCmd> {
330    let nd = t.n_dies();
331    let ops: Vec<(FlashOp, u32, u32)> = access_list(zones, gathers, order)
332        .into_iter()
333        .map(|(op, page)| {
334            let (chan, die) = map_page(t, page);
335            (op, chan, die)
336        })
337        .collect();
338    let mut erase_dies: Vec<u32> = Vec::new();
339    for _ in resets {
340        erase_dies.extend(0..nd);
341    }
342    schedule_ops(t, &ops, &erase_dies)
343}
344
345/// Placement-aware scheduler: each read/program stream and each reset targets a die WINDOW
346/// ([`Placement`]). A reset on a window DISJOINT from the read window runs concurrently with the
347/// reads and provably cannot collide with them — concurrent, zero-jitter reclamation.
348pub fn schedule_placed(
349    t: &FlashTimings,
350    reads: &[(ZoneDesc, Placement)],
351    resets: &[(ZoneReset, Placement)],
352    order: VisitOrder,
353) -> Vec<TimedCmd> {
354    let per_stream: Vec<Vec<(FlashOp, u32, u32)>> = reads
355        .iter()
356        .map(|(z, p)| {
357            let op = if z.write {
358                FlashOp::Program
359            } else {
360                FlashOp::Read
361            };
362            (0..z.count)
363                .map(|i| {
364                    let (chan, die) = p.map(t, z.base_page + i);
365                    (op, chan, die)
366                })
367                .collect()
368        })
369        .collect();
370    let mut ops: Vec<(FlashOp, u32, u32)> = Vec::new();
371    match order {
372        VisitOrder::ProgramOrder => {
373            for s in &per_stream {
374                ops.extend(s.iter().copied());
375            }
376        }
377        VisitOrder::Interleaved => {
378            let mut idx = vec![0usize; per_stream.len()];
379            loop {
380                let mut any = false;
381                for (s, stream) in per_stream.iter().enumerate() {
382                    if idx[s] < stream.len() {
383                        ops.push(stream[idx[s]]);
384                        idx[s] += 1;
385                        any = true;
386                    }
387                }
388                if !any {
389                    break;
390                }
391            }
392        }
393    }
394    let mut erase_dies: Vec<u32> = Vec::new();
395    for (_r, p) in resets {
396        erase_dies.extend(p.dies());
397    }
398    schedule_ops(t, &ops, &erase_dies)
399}
400
401/// The INDEPENDENT legality checker — a total function over the plan, sharing no code with
402/// the scheduler. Verifies per-die serialization (a die runs one op at a time, so a read is
403/// never issued while its die is mid-erase — the collision the whole design avoids) and
404/// per-channel data-bus disjointness. A die's ops arrive in issue order, so a running
405/// watermark is exact; a read's data-out is DELAYED by tR (sense first), so its bus window
406/// need not align with issue order — the channel is checked by interval disjointness, not a
407/// watermark. First violation as `Err(description)`.
408pub fn check_legal_flash(t: &FlashTimings, plan: &[TimedCmd]) -> Result<(), String> {
409    let nd = t.n_dies() as usize;
410    let nc = t.channels as usize;
411    let mut die_busy = vec![0u64; nd]; // die free at/after this cycle (issue-ordered per die)
412    let mut chan_bus: Vec<Vec<(u64, u64, usize)>> = vec![Vec::new(); nc]; // (start, end, cmd)
413    let mut prev_at = 0u64;
414
415    for (i, c) in plan.iter().enumerate() {
416        if c.at < prev_at {
417            return Err(format!("cmd {i}: plan not time-sorted"));
418        }
419        prev_at = c.at;
420        let (di, ci) = (c.cmd.die as usize, c.cmd.chan as usize);
421        if di >= nd {
422            return Err(format!("cmd {i}: die {} out of range", c.cmd.die));
423        }
424        if ci >= nc {
425            return Err(format!("cmd {i}: chan {} out of range", c.cmd.chan));
426        }
427        if c.at < die_busy[di] {
428            return Err(format!(
429                "cmd {i}: {:?} on die {} at {} while die busy until {} (op collision)",
430                c.cmd.op, c.cmd.die, c.at, die_busy[di]
431            ));
432        }
433        match c.cmd.op {
434            FlashOp::Read => {
435                // Sense holds the die for [at, at+tR); data-out holds the channel for
436                // [at+tR, at+tR+data_cycles) — the die is free to pipeline the next op.
437                let bus_start = c.at + t.t_r;
438                chan_bus[ci].push((bus_start, bus_start + t.data_cycles, i));
439                die_busy[di] = c.at + t.t_r;
440            }
441            FlashOp::Program => {
442                // Data-in holds the channel for [at, at+data); program holds the die after.
443                chan_bus[ci].push((c.at, c.at + t.data_cycles, i));
444                die_busy[di] = c.at + t.data_cycles + t.t_prog;
445            }
446            FlashOp::Erase => {
447                die_busy[di] = c.at + t.t_bers;
448            }
449        }
450    }
451
452    // Channel data-bus disjointness — order-independent (a read's window is delayed by tR).
453    for (ci, windows) in chan_bus.iter().enumerate() {
454        let mut sorted = windows.clone();
455        sorted.sort_by_key(|w| w.0);
456        for pair in sorted.windows(2) {
457            if pair[1].0 < pair[0].1 {
458                return Err(format!(
459                    "cmd {}: data bus on chan {ci} at {} overlaps a window busy until {}",
460                    pair[1].2, pair[1].0, pair[0].1
461                ));
462            }
463        }
464    }
465    Ok(())
466}
467
468/// For a pure read gather, the order in which the requested pages' data arrives — indices
469/// into `pages`, sorted by delivery beat (ties broken by issue order). Near-storage compute
470/// consumes beats in THIS order, so a reduction fed at the storage edge (merge-on-ingest,
471/// scan, hash) must be SOUND under it: the die-interleave that saturates the bus reorders the
472/// stream, so only order-invariant reductions (a CRDT join, a commutative fold) are safe here.
473pub fn gather_delivery_order(t: &FlashTimings, pages: &[u64]) -> Vec<usize> {
474    let nd = t.n_dies() as usize;
475    let nc = t.channels as usize;
476    let mut die_free = vec![0u64; nd];
477    let mut chan_free = vec![0u64; nc];
478    let mut beats: Vec<(u64, usize)> = Vec::with_capacity(pages.len());
479    for (i, &page) in pages.iter().enumerate() {
480        let (chan, g) = map_page(t, page);
481        let (ci, gi) = (chan as usize, g as usize);
482        let mut at = die_free[gi];
483        if chan_free[ci] > at + t.t_r {
484            at = chan_free[ci] - t.t_r;
485        }
486        at = at.max(die_free[gi]);
487        beats.push((at + t.t_r + t.data_cycles, i));
488        die_free[gi] = at + t.t_r;
489        chan_free[ci] = at + t.t_r + t.data_cycles;
490    }
491    beats.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
492    beats.into_iter().map(|(_, i)| i).collect()
493}
494
495/// Planned read-completion cycles straight from the plan (what the compiler promises).
496pub fn planned_read_beats(t: &FlashTimings, plan: &[TimedCmd]) -> Vec<u64> {
497    plan.iter()
498        .filter(|c| c.cmd.op == FlashOp::Read)
499        .map(|c| c.at + t.t_r + t.data_cycles)
500        .collect()
501}
502
503/// The measured outcome of a plan.
504#[derive(Clone, Copy, Debug, PartialEq)]
505pub struct Utilization {
506    /// Read data-out cycles on the channels.
507    pub read_data_cycles: u64,
508    /// Program data-in cycles on the channels (writes use the bus too, then bind the die on tPROG).
509    pub program_data_cycles: u64,
510    pub span_cycles: u64,
511    pub reads: u64,
512    pub programs: u64,
513    pub erases: u64,
514    /// Worst-case observed read latency (beat − issue) across all reads.
515    pub worst_read_latency: u64,
516    /// Channel count — the aggregate bus can move `channels` data cycles per cycle.
517    pub channels: u32,
518}
519
520impl Utilization {
521    /// Aggregate data-bus utilization ∈ `[0,1]`: read data-out + program data-in cycles ÷ the
522    /// total bus capacity over the span (span × channels), since the channels run in parallel.
523    /// A pure-write workload is tPROG-bound (die-bound), so its channel utilization is honestly
524    /// low — the bus idles while the dies program.
525    pub fn value(&self) -> f64 {
526        let cap = self.span_cycles * self.channels as u64;
527        if cap == 0 {
528            1.0
529        } else {
530            (self.read_data_cycles + self.program_data_cycles) as f64 / cap as f64
531        }
532    }
533}
534
535pub fn measure(t: &FlashTimings, plan: &[TimedCmd]) -> Utilization {
536    let mut u = Utilization {
537        read_data_cycles: 0,
538        program_data_cycles: 0,
539        span_cycles: 0,
540        reads: 0,
541        programs: 0,
542        erases: 0,
543        worst_read_latency: 0,
544        channels: t.channels,
545    };
546    let mut last_end = 0u64;
547    for c in plan {
548        match c.cmd.op {
549            FlashOp::Read => {
550                u.reads += 1;
551                u.read_data_cycles += t.data_cycles;
552                let end = c.at + t.t_r + t.data_cycles;
553                last_end = last_end.max(end);
554                u.worst_read_latency = u.worst_read_latency.max(t.t_r + t.data_cycles);
555            }
556            FlashOp::Program => {
557                u.programs += 1;
558                u.program_data_cycles += t.data_cycles;
559                last_end = last_end.max(c.at + t.data_cycles + t.t_prog);
560            }
561            FlashOp::Erase => {
562                u.erases += 1;
563                last_end = last_end.max(c.at + t.t_bers);
564            }
565        }
566    }
567    u.span_cycles = last_end;
568    u
569}
570
571/// Tier A — a die-state DEVICE that SIMULATES the plan and reports each read's actual
572/// completion cycle. It does not trust the plan's promise: it replays each command against a
573/// per-die busy timeline and STALLS a command whose die is not yet free (a read issued into a
574/// mid-erase die waits out the erase). So on a legal plan the simulated beats equal
575/// [`planned_read_beats`] EXACTLY (zero jitter, cross-checked against the promise), and on an
576/// illegal plan they DIVERGE — the device is an independent collision detector, not an echo.
577pub struct FlashDeviceModel;
578
579impl FlashDeviceModel {
580    pub fn execute(t: &FlashTimings, plan: &[TimedCmd]) -> Vec<u64> {
581        let nd = t.n_dies() as usize;
582        let mut die_free = vec![0u64; nd];
583        let mut beats = Vec::new();
584        for c in plan {
585            let di = c.cmd.die as usize;
586            if di >= nd {
587                continue;
588            }
589            let issue = c.at.max(die_free[di]); // stall until the die is genuinely free
590            match c.cmd.op {
591                FlashOp::Read => {
592                    beats.push(issue + t.t_r + t.data_cycles);
593                    die_free[di] = issue + t.t_r;
594                }
595                FlashOp::Program => die_free[di] = issue + t.data_cycles + t.t_prog,
596                FlashOp::Erase => die_free[di] = issue + t.t_bers,
597            }
598        }
599        beats
600    }
601}
602
603/// Tier B — an executor that lets a read PREEMPT an in-progress erase on its die, paying a
604/// bounded `suspend_window` instead of the full tBERS, then resuming the erase. Returns each
605/// read's completion cycle; every read stays within [`FlashTimings::read_latency_bound`].
606pub fn execute_with_suspend(t: &FlashTimings, plan: &[TimedCmd]) -> Vec<u64> {
607    let nd = t.n_dies() as usize;
608    // (busy_until, is_erase) per die.
609    let mut die_until = vec![0u64; nd];
610    let mut die_erasing = vec![false; nd];
611    let mut beats = Vec::new();
612    for c in plan {
613        let di = c.cmd.die as usize;
614        match c.cmd.op {
615            FlashOp::Read => {
616                let mut at = c.at;
617                if at < die_until[di] {
618                    // Die busy: if it's an erase, suspend it (bounded); else wait it out.
619                    at = if die_erasing[di] {
620                        c.at + t.suspend_window
621                    } else {
622                        die_until[di]
623                    };
624                }
625                beats.push(at + t.t_r + t.data_cycles);
626                die_until[di] = at + t.t_r;
627                die_erasing[di] = false;
628            }
629            FlashOp::Program => {
630                let at = c.at.max(die_until[di]);
631                die_until[di] = at + t.data_cycles + t.t_prog;
632                die_erasing[di] = false;
633            }
634            FlashOp::Erase => {
635                let at = c.at.max(die_until[di]);
636                die_until[di] = at + t.t_bers;
637                die_erasing[di] = true;
638            }
639        }
640    }
641    beats
642}
643
644/// Tier C — the black-box SSD: a HIDDEN FTL fires an un-suspendable erase on `victim_die`
645/// just before the `victim_read`-th read (0-based, over reads only), so that read stalls
646/// behind the full tBERS. Returns each read's completion cycle. This models what a commodity
647/// drive does; the point is that a read now BLOWS its latency bound — proving no static
648/// guarantee survives once reclamation timing is not host-owned.
649pub fn black_box_execute(
650    t: &FlashTimings,
651    plan: &[TimedCmd],
652    victim_die: u32,
653    victim_read: usize,
654) -> Vec<u64> {
655    let nd = t.n_dies() as usize;
656    let mut die_until = vec![0u64; nd];
657    let mut beats = Vec::new();
658    let mut read_ix = 0usize;
659    for c in plan {
660        let di = c.cmd.die as usize;
661        match c.cmd.op {
662            FlashOp::Read => {
663                // The hidden FTL slams a full erase onto the victim die right before it.
664                if read_ix == victim_read && c.cmd.die == victim_die {
665                    die_until[di] = die_until[di].max(c.at) + t.t_bers;
666                }
667                let at = c.at.max(die_until[di]);
668                beats.push(at + t.t_r + t.data_cycles);
669                die_until[di] = at + t.t_r;
670                read_ix += 1;
671            }
672            FlashOp::Program => {
673                let at = c.at.max(die_until[di]);
674                die_until[di] = at + t.data_cycles + t.t_prog;
675            }
676            FlashOp::Erase => {
677                let at = c.at.max(die_until[di]);
678                die_until[di] = at + t.t_bers;
679            }
680        }
681    }
682    beats
683}
684
685// ── The certified command stream + the dumb in-order executor ────────────────────────────
686//
687// The controller collapses to an executor that plays a compile-time NAND command stream
688// with NO reorder logic and NO reclamation guesses — because the plan already contains all
689// of it. The stream is delta-encoded, the executor is a countdown FSM, and the countdown
690// arithmetic is proven exact by our certified SAT (see the test battery).
691
692/// Encode a timed plan as executor words: `[delta:24][op:3][chan:5][die:8][pad:24]`.
693/// `delta` = cycles after the PREVIOUS command (first word: cycles after reset).
694/// op: 0=Read 1=Program 2=Erase.
695pub fn delta_encode(plan: &[TimedCmd]) -> Vec<u64> {
696    let mut out = Vec::with_capacity(plan.len());
697    let mut prev = 0u64;
698    for c in plan {
699        let delta = c.at - prev;
700        assert!(
701            delta < (1 << 24),
702            "command delta {delta} exceeds 24-bit executor field"
703        );
704        prev = c.at;
705        let op = match c.cmd.op {
706            FlashOp::Read => 0u64,
707            FlashOp::Program => 1,
708            FlashOp::Erase => 2,
709        };
710        out.push(
711            (delta << 40) | (op << 37) | ((c.cmd.chan as u64) << 32) | ((c.cmd.die as u64) << 24),
712        );
713    }
714    out
715}
716
717/// The dumb in-order executor: a countdown FSM. It loads the next word, waits exactly
718/// `delta` cycles, strobes the command pins, and acks the word — in arrival order, by
719/// construction (there is no other datapath). `cmd_ack` pulses on CONSUME (load), so the
720/// producer's read index advances immediately (the classic handshake-latency trap avoided,
721/// exactly as in `dram_executor`).
722pub fn emit_storage_controller_verilog() -> String {
723    r#"// storage_controller — the in-order NAND command-stream executor (storage analog of
724// dram_executor). The compile-time plan is delta-encoded ([delta:24][op:3][chan:5][die:8]);
725// this FSM waits each delta and fires the command pins. Commands can only issue in strict
726// word order — there is no queue or scheduling logic in the netlist to do otherwise. The
727// countdown arithmetic is proven exact by our certified SAT (see codegen_sva::storage_controller_rtl).
728module storage_controller(
729  input clk,
730  input rst,
731  input [63:0] cmd_word,
732  input cmd_valid,
733  output reg cmd_ack,
734  output reg issue,
735  output reg [2:0] op,
736  output reg [4:0] chan,
737  output reg [7:0] die
738);
739  reg loaded;
740  reg [23:0] wait_left;
741  reg [2:0] op_l;
742  reg [4:0] chan_l;
743  reg [7:0] die_l;
744  always @(posedge clk) begin
745    if (rst) begin
746      loaded <= 1'b0; wait_left <= 24'd0; issue <= 1'b0; cmd_ack <= 1'b0;
747      op <= 3'd0; chan <= 5'd0; die <= 8'd0; op_l <= 3'd0; chan_l <= 5'd0; die_l <= 8'd0;
748    end else begin
749      issue <= 1'b0;
750      cmd_ack <= 1'b0;
751      if (!loaded) begin
752        if (cmd_valid) begin
753          wait_left <= cmd_word[63:40];
754          op_l <= cmd_word[39:37];
755          chan_l <= cmd_word[36:32];
756          die_l <= cmd_word[31:24];
757          loaded <= 1'b1;
758          cmd_ack <= 1'b1;
759        end
760      end else if (wait_left == 24'd0) begin
761        issue <= 1'b1;
762        op <= op_l; chan <= chan_l; die <= die_l;
763        loaded <= 1'b0;
764      end else begin
765        wait_left <= wait_left - 24'd1;
766      end
767    end
768  end
769endmodule
770"#
771    .to_string()
772}
773
774/// A self-checking testbench: a ROM of REAL plan words (from [`delta_encode`]) drives the
775/// executor; the bench asserts every issue arrives IN ORDER with the exact op/chan/die of its
776/// word, AND that the hardware honors the compiled TIMING — the clk-cycle gap between two
777/// consecutive issues exceeds the command's own delta by a FIXED pipeline overhead (so every
778/// compiled inter-command interval is reproduced exactly, not just the order). Prints PASS/FAIL.
779pub fn emit_storage_controller_testbench(nwords: usize) -> String {
780    format!(
781        r#"// tb_storage_controller — replays a REAL compiled NAND stream (storage_plan.hex) and
782// checks order + payload + TIMING FAITHFULNESS (gap - delta is a fixed pipeline overhead).
783`timescale 1ns/1ps
784module tb_storage_controller;
785  reg clk = 0; always #5 clk = ~clk;
786  reg rst = 1;
787  reg [63:0] rom [0:{last}];
788  integer idx = 0;
789  integer issues = 0;
790  integer errors = 0;
791  integer cyc = 0;
792  integer prev_issue = -1;
793  integer overhead = -1;
794  integer dlt = 0;
795  wire [63:0] cmd_word = rom[idx];
796  wire cmd_valid = (idx <= {last});
797  wire cmd_ack, issue;
798  wire [2:0] op; wire [4:0] chan; wire [7:0] die;
799  storage_controller dut(.clk(clk), .rst(rst), .cmd_word(cmd_word), .cmd_valid(cmd_valid),
800                    .cmd_ack(cmd_ack), .issue(issue), .op(op), .chan(chan), .die(die));
801  reg [63:0] expect_word;
802  always @(posedge clk) begin
803    if (!rst) cyc = cyc + 1;         // global cycle counter (origin irrelevant; gaps are what matter)
804    if (cmd_ack) idx <= idx + 1;     // pop on CONSUME (load), not issue
805    if (issue) begin
806      expect_word = rom[issues];     // issues arrive strictly in word order
807      dlt = expect_word[63:40];      // this command's compiled delta
808      if (op !== expect_word[39:37] || chan !== expect_word[36:32] || die !== expect_word[31:24]) begin
809        errors = errors + 1;
810        $display("FAIL: issue %0d payload mismatch", issues);
811      end
812      // TIMING FAITHFULNESS: gap since the previous issue = delta + a constant pipeline overhead.
813      if (prev_issue >= 0) begin
814        if (overhead == -1) overhead = (cyc - prev_issue) - dlt;
815        else if (((cyc - prev_issue) - dlt) != overhead) begin
816          errors = errors + 1;
817          $display("FAIL: issue %0d timing skew (gap %0d, delta %0d, overhead %0d != %0d)",
818                   issues, cyc - prev_issue, dlt, (cyc - prev_issue) - dlt, overhead);
819        end
820      end
821      prev_issue = cyc;
822      issues = issues + 1;
823    end
824  end
825  initial begin
826    $readmemh("storage_plan.hex", rom);
827    repeat (4) @(posedge clk);
828    rst = 0;
829    repeat (2000000) @(posedge clk);
830    if (issues !== {n}) begin
831      $display("FAIL: issued %0d of {n} commands", issues);
832    end else if (errors == 0) begin
833      $display("PASS: %0d commands issued in order, exact payloads, timing faithful (overhead %0d cycles)", issues, overhead);
834    end
835    $finish;
836  end
837endmodule
838"#,
839        last = nwords - 1,
840        n = nwords
841    )
842}
843
844/// The standard measurement scenarios — kernel-shaped storage traffic, each checked legal
845/// and measured. Returns a stable JSON document (pure function of the timing table) — the
846/// `benchmarks/results/storage_sim.json` artifact the handout cites.
847pub fn standard_report() -> String {
848    let t = FlashTimings::zns_tlc();
849    let read_zone = |base: u64, count: u64| ZoneDesc {
850        zone: 0,
851        base_page: base,
852        count,
853        write: false,
854    };
855
856    let mut scenarios: Vec<(&str, Vec<TimedCmd>)> = Vec::new();
857    scenarios.push((
858        "sequential_read_8k_pages",
859        schedule_zoned(
860            &t,
861            &[read_zone(0, 8192)],
862            &[],
863            &[],
864            VisitOrder::ProgramOrder,
865        ),
866    ));
867
868    // ZNS append stream (program-heavy).
869    let append = ZoneDesc {
870        zone: 1,
871        base_page: 0,
872        count: 4096,
873        write: true,
874    };
875    scenarios.push((
876        "zns_append_4k_pages",
877        schedule_zoned(&t, &[append], &[], &[], VisitOrder::ProgramOrder),
878    ));
879
880    // CONCURRENT reclamation: a read stream on 56 dies while a full zone erases on the other
881    // 8 dies — the reads keep streaming, the GC runs alongside, neither touches the other.
882    let read_win = Placement {
883        die_base: 0,
884        die_count: 56,
885    };
886    let gc_win = Placement {
887        die_base: 56,
888        die_count: 8,
889    };
890    scenarios.push((
891        "reads_with_concurrent_gc",
892        schedule_placed(
893            &t,
894            &[(
895                ZoneDesc {
896                    zone: 0,
897                    base_page: 0,
898                    count: 6144,
899                    write: false,
900                },
901                read_win,
902            )],
903            &[(ZoneReset { zone: 1 }, gc_win)],
904            VisitOrder::ProgramOrder,
905        ),
906    ));
907
908    // The random tail (index lookup / pointer chase).
909    let mut x = 0x9E3779B97F4A7C15u64;
910    let pages: Vec<u64> = (0..4096)
911        .map(|_| {
912            x ^= x << 13;
913            x ^= x >> 7;
914            x ^= x << 17;
915            x % (1 << 24)
916        })
917        .collect();
918    scenarios.push((
919        "random_gather_4096",
920        schedule_zoned(&t, &[], &[Gather { pages }], &[], VisitOrder::ProgramOrder),
921    ));
922
923    let mut body = String::new();
924    for (name, plan) in &scenarios {
925        check_legal_flash(&t, plan).unwrap_or_else(|e| panic!("{name}: illegal plan: {e}"));
926        let u = measure(&t, plan);
927        body.push_str(&format!(
928            "    \"{name}\": {{ \"utilization\": {:.4}, \"reads\": {}, \"programs\": {}, \
929             \"erases\": {}, \"worst_read_latency\": {}, \"span_cycles\": {} }},\n",
930            u.value(),
931            u.reads,
932            u.programs,
933            u.erases,
934            u.worst_read_latency,
935            u.span_cycles
936        ));
937    }
938    let body = body.trim_end_matches(",\n").to_string();
939
940    format!(
941        "{{\n  \"meta\": {{\n    \"model\": \"host-managed ZNS-TLC, {ch}ch × {dc} dies, \
942         die-interleaved / channel-striped, erases scheduled into idle windows\",\n    \
943         \"timings\": {{ \"t_r\": {}, \"t_prog\": {}, \"t_bers\": {}, \"data_cycles\": {}, \
944         \"suspend_window\": {}, \"ldpc_retry_worst\": {}, \"channels\": {}, \"dies_per_channel\": {} }},\n    \
945         \"read_latency_bound\": {},\n    \"note\": \"pure function of the timing table — \
946         regenerate with STORAGE_RECORD=1\"\n  }},\n  \"scenarios\": {{\n{body}\n  }}\n}}\n",
947        t.t_r,
948        t.t_prog,
949        t.t_bers,
950        t.data_cycles,
951        t.suspend_window,
952        t.ldpc_retry_worst,
953        t.channels,
954        t.dies_per_channel,
955        t.read_latency_bound(),
956        ch = t.channels,
957        dc = t.dies_per_channel,
958    )
959}
960
961#[cfg(test)]
962#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/storage_controller_rtl/tests.rs"]
963mod tests;