vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! A2-PROCESS (`vj-sim::process`) — cross-track contract only: the process
//! template lowering shape shared between the A2-process track and CODEGEN
//! (process.md). Real liveness analysis / CFG construction belongs to the
//! A2-process track, not this wave.

use vj_abi::{SimHandle, SimTime};

use crate::sched::JoinKind;
use crate::state::{EdgeKind, StateCellId};

// Task S3a (CPS CFG + Segment IR) touches `sv_lang::Statement`/`Expression`
// directly, so it lives behind this crate's `frontend` feature per
// `docs/CONTRACTS.md` D4 -- unlike the plain POD shape types above, which
// stay sv-lang-free and ungated.
#[cfg(feature = "frontend")]
pub mod cfg;

// Task 7 (PR-116, XC-044): re-exported at this module's own path (rather
// than only `process::cfg::`) so `suspending_statement_kinds` sits exactly
// where the plan names it (`vj-sim::process`) -- physically defined
// next to `SuspendPoint` in `cfg` (see that function's own doc comment for
// why), not redeclared here.
#[cfg(feature = "frontend")]
pub use cfg::{SuspendPointKind, suspending_statement_kinds};

// Task S3b (Liveness + FrameLayout) touches `sv_lang::Symbol`/`SymbolId`
// directly (declared-local discovery via `Statement::block_symbol()` +
// `Symbol::members()`), so it lives behind this crate's `frontend` feature
// for the same reason `cfg` does -- see `cfg`'s doc comment above.
#[cfg(feature = "frontend")]
pub mod liveness;

// Task S5 (Template dedup + `ProcessInstanceId`) touches `sv_lang::Symbol`/
// `SymbolId`/`ConstantValue` directly (`Symbol::parameters()`,
// `instance_body_definition()`, `constant_value()`), so it lives behind this
// crate's `frontend` feature for the same reason `cfg`/`liveness` do -- see
// `cfg`'s doc comment above.
#[cfg(feature = "frontend")]
pub mod dedup;

/// Stable, elaboration-order-derived tie-break key (process.md §6: "derived from
/// a deterministic pre-order walk of the frozen Design's instance hierarchy,
/// never from allocation order or thread arrival"; ledger PR-075..PR-077,
/// PR-118, DT-011, DT-031). The scheduler consumes this as its canonical
/// cross-thread tie-break.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct ProcessInstanceId(pub u64);

/// Opaque, minted 1:1 by CODEGEN per trap-eligible statement at lowering time
/// (debugger.md; ledger CG-142, CG-149, CG-154, SC-104, SC-126, DB-034): stable
/// only within one compile, never across recompiles (no hot-reload of source into
/// a live debug session).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct StmtId(pub u64);

/// One frame slot's compile-time SHAPE only (process.md §3 `FrameSlot`/
/// `FourStateI2N`/`WideFourState`; ledger ST-103..ST-105): the number of u64
/// words the slot's payload occupies. This is layout metadata CODEGEN consumes
/// when computing a TEMPLATE's fixed frame offsets ("Frame slot layout is
/// decided once per process TEMPLATE... handed to codegen as a fixed struct
/// layout") — never the runtime payload itself, which is why it carries
/// counts, not `Vec<u64>` data (see [`ActivationRecord`]'s doc comment for why
/// that distinction is load-bearing).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FrameSlot {
    Narrow4State {
        word_count: u16,
    },
    Wide4State {
        aval_word_count: u32,
        bval_word_count: u32,
    },
    /// A fixed-size unpacked-array local: `rows` rows (all dimensions
    /// flattened, lowest index first) of `row_words` words each, a row
    /// holding one element's packed value.
    Array {
        rows: u32,
        row_words: u16,
    },
}

/// A fixed, pointer-free reference to a contiguous range of STATE-owned arena
/// storage (word- or element-addressed, per the referencing field's own unit).
/// Never an owning buffer — the one shape every [`ActivationRecord`]-embedded
/// field below uses in place of a heap-owning `Vec`.
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ArenaRange {
    pub base: u32,
    pub count: u32,
}

/// POD, pointer-free, memcpy-checkpointable — the only per-instance execution
/// state that must survive a suspend or a Time-Warp rollback (process.md §3;
/// ledger CG-164, CG-176, ST-005).
///
/// Fixed vs. an earlier draft of this contract (caught in review): storing
/// `frame: Vec<FrameSlot>` / `pending_wait: Option<WaitSpec>` (its `EdgeList`
/// variant owns a `Vec`) / `fork_state: Option<ForkFrame>` (its
/// `child_handles` owned a `Vec`) directly here made the "memcpy-
/// checkpointable" claim false — a `Vec` is a heap pointer + len + cap, so
/// byte-copying this struct for checkpoint/rollback would duplicate that
/// pointer, and dropping or mutating either copy double-frees or
/// use-after-frees the other. `frame: [FrameSlot]` in
/// `docs/design/process.md`'s own struct sketch (§3, line 40) is a DST slice,
/// meaningful only as a *reference* into memory STATE owns ("owned/allocated
/// by STATE, laid out by this subsystem via FrameLayout") — never data this
/// struct owns inline. `frame` below is exactly that reference: an
/// [`ArenaRange`] into STATE's per-process frame arena (word-addressed); the
/// per-slot shape list (`FrameLayout`/`Vec<FrameSlot>`) stays a compile-time-
/// only artifact of `ProcessLoweringPlan`, analogous to `state::SlotOffset`,
/// and is never embedded in the per-instance runtime record. `pending_wait` /
/// `fork_state` are fixed for the same reason — see [`WaitSpecStorage`] /
/// [`ForkFrame`]'s own doc comments.
// No `PartialEq`/`Eq`: transitively embeds `PredicateFn` (a bare fn pointer,
// whose equality is deliberately not derived -- see `PredicateFn`'s own doc
// comment). `Copy` is what the POD/memcpy-checkpointable contract needs.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct ActivationRecord {
    pub pc: u32,
    pub frame: ArenaRange,
    pub pending_wait: WaitSpecStorage,
    pub fork_state: Option<ForkFrame>,
}

/// The POD cache of the last-returned suspend reason, embedded directly in
/// [`ActivationRecord`]. `docs/design/process.md` §3/§4 names this exact
/// split: the persisted field is `WaitSpecStorage`, distinct from the richer
/// [`WaitSpec`] the suspend/resume ABI exchanges with the scheduler on every
/// call ("last-returned WaitSpec, re-derivable but cached for O(1) resume
/// dispatch and for the debugger inspector"). `Edge`'s single term is already
/// `Copy` (`PredicateFn` is a bare `extern "C"` fn pointer) so it is inlined
/// directly; `EdgeList`'s term count is unbounded (an arbitrary-length
/// `@(a or b or c or ...)`), so like `ActivationRecord::frame` it is instead
/// an [`ArenaRange`] into a static, elaboration-time-built edge-term table
/// (the concrete `StateCellId`s for a given suspend point are fixed once
/// elaboration resolves the instance's signals and never change for the life
/// of the run, so this table is a read-only lookup, never a rollback hazard).
// No `PartialEq`/`Eq`: the `Edge` variant embeds `Option<PredicateFn>` -- see
// `PredicateFn`'s own doc comment for why fn-pointer equality isn't derived.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub enum WaitSpecStorage {
    None,
    Delay(SimTime),
    Edge {
        edge: EdgeKind,
        signal: StateCellId,
        iff: Option<PredicateFn>,
    },
    EdgeList(ArenaRange),
    ImplicitComb(SubscriptionSetId),
}

/// Fork/join bookkeeping (process.md §7: "construction only, no scheduling
/// semantics in v1"; ledger PR-086, fork/join family PR-001, PR-047, PR-061).
/// `children` is a fixed [`ArenaRange`] into the engine's dynamic fork-child
/// table — never an owned `Vec`, the same memcpy-safety reason as
/// `ActivationRecord::frame`. Its elements are [`crate::sched::ProcessId`],
/// **not** [`ProcessInstanceId`]: scheduler.md is explicit that "fork
/// children are dynamically created ProcessIds not present in static
/// elaboration," so `ProcessInstanceId` (elaboration-derived, static-only per
/// this file's own doc comment above) structurally cannot name them. The
/// branch *count* for a given fork site is statically known at lowering time;
/// the concrete `ProcessId`s are assigned dynamically at fork time and live
/// in that out-of-line table, never inline here.
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ForkFrame {
    pub children: ArenaRange,
    pub join_kind: JoinKind,
    pub remaining: u32,
}

/// The settled `CondThunk` shape for a `SignalEventControl`'s `iff` re-check
/// AND `wait(expr)`'s own re-check (PR-106, A2-part2 Task 3): a plain
/// `extern "C"` function pointer over the shared `SimHandle`, produced by
/// exactly one compiler entry point, `vj-codegen::process_lower::
/// compile_iff_guard` -- see that function's own doc comment. Through Task 1
/// this shape was left an open question (process.md: "needs to be settled
/// jointly with codegen's dispatcher shape... not decided unilaterally
/// here"); Task 3 settles it: a raw function pointer into a standalone,
/// independently-compiled guard function, never a re-entry into a dedicated
/// micro-segment.
// Deliberately no `PartialEq`/`Eq`: fn-pointer comparisons are not
// meaningful (address is not guaranteed stable/unique across codegen units,
// merged identical functions can collide) -- `rustc` warns on this
// (`unpredictable_function_pointer_comparisons`). `Copy` alone is what the
// POD/memcpy-checkpointable contract actually needs.
#[derive(Clone, Copy, Debug)]
pub struct PredicateFn(pub extern "C" fn(SimHandle) -> bool);

/// A precomputed, static subscription-set id for `always_comb`/`always_latch`'s
/// implicit sensitivity (scheduler.md §4/§5) — O(1) resubscribe, computed once
/// ever from `AnalyzedProcedure::read_set()`.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct SubscriptionSetId(pub u32);

/// The ABI-facing resume contract (process.md §4 `WaitSpec`; ledger CG-169,
/// CG-170, SC-085, SC-119, PR-012, PR-037) a compiled process kernel reports back
/// through [`ProcessStatus::Suspended`].
///
/// Reconciliation: distinct from [`crate::sched::Suspend`]. `sched::Suspend` is
/// the Rust-trait-level abstraction `CompiledProcess::resume()` returns to the
/// *in-process* scheduler orchestration loop (scheduler.md §5); `WaitSpec` here is
/// the lower-level, codegen-facing shape process.md §4 sketches for the compiled
/// `process_kernel`-style ABI. ARCHITECTURE.md does not collapse the two designs'
/// differing field lists into one type, so per the reconciliation rule both are
/// kept, layered rather than merged: `sched::Suspend` is built by decoding a
/// returned `WaitSpec`/`RescheduleReq`, not by replacing it.
#[derive(Clone, Debug)]
pub enum WaitSpec {
    Delay(SimTime),
    Edge {
        edge: EdgeKind,
        signal: StateCellId,
        iff: Option<PredicateFn>,
    },
    EdgeList(Vec<(EdgeKind, StateCellId, Option<PredicateFn>)>),
    ImplicitComb(SubscriptionSetId),
}

/// What woke a process (process.md §4 `ResumeInfo`/`ResumeReason`).
#[derive(Clone, Copy, Debug)]
pub struct ResumeInfo {
    pub reason: ResumeReason,
    pub at_time: SimTime,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ResumeReason {
    InitialDispatch,
    EdgeFired { signal_generation: u64 },
    DelayElapsed,
    DebugStep,
    ForkChildDone,
}

#[derive(Clone, Debug)]
pub struct ProcessResult {
    pub status: ProcessStatus,
}

#[derive(Clone, Debug)]
pub enum ProcessStatus {
    Suspended {
        wait: WaitSpec,
    },
    Finished,
    /// Debug-mode only: paused at a micro-segment boundary, NOT a real suspend
    /// (process.md §8).
    Yielded,
}

/// The compiled-kernel calling convention every process TEMPLATE compiles to
/// (process.md §4). **Not** the literal ABI-crossing symbol — that is
/// [`vj_abi::ProcessFn`] (`extern "C"`, using the frozen `SimHandle`/
/// `EvalResult`/`RescheduleReq` shapes so every field stays FFI-safe). This is the
/// richer, process-track-internal Rust-level decoding of that call, matching
/// process.md's own explicitly-flagged scope ("proposed here at the logical
/// (Suspend/ResumeArg) level only; the physical ABI needs to be pinned jointly
/// with CODEGEN") — an ordinary Rust `fn` pointer, deliberately not `extern "C"`,
/// since [`ProcessResult`] owns a heap-allocated field (`WaitSpec::EdgeList`'s
/// `Vec<..>`, via `ProcessStatus::Suspended`) that is not FFI-safe by value.
/// [`ActivationRecord`] itself is `#[repr(C)]`/POD (see its own doc comment) —
/// it is the *return type*, not the activation pointer, that blocks
/// `extern "C"` here.
pub type ProcessKernelFn =
    fn(state: *mut u8, activation: *mut ActivationRecord, resume: ResumeInfo) -> ProcessResult;

/// Rollback-safety classification per `LoweredStmt`/`Segment` (process.md §6),
/// consumed by the engine's Time-Warp commit policy.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Effect {
    Pure,
    StateWrite,
    Irrevocable,
}

/// Task 10 (A2-part2, PR-122): the per-partition additive bias CODEGEN adds
/// to a `SignalSlot::slot` arena-slot integer operand before it reaches
/// `accessor::build_get_scalar`/`build_set_scalar`, so two processes in
/// different WOCB partitions (`region_id`s) address disjoint arena-base
/// bands: partition `region_id`'s slots occupy `[region_id *
/// slots_per_region, (region_id + 1) * slots_per_region)`. A plain multiply,
/// not a hardcoded zero -- `region_id == 0` (v1's only real value) yields
/// `0`, leaving every real accessor call's slot operand byte-identical to
/// today's unbiased value (this task changes zero observable behavior for
/// any real v1 design); a caller that hand-sets a synthetic `region_id >= 1`
/// (no such partition exists yet -- the WOCB analysis pass that would assign
/// one is unbuilt) gets a real, disjoint, nonzero band, which is exactly
/// what the codegen-side IR-inspection test proves. One definition, reused
/// by both the real biasing call site (`vj-codegen::process_lower`'s
/// `prime_external_signals`/`flush_external_signals`) and this task's own
/// unit test -- never reimplemented a second time.
pub fn arena_base_offset_for_region(region_id: u32, slots_per_region: u32) -> u32 {
    region_id * slots_per_region
}

#[cfg(test)]
mod tests {
    use super::*;

    /// PR-104: a source file "looks like" 4-state truth-table/operator-
    /// semantics logic when it references all four `sv_lang::Bit` per-bit
    /// variants (`Bit::Zero`/`Bit::One`/`Bit::X`/`Bit::Z`) AND combines them
    /// with a bitwise operator -- the exact shape `vj-codegen::
    /// ops::ref4`'s own `and`/`or`/`xor` functions have (verified against
    /// that module's real source: a `vu`/`bit` per-bit decode/encode pair
    /// plus a `&`/`|`/`^` formula on the decoded value/unknown planes).
    /// `vj-sim::process` must contain none of this -- PR-104's whole
    /// claim is that this crate decides no 4-state operator semantics at
    /// all, that being codegen's job exclusively.
    ///
    /// **Crate-boundary note (this task's own, mirroring `sched::
    /// kernel_adapter.rs`'s identical precedent):** this test needs
    /// `crate::determinism::scan_support::strip_cfg_test_blocks`, which is
    /// `pub(crate)` and therefore unreachable from an external `tests/`
    /// binary -- `crates/vj-sim/tests/process_contract.rs`'s own doc
    /// comment records this same reasoning for why this test (and its
    /// injected-fixture sibling below) lives here instead.
    fn looks_like_four_state_truth_table(src: &str) -> bool {
        let has_all_bit_variants = ["Bit::Zero", "Bit::One", "Bit::X", "Bit::Z"]
            .iter()
            .all(|m| src.contains(m));
        let has_bitwise_combinator =
            src.contains(" & ") || src.contains(" | ") || src.contains(" ^ ");
        has_all_bit_variants && has_bitwise_combinator
    }

    #[test]
    fn no_truth_table_logic_in_process_crate() {
        let src_root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src/process"));
        let mut offenders = Vec::new();
        let mut stack = vec![src_root.to_path_buf()];
        while let Some(dir) = stack.pop() {
            for entry in std::fs::read_dir(&dir).expect("read_dir must succeed under src/process") {
                let entry = entry.expect("dir entry must be readable");
                let path = entry.path();
                if path.is_dir() {
                    stack.push(path);
                    continue;
                }
                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
                    continue;
                }
                let text = std::fs::read_to_string(&path)
                    .unwrap_or_else(|e| panic!("must be able to read {path:?}: {e}"));
                let production_only =
                    crate::determinism::scan_support::strip_cfg_test_blocks(&text);
                if looks_like_four_state_truth_table(&production_only) {
                    offenders.push(path);
                }
            }
        }
        assert!(
            offenders.is_empty(),
            "PR-104: found 4-state truth-table-shaped logic in the process crate (codegen's \
             job alone, never this crate's): {offenders:?}"
        );
    }

    /// The negative-fixture half of PR-104's own acceptance ("include a
    /// negative fixture proving the scan fires on an injected truth table"):
    /// proves [`looks_like_four_state_truth_table`] is a real, firing check,
    /// not a gate that cannot fail (it requires ALL FOUR variants present,
    /// so a fixture referencing only one, like a real early-return arm
    /// might, must NOT false-positive).
    #[test]
    fn no_truth_table_logic_scan_fires_on_injected_truth_table() {
        let one_variant_only =
            "fn and(a: Bit, b: Bit) -> Bit { let v = (a & b) | (a ^ b); Bit::Zero }";
        assert!(
            !looks_like_four_state_truth_table(one_variant_only),
            "sanity: referencing only one of the four Bit variants must not false-positive"
        );

        let injected_truth_table = format!(
            "{one_variant_only}\nconst _EXAMPLES: [Bit; 4] = [Bit::Zero, Bit::One, Bit::X, Bit::Z];"
        );
        assert!(
            looks_like_four_state_truth_table(&injected_truth_table),
            "the scan must fire once an injected fixture references all four Bit variants \
             alongside a bitwise combinator"
        );
    }

    #[test]
    fn wait_spec_delay_round_trips_sim_time() {
        let t = SimTime {
            sim_units: 42,
            delta: 0,
        };
        let w = WaitSpec::Delay(t);
        match w {
            WaitSpec::Delay(got) => assert_eq!(got, t),
            _ => panic!("expected Delay"),
        }
    }

    /// Compile-time proof of the review-fixed claim: `ActivationRecord` (and
    /// everything it embeds) must be `Copy` — i.e. own no heap pointer — for
    /// "memcpy-checkpointable" to be true. Before the fix this would not even
    /// compile (`Vec` fields are not `Copy`), which was exactly the point: the
    /// original code silently skipped `derive(Copy)` entirely instead of
    /// surfacing that failure.
    #[test]
    fn activation_record_is_pod_and_memcpy_safe() {
        fn assert_copy<T: Copy>() {}
        assert_copy::<ActivationRecord>();
        assert_copy::<ForkFrame>();
        assert_copy::<WaitSpecStorage>();
        assert_copy::<FrameSlot>();
        assert_copy::<ArenaRange>();

        let ar = ActivationRecord {
            pc: 0,
            frame: ArenaRange { base: 0, count: 4 },
            pending_wait: WaitSpecStorage::EdgeList(ArenaRange { base: 4, count: 2 }),
            fork_state: Some(ForkFrame {
                children: ArenaRange { base: 8, count: 3 },
                join_kind: JoinKind::All,
                remaining: 3,
            }),
        };
        // A plain byte-copy (what checkpoint/rollback does) must never
        // require a Drop impl or duplicate a heap allocation.
        let copy = ar;
        assert_eq!(copy.pc, ar.pc);
        assert_eq!(copy.frame.count, 4);
    }
}