vj_sim/state/snapshot.rs
1//! sva plan T1 (`docs/superpowers/plans/2026-10-03-sva.md` §1.3) — the
2//! Preponed-region snapshot ring (LRM 1800-2023 §16.5.1, §16.5.2): a flat,
3//! bounded ring of whole/partial-arena [`ArenaSnapshot`]s, built directly on
4//! the existing coarse checkpoint primitive ([`StateEngine::snapshot`]/
5//! [`StateEngine::restore`], Task 16, ST-098) rather than a second accessor
6//! plane -- the plan's own §1.3 framing: "a `sim_save`-shaped snapshot ring,
7//! not a second accessor plane".
8//!
9//! One ring entry is taken once per simulation time step, at the Preponed
10//! region boundary (before `Region::Active` runs that step) --
11//! [`Self::get`]`(0)` is always "the value as of the end of the *previous*
12//! time step" (the literal 16.5.1 sampled value, and the same value a
13//! clocking block's `#1step` input skew samples: §1.3's "both consumers
14//! read ring entry 0"); entries `1..=depth` are progressively older ticks,
15//! consumed by `$past(expr, n)` (16.9.3) once a later task lowers it.
16//!
17//! **Deviation (recorded per this task's own instructions).** The plan's
18//! prose ("wire `depth` from a new `StaticAnalysisView` query... computed
19//! by Task 1's own small elaboration walk") describes threading a live
20//! `sv_lang::Design` walk through `analysis::StaticAnalysisView` and from
21//! there into a running `Engine`. Neither `analysis/mod.rs` (that trait)
22//! nor `vj`/`vj-runtime` (the one place that constructs a real,
23//! `StateEngine`-backed `Engine`) is in this task's own `Files:` list, and
24//! no consumer of a live depth value exists yet (`$past` itself is
25//! T16-T18; the Observed-region automaton that would actually *read* a
26//! ring entry is T2+). Wiring an elaboration-time query through two files
27//! this task does not own, to feed a value nothing yet reads, would be
28//! premature integration. This task instead makes `depth` a plain runtime
29//! constructor parameter -- satisfying the acceptance text's actual
30//! requirement ("ring depth is computed per-design, not fixed", i.e. not a
31//! hardcoded constant baked into this type) -- and leaves computing that
32//! parameter from a live `Design` to the first task with a real consumer
33//! for the result.
34
35use super::rollback::ArenaSnapshot;
36
37/// A bounded ring of [`ArenaSnapshot`]s: "current" (the most recently
38/// pushed entry, ring index 0) plus up to `depth` older entries. Never
39/// retains more than `depth + 1` entries, no matter how many times
40/// [`Self::push`] is called -- the "a design with no `$past` retention need
41/// pays no retention cost beyond entry 0" guard (see
42/// `snapshot_ring_depth_0_design_allocates_no_ring` below). Whether a ring
43/// is allocated *at all* for a given instance is a decision this type does
44/// not make -- `sched::control::Engine` only ever constructs one when the
45/// design has at least one assertion/clocking-block/`$past` site to begin
46/// with (§1.4's "not every instance, to avoid taxing designs with no SVA at
47/// all"); a design with none never calls [`Self::new`] and pays nothing.
48pub struct SnapshotRing {
49 /// Front = oldest retained, back = most recent (`push` appends,
50 /// dropping the front once more than `depth + 1` entries are held) --
51 /// a `VecDeque` so both ends are O(1), matching `RegionQueues`' own
52 /// established "`VecDeque` when both ends matter" convention
53 /// (`sched::queues`).
54 entries: std::collections::VecDeque<ArenaSnapshot>,
55 depth: usize,
56}
57
58impl SnapshotRing {
59 /// `depth` = N (16.9.3/16.9.4's largest literal `number_of_ticks`
60 /// argument across the whole design that needs this ring; `0` if
61 /// `$past`/`$past_gclk` is never called with a nonzero tick count for
62 /// it). Allocates nothing up front (`VecDeque::new()`, never
63 /// `with_capacity`) -- the first [`Self::push`] call is this ring's
64 /// first real allocation.
65 pub fn new(depth: usize) -> Self {
66 SnapshotRing {
67 entries: std::collections::VecDeque::new(),
68 depth,
69 }
70 }
71
72 /// `N` as passed to [`Self::new`] -- how many entries beyond "current"
73 /// this ring retains.
74 pub fn depth(&self) -> usize {
75 self.depth
76 }
77
78 /// How many entries are currently retained (`<= depth + 1`) -- a test/
79 /// diagnostic accessor; [`Self::push`]/[`Self::get`] already enforce the
80 /// bound internally without consulting this.
81 pub fn len(&self) -> usize {
82 self.entries.len()
83 }
84
85 /// `true` before the first [`Self::push`] (clippy's `len_without_is_empty`).
86 pub fn is_empty(&self) -> bool {
87 self.entries.is_empty()
88 }
89
90 /// Appends `snap` as the new "current" entry, evicting the oldest entry
91 /// once the ring already holds `depth + 1` entries -- never grows past
92 /// that bound no matter how many times this is called.
93 pub fn push(&mut self, snap: ArenaSnapshot) {
94 self.entries.push_back(snap);
95 while self.entries.len() > self.depth + 1 {
96 self.entries.pop_front();
97 }
98 }
99
100 /// The entry `ticks_back` pushes before the most recent one (`0` =
101 /// "current", the value [`Self::push`] most recently stored). `None`
102 /// when `ticks_back` exceeds how many entries are actually retained --
103 /// either because the ring hasn't been pushed to that many times yet
104 /// (simulation start; 16.9.3's own "before the Nth previous clock tick,
105 /// treat the value as X" case, left to the `$past` lowering task to
106 /// turn into the right X-valued result) or because `ticks_back >
107 /// depth` (a caller bug this primitive reports rather than panics on --
108 /// unlike `RegionQueues`'s "this region can never be serviced at all"
109 /// structural impossibility, this is a plain out-of-range read).
110 pub fn get(&self, ticks_back: usize) -> Option<&ArenaSnapshot> {
111 if ticks_back > self.depth {
112 return None;
113 }
114 let len = self.entries.len();
115 let idx = len.checked_sub(1)?.checked_sub(ticks_back)?;
116 self.entries.get(idx)
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::state::{
124 FourStateValue, SignalAccess, SlotClass, SlotLocation, StateCellId, StateEngine,
125 TemplateRegistry,
126 };
127
128 /// Mirrors `rollback.rs`'s own `fresh_engine`/`inline_cell` test
129 /// helpers (duplicated locally per this crate's existing
130 /// per-task-module test helper convention, `rollback.rs`'s own doc
131 /// comment on `inline_cell`).
132 fn fresh_engine() -> StateEngine {
133 StateEngine::new(TemplateRegistry::new())
134 }
135
136 fn inline_cell(engine: &mut StateEngine) -> StateCellId {
137 // SAFETY: local, single-owned engine; no other live borrow of the
138 // inline plane, and this helper performs no concurrent allocation
139 // on the same engine.
140 let word_offset = unsafe { engine.arena.alloc_inline_word() };
141 let loc = SlotLocation {
142 class: SlotClass::Inline,
143 word_offset,
144 bit_width: 8,
145 elem_word_count: 1,
146 row_stride_words: 0,
147 num_rows: 0,
148 };
149 engine.alloc_cell(loc)
150 }
151
152 fn narrow(val: u64) -> FourStateValue {
153 FourStateValue::Narrow(val as u128)
154 }
155
156 fn read_narrow(engine: &StateEngine, cell: StateCellId) -> u128 {
157 match engine.read(cell) {
158 FourStateValue::Narrow(w) => w,
159 FourStateValue::Wide { .. } => panic!("expected Narrow"),
160 }
161 }
162
163 /// 16.5.1: a depth-1 ring (one prior tick retained beyond "current")
164 /// pushed once per simulated tick must give back exactly the value that
165 /// was live at the moment of each push -- `get(1)` (one tick back) must
166 /// read as the OLDER tick's value even after a newer one has been
167 /// pushed, and `get(0)` (current) must read as the newest.
168 #[test]
169 fn snapshot_ring_depth_1_returns_previous_ticks_value() {
170 let mut engine = fresh_engine();
171 let cell = inline_cell(&mut engine);
172 let mut ring = SnapshotRing::new(1);
173
174 engine.commit_write(cell, narrow(10), &mut ());
175 ring.push(engine.snapshot(None)); // tick 0: cell == 10
176
177 engine.commit_write(cell, narrow(20), &mut ());
178 ring.push(engine.snapshot(None)); // tick 1: cell == 20
179
180 // Mutate further without pushing -- proves `get` reads the RING's
181 // own retained copies, never the engine's live, since-mutated state.
182 engine.commit_write(cell, narrow(99), &mut ());
183
184 engine
185 .restore(ring.get(1).expect("depth 1 retains one prior tick"))
186 .expect("same engine, same layout");
187 assert_eq!(
188 read_narrow(&engine, cell),
189 10,
190 "ticks_back=1 must give back the OLDER (tick 0) value"
191 );
192
193 engine
194 .restore(ring.get(0).expect("current is always retained"))
195 .expect("same engine, same layout");
196 assert_eq!(
197 read_narrow(&engine, cell),
198 20,
199 "ticks_back=0 (current) must give back the newest (tick 1) value"
200 );
201
202 assert!(
203 ring.get(2).is_none(),
204 "depth 1 retains only ticks_back in {{0,1}} -- 2 is out of range"
205 );
206 }
207
208 /// Perf/memory guard (§1.4's "not every instance, to avoid taxing
209 /// designs with no SVA at all", applied at the ring's own smallest
210 /// nonzero case): a depth-0 ring never retains more than its single
211 /// "current" entry, no matter how many times it is pushed to -- proving
212 /// the ring is genuinely bounded (never an ever-growing log) rather
213 /// than merely documented to be.
214 #[test]
215 fn snapshot_ring_depth_0_design_allocates_no_ring() {
216 let mut engine = fresh_engine();
217 let cell = inline_cell(&mut engine);
218 let mut ring = SnapshotRing::new(0);
219 assert_eq!(ring.depth(), 0);
220
221 for i in 0..5u64 {
222 engine.commit_write(cell, narrow(i), &mut ());
223 ring.push(engine.snapshot(None));
224 assert_eq!(
225 ring.len(),
226 1,
227 "depth 0 must never retain more than entry 0, even after {} pushes",
228 i + 1
229 );
230 }
231
232 engine
233 .restore(ring.get(0).expect("current is always retained"))
234 .expect("same engine, same layout");
235 assert_eq!(
236 read_narrow(&engine, cell),
237 4,
238 "the lone retained entry must be the MOST RECENT push"
239 );
240 assert!(
241 ring.get(1).is_none(),
242 "depth 0 retains no prior ticks at all"
243 );
244 }
245
246 #[test]
247 fn fresh_ring_has_no_entries_yet() {
248 let ring = SnapshotRing::new(3);
249 assert_eq!(ring.len(), 0);
250 assert!(ring.get(0).is_none());
251 }
252}