Skip to main content

vyre_primitives/graph/
knowledge_compile.rs

1//! Probabilistic knowledge compilation primitive (#38).
2//!
3//! Knowledge compilation (Darwiche 2002) compiles a probabilistic
4//! logic program into a tractable circuit (d-DNNF, SDD). The
5//! compilation step is host-side; the **evaluation** of a compiled
6//! circuit is GPU-shaped  -  exactly what #10 sum_product_circuit
7//! does. This file ships a thin wrapper that confirms the compose
8//! contract and adds a host-side d-DNNF satisfiability oracle helper.
9//!
10//! # Why this primitive is dual-use
11//!
12//! | Consumer | Use |
13//! |---|---|
14//! | `vyre-libs::ml::probabilistic_logic` | neuro-symbolic systems |
15//! | `vyre-libs::security::policy_engine` | rule-conflict resolution as probabilistic logic |
16
17use std::sync::Arc;
18
19use vyre_foundation::ir::model::expr::Ident;
20use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
21
22/// d-DNNF "literal kind" tag.
23pub const LITERAL_TRUE: u32 = 1;
24/// d-DNNF "literal kind" tag for false.
25pub const LITERAL_FALSE: u32 = 2;
26/// AND node tag.
27pub const AND_NODE: u32 = 3;
28/// OR node tag.
29pub const OR_NODE: u32 = 4;
30
31/// Op id for the GPU-shaped d-DNNF evaluator.
32pub const OP_ID: &str = "vyre-primitives::graph::ddnnf_evaluate";
33/// One lane per compiled d-DNNF node in a bottom-up evaluation wave.
34pub const DDNNF_EVALUATE_WORKGROUP_SIZE: [u32; 3] = [256, 1, 1];
35
36/// Dispatch grid that covers every compiled d-DNNF node lane.
37#[must_use]
38pub const fn ddnnf_evaluate_dispatch_grid(n_nodes: u32) -> [u32; 3] {
39    let lanes_per_block = DDNNF_EVALUATE_WORKGROUP_SIZE[0];
40    let full_blocks = n_nodes / lanes_per_block;
41    let tail_block = if n_nodes % lanes_per_block == 0 { 0 } else { 1 };
42    let blocks = full_blocks + tail_block;
43    [if blocks == 0 { 1 } else { blocks }, 1, 1]
44}
45
46/// Emit one bottom-up d-DNNF evaluation step. The dispatch is
47/// `n_nodes` lanes; each lane evaluates one node from already-evaluated
48/// children. Callers compose this with `level_wave_program` or another
49/// topological wave scheduler when parent nodes must wait for child
50/// outputs.
51///
52/// Buffers:
53/// - `node_kinds`: u32 per node, using [`LITERAL_TRUE`],
54///   [`LITERAL_FALSE`], [`AND_NODE`], [`OR_NODE`].
55/// - `node_var`: u32 per node, meaningful for literal nodes.
56/// - `child_offsets`: u32 per node into `children`.
57/// - `child_counts`: u32 per node.
58/// - `children`: concatenated child node indices.
59/// - `var_assignments`: u32 per variable, 0/1/`u32::MAX` unknown.
60/// - `out`: u32 per node.
61#[must_use]
62#[allow(clippy::too_many_arguments)]
63pub fn ddnnf_evaluate(
64    node_kinds: &str,
65    node_var: &str,
66    child_offsets: &str,
67    child_counts: &str,
68    children: &str,
69    var_assignments: &str,
70    out: &str,
71    n_nodes: u32,
72    n_children: u32,
73    n_vars: u32,
74) -> Program {
75    match try_ddnnf_evaluate(
76        node_kinds,
77        node_var,
78        child_offsets,
79        child_counts,
80        children,
81        var_assignments,
82        out,
83        n_nodes,
84        n_children,
85        n_vars,
86    ) {
87        Ok(program) => program,
88        Err(error) => crate::invalid_output_program(OP_ID, out, DataType::U32, error),
89    }
90}
91
92/// Emit one bottom-up d-DNNF evaluation step with checked domain shape.
93#[allow(clippy::too_many_arguments)]
94pub fn try_ddnnf_evaluate(
95    node_kinds: &str,
96    node_var: &str,
97    child_offsets: &str,
98    child_counts: &str,
99    children: &str,
100    var_assignments: &str,
101    out: &str,
102    n_nodes: u32,
103    n_children: u32,
104    n_vars: u32,
105) -> Result<Program, String> {
106    if n_nodes == 0 {
107        return Err(format!(
108            "Fix: ddnnf_evaluate requires n_nodes > 0, got {n_nodes}."
109        ));
110    }
111    if n_vars == 0 {
112        return Err(format!(
113            "Fix: ddnnf_evaluate requires n_vars > 0, got {n_vars}."
114        ));
115    }
116
117    let lane = Expr::InvocationId { axis: 0 };
118    let child_index = Expr::add(Expr::var("child_base"), Expr::var("k"));
119    let body = vec![Node::if_then(
120        Expr::lt(lane.clone(), Expr::u32(n_nodes)),
121        vec![
122            Node::let_bind("kind", Expr::load(node_kinds, lane.clone())),
123            Node::let_bind("var_id", Expr::load(node_var, lane.clone())),
124            Node::let_bind("child_base", Expr::load(child_offsets, lane.clone())),
125            Node::let_bind("child_count", Expr::load(child_counts, lane.clone())),
126            Node::if_then(
127                Expr::eq(Expr::var("kind"), Expr::u32(LITERAL_TRUE)),
128                vec![
129                    Node::let_bind(
130                        "assigned_true",
131                        Expr::load(var_assignments, Expr::var("var_id")),
132                    ),
133                    Node::store(
134                        out,
135                        lane.clone(),
136                        Expr::select(
137                            Expr::or(
138                                Expr::eq(Expr::var("assigned_true"), Expr::u32(1)),
139                                Expr::eq(Expr::var("assigned_true"), Expr::u32(u32::MAX)),
140                            ),
141                            Expr::u32(1),
142                            Expr::u32(0),
143                        ),
144                    ),
145                ],
146            ),
147            Node::if_then(
148                Expr::eq(Expr::var("kind"), Expr::u32(LITERAL_FALSE)),
149                vec![
150                    Node::let_bind(
151                        "assigned_false",
152                        Expr::load(var_assignments, Expr::var("var_id")),
153                    ),
154                    Node::store(
155                        out,
156                        lane.clone(),
157                        Expr::select(
158                            Expr::or(
159                                Expr::eq(Expr::var("assigned_false"), Expr::u32(0)),
160                                Expr::eq(Expr::var("assigned_false"), Expr::u32(u32::MAX)),
161                            ),
162                            Expr::u32(1),
163                            Expr::u32(0),
164                        ),
165                    ),
166                ],
167            ),
168            Node::if_then(
169                Expr::eq(Expr::var("kind"), Expr::u32(AND_NODE)),
170                vec![
171                    Node::let_bind("acc_and", Expr::u32(1)),
172                    Node::loop_for(
173                        "k",
174                        Expr::u32(0),
175                        Expr::var("child_count"),
176                        vec![
177                            Node::let_bind("child_node", Expr::load(children, child_index.clone())),
178                            Node::assign(
179                                "acc_and",
180                                Expr::mul(
181                                    Expr::var("acc_and"),
182                                    Expr::load(out, Expr::var("child_node")),
183                                ),
184                            ),
185                        ],
186                    ),
187                    Node::store(out, lane.clone(), Expr::var("acc_and")),
188                ],
189            ),
190            Node::if_then(
191                Expr::eq(Expr::var("kind"), Expr::u32(OR_NODE)),
192                vec![
193                    Node::let_bind("acc_or", Expr::u32(0)),
194                    Node::loop_for(
195                        "kk",
196                        Expr::u32(0),
197                        Expr::var("child_count"),
198                        vec![
199                            Node::let_bind(
200                                "or_child_node",
201                                Expr::load(
202                                    children,
203                                    Expr::add(Expr::var("child_base"), Expr::var("kk")),
204                                ),
205                            ),
206                            Node::assign(
207                                "acc_or",
208                                Expr::add(
209                                    Expr::var("acc_or"),
210                                    Expr::load(out, Expr::var("or_child_node")),
211                                ),
212                            ),
213                        ],
214                    ),
215                    Node::store(out, lane.clone(), Expr::var("acc_or")),
216                ],
217            ),
218            Node::if_then(
219                Expr::and(
220                    Expr::and(
221                        Expr::ne(Expr::var("kind"), Expr::u32(LITERAL_TRUE)),
222                        Expr::ne(Expr::var("kind"), Expr::u32(LITERAL_FALSE)),
223                    ),
224                    Expr::and(
225                        Expr::ne(Expr::var("kind"), Expr::u32(AND_NODE)),
226                        Expr::ne(Expr::var("kind"), Expr::u32(OR_NODE)),
227                    ),
228                ),
229                vec![Node::store(out, lane.clone(), Expr::u32(0))],
230            ),
231        ],
232    )];
233
234    Ok(Program::wrapped(
235        vec![
236            BufferDecl::storage(node_kinds, 0, BufferAccess::ReadOnly, DataType::U32)
237                .with_count(n_nodes),
238            BufferDecl::storage(node_var, 1, BufferAccess::ReadOnly, DataType::U32)
239                .with_count(n_nodes),
240            BufferDecl::storage(child_offsets, 2, BufferAccess::ReadOnly, DataType::U32)
241                .with_count(n_nodes),
242            BufferDecl::storage(child_counts, 3, BufferAccess::ReadOnly, DataType::U32)
243                .with_count(n_nodes),
244            BufferDecl::storage(children, 4, BufferAccess::ReadOnly, DataType::U32)
245                .with_count(n_children.max(1)),
246            BufferDecl::storage(var_assignments, 5, BufferAccess::ReadOnly, DataType::U32)
247                .with_count(n_vars),
248            BufferDecl::storage(out, 6, BufferAccess::ReadWrite, DataType::U32).with_count(n_nodes),
249        ],
250        DDNNF_EVALUATE_WORKGROUP_SIZE,
251        vec![Node::Region {
252            generator: Ident::from(OP_ID),
253            source_region: None,
254            body: Arc::new(body),
255        }],
256    ))
257}
258
259/// CPU helper: evaluate a d-DNNF compiled circuit under a partial
260/// variable assignment. Returns the model count weighted by node
261/// types (the canonical KC inference query).
262///
263/// `var_assignments[var_id] = 0/1/u32::MAX` (unknown).
264/// `nodes[i] = (kind, child_offset, child_count)` row-major.
265/// `node_var[i]` = variable id (only meaningful for literal nodes).
266/// `topo_order` is the bottom-up evaluation order.
267#[must_use]
268#[cfg(any(test, feature = "cpu-parity"))]
269pub fn ddnnf_evaluate_cpu(
270    nodes: &[(u32, u32, u32)],
271    node_var: &[u32],
272    children: &[u32],
273    var_assignments: &[u32],
274    topo_order: &[u32],
275) -> Vec<u32> {
276    match try_ddnnf_evaluate_cpu(nodes, node_var, children, var_assignments, topo_order) {
277        Ok(out) => out,
278        // Returning empty on failure makes a GPU-vs-CPU parity assertion pass on
279        // empty==empty, silently masking a divergence (Law 10 / Law 6). Fail
280        // loud; callers use try_ddnnf_evaluate_cpu.
281        Err(error) => panic!("vyre-primitives d-DNNF evaluate CPU reference failed: {error}"),
282    }
283}
284
285/// CPU helper with checked compiled-circuit indexing and arithmetic.
286#[cfg(any(test, feature = "cpu-parity"))]
287pub fn try_ddnnf_evaluate_cpu(
288    nodes: &[(u32, u32, u32)],
289    node_var: &[u32],
290    children: &[u32],
291    var_assignments: &[u32],
292    topo_order: &[u32],
293) -> Result<Vec<u32>, String> {
294    let mut out = Vec::new();
295    try_ddnnf_evaluate_cpu_into(
296        nodes,
297        node_var,
298        children,
299        var_assignments,
300        topo_order,
301        &mut out,
302    )?;
303    Ok(out)
304}
305
306/// CPU helper with checked indexing/arithmetic and caller-owned output storage.
307#[cfg(any(test, feature = "cpu-parity"))]
308pub fn try_ddnnf_evaluate_cpu_into(
309    nodes: &[(u32, u32, u32)],
310    node_var: &[u32],
311    children: &[u32],
312    var_assignments: &[u32],
313    topo_order: &[u32],
314    out: &mut Vec<u32>,
315) -> Result<(), String> {
316    let mut scratch = DdnnfCpuScratch::default();
317    try_ddnnf_evaluate_cpu_into_with_scratch(
318        nodes,
319        node_var,
320        children,
321        var_assignments,
322        topo_order,
323        out,
324        &mut scratch,
325    )
326}
327
328/// Caller-owned workspace for d-DNNF CPU evaluation.
329#[cfg(any(test, feature = "cpu-parity"))]
330#[derive(Debug, Default, Clone)]
331pub struct DdnnfCpuScratch {
332    /// Transactional value buffer populated before committing to caller output.
333    pub values: Vec<u32>,
334}
335
336#[cfg(any(test, feature = "cpu-parity"))]
337impl DdnnfCpuScratch {
338    /// Create an empty reusable d-DNNF evaluation workspace.
339    pub fn new() -> Self {
340        Self::default()
341    }
342}
343
344/// CPU helper with caller-owned output and transactional scratch storage.
345///
346/// Malformed compiled circuits are rejected before `out` or `scratch` are
347/// cleared. Arithmetic failures leave `out` unchanged because all writes happen
348/// in the scratch buffer until the full evaluation succeeds.
349#[cfg(any(test, feature = "cpu-parity"))]
350pub fn try_ddnnf_evaluate_cpu_into_with_scratch(
351    nodes: &[(u32, u32, u32)],
352    node_var: &[u32],
353    children: &[u32],
354    var_assignments: &[u32],
355    topo_order: &[u32],
356    out: &mut Vec<u32>,
357    scratch: &mut DdnnfCpuScratch,
358) -> Result<(), String> {
359    validate_ddnnf_evaluate_inputs(nodes, node_var, children, var_assignments, topo_order)?;
360    let n_nodes = nodes.len();
361    scratch.values.clear();
362    crate::graph::scratch::resize_graph_vec(
363        &mut scratch.values,
364        n_nodes,
365        0u32,
366        "d-DNNF CPU oracle",
367        "ddnnf_evaluate CPU scratch",
368    )?;
369    for &node in topo_order {
370        let i = node as usize;
371        let (kind, co, cc) = nodes[i];
372        match kind {
373            LITERAL_TRUE => {
374                let assigned = var_assignments[node_var[i] as usize];
375                scratch.values[i] = if assigned == 1 || assigned == u32::MAX {
376                    1
377                } else {
378                    0
379                };
380            }
381            LITERAL_FALSE => {
382                let assigned = var_assignments[node_var[i] as usize];
383                scratch.values[i] = if assigned == 0 || assigned == u32::MAX {
384                    1
385                } else {
386                    0
387                };
388            }
389            AND_NODE => {
390                let mut acc = 1u32;
391                for k in 0..cc as usize {
392                    let child_index = co as usize + k;
393                    let cn = children[child_index] as usize;
394                    let child_value = scratch.values[cn];
395                    acc = acc.checked_mul(child_value).ok_or_else(|| {
396                        format!(
397                            "ddnnf_evaluate CPU oracle AND node {i} model count overflowed u32. Fix: shard or widen model-count accumulation."
398                        )
399                    })?;
400                }
401                scratch.values[i] = acc;
402            }
403            OR_NODE => {
404                let mut acc = 0u32;
405                for k in 0..cc as usize {
406                    let child_index = co as usize + k;
407                    let cn = children[child_index] as usize;
408                    let child_value = scratch.values[cn];
409                    acc = acc.checked_add(child_value).ok_or_else(|| {
410                        format!(
411                            "ddnnf_evaluate CPU oracle OR node {i} model count overflowed u32. Fix: shard or widen model-count accumulation."
412                        )
413                    })?;
414                }
415                scratch.values[i] = acc;
416            }
417            _ => {
418                scratch.values[i] = 0;
419            }
420        }
421    }
422    if n_nodes > out.len() {
423        crate::graph::scratch::reserve_graph_items(
424            out,
425            n_nodes - out.len(),
426            "d-DNNF CPU oracle",
427            "ddnnf_evaluate CPU output",
428        )?;
429    }
430    out.clear();
431    out.extend_from_slice(&scratch.values);
432    Ok(())
433}
434
435#[cfg(any(test, feature = "cpu-parity"))]
436fn validate_ddnnf_evaluate_inputs(
437    nodes: &[(u32, u32, u32)],
438    node_var: &[u32],
439    children: &[u32],
440    var_assignments: &[u32],
441    topo_order: &[u32],
442) -> Result<(), String> {
443    let n_nodes = nodes.len();
444    if node_var.len() != n_nodes {
445        return Err(format!(
446            "ddnnf_evaluate CPU oracle received node_var_len={} for node_count={n_nodes}. Fix: pass one variable slot per compiled node.",
447            node_var.len()
448        ));
449    }
450    for &node in topo_order {
451        let i = node as usize;
452        let Some(&(kind, co, cc)) = nodes.get(i) else {
453            return Err(format!(
454                "ddnnf_evaluate CPU oracle topo node {node} is outside node_count={n_nodes}. Fix: rebuild the compiled-circuit topological order."
455            ));
456        };
457        match kind {
458            LITERAL_TRUE | LITERAL_FALSE => {
459                let v = node_var[i] as usize;
460                if v >= var_assignments.len() {
461                    return Err(format!(
462                        "ddnnf_evaluate CPU oracle literal node {i} references var {v} outside assignment_count={}. Fix: pass a complete assignment vector.",
463                        var_assignments.len()
464                    ));
465                }
466            }
467            AND_NODE | OR_NODE => {
468                let co = co as usize;
469                let cc = cc as usize;
470                let end = co.checked_add(cc).ok_or_else(|| {
471                    format!(
472                        "ddnnf_evaluate CPU oracle child offset overflow at node {i}. Fix: rebuild child_offsets before parity comparison."
473                    )
474                })?;
475                if end > children.len() {
476                    return Err(format!(
477                        "ddnnf_evaluate CPU oracle node {i} child range {co}..{end} exceeds child_count={}. Fix: pass a complete child list.",
478                        children.len()
479                    ));
480                }
481                for child_index in co..end {
482                    let cn = children[child_index] as usize;
483                    if cn >= n_nodes {
484                        return Err(format!(
485                            "ddnnf_evaluate CPU oracle node {i} references child node {cn} outside node_count={n_nodes}. Fix: rebuild compiled child ids."
486                        ));
487                    }
488                }
489            }
490            _ => {}
491        }
492    }
493    Ok(())
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn cpu_single_true_literal_with_assigned_var() {
502        // 1 node, kind=LITERAL_TRUE, var 0 assigned to 1 → out = 1.
503        let nodes = vec![(LITERAL_TRUE, 0, 0)];
504        let node_var = vec![0];
505        let children = vec![];
506        let assigns = vec![1];
507        let order = vec![0];
508        let out = ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order);
509        assert_eq!(out[0], 1);
510    }
511
512    #[test]
513    fn cpu_single_true_literal_with_unset_var() {
514        // var 0 unknown → output 1 (counts both true assignments).
515        let nodes = vec![(LITERAL_TRUE, 0, 0)];
516        let node_var = vec![0];
517        let children = vec![];
518        let assigns = vec![u32::MAX];
519        let order = vec![0];
520        let out = ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order);
521        assert_eq!(out[0], 1);
522    }
523
524    #[test]
525    fn cpu_and_of_two_literals() {
526        // (x_0=true) AND (x_1=true), both unknown → mc = 1
527        let nodes = vec![(LITERAL_TRUE, 0, 0), (LITERAL_TRUE, 0, 0), (AND_NODE, 0, 2)];
528        let node_var = vec![0, 1, 0];
529        let children = vec![0, 1];
530        let assigns = vec![u32::MAX; 2];
531        let order = vec![0, 1, 2];
532        let out = ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order);
533        assert_eq!(out[2], 1);
534    }
535
536    #[test]
537    fn cpu_or_of_two_literals_counts_both() {
538        // (x_0=true) OR (x_1=true), both unknown → mc = 2
539        let nodes = vec![(LITERAL_TRUE, 0, 0), (LITERAL_TRUE, 0, 0), (OR_NODE, 0, 2)];
540        let node_var = vec![0, 1, 0];
541        let children = vec![0, 1];
542        let assigns = vec![u32::MAX; 2];
543        let order = vec![0, 1, 2];
544        let out = ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order);
545        assert_eq!(out[2], 2);
546    }
547
548    #[test]
549    fn cpu_partial_assignment_constrains_count() {
550        // With var 0 fixed to true, the OR (x_0 OR x_1) becomes
551        // mc = 1 (x_0 satisfied) for any x_1.
552        let nodes = vec![(LITERAL_TRUE, 0, 0), (LITERAL_TRUE, 0, 0), (OR_NODE, 0, 2)];
553        let node_var = vec![0, 1, 0];
554        let children = vec![0, 1];
555        let assigns = vec![1, 0]; // x_0 = true, x_1 = false
556        let order = vec![0, 1, 2];
557        let out = ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order);
558        // out[0] = 1 (x_0 = true literal evaluates to 1)
559        // out[1] = 0 (x_1 = true literal but x_1 is assigned false)
560        // out[2] = 1 + 0 = 1
561        assert_eq!(out[2], 1);
562    }
563
564    #[test]
565    fn checked_cpu_oracle_rejects_missing_assignment() {
566        let nodes = vec![(LITERAL_TRUE, 0, 0)];
567        let node_var = vec![7];
568        let children = vec![];
569        let assigns = vec![u32::MAX];
570        let order = vec![0];
571        let error = try_ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order)
572            .expect_err("checked d-DNNF oracle must reject missing variable assignments");
573
574        assert!(
575            error.contains("outside assignment_count"),
576            "error should describe the missing assignment: {error}"
577        );
578    }
579
580    #[test]
581    fn checked_cpu_oracle_rejects_missing_child() {
582        let nodes = vec![(LITERAL_TRUE, 0, 0), (AND_NODE, 0, 1)];
583        let node_var = vec![0, 0];
584        let children = vec![];
585        let assigns = vec![u32::MAX];
586        let order = vec![0, 1];
587        let error = try_ddnnf_evaluate_cpu(&nodes, &node_var, &children, &assigns, &order)
588            .expect_err("checked d-DNNF oracle must reject missing child list entries");
589
590        assert!(
591            error.contains("exceeds child_count"),
592            "error should describe the missing child entry: {error}"
593        );
594    }
595
596    #[test]
597    fn scratch_cpu_oracle_rejects_bad_child_without_clobbering_storage() {
598        let nodes = vec![(LITERAL_TRUE, 0, 0), (AND_NODE, 0, 1)];
599        let node_var = vec![0, 0];
600        let children = vec![9];
601        let assigns = vec![u32::MAX];
602        let order = vec![0, 1];
603        let mut out = vec![0xDEAD_BEEF, 0xCAFE_BABE];
604        let mut scratch = DdnnfCpuScratch {
605            values: vec![7, 8, 9],
606        };
607
608        let error = try_ddnnf_evaluate_cpu_into_with_scratch(
609            &nodes,
610            &node_var,
611            &children,
612            &assigns,
613            &order,
614            &mut out,
615            &mut scratch,
616        )
617        .expect_err("checked d-DNNF oracle must reject out-of-range child ids");
618
619        assert!(
620            error.contains("outside node_count"),
621            "error should describe the invalid child node: {error}"
622        );
623        assert_eq!(
624            out,
625            vec![0xDEAD_BEEF, 0xCAFE_BABE],
626            "Fix: malformed d-DNNF inputs must not clobber caller output."
627        );
628        assert_eq!(
629            scratch.values,
630            vec![7, 8, 9],
631            "Fix: structural validation failures must not clear reusable scratch."
632        );
633    }
634
635    #[test]
636    fn scratch_cpu_oracle_reuses_values_and_clears_stale_tail() {
637        let nodes = vec![(LITERAL_TRUE, 0, 0), (LITERAL_FALSE, 0, 0), (OR_NODE, 0, 2)];
638        let node_var = vec![0, 1, 0];
639        let children = vec![0, 1];
640        let assigns = vec![1, 0];
641        let order = vec![0, 1, 2];
642        let mut out = Vec::with_capacity(8);
643        out.extend_from_slice(&[99, 98, 97, 96]);
644        let mut scratch = DdnnfCpuScratch {
645            values: Vec::with_capacity(8),
646        };
647        scratch.values.extend_from_slice(&[11, 12, 13, 14, 15]);
648        let out_capacity = out.capacity();
649        let scratch_capacity = scratch.values.capacity();
650
651        try_ddnnf_evaluate_cpu_into_with_scratch(
652            &nodes,
653            &node_var,
654            &children,
655            &assigns,
656            &order,
657            &mut out,
658            &mut scratch,
659        )
660        .expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - valid d-DNNF circuit must evaluate into reusable storage");
661
662        assert_eq!(out, vec![1, 1, 2]);
663        assert_eq!(scratch.values, vec![1, 1, 2]);
664        assert_eq!(out.capacity(), out_capacity);
665        assert_eq!(scratch.values.capacity(), scratch_capacity);
666
667        try_ddnnf_evaluate_cpu_into_with_scratch(
668            &nodes[..1],
669            &node_var[..1],
670            &[],
671            &assigns,
672            &[0],
673            &mut out,
674            &mut scratch,
675        )
676        .expect("Fix: replace expect with fallible API or document caller precondition; panic only on programmer error - smaller d-DNNF circuit must reuse and truncate storage");
677
678        assert_eq!(out, vec![1]);
679        assert_eq!(scratch.values, vec![1]);
680        assert_eq!(out.capacity(), out_capacity);
681        assert_eq!(scratch.values.capacity(), scratch_capacity);
682    }
683
684    #[test]
685    fn generated_cpu_oracle_matches_independent_ddnnf_evaluator() {
686        let mut out = Vec::new();
687        let mut scratch = DdnnfCpuScratch::new();
688        for case in 0..4096usize {
689            let n_literals = match case {
690                1 => 256,
691                2 => 257,
692                3 => 1025,
693                _ => 1 + case % 6,
694            };
695            let n_nodes = n_literals + 4;
696            let n_vars = 1 + (case / 7) % 6;
697            let mut nodes = Vec::new();
698            let mut node_var = Vec::new();
699            let mut children = Vec::new();
700
701            for idx in 0..n_literals {
702                let kind = if (case + idx) % 2 == 0 {
703                    LITERAL_TRUE
704                } else {
705                    LITERAL_FALSE
706                };
707                nodes.push((kind, 0, 0));
708                node_var.push((idx % n_vars) as u32);
709            }
710
711            for op_idx in 0..4usize {
712                let child_count = 1 + ((case + op_idx) % n_literals);
713                let offset = children.len() as u32;
714                for child in 0..child_count {
715                    children.push(((child + op_idx) % (n_literals + op_idx)) as u32);
716                }
717                let kind = if op_idx % 2 == 0 { AND_NODE } else { OR_NODE };
718                nodes.push((kind, offset, child_count as u32));
719                node_var.push(0);
720            }
721
722            let assignments: Vec<u32> = (0..n_vars)
723                .map(|idx| match (case + idx) % 3 {
724                    0 => 0,
725                    1 => 1,
726                    _ => u32::MAX,
727                })
728                .collect();
729            let topo_order: Vec<u32> = (0..n_nodes as u32).collect();
730
731            try_ddnnf_evaluate_cpu_into_with_scratch(
732                &nodes,
733                &node_var,
734                &children,
735                &assignments,
736                &topo_order,
737                &mut out,
738                &mut scratch,
739            )
740            .expect("Fix: caller must pre-size buffers; use fallible reserve or return ResourceExhausted - generated d-DNNF CPU oracle should reserve and evaluate");
741            let expected =
742                independent_ddnnf_evaluate(&nodes, &node_var, &children, &assignments, &topo_order);
743
744            assert_eq!(out, expected, "case {case}: d-DNNF evaluation mismatch");
745        }
746    }
747
748    fn independent_ddnnf_evaluate(
749        nodes: &[(u32, u32, u32)],
750        node_var: &[u32],
751        children: &[u32],
752        assignments: &[u32],
753        topo_order: &[u32],
754    ) -> Vec<u32> {
755        let mut out = Vec::new();
756        out.resize(nodes.len(), 0);
757        for &node in topo_order {
758            let i = node as usize;
759            let (kind, offset, count) = nodes[i];
760            out[i] = match kind {
761                LITERAL_TRUE => {
762                    let assigned = assignments[node_var[i] as usize];
763                    u32::from(assigned == 1 || assigned == u32::MAX)
764                }
765                LITERAL_FALSE => {
766                    let assigned = assignments[node_var[i] as usize];
767                    u32::from(assigned == 0 || assigned == u32::MAX)
768                }
769                AND_NODE => {
770                    let mut acc = 1u32;
771                    for k in 0..count as usize {
772                        acc *= out[children[offset as usize + k] as usize];
773                    }
774                    acc
775                }
776                OR_NODE => {
777                    let mut acc = 0u32;
778                    for k in 0..count as usize {
779                        acc += out[children[offset as usize + k] as usize];
780                    }
781                    acc
782                }
783                _ => 0,
784            };
785        }
786        out
787    }
788
789    #[test]
790    fn gpu_program_builder_exposes_ddnnf_buffers() {
791        let program = ddnnf_evaluate(
792            "kinds",
793            "node_var",
794            "child_offsets",
795            "child_counts",
796            "children",
797            "assignments",
798            "out",
799            3,
800            2,
801            2,
802        );
803        assert_eq!(program.buffers().len(), 7);
804        assert_eq!(program.workgroup_size(), DDNNF_EVALUATE_WORKGROUP_SIZE);
805        assert!(
806            program
807                .entry()
808                .iter()
809                .any(|node| matches!(node, vyre_foundation::ir::Node::Region { generator, .. } if generator.as_str() == OP_ID))
810        );
811    }
812
813    #[test]
814    fn dispatch_grid_packs_ddnnf_nodes_into_workgroups() {
815        assert_eq!(ddnnf_evaluate_dispatch_grid(0), [1, 1, 1]);
816        assert_eq!(ddnnf_evaluate_dispatch_grid(1), [1, 1, 1]);
817        assert_eq!(ddnnf_evaluate_dispatch_grid(256), [1, 1, 1]);
818        assert_eq!(ddnnf_evaluate_dispatch_grid(257), [2, 1, 1]);
819        assert_eq!(ddnnf_evaluate_dispatch_grid(1025), [5, 1, 1]);
820    }
821
822    #[test]
823    fn gpu_program_builder_rejects_empty_node_count_with_trap_program() {
824        let program = ddnnf_evaluate(
825            "kinds",
826            "node_var",
827            "child_offsets",
828            "child_counts",
829            "children",
830            "assignments",
831            "out",
832            0,
833            0,
834            1,
835        );
836        assert_eq!(program.buffers().len(), 1);
837        assert!(
838            program
839                .entry()
840                .iter()
841                .any(|node| matches!(node, vyre_foundation::ir::Node::Region { body, .. } if body.iter().any(|inner| matches!(inner, vyre_foundation::ir::Node::Trap { .. }))))
842        );
843    }
844
845    #[test]
846    fn checked_gpu_builder_rejects_empty_var_domain() {
847        let error = try_ddnnf_evaluate(
848            "kinds",
849            "node_var",
850            "child_offsets",
851            "child_counts",
852            "children",
853            "assignments",
854            "out",
855            1,
856            0,
857            0,
858        )
859        .expect_err("checked d-DNNF builder must reject empty variable domains");
860
861        assert!(
862            error.contains("requires n_vars > 0"),
863            "error should describe the invalid variable domain: {error}"
864        );
865    }
866}