Skip to main content

weir/
graph_layout.rs

1//! Shared graph-layout contracts for Weir GPU analyses.
2//!
3//! Dataflow analyses all consume the same forward CSR shape: node count,
4//! row offsets, edge targets, and edge-kind masks. This module centralizes
5//! validation and canonicalization so fixed-point, resident, IFDS, slicing,
6//! dominators, and range-oriented paths can converge on one layout contract
7//! instead of growing analysis-local CSR dialects.
8
9use vyre_primitives::bitset::bitset_words;
10
11/// Shared one-dimensional analysis domain.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct LinearDomain {
14    element_count: u32,
15}
16
17impl LinearDomain {
18    /// Build a linear analysis domain.
19    #[inline]
20    #[must_use]
21    pub const fn new(element_count: u32) -> Self {
22        Self { element_count }
23    }
24
25    /// Number of logical elements in the domain.
26    #[inline]
27    #[must_use]
28    pub const fn element_count(self) -> u32 {
29        self.element_count
30    }
31
32    /// Number of u32 words required to represent this domain as a bitset.
33    #[inline]
34    #[must_use]
35    pub fn bitset_words(self) -> u32 {
36        bitset_words(self.element_count)
37    }
38
39    /// Number of u32 slots required when every element owns `slots_per_element`
40    /// scalar lanes.
41    pub fn scalar_slots(self, stage: &str, slots_per_element: u32) -> Result<u32, String> {
42        self.element_count
43            .checked_mul(slots_per_element)
44            .map(|slots| slots.max(1))
45            .ok_or_else(|| {
46                format!(
47                    "{stage} element_count={} with {slots_per_element} slot(s) per element overflows u32 buffer slots. Fix: shard the analysis domain before building the GPU Program.",
48                    self.element_count
49                )
50            })
51    }
52}
53
54/// Borrowed CSR graph view shared by Weir analyses.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct CsrGraph<'a> {
57    /// Number of graph nodes in the analysis domain.
58    pub node_count: u32,
59    /// CSR row offsets, length `node_count + 1`.
60    pub edge_offsets: &'a [u32],
61    /// CSR edge targets.
62    pub edge_targets: &'a [u32],
63    /// CSR edge-kind masks, length equal to `edge_targets`.
64    pub edge_kind_mask: &'a [u32],
65}
66
67impl<'a> CsrGraph<'a> {
68    /// Build a borrowed CSR graph view.
69    #[inline]
70    #[must_use]
71    pub fn new(
72        node_count: u32,
73        edge_offsets: &'a [u32],
74        edge_targets: &'a [u32],
75        edge_kind_mask: &'a [u32],
76    ) -> Self {
77        Self {
78            node_count,
79            edge_offsets,
80            edge_targets,
81            edge_kind_mask,
82        }
83    }
84
85    /// Validate this graph against the shared Weir CSR ABI.
86    pub fn validate(self, stage: &str) -> Result<Self, String> {
87        crate::dispatch_decode::require_csr_shape(
88            stage,
89            self.node_count,
90            self.edge_offsets,
91            self.edge_targets,
92            self.edge_kind_mask,
93        )?;
94        Ok(self)
95    }
96
97    /// Validate and canonicalize rows into sorted, duplicate-free edge order.
98    pub fn normalize(self, stage: &str) -> Result<NormalizedCsrGraph, String> {
99        let mut normalized = NormalizedCsrGraph::empty();
100        let mut scratch = CsrGraphNormalizationScratch::new();
101        self.normalize_with_edge_kind_map_into(
102            stage,
103            false,
104            |mask| mask,
105            &mut normalized,
106            &mut scratch,
107        )?;
108        Ok(normalized)
109    }
110
111    /// Validate and canonicalize rows after masking each edge-kind word.
112    ///
113    /// This avoids analysis-local temporary `Vec<u32>` filters before graph
114    /// normalization. Analyses such as points-to can keep a single shared CSR
115    /// contract while applying their allowed-edge predicate during packing.
116    pub fn normalize_with_edge_kind_mask(
117        self,
118        stage: &str,
119        allowed_mask: u32,
120    ) -> Result<NormalizedCsrGraph, String> {
121        let mut normalized = NormalizedCsrGraph::empty();
122        let mut scratch = CsrGraphNormalizationScratch::new();
123        self.normalize_with_edge_kind_map_into(
124            stage,
125            true,
126            |mask| mask & allowed_mask,
127            &mut normalized,
128            &mut scratch,
129        )?;
130        Ok(normalized)
131    }
132
133    /// Validate and canonicalize rows into caller-owned output and scratch.
134    pub fn normalize_into(
135        self,
136        stage: &str,
137        output: &mut NormalizedCsrGraph,
138        scratch: &mut CsrGraphNormalizationScratch,
139    ) -> Result<(), String> {
140        self.normalize_with_edge_kind_map_into(stage, false, |mask| mask, output, scratch)
141    }
142
143    /// Validate and canonicalize rows after masking into caller-owned buffers.
144    pub fn normalize_with_edge_kind_mask_into(
145        self,
146        stage: &str,
147        allowed_mask: u32,
148        output: &mut NormalizedCsrGraph,
149        scratch: &mut CsrGraphNormalizationScratch,
150    ) -> Result<(), String> {
151        self.normalize_with_edge_kind_map_into(
152            stage,
153            true,
154            |mask| mask & allowed_mask,
155            output,
156            scratch,
157        )
158    }
159
160    fn normalize_with_edge_kind_map_into<F>(
161        self,
162        stage: &str,
163        drop_zero_mapped_edges: bool,
164        mut map_edge_kind: F,
165        output: &mut NormalizedCsrGraph,
166        scratch: &mut CsrGraphNormalizationScratch,
167    ) -> Result<(), String>
168    where
169        F: FnMut(u32) -> u32,
170    {
171        self.validate(stage)?;
172        let nodes = usize::try_from(self.node_count).map_err(|_| {
173            format!("{stage} node_count={} cannot fit usize during CSR normalization. Fix: shard the graph before dispatch.", self.node_count)
174        })?;
175        let offset_capacity = nodes.checked_add(1).ok_or_else(|| {
176            format!(
177                "{stage} node_count={} overflows CSR offset capacity. Fix: shard the graph before dispatch.",
178                self.node_count
179            )
180        })?;
181        output.clear();
182        crate::staging_reserve::reserve_vec(
183            &mut output.edge_offsets,
184            offset_capacity,
185            "CSR normalized row offset",
186        )?;
187        crate::staging_reserve::reserve_vec(
188            &mut output.edge_targets,
189            self.edge_targets.len(),
190            "CSR normalized edge target",
191        )?;
192        crate::staging_reserve::reserve_vec(
193            &mut output.edge_kind_mask,
194            self.edge_kind_mask.len(),
195            "CSR normalized edge-kind mask",
196        )?;
197        output.edge_offsets.push(0);
198        for node in 0..nodes {
199            let start = crate::dispatch_decode::u32_to_usize(
200                self.edge_offsets[node],
201                "CSR normalization row start offset",
202            )?;
203            let end = crate::dispatch_decode::u32_to_usize(
204                self.edge_offsets[node + 1],
205                "CSR normalization row end offset",
206            )?;
207            let row_len = end.checked_sub(start).ok_or_else(|| {
208                format!(
209                    "{stage} CSR offsets decreased at node {node}: start={start}, end={end}. Fix: pass monotonically increasing edge offsets."
210                )
211            })?;
212            if row_len <= 1 {
213                if start != end {
214                    let mapped = map_edge_kind(self.edge_kind_mask[start]);
215                    if !(drop_zero_mapped_edges && mapped == 0) {
216                        output.edge_targets.push(self.edge_targets[start]);
217                        output.edge_kind_mask.push(mapped);
218                    }
219                }
220                let offset = u32::try_from(output.edge_targets.len()).map_err(|error| {
221                    format!(
222                        "{stage} normalized CSR row {node} offset does not fit u32: {error}. Fix: shard the ProgramGraph before GPU dispatch."
223                    )
224                })?;
225                output.edge_offsets.push(offset);
226                continue;
227            }
228            scratch.row.clear();
229            let output_row_start = output.edge_targets.len();
230            let mut previous_direct_target = None;
231            let mut direct_sorted_unique = true;
232            for (&target, &mask) in self.edge_targets[start..end]
233                .iter()
234                .zip(self.edge_kind_mask[start..end].iter())
235            {
236                let mapped = map_edge_kind(mask);
237                if drop_zero_mapped_edges && mapped == 0 {
238                    continue;
239                }
240                if let Some(previous) = previous_direct_target {
241                    if target <= previous {
242                        direct_sorted_unique = false;
243                    }
244                }
245                previous_direct_target = Some(target);
246                output.edge_targets.push(target);
247                output.edge_kind_mask.push(mapped);
248            }
249            if direct_sorted_unique {
250                let offset = u32::try_from(output.edge_targets.len()).map_err(|error| {
251                    format!(
252                        "{stage} normalized CSR row {node} offset does not fit u32: {error}. Fix: shard the ProgramGraph before GPU dispatch."
253                    )
254                })?;
255                output.edge_offsets.push(offset);
256                continue;
257            }
258            let output_row_len =
259                output.edge_targets.len().checked_sub(output_row_start).ok_or_else(|| {
260                    format!(
261                        "{stage} normalized CSR output row start exceeded target length at node {node}. Fix: rebuild the graph normalization state before dispatch."
262                    )
263                })?;
264            crate::staging_reserve::reserve_vec(
265                &mut scratch.row,
266                output_row_len,
267                "CSR graph normalization row scratch",
268            )?;
269            scratch.row.extend(
270                output.edge_targets[output_row_start..]
271                    .iter()
272                    .copied()
273                    .zip(output.edge_kind_mask[output_row_start..].iter().copied()),
274            );
275            output.edge_targets.truncate(output_row_start);
276            output.edge_kind_mask.truncate(output_row_start);
277            scratch.row.sort_unstable_by_key(|(target, _)| *target);
278            let mut merged = 0usize;
279            for idx in 0..scratch.row.len() {
280                if merged != 0 && scratch.row[merged - 1].0 == scratch.row[idx].0 {
281                    scratch.row[merged - 1].1 |= scratch.row[idx].1;
282                } else {
283                    scratch.row[merged] = scratch.row[idx];
284                    merged += 1;
285                }
286            }
287            output
288                .edge_targets
289                .extend(scratch.row[..merged].iter().map(|(target, _)| *target));
290            output
291                .edge_kind_mask
292                .extend(scratch.row[..merged].iter().map(|(_, mask)| *mask));
293            let offset = u32::try_from(output.edge_targets.len()).map_err(|error| {
294                format!(
295                    "{stage} normalized CSR row {node} offset does not fit u32: {error}. Fix: shard the ProgramGraph before GPU dispatch."
296                )
297            })?;
298            output.edge_offsets.push(offset);
299        }
300        let edge_count = u32::try_from(output.edge_targets.len()).map_err(|error| {
301            format!(
302                "{stage} normalized edge target count does not fit u32: {error}. Fix: shard the ProgramGraph before GPU dispatch."
303            )
304        })?;
305        output.node_count = self.node_count;
306        output.edge_count = edge_count;
307        output.stable_layout_hash = stable_csr_layout_hash(
308            self.node_count,
309            edge_count,
310            &output.edge_offsets,
311            &output.edge_targets,
312            &output.edge_kind_mask,
313        );
314        Ok(())
315    }
316}
317
318/// Caller-owned scratch for CSR graph normalization.
319#[derive(Clone, Debug, Default, Eq, PartialEq)]
320pub struct CsrGraphNormalizationScratch {
321    row: Vec<(u32, u32)>,
322}
323
324impl CsrGraphNormalizationScratch {
325    /// Create empty reusable normalization scratch.
326    #[inline]
327    #[must_use]
328    pub const fn new() -> Self {
329        Self { row: Vec::new() }
330    }
331
332    /// Create reusable normalization scratch with row capacity.
333    #[inline]
334    #[must_use]
335    #[cfg(any(test, feature = "legacy-infallible"))]
336    pub fn with_row_capacity(row_capacity: usize) -> Self {
337        Self::try_with_row_capacity(row_capacity)
338            .expect("CSR graph normalization scratch allocation failed. Fix: shard the graph before preparing fixed-point analysis state.")
339    }
340
341    /// Create reusable normalization scratch with a fallible row reservation.
342    #[inline]
343    pub fn try_with_row_capacity(row_capacity: usize) -> Result<Self, String> {
344        let mut scratch = Self::new();
345        crate::staging_reserve::reserve_vec(
346            &mut scratch.row,
347            row_capacity,
348            "CSR graph normalization row scratch",
349        )?;
350        Ok(scratch)
351    }
352
353    /// Retained row capacity.
354    #[inline]
355    #[must_use]
356    pub fn row_capacity(&self) -> usize {
357        self.row.capacity()
358    }
359}
360
361/// Owned canonical CSR layout for cache keys, packed graph state, and reuse.
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub struct NormalizedCsrGraph {
364    node_count: u32,
365    edge_count: u32,
366    stable_layout_hash: u64,
367    edge_offsets: Vec<u32>,
368    edge_targets: Vec<u32>,
369    edge_kind_mask: Vec<u32>,
370}
371
372impl NormalizedCsrGraph {
373    /// Create an empty reusable normalized graph output.
374    #[inline]
375    #[must_use]
376    pub const fn empty() -> Self {
377        Self {
378            node_count: 0,
379            edge_count: 0,
380            stable_layout_hash: 0,
381            edge_offsets: Vec::new(),
382            edge_targets: Vec::new(),
383            edge_kind_mask: Vec::new(),
384        }
385    }
386
387    #[inline]
388    fn clear(&mut self) {
389        self.node_count = 0;
390        self.edge_count = 0;
391        self.stable_layout_hash = 0;
392        self.edge_offsets.clear();
393        self.edge_targets.clear();
394        self.edge_kind_mask.clear();
395    }
396
397    /// Number of nodes in the normalized graph domain.
398    #[inline]
399    #[must_use]
400    pub fn node_count(&self) -> u32 {
401        self.node_count
402    }
403
404    /// Number of canonical edges after duplicate row entries are removed.
405    #[inline]
406    #[must_use]
407    pub fn edge_count(&self) -> u32 {
408        self.edge_count
409    }
410
411    /// Canonical CSR row offsets.
412    #[inline]
413    #[must_use]
414    pub fn edge_offsets(&self) -> &[u32] {
415        &self.edge_offsets
416    }
417
418    /// Canonical CSR edge targets.
419    #[inline]
420    #[must_use]
421    pub fn edge_targets(&self) -> &[u32] {
422        &self.edge_targets
423    }
424
425    /// Canonical CSR edge-kind masks.
426    #[inline]
427    #[must_use]
428    pub fn edge_kind_mask(&self) -> &[u32] {
429        &self.edge_kind_mask
430    }
431
432    /// Retained canonical offset capacity.
433    #[inline]
434    #[must_use]
435    pub fn edge_offsets_capacity(&self) -> usize {
436        self.edge_offsets.capacity()
437    }
438
439    /// Retained canonical target capacity.
440    #[inline]
441    #[must_use]
442    pub fn edge_targets_capacity(&self) -> usize {
443        self.edge_targets.capacity()
444    }
445
446    /// Retained canonical edge-kind capacity.
447    #[inline]
448    #[must_use]
449    pub fn edge_kind_mask_capacity(&self) -> usize {
450        self.edge_kind_mask.capacity()
451    }
452
453    /// Stable hash of the canonical CSR graph layout.
454    ///
455    /// Cache identity belongs to the shared graph contract: domain size,
456    /// canonical row offsets, targets, and edge-kind masks. It intentionally
457    /// excludes transient dispatch buffers such as zeroed ProgramGraph node
458    /// arrays.
459    #[inline]
460    #[must_use]
461    pub fn stable_layout_hash(&self) -> u64 {
462        self.stable_layout_hash
463    }
464}
465
466const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
467const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
468
469#[inline]
470fn mix_layout_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
471    for byte in bytes {
472        hash ^= u64::from(*byte);
473        hash = hash.wrapping_mul(FNV_PRIME);
474    }
475    hash
476}
477
478#[inline]
479fn mix_layout_words(hash: u64, words: &[u32]) -> u64 {
480    #[cfg(target_endian = "little")]
481    {
482        mix_layout_bytes(hash, bytemuck::cast_slice(words))
483    }
484    #[cfg(target_endian = "big")]
485    {
486        for word in words {
487            hash = mix_layout_bytes(hash, &word.to_le_bytes());
488        }
489        hash
490    }
491}
492
493/// Stable hash for canonical Weir CSR layouts.
494#[must_use]
495pub fn stable_csr_layout_hash(
496    node_count: u32,
497    edge_count: u32,
498    edge_offsets: &[u32],
499    edge_targets: &[u32],
500    edge_kind_mask: &[u32],
501) -> u64 {
502    let mut hash = FNV_OFFSET;
503    hash = mix_layout_bytes(hash, &node_count.to_le_bytes());
504    hash = mix_layout_bytes(hash, &edge_count.to_le_bytes());
505    hash = mix_layout_words(hash, edge_offsets);
506    hash = mix_layout_words(hash, edge_targets);
507    mix_layout_words(hash, edge_kind_mask)
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn linear_domain_computes_bitset_and_scalar_slots() {
516        let domain = LinearDomain::new(65);
517
518        assert_eq!(domain.element_count(), 65);
519        assert_eq!(domain.bitset_words(), 3);
520        assert_eq!(
521            domain
522                .scalar_slots("shared linear domain scalar test", 2)
523                .expect("scalar slots must fit"),
524            130
525        );
526    }
527
528    #[test]
529    fn linear_domain_rejects_scalar_slot_overflow() {
530        let err = LinearDomain::new(u32::MAX)
531            .scalar_slots("shared linear domain overflow test", 2)
532            .expect_err("slot multiplication must fail loudly");
533        assert!(err.contains("overflows"), "unexpected diagnostic: {err}");
534    }
535
536    #[test]
537    fn csr_graph_rejects_mismatched_masks() {
538        let err = CsrGraph::new(2, &[0, 1, 1], &[1], &[])
539            .validate("shared graph layout test")
540            .expect_err("mask length must match final CSR edge count");
541        assert!(
542            err.contains("edge_kind_mask"),
543            "unexpected diagnostic: {err}"
544        );
545    }
546
547    #[test]
548    fn csr_graph_normalizes_rows_for_shared_cache_keys() {
549        let graph = CsrGraph::new(
550            3,
551            &[0, 3, 4, 4],
552            &[2, 1, 1, 2],
553            &[
554                vyre_primitives::predicate::edge_kind::CONTROL,
555                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
556                vyre_primitives::predicate::edge_kind::CALL_ARG,
557                vyre_primitives::predicate::edge_kind::CONTROL,
558            ],
559        )
560        .normalize("shared graph layout normalize test")
561        .expect("valid graph must normalize");
562
563        assert_eq!(graph.node_count(), 3);
564        assert_eq!(graph.edge_count(), 3);
565        assert_eq!(graph.edge_offsets(), &[0, 2, 3, 3]);
566        assert_eq!(graph.edge_targets(), &[1, 2, 2]);
567        assert_eq!(
568            graph.edge_kind_mask(),
569            &[
570                vyre_primitives::predicate::edge_kind::ASSIGNMENT
571                    | vyre_primitives::predicate::edge_kind::CALL_ARG,
572                vyre_primitives::predicate::edge_kind::CONTROL,
573                vyre_primitives::predicate::edge_kind::CONTROL,
574            ]
575        );
576    }
577
578    #[test]
579    fn csr_graph_singleton_rows_normalize_without_sort_scratch_growth() {
580        let mut normalized = NormalizedCsrGraph::empty();
581        let mut scratch = CsrGraphNormalizationScratch::new();
582        CsrGraph::new(
583            5,
584            &[0, 1, 1, 2, 2, 3],
585            &[3, 4, 1],
586            &[
587                vyre_primitives::predicate::edge_kind::CONTROL,
588                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
589                vyre_primitives::predicate::edge_kind::CALL_ARG,
590            ],
591        )
592        .normalize_into(
593            "shared graph layout singleton-row fast path test",
594            &mut normalized,
595            &mut scratch,
596        )
597        .expect("singleton-heavy graph must normalize");
598
599        assert_eq!(normalized.edge_offsets(), &[0, 1, 1, 2, 2, 3]);
600        assert_eq!(normalized.edge_targets(), &[3, 4, 1]);
601        assert_eq!(
602            normalized.edge_kind_mask(),
603            &[
604                vyre_primitives::predicate::edge_kind::CONTROL,
605                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
606                vyre_primitives::predicate::edge_kind::CALL_ARG,
607            ]
608        );
609        assert!(
610            scratch.row_capacity() <= 1,
611            "Fix: singleton CSR rows should not grow normalization scratch for sort/dedup work"
612        );
613    }
614
615    #[test]
616    fn csr_graph_sorted_unique_multi_rows_bypass_sort_scratch() {
617        let mut normalized = NormalizedCsrGraph::empty();
618        let mut scratch = CsrGraphNormalizationScratch::new();
619        CsrGraph::new(
620            5,
621            &[0, 3, 5, 5, 5, 5],
622            &[1, 2, 4, 0, 2],
623            &[
624                vyre_primitives::predicate::edge_kind::CONTROL,
625                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
626                vyre_primitives::predicate::edge_kind::CALL_ARG,
627                vyre_primitives::predicate::edge_kind::CONTROL,
628                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
629            ],
630        )
631        .normalize_into(
632            "shared graph layout sorted-row fast path test",
633            &mut normalized,
634            &mut scratch,
635        )
636        .expect("sorted unique rows must normalize without scratch sorting");
637
638        assert_eq!(normalized.edge_offsets(), &[0, 3, 5, 5, 5, 5]);
639        assert_eq!(normalized.edge_targets(), &[1, 2, 4, 0, 2]);
640        assert_eq!(
641            scratch.row_capacity(),
642            0,
643            "Fix: already-canonical multi-edge rows must append directly without row scratch growth."
644        );
645    }
646
647    #[test]
648    fn csr_graph_normalizes_into_caller_owned_buffers_without_reallocating() {
649        let mut normalized = NormalizedCsrGraph::empty();
650        let mut scratch = CsrGraphNormalizationScratch::with_row_capacity(8);
651        CsrGraph::new(
652            3,
653            &[0, 3, 4, 4],
654            &[2, 1, 1, 2],
655            &[
656                vyre_primitives::predicate::edge_kind::CONTROL,
657                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
658                vyre_primitives::predicate::edge_kind::CALL_ARG,
659                vyre_primitives::predicate::edge_kind::CONTROL,
660            ],
661        )
662        .normalize_into(
663            "shared graph layout normalize into scratch test",
664            &mut normalized,
665            &mut scratch,
666        )
667        .expect("first graph must normalize into reusable buffers");
668        let offsets_capacity = normalized.edge_offsets.capacity();
669        let targets_capacity = normalized.edge_targets.capacity();
670        let masks_capacity = normalized.edge_kind_mask.capacity();
671        let row_capacity = scratch.row_capacity();
672        let first_hash = normalized.stable_layout_hash();
673
674        CsrGraph::new(
675            3,
676            &[0, 2, 3, 3],
677            &[1, 2, 2],
678            &[
679                vyre_primitives::predicate::edge_kind::ASSIGNMENT
680                    | vyre_primitives::predicate::edge_kind::CALL_ARG,
681                vyre_primitives::predicate::edge_kind::CONTROL,
682                vyre_primitives::predicate::edge_kind::CONTROL,
683            ],
684        )
685        .normalize_into(
686            "shared graph layout normalize into scratch test",
687            &mut normalized,
688            &mut scratch,
689        )
690        .expect("equivalent canonical graph must reuse normalization buffers");
691
692        assert_eq!(normalized.stable_layout_hash(), first_hash);
693        assert_eq!(normalized.edge_offsets.capacity(), offsets_capacity);
694        assert_eq!(normalized.edge_targets.capacity(), targets_capacity);
695        assert_eq!(normalized.edge_kind_mask.capacity(), masks_capacity);
696        assert_eq!(scratch.row_capacity(), row_capacity);
697    }
698
699    #[test]
700    fn csr_graph_masks_edge_kinds_during_normalization_without_prefilter() {
701        let graph = CsrGraph::new(
702            2,
703            &[0, 2, 2],
704            &[1, 1],
705            &[
706                vyre_primitives::predicate::edge_kind::CONTROL
707                    | vyre_primitives::predicate::edge_kind::ASSIGNMENT,
708                vyre_primitives::predicate::edge_kind::DOMINANCE,
709            ],
710        )
711        .normalize_with_edge_kind_mask(
712            "shared graph layout masked normalize test",
713            vyre_primitives::predicate::edge_kind::ASSIGNMENT,
714        )
715        .expect("valid graph must normalize with masked edge kinds");
716
717        assert_eq!(graph.edge_targets(), &[1]);
718        assert_eq!(
719            graph.edge_kind_mask(),
720            &[vyre_primitives::predicate::edge_kind::ASSIGNMENT]
721        );
722    }
723
724    #[test]
725    fn csr_graph_preserves_zero_mask_singletons_during_normalization() {
726        let graph = CsrGraph::new(2, &[0, 2, 2], &[1, 1], &[0, 0])
727            .normalize("shared graph layout zero-mask singleton normalize test")
728            .expect("valid graph must normalize zero-mask singleton edges");
729
730        assert_eq!(graph.edge_count(), 1);
731        assert_eq!(graph.edge_offsets(), &[0, 1, 1]);
732        assert_eq!(graph.edge_targets(), &[1]);
733        assert_eq!(graph.edge_kind_mask(), &[0]);
734    }
735
736    #[test]
737    fn masked_normalization_drops_edges_that_cannot_fire() {
738        let graph = CsrGraph::new(
739            3,
740            &[0, 2, 3, 3],
741            &[1, 2, 2],
742            &[
743                vyre_primitives::predicate::edge_kind::CONTROL,
744                vyre_primitives::predicate::edge_kind::ASSIGNMENT,
745                vyre_primitives::predicate::edge_kind::CONTROL,
746            ],
747        )
748        .normalize_with_edge_kind_mask(
749            "shared graph layout masked normalize test",
750            vyre_primitives::predicate::edge_kind::ASSIGNMENT,
751        )
752        .expect("valid graph must normalize under mask");
753
754        assert_eq!(graph.edge_count(), 1);
755        assert_eq!(graph.edge_offsets(), &[0, 1, 1, 1]);
756        assert_eq!(graph.edge_targets(), &[2]);
757        assert_eq!(
758            graph.edge_kind_mask(),
759            &[vyre_primitives::predicate::edge_kind::ASSIGNMENT]
760        );
761    }
762
763    #[test]
764    fn csr_graph_rejects_zero_node_graph_with_non_empty_edges() {
765        let err = CsrGraph::new(0, &[1], &[0], &[0])
766            .validate("test")
767            .expect_err("zero-node with edges");
768        assert!(err.contains("edge_offsets[0]"), "{err}");
769    }
770
771    #[test]
772    fn csr_graph_rejects_nonzero_first_offset() {
773        let err = CsrGraph::new(2, &[1, 1, 1], &[0], &[0])
774            .validate("test")
775            .expect_err("nonzero first offset");
776        assert!(err.contains("edge_offsets[0]"), "{err}");
777    }
778
779    #[test]
780    fn csr_graph_rejects_missing_sentinel_offset() {
781        let err = CsrGraph::new(2, &[0, 1], &[0], &[0])
782            .validate("test")
783            .expect_err("missing sentinel");
784        assert!(err.contains("expected exactly node_count + 1"), "{err}");
785    }
786
787    #[test]
788    fn csr_graph_rejects_target_outside_domain() {
789        let err = CsrGraph::new(2, &[0, 1, 1], &[2], &[0])
790            .validate("test")
791            .expect_err("OOB target");
792        assert!(err.contains("targets node 2"), "{err}");
793    }
794
795    #[test]
796    fn csr_graph_rejects_mismatched_edge_kind_mask() {
797        let err = CsrGraph::new(2, &[0, 1, 1], &[0], &[0, 0])
798            .validate("test")
799            .expect_err("mask mismatch");
800        assert!(err.contains("edge_kind_mask"), "{err}");
801    }
802
803    #[test]
804    fn normalize_rejects_non_monotonic_offsets() {
805        let err = CsrGraph::new(3, &[0, 1, 0, 1], &[0], &[0])
806            .normalize("test")
807            .expect_err("non-monotonic");
808        assert!(err.contains("not monotonic"), "{err}");
809    }
810
811    #[test]
812    fn normalize_rejects_mismatched_edge_target_count() {
813        let err = CsrGraph::new(2, &[0, 1, 2], &[0], &[0])
814            .normalize("test")
815            .expect_err("mismatched edge target count");
816        assert!(err.contains("edge_targets has 1"), "{err}");
817    }
818
819    #[test]
820    fn linear_domain_rejects_scalar_slot_overflow_large() {
821        let err = LinearDomain::new(u32::MAX)
822            .scalar_slots("test", 2)
823            .expect_err("overflow");
824        assert!(err.contains("overflows"), "{err}");
825    }
826
827    #[test]
828    fn linear_domain_scalar_slots_returns_one_for_zero() {
829        assert_eq!(LinearDomain::new(0).scalar_slots("test", 2).unwrap(), 1);
830    }
831}