Skip to main content

vyre_primitives/graph/
motif.rs

1//! `motif`  -  intersect edge witnesses for a small graph pattern.
2//!
3//! Each motif edge is checked independently against the canonical
4//! ProgramGraph CSR. If every requested motif edge exists, every
5//! endpoint participating in the motif is marked in the final witness.
6
7use std::sync::Arc;
8
9use super::padded_u32_slice_fingerprint as motif_padded_slice_fingerprint;
10use vyre_foundation::ir::model::expr::Ident;
11use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
12
13use crate::graph::program_graph::{
14    ProgramGraphShape, BINDING_PRIMITIVE_START, NAME_EDGE_KIND_MASK, NAME_EDGE_OFFSETS,
15    NAME_EDGE_TARGETS,
16};
17
18/// Canonical op id.
19pub const OP_ID: &str = "vyre-primitives::graph::motif";
20/// Canonical binding index for motif scratch hits.
21pub const MOTIF_HITS_BUFFER: u32 = BINDING_PRIMITIVE_START;
22/// Canonical binding index for the public witness output.
23pub const MOTIF_WITNESS_OUT_BUFFER: u32 = BINDING_PRIMITIVE_START + 1;
24/// Motif matching is serial over the small pattern by construction.
25pub const MOTIF_WORKGROUP_SIZE: [u32; 3] = [1, 1, 1];
26/// Canonical motif dispatch grid.
27pub const MOTIF_DISPATCH_GRID: [u32; 3] = [1, 1, 1];
28
29/// Validated motif dispatch layout.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct MotifLayout {
32    /// Number of graph nodes and output words.
33    pub node_count: u32,
34    /// Number of graph nodes and output words, widened for host buffer sizing.
35    pub output_words: usize,
36    /// Number of physical CSR edges.
37    pub edge_count: u32,
38    /// Number of u32 words required by physical edge buffers after padding.
39    pub edge_storage_words: usize,
40    /// Number of requested motif edges.
41    pub motif_edge_count: u32,
42}
43
44/// One directed motif edge.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct MotifEdge {
47    /// Source node id.
48    pub from: u32,
49    /// Edge-kind mask that must match.
50    pub kind_mask: u32,
51    /// Destination node id.
52    pub to: u32,
53}
54
55/// Primitive-owned cache identity for motif Programs.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct MotifProgramCacheKey {
58    /// Number of graph nodes baked into the generated Program.
59    pub node_count: u32,
60    /// Number of physical CSR edges baked into the generated Program shape.
61    pub edge_count: u32,
62    /// Motif edges lowered as Program constants.
63    pub motif_edges: Vec<MotifEdge>,
64    /// Witness output buffer name baked into the Program.
65    pub witness_out: String,
66}
67
68/// Primitive-owned identity for reusable motif static graph inputs.
69///
70/// Motif edges are compiled into the generated Program and are therefore part
71/// of [`MotifProgramCacheKey`]. This key only tracks staged CSR graph inputs so
72/// dispatch wrappers can reuse static graph buffers across motif-program
73/// changes without forking fingerprint rules.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub struct MotifStaticInputKey {
76    /// Number of graph nodes and witness words.
77    pub node_count: u32,
78    /// Number of u32 witness/scratch words staged by the wrapper.
79    pub output_words: usize,
80    /// Number of u32 words staged for edge targets and kind masks.
81    pub edge_storage_words: usize,
82    /// Stable fingerprint of the CSR offsets upload.
83    pub edge_offsets_hash: u64,
84    /// Stable fingerprint of the padded target upload.
85    pub edge_targets_hash: u64,
86    /// Stable fingerprint of the padded kind-mask upload.
87    pub edge_kind_mask_hash: u64,
88}
89
90/// Validated motif launch plan without eager Program materialization.
91pub struct MotifLaunchPlan {
92    layout: MotifLayout,
93    cache_key: MotifProgramCacheKey,
94}
95
96impl MotifLaunchPlan {
97    /// Validated motif graph/pattern layout.
98    #[must_use]
99    pub const fn layout(&self) -> MotifLayout {
100        self.layout
101    }
102
103    /// Stable cache identity for the generated Program.
104    #[must_use]
105    pub fn cache_key(&self) -> &MotifProgramCacheKey {
106        &self.cache_key
107    }
108
109    /// Number of u32 words in motif scratch and witness outputs.
110    #[must_use]
111    pub const fn output_words(&self) -> usize {
112        self.layout.output_words
113    }
114
115    /// Number of u32 words required by physical edge buffers after padding.
116    #[must_use]
117    pub const fn edge_storage_words(&self) -> usize {
118        self.layout.edge_storage_words
119    }
120
121    /// Canonical one-workgroup dispatch grid.
122    #[must_use]
123    pub const fn dispatch_grid(&self) -> [u32; 3] {
124        MOTIF_DISPATCH_GRID
125    }
126
127    /// Materialize the canonical primitive Program for this launch plan.
128    #[must_use]
129    pub fn program(&self) -> Program {
130        motif(
131            ProgramGraphShape::new(self.layout.node_count, self.layout.edge_count.max(1)),
132            &self.cache_key.motif_edges,
133            &self.cache_key.witness_out,
134        )
135    }
136
137    /// Return the primitive-owned cache identity for static CSR graph inputs.
138    ///
139    /// # Errors
140    ///
141    /// Returns an actionable diagnostic when the supplied CSR slices no longer
142    /// match the validated launch-plan shape.
143    pub fn static_input_key(
144        &self,
145        edge_offsets: &[u32],
146        edge_targets: &[u32],
147        edge_kind_mask: &[u32],
148    ) -> Result<MotifStaticInputKey, String> {
149        if edge_offsets.len() != self.layout.node_count as usize + 1 {
150            return Err(format!(
151                "Fix: motif static key expected {} offset words, got {}.",
152                self.layout.node_count as usize + 1,
153                edge_offsets.len()
154            ));
155        }
156        if edge_targets.len() != self.layout.edge_count as usize {
157            return Err(format!(
158                "Fix: motif static key expected {} target word(s), got {}.",
159                self.layout.edge_count,
160                edge_targets.len()
161            ));
162        }
163        if edge_kind_mask.len() != self.layout.edge_count as usize {
164            return Err(format!(
165                "Fix: motif static key expected {} kind-mask word(s), got {}.",
166                self.layout.edge_count,
167                edge_kind_mask.len()
168            ));
169        }
170        Ok(MotifStaticInputKey {
171            node_count: self.layout.node_count,
172            output_words: self.layout.output_words,
173            edge_storage_words: self.layout.edge_storage_words,
174            edge_offsets_hash: motif_padded_slice_fingerprint(edge_offsets, edge_offsets.len()),
175            edge_targets_hash: motif_padded_slice_fingerprint(
176                edge_targets,
177                self.layout.edge_storage_words,
178            ),
179            edge_kind_mask_hash: motif_padded_slice_fingerprint(
180                edge_kind_mask,
181                self.layout.edge_storage_words,
182            ),
183        })
184    }
185}
186
187/// Primitive-owned motif dispatch plan.
188pub struct MotifDispatchPlan {
189    layout: MotifLayout,
190    program: Program,
191}
192
193impl MotifDispatchPlan {
194    /// Validated motif graph/pattern layout.
195    #[must_use]
196    pub const fn layout(&self) -> MotifLayout {
197        self.layout
198    }
199
200    /// Canonical primitive program for this motif.
201    #[must_use]
202    pub const fn program(&self) -> &Program {
203        &self.program
204    }
205
206    /// Number of u32 words in motif scratch and witness outputs.
207    #[must_use]
208    pub const fn output_words(&self) -> usize {
209        self.layout.output_words
210    }
211
212    /// Number of u32 words required by physical edge buffers after padding.
213    #[must_use]
214    pub const fn edge_storage_words(&self) -> usize {
215        self.layout.edge_storage_words
216    }
217
218    /// Canonical one-workgroup dispatch grid.
219    #[must_use]
220    pub const fn dispatch_grid(&self) -> [u32; 3] {
221        MOTIF_DISPATCH_GRID
222    }
223}
224
225/// Validate motif inputs and build the canonical dispatch plan.
226///
227/// # Errors
228///
229/// Returns an actionable diagnostic for malformed CSR inputs, mismatched edge
230/// masks, out-of-range destinations, or motif patterns too large for GPU
231/// metadata.
232pub fn plan_motif_launch(
233    node_count: u32,
234    edge_offsets: &[u32],
235    edge_targets: &[u32],
236    edge_kind_mask: &[u32],
237    motif_edges: &[MotifEdge],
238    witness_out: &str,
239) -> Result<MotifLaunchPlan, String> {
240    let layout = validate_motif_inputs(
241        node_count,
242        edge_offsets,
243        edge_targets,
244        edge_kind_mask,
245        motif_edges,
246    )?;
247
248    Ok(MotifLaunchPlan {
249        layout,
250        cache_key: MotifProgramCacheKey {
251            node_count: layout.node_count,
252            edge_count: layout.edge_count,
253            motif_edges: motif_edges.to_vec(),
254            witness_out: witness_out.to_string(),
255        },
256    })
257}
258
259/// Validate motif inputs and build the canonical dispatch plan.
260///
261/// # Errors
262///
263/// Returns an actionable diagnostic for malformed CSR inputs, mismatched edge
264/// masks, out-of-range destinations, or motif patterns too large for GPU
265/// metadata.
266pub fn plan_motif_dispatch(
267    node_count: u32,
268    edge_offsets: &[u32],
269    edge_targets: &[u32],
270    edge_kind_mask: &[u32],
271    motif_edges: &[MotifEdge],
272    witness_out: &str,
273) -> Result<MotifDispatchPlan, String> {
274    let launch = plan_motif_launch(
275        node_count,
276        edge_offsets,
277        edge_targets,
278        edge_kind_mask,
279        motif_edges,
280        witness_out,
281    )?;
282    let layout = launch.layout();
283    let program = launch.program();
284    Ok(MotifDispatchPlan { layout, program })
285}
286
287/// Build a Program: one invocation checks every motif edge, records
288/// participating endpoint bits only for matched edges, and publishes
289/// the participant union if the whole motif matched.
290///
291/// Invalid motif sizes lower to an explicit trap program. Prior code
292/// silently truncated `edges.len() as u32`; this path keeps the failure
293/// executable without crashing the host process.
294#[must_use]
295pub fn motif(shape: ProgramGraphShape, edges: &[MotifEdge], witness_out: &str) -> Program {
296    let Ok(edge_count) = u32::try_from(edges.len()) else {
297        return crate::invalid_output_program(
298            OP_ID,
299            witness_out,
300            DataType::U32,
301            "Fix: motif edges.len() exceeds u32::MAX; split the motif or redesign the caller."
302                .to_string(),
303        );
304    };
305    let mut buffers = shape.read_only_buffers();
306    buffers.push(
307        BufferDecl::storage(
308            "motif_hits",
309            MOTIF_HITS_BUFFER,
310            BufferAccess::ReadWrite,
311            DataType::U32,
312        )
313        .with_count(shape.node_count.max(1)),
314    );
315    buffers.push(
316        BufferDecl::storage(
317            witness_out,
318            MOTIF_WITNESS_OUT_BUFFER,
319            BufferAccess::ReadWrite,
320            DataType::U32,
321        )
322        .with_count(shape.node_count.max(1)),
323    );
324
325    let clear_outputs = vec![
326        Node::store("motif_hits", Expr::var("node"), Expr::u32(0)),
327        Node::store(witness_out, Expr::var("node"), Expr::u32(0)),
328    ];
329    // Motif edges are compile-time operands of this generated program, not
330    // runtime graph data. Lowering them as constants removes three input
331    // buffers and prevents loop-carried scratch state from making a partial
332    // motif look like a complete match.
333    let Some(scan_capacity) = edges.len().checked_mul(5) else {
334        return crate::invalid_output_program(
335            OP_ID,
336            witness_out,
337            DataType::U32,
338            "Fix: motif scan node count overflows usize; split the motif before lowering."
339                .to_string(),
340        );
341    };
342    let Some(mark_capacity) = edges.len().checked_mul(2) else {
343        return crate::invalid_output_program(
344            OP_ID,
345            witness_out,
346            DataType::U32,
347            "Fix: motif witness mark count overflows usize; split the motif before lowering."
348                .to_string(),
349        );
350    };
351    let mut scan_edges: Vec<Node> = Vec::new();
352    if let Err(error) = scan_edges.try_reserve(scan_capacity) {
353        return crate::invalid_output_program(
354            OP_ID,
355            witness_out,
356            DataType::U32,
357            format!("Fix: motif lowering could not reserve {scan_capacity} scan nodes: {error}"),
358        );
359    }
360    let mut mark_hits: Vec<Node> = Vec::new();
361    if let Err(error) = mark_hits.try_reserve(mark_capacity) {
362        return crate::invalid_output_program(
363            OP_ID,
364            witness_out,
365            DataType::U32,
366            format!("Fix: motif lowering could not reserve {mark_capacity} mark nodes: {error}"),
367        );
368    }
369    for (idx, edge) in edges.iter().enumerate() {
370        let edge_found = format!("edge_found_{idx}");
371        let edge_start = format!("edge_start_{idx}");
372        let edge_end = format!("edge_end_{idx}");
373        let edge_index = format!("e_{idx}");
374        let actual_dst = format!("actual_dst_{idx}");
375        let actual_kind = format!("actual_kind_{idx}");
376        scan_edges.push(Node::let_bind(&edge_found, Expr::u32(0)));
377        if edge.from < shape.node_count {
378            scan_edges.push(Node::let_bind(
379                &edge_start,
380                Expr::load(NAME_EDGE_OFFSETS, Expr::u32(edge.from)),
381            ));
382            scan_edges.push(Node::let_bind(
383                &edge_end,
384                Expr::load(NAME_EDGE_OFFSETS, Expr::u32(edge.from.saturating_add(1))),
385            ));
386            scan_edges.push(Node::loop_for(
387                &edge_index,
388                Expr::var(&edge_start),
389                Expr::var(&edge_end),
390                vec![
391                    Node::let_bind(
392                        &actual_dst,
393                        Expr::load(NAME_EDGE_TARGETS, Expr::var(&edge_index)),
394                    ),
395                    Node::let_bind(
396                        &actual_kind,
397                        Expr::load(NAME_EDGE_KIND_MASK, Expr::var(&edge_index)),
398                    ),
399                    Node::if_then(
400                        Expr::and(
401                            Expr::eq(Expr::var(&actual_dst), Expr::u32(edge.to)),
402                            Expr::ne(
403                                Expr::bitand(Expr::var(&actual_kind), Expr::u32(edge.kind_mask)),
404                                Expr::u32(0),
405                            ),
406                        ),
407                        vec![Node::assign(&edge_found, Expr::u32(1))],
408                    ),
409                ],
410            ));
411        }
412        scan_edges.push(Node::if_then(
413            Expr::ne(Expr::var(&edge_found), Expr::u32(0)),
414            vec![Node::assign(
415                "matched_edges",
416                Expr::add(Expr::var("matched_edges"), Expr::u32(1)),
417            )],
418        ));
419        if edge.from < shape.node_count {
420            mark_hits.push(Node::store(
421                "motif_hits",
422                Expr::u32(edge.from),
423                Expr::u32(1),
424            ));
425        }
426        if edge.to < shape.node_count {
427            mark_hits.push(Node::store("motif_hits", Expr::u32(edge.to), Expr::u32(1)));
428        }
429    }
430    let materialize = vec![Node::store(
431        witness_out,
432        Expr::var("node"),
433        Expr::load("motif_hits", Expr::var("node")),
434    )];
435    let mut publish_full_match = mark_hits;
436    publish_full_match.push(Node::loop_for(
437        "node",
438        Expr::u32(0),
439        Expr::u32(shape.node_count),
440        materialize,
441    ));
442
443    // PHASE7_GRAPH C2: motif is fundamentally serial  -  one thread loops
444    // over every motif edge in order and accumulates `matched_edges`.
445    // Using a [256,1,1] workgroup with a `gid_x() == 0` gate burns 255
446    // idle lanes per workgroup. Dispatch a single 1-lane workgroup
447    // instead so the wasted parallelism is gone, and drop the redundant
448    // gate.
449    Program::wrapped(
450        buffers,
451        MOTIF_WORKGROUP_SIZE,
452        vec![Node::Region {
453            generator: Ident::from(OP_ID),
454            source_region: None,
455            body: Arc::new(vec![
456                Node::loop_for(
457                    "node",
458                    Expr::u32(0),
459                    Expr::u32(shape.node_count),
460                    clear_outputs,
461                ),
462                Node::let_bind("matched_edges", Expr::u32(0)),
463                Node::Block(scan_edges),
464                Node::if_then(
465                    Expr::eq(Expr::var("matched_edges"), Expr::u32(edge_count)),
466                    publish_full_match,
467                ),
468            ]),
469        }],
470    )
471}
472
473/// CPU reference: return one byte-per-node witness set where `1`
474/// means the node participates in a complete motif match.
475#[must_use]
476#[cfg(any(test, feature = "cpu-parity"))]
477pub fn cpu_ref(
478    node_count: u32,
479    edge_offsets: &[u32],
480    edge_targets: &[u32],
481    edge_kind_mask: &[u32],
482    motif_edges: &[MotifEdge],
483) -> Vec<u32> {
484    let mut participants = Vec::new();
485    try_cpu_ref_into(
486        node_count,
487        edge_offsets,
488        edge_targets,
489        edge_kind_mask,
490        motif_edges,
491        &mut participants,
492    )
493    .unwrap_or_else(|err| panic!("motif CPU oracle received malformed input. {err}"));
494    participants
495}
496
497/// Fallible CPU reference into caller-owned witness storage.
498#[cfg(any(test, feature = "cpu-parity"))]
499pub fn try_cpu_ref_into(
500    node_count: u32,
501    edge_offsets: &[u32],
502    edge_targets: &[u32],
503    edge_kind_mask: &[u32],
504    motif_edges: &[MotifEdge],
505    participants: &mut Vec<u32>,
506) -> Result<(), String> {
507    let layout = validate_motif_inputs(
508        node_count,
509        edge_offsets,
510        edge_targets,
511        edge_kind_mask,
512        motif_edges,
513    )?;
514    crate::graph::scratch::reserve_graph_items(
515        participants,
516        layout.output_words,
517        "motif CPU oracle",
518        "motif witness output",
519    )?;
520    participants.clear();
521    participants.resize(layout.output_words, 0);
522    if !motif_all_edges_present(edge_offsets, edge_targets, edge_kind_mask, motif_edges) {
523        return Ok(());
524    }
525    for motif_edge in motif_edges {
526        if let Some(hit) = participants.get_mut(motif_edge.from as usize) {
527            *hit = 1;
528        }
529        if let Some(hit) = participants.get_mut(motif_edge.to as usize) {
530            *hit = 1;
531        }
532    }
533    Ok(())
534}
535
536/// CPU reference into caller-owned witness storage.
537#[cfg(any(test, feature = "cpu-parity"))]
538pub fn cpu_ref_into(
539    node_count: u32,
540    edge_offsets: &[u32],
541    edge_targets: &[u32],
542    edge_kind_mask: &[u32],
543    motif_edges: &[MotifEdge],
544    participants: &mut Vec<u32>,
545) {
546    try_cpu_ref_into(
547        node_count,
548        edge_offsets,
549        edge_targets,
550        edge_kind_mask,
551        motif_edges,
552        participants,
553    )
554    .unwrap_or_else(|err| panic!("motif CPU oracle received malformed input. {err}"));
555}
556
557/// Return true iff the complete motif exists.
558///
559/// This avoids allocating a full witness vector for existence checks.
560#[must_use]
561#[cfg(any(test, feature = "cpu-parity"))]
562pub fn cpu_ref_matches(
563    edge_offsets: &[u32],
564    edge_targets: &[u32],
565    edge_kind_mask: &[u32],
566    motif_edges: &[MotifEdge],
567) -> bool {
568    motif_all_edges_present(edge_offsets, edge_targets, edge_kind_mask, motif_edges)
569}
570
571/// Count distinct nodes participating in a complete motif match.
572///
573/// This avoids materializing the witness vector when callers only need a
574/// scheduling signal.
575#[must_use]
576#[cfg(any(test, feature = "cpu-parity"))]
577pub fn cpu_ref_participation_count(
578    node_count: u32,
579    edge_offsets: &[u32],
580    edge_targets: &[u32],
581    edge_kind_mask: &[u32],
582    motif_edges: &[MotifEdge],
583) -> u32 {
584    try_cpu_ref_participation_count(
585        node_count,
586        edge_offsets,
587        edge_targets,
588        edge_kind_mask,
589        motif_edges,
590    )
591    .unwrap_or_else(|err| panic!("motif participation oracle received malformed input. {err}"))
592}
593
594/// Caller-owned workspace for motif CPU reference helpers.
595#[cfg(any(test, feature = "cpu-parity"))]
596#[derive(Debug, Default, Clone)]
597pub struct MotifCpuScratch {
598    /// Distinct endpoint scratch used by participation-count queries.
599    pub endpoints: Vec<u32>,
600}
601
602#[cfg(any(test, feature = "cpu-parity"))]
603impl MotifCpuScratch {
604    /// Create an empty reusable motif workspace.
605    pub fn new() -> Self {
606        Self::default()
607    }
608}
609
610/// Fallible count of distinct nodes participating in a complete motif match.
611#[cfg(any(test, feature = "cpu-parity"))]
612pub fn try_cpu_ref_participation_count(
613    node_count: u32,
614    edge_offsets: &[u32],
615    edge_targets: &[u32],
616    edge_kind_mask: &[u32],
617    motif_edges: &[MotifEdge],
618) -> Result<u32, String> {
619    let mut scratch = MotifCpuScratch::default();
620    try_cpu_ref_participation_count_with_scratch(
621        node_count,
622        edge_offsets,
623        edge_targets,
624        edge_kind_mask,
625        motif_edges,
626        &mut scratch,
627    )
628}
629
630/// Fallible participation count using caller-owned endpoint scratch.
631///
632/// Validation happens before the scratch vector is touched. For valid inputs,
633/// the scratch vector is cleared and reused even when the complete motif is not
634/// present, so stale endpoints cannot leak into later proof cases.
635#[cfg(any(test, feature = "cpu-parity"))]
636pub fn try_cpu_ref_participation_count_with_scratch(
637    node_count: u32,
638    edge_offsets: &[u32],
639    edge_targets: &[u32],
640    edge_kind_mask: &[u32],
641    motif_edges: &[MotifEdge],
642    scratch: &mut MotifCpuScratch,
643) -> Result<u32, String> {
644    validate_motif_inputs(
645        node_count,
646        edge_offsets,
647        edge_targets,
648        edge_kind_mask,
649        motif_edges,
650    )?;
651    let endpoint_count = motif_edges
652        .len()
653        .checked_mul(2)
654        .ok_or_else(|| "Fix: motif endpoint count overflows usize.".to_string())?;
655    scratch
656        .endpoints
657        .try_reserve(endpoint_count)
658        .map_err(|error| {
659            format!(
660            "Fix: motif participation oracle could not reserve {endpoint_count} endpoints: {error}"
661        )
662        })?;
663    scratch.endpoints.clear();
664    if !motif_all_edges_present(edge_offsets, edge_targets, edge_kind_mask, motif_edges) {
665        return Ok(0);
666    }
667    for motif_edge in motif_edges {
668        if motif_edge.from < node_count {
669            scratch.endpoints.push(motif_edge.from);
670        }
671        if motif_edge.to < node_count {
672            scratch.endpoints.push(motif_edge.to);
673        }
674    }
675    scratch.endpoints.sort_unstable();
676    scratch.endpoints.dedup();
677    u32::try_from(scratch.endpoints.len()).map_err(|error| {
678        format!("Fix: motif participation count does not fit u32 after deduplication: {error}")
679    })
680}
681
682/// Validate the public CSR inputs consumed by the motif primitive.
683///
684/// Returns the exact edge count declared by `edge_offsets[node_count]`, so
685/// dispatch wrappers can pad zero-edge buffers without duplicating CSR
686/// validation logic.
687///
688/// # Errors
689///
690/// Returns an actionable diagnostic for malformed row offsets, edge arrays, or
691/// out-of-range destinations.
692pub fn validate_csr_inputs(
693    node_count: u32,
694    edge_offsets: &[u32],
695    edge_targets: &[u32],
696    edge_kind_mask: &[u32],
697) -> Result<MotifLayout, String> {
698    validate_motif_inputs(node_count, edge_offsets, edge_targets, edge_kind_mask, &[])
699}
700
701/// Validate the public CSR and motif inputs consumed by the motif primitive.
702///
703/// # Errors
704///
705/// Returns an actionable diagnostic for malformed row offsets, edge arrays,
706/// out-of-range destinations, or motif edge counts that exceed u32 dispatch
707/// metadata.
708pub fn validate_motif_inputs(
709    node_count: u32,
710    edge_offsets: &[u32],
711    edge_targets: &[u32],
712    edge_kind_mask: &[u32],
713    motif_edges: &[MotifEdge],
714) -> Result<MotifLayout, String> {
715    let expected_offsets = (node_count as usize).checked_add(1).ok_or_else(|| {
716        format!("Fix: motif node_count + 1 overflows usize for node_count={node_count}.")
717    })?;
718    if edge_offsets.len() != expected_offsets {
719        return Err(format!(
720            "Fix: motif requires edge_offsets.len() == node_count + 1, got len={}, node_count={node_count}.",
721            edge_offsets.len()
722        ));
723    }
724    if edge_targets.len() != edge_kind_mask.len() {
725        return Err(format!(
726            "Fix: motif requires edge_targets.len() == edge_kind_mask.len(), got {} vs {}.",
727            edge_targets.len(),
728            edge_kind_mask.len()
729        ));
730    }
731    if let Some(&first) = edge_offsets.first() {
732        if first != 0 {
733            return Err(format!(
734                "Fix: motif requires edge_offsets[0] == 0, got {first}."
735            ));
736        }
737    }
738    for (index, pair) in edge_offsets.windows(2).enumerate() {
739        if pair[0] > pair[1] {
740            return Err(format!(
741                "Fix: motif offsets must be monotonic; offsets[{index}]={} > offsets[{}]={}.",
742                pair[0],
743                index + 1,
744                pair[1]
745            ));
746        }
747    }
748    let edge_count = edge_offsets[expected_offsets - 1] as usize;
749    if edge_targets.len() != edge_count {
750        return Err(format!(
751            "Fix: motif final offset declares edge_count={edge_count}, but targets_len={} and kind_mask_len={}.",
752            edge_targets.len(),
753            edge_kind_mask.len()
754        ));
755    }
756    for (index, &target) in edge_targets.iter().enumerate() {
757        if target >= node_count {
758            return Err(format!(
759                "Fix: motif edge_targets[{index}]={target} is outside node_count {node_count}."
760            ));
761        }
762    }
763    for (index, motif_edge) in motif_edges.iter().enumerate() {
764        if motif_edge.from >= node_count {
765            return Err(format!(
766                "Fix: motif_edges[{index}].from={} is outside node_count {node_count}.",
767                motif_edge.from
768            ));
769        }
770        if motif_edge.to >= node_count {
771            return Err(format!(
772                "Fix: motif_edges[{index}].to={} is outside node_count {node_count}.",
773                motif_edge.to
774            ));
775        }
776    }
777    let edge_count = u32::try_from(edge_count)
778        .map_err(|_| format!("Fix: motif edge count {edge_count} exceeds u32 index space."))?;
779    let motif_edge_count = u32::try_from(motif_edges.len()).map_err(|_| {
780        format!(
781            "Fix: motif edge pattern count {} exceeds u32 index space.",
782            motif_edges.len()
783        )
784    })?;
785    Ok(MotifLayout {
786        node_count,
787        output_words: node_count as usize,
788        edge_count,
789        edge_storage_words: edge_targets.len().max(1),
790        motif_edge_count,
791    })
792}
793
794/// Count nonzero witness entries using the primitive's u32 result contract.
795///
796/// # Errors
797///
798/// Returns an actionable diagnostic if the witness vector is too large to
799/// report with the primitive's u32 count metadata.
800pub fn count_witness_participants(witness: &[u32]) -> Result<u32, String> {
801    let count = witness.iter().filter(|&&value| value != 0).count();
802    u32::try_from(count)
803        .map_err(|_| format!("Fix: motif witness participant count {count} exceeds u32::MAX."))
804}
805
806/// Validate the primitive's u32 witness output contract.
807///
808/// # Errors
809///
810/// Returns an actionable diagnostic if the backend returns the wrong number of
811/// witness words or any non-boolean witness entry.
812pub fn validate_motif_witness(layout: MotifLayout, witness: &[u32]) -> Result<(), String> {
813    if witness.len() != layout.output_words {
814        return Err(format!(
815            "Fix: motif witness expected {} word(s), got {}.",
816            layout.output_words,
817            witness.len()
818        ));
819    }
820    for (index, &value) in witness.iter().enumerate() {
821        if value > 1 {
822            return Err(format!(
823                "Fix: motif witness[{index}]={value} is not boolean; expected 0 or 1."
824            ));
825        }
826    }
827    Ok(())
828}
829
830#[cfg(test)]
831mod dispatch_contract_tests {
832    use super::*;
833
834    #[test]
835    fn static_input_key_tracks_graph_content_not_motif_program() {
836        let first_motif = [MotifEdge {
837            from: 0,
838            kind_mask: 1,
839            to: 1,
840        }];
841        let second_motif = [MotifEdge {
842            from: 1,
843            kind_mask: 1,
844            to: 2,
845        }];
846        let first = plan_motif_launch(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &first_motif, "w")
847            .expect("Fix: first motif launch should plan");
848        let second = plan_motif_launch(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &second_motif, "w")
849            .expect("Fix: second motif launch should plan");
850
851        assert_ne!(first.cache_key(), second.cache_key());
852        assert_eq!(
853            first
854                .static_input_key(&[0, 1, 2, 2], &[1, 2], &[1, 1])
855                .expect("Fix: first motif static key should build"),
856            second
857                .static_input_key(&[0, 1, 2, 2], &[1, 2], &[1, 1])
858                .expect("Fix: second motif static key should build")
859        );
860    }
861
862    #[test]
863    fn static_input_key_refreshes_on_same_shape_graph_content_change() {
864        let motif = [MotifEdge {
865            from: 0,
866            kind_mask: 1,
867            to: 1,
868        }];
869        let plan = plan_motif_launch(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &motif, "w")
870            .expect("Fix: motif launch should plan");
871        let first = plan
872            .static_input_key(&[0, 1, 2, 2], &[1, 2], &[1, 1])
873            .expect("Fix: first static key should build");
874        let changed = plan
875            .static_input_key(&[0, 1, 2, 2], &[2, 2], &[1, 1])
876            .expect("Fix: same-shape changed graph should key");
877
878        assert_eq!(first.edge_offsets_hash, changed.edge_offsets_hash);
879        assert_eq!(first.edge_kind_mask_hash, changed.edge_kind_mask_hash);
880        assert_ne!(first.edge_targets_hash, changed.edge_targets_hash);
881        assert_ne!(first, changed);
882    }
883
884    #[test]
885    fn static_input_key_rejects_shape_drift() {
886        let motif = [MotifEdge {
887            from: 0,
888            kind_mask: 1,
889            to: 1,
890        }];
891        let plan = plan_motif_launch(2, &[0, 1, 1], &[1], &[1], &motif, "w")
892            .expect("Fix: motif launch should plan");
893        let err = plan
894            .static_input_key(&[0, 1, 1], &[], &[])
895            .expect_err("Fix: stale motif plan must reject edge-array drift");
896
897        assert!(err.contains("expected 1 target"));
898    }
899
900    #[test]
901    fn witness_validation_rejects_non_boolean_backend_output() {
902        let layout = validate_motif_inputs(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &[])
903            .expect("Fix: valid graph should validate");
904
905        validate_motif_witness(layout, &[0, 1, 0]).expect("Fix: boolean witness is valid");
906        let err = validate_motif_witness(layout, &[0, 2, 0])
907            .expect_err("Fix: non-boolean witness must be rejected");
908
909        assert!(err.contains("witness[1]=2 is not boolean"));
910    }
911}
912
913#[cfg(any(test, feature = "cpu-parity"))]
914fn motif_all_edges_present(
915    edge_offsets: &[u32],
916    edge_targets: &[u32],
917    edge_kind_mask: &[u32],
918    motif_edges: &[MotifEdge],
919) -> bool {
920    for motif_edge in motif_edges {
921        let Some(start) = edge_offsets.get(motif_edge.from as usize).copied() else {
922            return false;
923        };
924        let Some(end) = edge_offsets.get(motif_edge.from as usize + 1).copied() else {
925            return false;
926        };
927        let start = start as usize;
928        let end = end as usize;
929        let mut found = false;
930        for edge_idx in start..end {
931            let Some(dst) = edge_targets.get(edge_idx).copied() else {
932                break;
933            };
934            let Some(kind) = edge_kind_mask.get(edge_idx).copied() else {
935                break;
936            };
937            if dst == motif_edge.to && (kind & motif_edge.kind_mask) != 0 {
938                found = true;
939                break;
940            }
941        }
942        if !found {
943            return false;
944        }
945    }
946    true
947}
948
949#[cfg(feature = "inventory-registry")]
950inventory::submit! {
951    vyre_foundation::operation::OperationRegistration::primitive(
952        OP_ID,
953        || motif(ProgramGraphShape::new(4, 4), &[MotifEdge { from: 0, to: 1, kind_mask: 1 }], "witness"),
954        Some(|| {
955            let to_bytes = |w: &[u32]| crate::wire::pack_u32_slice(w);
956            vec![vec![
957                to_bytes(&[0, 0, 0, 0]),          // pg_nodes
958                to_bytes(&[0, 2, 3, 4, 4]),       // pg_edge_offsets
959                to_bytes(&[1, 2, 3, 3]),          // pg_edge_targets
960                to_bytes(&[1, 1, 1, 1]),          // pg_edge_kind_mask
961                to_bytes(&[0, 0, 0, 0]),          // pg_node_tags
962                to_bytes(&[0, 0, 0, 0]),          // motif_hits
963                to_bytes(&[0, 0, 0, 0]),          // witness
964            ]]
965        }),
966        Some(|| {
967            let to_bytes = |w: &[u32]| crate::wire::pack_u32_slice(w);
968            vec![vec![
969                to_bytes(&[1, 1, 0, 0]),          // expected motif_hits
970                to_bytes(&[1, 1, 0, 0]),          // expected witness
971            ]]
972        }),
973    )
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    #[test]
981    fn try_cpu_ref_into_rejects_bad_motif_endpoint_without_clobbering_witness() {
982        let mut witness = vec![9, 8, 7];
983        let motif = [MotifEdge {
984            from: 0,
985            kind_mask: 1,
986            to: 3,
987        }];
988
989        let err = try_cpu_ref_into(3, &[0, 1, 1, 1], &[1], &[1], &motif, &mut witness)
990            .expect_err("motif endpoint beyond node_count must fail validation");
991
992        assert!(
993            err.contains("motif_edges[0].to=3 is outside node_count 3"),
994            "Fix: motif endpoint errors must identify the bad endpoint, got: {err}"
995        );
996        assert_eq!(
997            witness,
998            vec![9, 8, 7],
999            "failed motif preflight must preserve the previous witness vector"
1000        );
1001    }
1002
1003    #[test]
1004    fn try_participation_count_rejects_bad_motif_endpoint() {
1005        let motif = [MotifEdge {
1006            from: 4,
1007            kind_mask: 1,
1008            to: 0,
1009        }];
1010
1011        let err = try_cpu_ref_participation_count(3, &[0, 0, 0, 0], &[], &[], &motif)
1012            .expect_err("motif participation count must validate pattern endpoints");
1013
1014        assert!(
1015            err.contains("motif_edges[0].from=4 is outside node_count 3"),
1016            "Fix: motif participation count must surface endpoint shape errors, got: {err}"
1017        );
1018    }
1019
1020    #[test]
1021    fn try_participation_count_with_scratch_reuses_endpoint_storage() {
1022        let mut endpoints = Vec::with_capacity(8);
1023        endpoints.extend_from_slice(&[99, 98, 97]);
1024        let mut scratch = MotifCpuScratch { endpoints };
1025        let capacity = scratch.endpoints.capacity();
1026        let motif = [
1027            MotifEdge {
1028                from: 0,
1029                kind_mask: 1,
1030                to: 1,
1031            },
1032            MotifEdge {
1033                from: 1,
1034                kind_mask: 1,
1035                to: 2,
1036            },
1037        ];
1038
1039        let count = try_cpu_ref_participation_count_with_scratch(
1040            3,
1041            &[0, 1, 2, 2],
1042            &[1, 2],
1043            &[1, 1],
1044            &motif,
1045            &mut scratch,
1046        )
1047        .expect("Fix: valid motif count must run with reusable endpoint scratch.");
1048
1049        assert_eq!(count, 3);
1050        assert_eq!(scratch.endpoints.capacity(), capacity);
1051        assert_eq!(
1052            scratch.endpoints,
1053            vec![0, 1, 2],
1054            "Fix: endpoint scratch must be sorted and deduplicated for the live motif."
1055        );
1056
1057        let count = try_cpu_ref_participation_count_with_scratch(
1058            3,
1059            &[0, 1, 1, 1],
1060            &[1],
1061            &[1],
1062            &motif,
1063            &mut scratch,
1064        )
1065        .expect("Fix: valid graph with missing motif must return zero without stale endpoints.");
1066
1067        assert_eq!(count, 0);
1068        assert_eq!(scratch.endpoints.capacity(), capacity);
1069        assert!(
1070            scratch.endpoints.is_empty(),
1071            "Fix: missing motif must clear stale endpoint scratch."
1072        );
1073    }
1074
1075    #[test]
1076    fn try_participation_count_with_scratch_validates_before_mutating_storage() {
1077        let mut scratch = MotifCpuScratch {
1078            endpoints: vec![0xCAFE_BABE, 0xDEAD_BEEF],
1079        };
1080        let motif = [MotifEdge {
1081            from: 4,
1082            kind_mask: 1,
1083            to: 0,
1084        }];
1085
1086        let err = try_cpu_ref_participation_count_with_scratch(
1087            3,
1088            &[0, 0, 0, 0],
1089            &[],
1090            &[],
1091            &motif,
1092            &mut scratch,
1093        )
1094        .expect_err("Fix: motif endpoint validation must run before scratch reuse.");
1095
1096        assert!(
1097            err.contains("motif_edges[0].from=4 is outside node_count 3"),
1098            "Fix: motif participation count must surface endpoint shape errors, got: {err}"
1099        );
1100        assert_eq!(
1101            scratch.endpoints,
1102            vec![0xCAFE_BABE, 0xDEAD_BEEF],
1103            "Fix: validation failure must not clear reusable endpoint scratch."
1104        );
1105    }
1106
1107    #[test]
1108    fn generated_participation_count_matches_witness_count() {
1109        for node_count in 2u32..=7 {
1110            let mut offsets = Vec::with_capacity(node_count as usize + 1);
1111            let mut targets = Vec::new();
1112            let mut masks = Vec::new();
1113            offsets.push(0);
1114            for node in 0..node_count {
1115                targets.push((node + 1) % node_count);
1116                masks.push(1);
1117                offsets.push(targets.len() as u32);
1118            }
1119            let motif = [MotifEdge {
1120                from: 0,
1121                kind_mask: 1,
1122                to: 1,
1123            }];
1124            let witness = cpu_ref(node_count, &offsets, &targets, &masks, &motif);
1125            let witness_count =
1126                count_witness_participants(&witness).expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - generated witness count must fit u32");
1127            let count =
1128                try_cpu_ref_participation_count(node_count, &offsets, &targets, &masks, &motif)
1129                    .expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - generated motif participation count must pass validation");
1130
1131            assert_eq!(
1132                count, witness_count,
1133                "participation count diverged from witness count at node_count={node_count}"
1134            );
1135        }
1136    }
1137
1138    #[test]
1139    fn three_node_chain_motif_marks_every_participant() {
1140        let witness = cpu_ref(
1141            3,
1142            &[0, 1, 2, 2],
1143            &[1, 2],
1144            &[1, 1],
1145            &[
1146                MotifEdge {
1147                    from: 0,
1148                    kind_mask: 1,
1149                    to: 1,
1150                },
1151                MotifEdge {
1152                    from: 1,
1153                    kind_mask: 1,
1154                    to: 2,
1155                },
1156            ],
1157        );
1158        assert_eq!(witness, vec![1, 1, 1]);
1159    }
1160
1161    #[test]
1162    fn missing_motif_edge_clears_all_participants() {
1163        let witness = cpu_ref(
1164            3,
1165            &[0, 1, 1, 1],
1166            &[1],
1167            &[1],
1168            &[
1169                MotifEdge {
1170                    from: 0,
1171                    kind_mask: 1,
1172                    to: 1,
1173                },
1174                MotifEdge {
1175                    from: 1,
1176                    kind_mask: 1,
1177                    to: 2,
1178                },
1179            ],
1180        );
1181        assert_eq!(witness, vec![0, 0, 0]);
1182    }
1183
1184    #[test]
1185    fn cpu_ref_into_reuses_witness_storage() {
1186        let mut witness = Vec::with_capacity(8);
1187        cpu_ref_into(
1188            3,
1189            &[0, 1, 2, 2],
1190            &[1, 2],
1191            &[1, 1],
1192            &[
1193                MotifEdge {
1194                    from: 0,
1195                    kind_mask: 1,
1196                    to: 1,
1197                },
1198                MotifEdge {
1199                    from: 1,
1200                    kind_mask: 1,
1201                    to: 2,
1202                },
1203            ],
1204            &mut witness,
1205        );
1206        let capacity = witness.capacity();
1207        assert_eq!(witness, vec![1, 1, 1]);
1208
1209        cpu_ref_into(
1210            3,
1211            &[0, 1, 1, 1],
1212            &[1],
1213            &[1],
1214            &[MotifEdge {
1215                from: 1,
1216                kind_mask: 1,
1217                to: 2,
1218            }],
1219            &mut witness,
1220        );
1221        assert_eq!(witness.capacity(), capacity);
1222        assert_eq!(witness, vec![0, 0, 0]);
1223    }
1224
1225    #[test]
1226    fn cpu_ref_into_validates_before_clearing_witness_storage() {
1227        let mut witness = vec![0xCAFE_BABEu32, 0xDEAD_BEEF];
1228        let ptr = witness.as_ptr();
1229        let previous_hook = std::panic::take_hook();
1230        std::panic::set_hook(Box::new(|_| {}));
1231        let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1232            cpu_ref_into(
1233                2,
1234                &[0, 1, 1],
1235                &[1],
1236                &[],
1237                &[MotifEdge {
1238                    from: 0,
1239                    kind_mask: 1,
1240                    to: 1,
1241                }],
1242                &mut witness,
1243            );
1244        }));
1245        std::panic::set_hook(previous_hook);
1246
1247        assert!(err.is_err(), "mismatched CSR edge arrays must be rejected");
1248        assert_eq!(
1249            witness,
1250            vec![0xCAFE_BABEu32, 0xDEAD_BEEF],
1251            "Fix: motif CPU oracle must validate before clearing caller witness storage."
1252        );
1253        assert_eq!(witness.as_ptr(), ptr);
1254    }
1255
1256    #[test]
1257    fn generated_try_cpu_ref_into_and_count_match_witness() {
1258        for node_count in 1u32..=64 {
1259            let mut scratch = MotifCpuScratch::new();
1260            let edge_offsets: Vec<u32> = (0..=node_count).collect();
1261            let edge_targets: Vec<u32> = (0..node_count)
1262                .map(|node| (node + 1) % node_count)
1263                .collect();
1264            let edge_kind_mask = vec![1u32; node_count as usize];
1265            for motif_len in 0usize..64 {
1266                let motif_edges: Vec<MotifEdge> = (0..motif_len)
1267                    .map(|index| {
1268                        let from = (index as u32) % node_count;
1269                        MotifEdge {
1270                            from,
1271                            kind_mask: 1,
1272                            to: (from + 1) % node_count,
1273                        }
1274                    })
1275                    .collect();
1276                let mut witness = vec![0xCAFE_BABEu32; 3];
1277                try_cpu_ref_into(
1278                    node_count,
1279                    &edge_offsets,
1280                    &edge_targets,
1281                    &edge_kind_mask,
1282                    &motif_edges,
1283                    &mut witness,
1284                )
1285                .unwrap();
1286                let count = try_cpu_ref_participation_count_with_scratch(
1287                    node_count,
1288                    &edge_offsets,
1289                    &edge_targets,
1290                    &edge_kind_mask,
1291                    &motif_edges,
1292                    &mut scratch,
1293                )
1294                .unwrap();
1295                assert_eq!(witness.len(), node_count as usize);
1296                assert_eq!(
1297                    count,
1298                    witness.iter().filter(|&&value| value != 0).count() as u32
1299                );
1300            }
1301        }
1302    }
1303
1304    #[test]
1305    fn allocation_free_predicates_match_witness_contract() {
1306        let motif = [
1307            MotifEdge {
1308                from: 0,
1309                kind_mask: 1,
1310                to: 1,
1311            },
1312            MotifEdge {
1313                from: 1,
1314                kind_mask: 1,
1315                to: 2,
1316            },
1317        ];
1318        assert!(cpu_ref_matches(&[0, 1, 2, 2], &[1, 2], &[1, 1], &motif));
1319        assert_eq!(
1320            cpu_ref_participation_count(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &motif),
1321            3
1322        );
1323        assert!(!cpu_ref_matches(&[0, 1, 1, 1], &[1], &[1], &motif));
1324        assert_eq!(
1325            cpu_ref_participation_count(3, &[0, 1, 1, 1], &[1], &[1], &motif),
1326            0
1327        );
1328        assert!(
1329            cpu_ref_matches(&[0, 1, 2, 2], &[1, 2], &[1, 1], &[]),
1330            "empty motif has no missing edges"
1331        );
1332        assert_eq!(
1333            cpu_ref_participation_count(3, &[0, 1, 2, 2], &[1, 2], &[1, 1], &[]),
1334            0,
1335            "empty motif has no participating nodes"
1336        );
1337    }
1338
1339    #[test]
1340    fn validate_csr_inputs_accepts_empty_and_canonical_graphs() {
1341        assert_eq!(
1342            validate_motif_inputs(0, &[0], &[], &[], &[]).unwrap(),
1343            MotifLayout {
1344                node_count: 0,
1345                output_words: 0,
1346                edge_count: 0,
1347                edge_storage_words: 1,
1348                motif_edge_count: 0,
1349            }
1350        );
1351        assert_eq!(
1352            validate_motif_inputs(
1353                3,
1354                &[0, 1, 2, 2],
1355                &[1, 2],
1356                &[1, 1],
1357                &[MotifEdge {
1358                    from: 0,
1359                    kind_mask: 1,
1360                    to: 1,
1361                }],
1362            )
1363            .unwrap(),
1364            MotifLayout {
1365                node_count: 3,
1366                output_words: 3,
1367                edge_count: 2,
1368                edge_storage_words: 2,
1369                motif_edge_count: 1,
1370            }
1371        );
1372    }
1373
1374    #[test]
1375    fn dispatch_plan_owns_shape_grid_buffers_and_readback_words() {
1376        let motif_edges = [MotifEdge {
1377            from: 0,
1378            kind_mask: 1,
1379            to: 1,
1380        }];
1381        let launch = plan_motif_launch(
1382            3,
1383            &[0, 1, 2, 2],
1384            &[1, 2],
1385            &[1, 1],
1386            &motif_edges,
1387            "witness_out",
1388        )
1389        .expect("Fix: canonical motif launch plan should validate without materializing a Program");
1390        assert_eq!(launch.layout().node_count, 3);
1391        assert_eq!(launch.output_words(), 3);
1392        assert_eq!(launch.edge_storage_words(), 2);
1393        assert_eq!(launch.dispatch_grid(), MOTIF_DISPATCH_GRID);
1394        assert_eq!(
1395            launch.cache_key(),
1396            &MotifProgramCacheKey {
1397                node_count: 3,
1398                edge_count: 2,
1399                motif_edges: motif_edges.to_vec(),
1400                witness_out: "witness_out".to_string(),
1401            }
1402        );
1403
1404        let plan = plan_motif_dispatch(
1405            3,
1406            &[0, 1, 2, 2],
1407            &[1, 2],
1408            &[1, 1],
1409            &motif_edges,
1410            "witness_out",
1411        )
1412        .expect("Fix: canonical motif dispatch plan should validate");
1413
1414        assert_eq!(plan.layout().node_count, 3);
1415        assert_eq!(plan.layout().edge_count, 2);
1416        assert_eq!(plan.layout().motif_edge_count, 1);
1417        assert_eq!(plan.output_words(), 3);
1418        assert_eq!(plan.edge_storage_words(), 2);
1419        assert_eq!(plan.dispatch_grid(), MOTIF_DISPATCH_GRID);
1420        assert_eq!(plan.program().workgroup_size, MOTIF_WORKGROUP_SIZE);
1421        let bindings = plan
1422            .program()
1423            .buffers
1424            .iter()
1425            .map(|buffer| buffer.binding)
1426            .collect::<Vec<_>>();
1427        assert!(bindings.contains(&MOTIF_HITS_BUFFER));
1428        assert!(bindings.contains(&MOTIF_WITNESS_OUT_BUFFER));
1429
1430        let empty_edge_plan = plan_motif_dispatch(1, &[0, 0], &[], &[], &[], "witness_out")
1431            .expect("Fix: zero-edge motif graph should still have padded edge storage");
1432        assert_eq!(empty_edge_plan.layout().edge_count, 0);
1433        assert_eq!(empty_edge_plan.edge_storage_words(), 1);
1434    }
1435
1436    #[test]
1437    fn witness_participant_count_uses_primitive_contract() {
1438        assert_eq!(count_witness_participants(&[1, 0, 2, 0]).unwrap(), 2);
1439    }
1440
1441    #[test]
1442    fn validate_csr_inputs_rejects_malformed_csr() {
1443        let err = validate_csr_inputs(2, &[0, 1, 1], &[1], &[]).unwrap_err();
1444        assert!(err.contains("edge_targets.len() == edge_kind_mask.len()"));
1445
1446        let err = validate_csr_inputs(2, &[0, 2, 1], &[1], &[1]).unwrap_err();
1447        assert!(err.contains("offsets must be monotonic"));
1448
1449        let err = validate_csr_inputs(2, &[0, 1, 1], &[5], &[1]).unwrap_err();
1450        assert!(err.contains("outside node_count"));
1451    }
1452}