Skip to main content

xlog_prob/
exact.rs

1//! Exact probabilistic inference via GPU-native Decision-DNNF knowledge compilation
2//! and weighted model counting.
3
4#[cfg(feature = "host-io")]
5use std::collections::BTreeMap;
6use std::collections::{HashMap, HashSet};
7#[cfg(feature = "host-io")]
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10
11use cudarc::driver::LaunchConfig;
12use xlog_core::{MemoryBudget, Result, ScalarType, XlogError};
13use xlog_cuda::LaunchAsync;
14use xlog_logic::ast::Program;
15
16use crate::compilation::gpu_cache::{
17    GpuCircuitCache, GpuCircuitCacheConfig, GpuCircuitCacheHandle,
18};
19use crate::compilation::gpu_cnf::GpuCnfVarTables;
20#[cfg(feature = "host-io")]
21use crate::compilation::gpu_weights::map_nodes_to_vars_gpu;
22use crate::compilation::gpu_weights::{build_evidence_by_var_gpu, build_weights_gpu};
23use crate::compilation::{
24    compile_gpu_d4_and_verify_cached_with_ledger, encode_cnf_gpu, CircuitCompilationContext,
25    CircuitCompilationLedger, CircuitCompileProfile, DeviceRandomVarList, GpuCompileConfig,
26    GpuPirGraph, GpuPirRoots,
27};
28#[cfg(feature = "host-io")]
29use crate::logsumexp::{validate_circuit_gradient_values, validate_circuit_value};
30use crate::neural_fast_path::{GpuWeightSlots, NeuralFastPathConfig};
31use crate::provenance::{extract_from_program, extract_from_source, GroundAtom, Provenance};
32#[cfg(feature = "host-io")]
33use crate::provenance::{AggregateLiftStatus, Value};
34use xlog_cuda::memory::TrackedCudaSlice;
35use xlog_cuda::provider::{
36    arith_kernels, filter_kernels, neural_kernels, weights_kernels, ARITH_MODULE, FILTER_MODULE,
37    NEURAL_MODULE, WEIGHTS_MODULE,
38};
39use xlog_cuda::{CudaBuffer, CudaKernelProvider};
40
41#[derive(Debug, Clone)]
42/// Exact probability reported for one ground query.
43pub struct QueryProbability {
44    /// Ground query atom.
45    pub atom: GroundAtom,
46    /// Natural logarithm of the conditional query probability.
47    pub log_prob: f64,
48    /// Conditional query probability in linear space.
49    pub prob: f64,
50}
51
52#[derive(Debug, Clone)]
53/// Result of exact probabilistic evaluation.
54pub struct ExactResult {
55    /// Log probability of the compiled evidence.
56    pub log_z_e: f64,
57    /// Conditional probabilities for requested queries.
58    pub query_probs: Vec<QueryProbability>,
59}
60
61#[derive(Debug, Clone)]
62/// Exact probability and literal-log-weight gradients for one query.
63pub struct QueryGradients {
64    /// Ground query atom.
65    pub atom: GroundAtom,
66    /// Natural logarithm of the conditional query probability.
67    pub log_prob: f64,
68    /// Conditional query probability in linear space.
69    pub prob: f64,
70    /// Derivative with respect to each true literal log weight.
71    pub grad_true: Vec<f64>,
72    /// Derivative with respect to each false literal log weight.
73    pub grad_false: Vec<f64>,
74}
75
76#[derive(Debug, Clone)]
77/// Result of exact evaluation with per-query gradients.
78pub struct ExactResultWithGrads {
79    /// Log probability of the compiled evidence.
80    pub log_z_e: f64,
81    /// Query probabilities and their literal-log-weight gradients.
82    pub query_grads: Vec<QueryGradients>,
83}
84
85#[derive(Debug, Clone)]
86struct QuerySpec {
87    #[cfg(feature = "host-io")]
88    atom: GroundAtom,
89    var: Option<u32>,
90}
91
92fn neural_slot_count_u32(slot_count: usize) -> Result<u32> {
93    u32::try_from(slot_count).map_err(|_| {
94        XlogError::Compilation(
95            "Neural fast-path group slot count exceeds GPU u32 index space".to_string(),
96        )
97    })
98}
99
100fn checked_launch_grid_u32(context: &str, item_count: u32, block_size: u32) -> Result<u32> {
101    if block_size == 0 {
102        return Err(XlogError::Kernel(format!(
103            "{context} launch block size must be non-zero"
104        )));
105    }
106    if item_count == 0 {
107        return Ok(0);
108    }
109    item_count
110        .checked_add(block_size - 1)
111        .map(|rounded| rounded / block_size)
112        .ok_or_else(|| XlogError::Kernel(format!("{context} launch grid overflow")))
113}
114
115struct GpuExactState {
116    provider: Arc<CudaKernelProvider>,
117    cache: Mutex<GpuCircuitCache>,
118    handle: GpuCircuitCacheHandle,
119    #[cfg(feature = "host-io")]
120    circuit_generation: u64,
121    #[cfg(feature = "host-io")]
122    compilation_ledger: Arc<CircuitCompilationLedger>,
123    invalid_reason: OnceLock<String>,
124    /// Device-resident batched query-var metadata, keyed by the host vector of
125    /// CNF query vars. Lets a warm training loop reuse a single upload instead
126    /// of re-uploading (a tracked htod) on every batched force call.
127    query_var_batch_cache: Mutex<HashMap<Vec<u32>, Arc<TrackedCudaSlice<u32>>>>,
128}
129
130#[cfg(feature = "host-io")]
131static NEXT_EXACT_CIRCUIT_GENERATION: AtomicU64 = AtomicU64::new(1);
132
133/// Immutable identity of one successfully materialized exact GPU circuit.
134///
135/// The generation is process-local and allocated only after compilation or
136/// cache restoration has produced the handle stored by [`GpuExactState`].
137#[cfg(feature = "host-io")]
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub(crate) struct ExactCircuitWitness {
140    pub(crate) circuit_generation: u64,
141    pub(crate) compiler_invocations: u64,
142    pub(crate) materializations: u64,
143    pub(crate) disk_cache_restores: u64,
144    pub(crate) gpu_cache_hits: u64,
145    pub(crate) cache_slot: u32,
146}
147
148#[cfg(feature = "host-io")]
149fn next_exact_circuit_generation() -> Result<u64> {
150    NEXT_EXACT_CIRCUIT_GENERATION
151        .try_update(Ordering::Relaxed, Ordering::Relaxed, |generation| {
152            generation.checked_add(1)
153        })
154        .map_err(|_| {
155            XlogError::Compilation(
156                "Exact circuit compilation generation counter overflowed".to_string(),
157            )
158        })
159}
160
161/// GPU device selection and memory budget for probabilistic inference.
162///
163/// Use [`GpuConfig::default()`] and override individual fields as needed.
164#[derive(Debug, Clone, Copy)]
165#[non_exhaustive]
166pub struct GpuConfig {
167    /// CUDA device ordinal (0-based).
168    pub device_ordinal: usize,
169    /// Device memory budget in bytes (clamped to available memory at runtime).
170    pub memory_bytes: u64,
171    /// Host-side Decision-DNNF compiler decision-order hint: renumber leaf/choice
172    /// variables by descending structural fanout in the provenance DAG before CNF
173    /// encoding, steering the deterministic variable-id tie-breaks of the
174    /// (unchanged) GPU-native Decision-DNNF branching heuristic. Query probabilities
175    /// are unaffected; only compile-time search shape can differ.
176    pub decision_order_hint: bool,
177}
178
179impl Default for GpuConfig {
180    fn default() -> Self {
181        Self {
182            device_ordinal: 0,
183            memory_bytes: 32 * 1024 * 1024 * 1024, // 32 GB — clamped to available device memory by GpuMemoryManager at runtime.
184            decision_order_hint: false,
185        }
186    }
187}
188
189impl GpuExactState {
190    #[cfg(feature = "host-io")]
191    fn new(
192        provider: Arc<CudaKernelProvider>,
193        cache: GpuCircuitCache,
194        handle: GpuCircuitCacheHandle,
195        compilation_ledger: Arc<CircuitCompilationLedger>,
196    ) -> Result<Self> {
197        let circuit_generation = next_exact_circuit_generation()?;
198        Ok(Self {
199            provider,
200            cache: Mutex::new(cache),
201            handle,
202            circuit_generation,
203            compilation_ledger,
204            invalid_reason: OnceLock::new(),
205            query_var_batch_cache: Mutex::new(HashMap::new()),
206        })
207    }
208
209    #[cfg(not(feature = "host-io"))]
210    fn new(
211        provider: Arc<CudaKernelProvider>,
212        cache: GpuCircuitCache,
213        handle: GpuCircuitCacheHandle,
214    ) -> Self {
215        Self {
216            provider,
217            cache: Mutex::new(cache),
218            handle,
219            invalid_reason: OnceLock::new(),
220            query_var_batch_cache: Mutex::new(HashMap::new()),
221        }
222    }
223
224    fn provider(&self) -> &Arc<CudaKernelProvider> {
225        &self.provider
226    }
227
228    fn handle(&self) -> &GpuCircuitCacheHandle {
229        &self.handle
230    }
231
232    #[cfg(feature = "host-io")]
233    fn compilation_witness(&self) -> ExactCircuitWitness {
234        let ledger = self.compilation_ledger.snapshot();
235        ExactCircuitWitness {
236            circuit_generation: self.circuit_generation,
237            compiler_invocations: ledger.compiler_invocations,
238            materializations: ledger.materializations,
239            disk_cache_restores: ledger.disk_cache_restores,
240            gpu_cache_hits: ledger.gpu_cache_hits,
241            cache_slot: self.handle.slot_index(),
242        }
243    }
244
245    #[cfg(feature = "host-io")]
246    fn invalidate(&self, reason: String) {
247        let _ = self.invalid_reason.set(reason);
248    }
249
250    fn ensure_usable(&self) -> Result<()> {
251        if let Some(reason) = self.invalid_reason.get() {
252            return Err(XlogError::Execution(format!(
253                "Exact GPU circuit state is permanently invalid after a failed device rollback: {reason}"
254            )));
255        }
256        Ok(())
257    }
258
259    /// Device-resident batched query vars for `query_vars_host`, uploading once
260    /// and reusing the cached slice on repeat calls with the same vars. The
261    /// upload is a tracked htod; caching it keeps a warm training loop free of
262    /// per-step host transfers.
263    fn cached_query_var_batch(
264        &self,
265        query_vars_host: Vec<u32>,
266    ) -> Result<Arc<TrackedCudaSlice<u32>>> {
267        let mut cache = self
268            .query_var_batch_cache
269            .lock()
270            .unwrap_or_else(|poisoned| poisoned.into_inner());
271        if let Some(cached) = cache.get(&query_vars_host) {
272            return Ok(Arc::clone(cached));
273        }
274        let mut query_vars = self.provider.memory().alloc::<u32>(query_vars_host.len())?;
275        self.provider
276            .htod_sync_copy_into_tracked(&query_vars_host, &mut query_vars)
277            .map_err(|e| {
278                XlogError::Kernel(format!("Failed to upload batched query vars: {}", e))
279            })?;
280        let query_vars = Arc::new(query_vars);
281        cache.insert(query_vars_host, Arc::clone(&query_vars));
282        Ok(query_vars)
283    }
284}
285
286#[cfg(feature = "host-io")]
287struct GpuCountLiftQuery {
288    atom: GroundAtom,
289    target_count: u32,
290    leaf_count: u32,
291    leaf_probs: TrackedCudaSlice<f64>,
292}
293
294#[cfg(feature = "host-io")]
295struct GpuCountLiftState {
296    provider: Arc<CudaKernelProvider>,
297    queries: Vec<GpuCountLiftQuery>,
298}
299
300#[cfg(feature = "host-io")]
301impl GpuCountLiftState {
302    fn new(provider: Arc<CudaKernelProvider>, queries: Vec<GpuCountLiftQuery>) -> Self {
303        Self { provider, queries }
304    }
305
306    #[cfg(feature = "host-io")]
307    fn evaluate(&self) -> Result<ExactResult> {
308        let func = self
309            .provider
310            .device()
311            .inner()
312            .get_func(WEIGHTS_MODULE, weights_kernels::WEIGHTS_COUNT_LIFT_EXACT)
313            .ok_or_else(|| {
314                XlogError::Kernel("weights_count_lift_exact kernel not found".to_string())
315            })?;
316        let mut query_probs = Vec::with_capacity(self.queries.len());
317        for query in &self.queries {
318            let scratch_len = query
319                .target_count
320                .checked_add(1)
321                .ok_or_else(|| XlogError::Compilation("count-lift target overflow".to_string()))?;
322            let mut scratch = self.provider.memory().alloc::<f64>(scratch_len as usize)?;
323            let mut out = self.provider.memory().alloc::<f64>(1)?;
324            unsafe {
325                func.clone().launch(
326                    LaunchConfig {
327                        grid_dim: (1, 1, 1),
328                        block_dim: (1, 1, 1),
329                        shared_mem_bytes: 0,
330                    },
331                    (
332                        &query.leaf_probs,
333                        query.leaf_count,
334                        query.target_count,
335                        &mut scratch,
336                        &mut out,
337                    ),
338                )
339            }
340            .map_err(|e| XlogError::Kernel(format!("weights_count_lift_exact failed: {}", e)))?;
341            let mut host = vec![0.0f64; 1];
342            self.provider
343                .device()
344                .inner()
345                .dtoh_sync_copy_into(&out, &mut host)
346                .map_err(|e| XlogError::Kernel(format!("count-lift result dtoh failed: {}", e)))?;
347            let mut prob = host[0];
348            if (-1e-12..0.0).contains(&prob) || prob == -1e-12 {
349                prob = 0.0;
350            } else if prob > 1.0 && (1.0..=1.0 + 1e-12).contains(&prob) {
351                prob = 1.0;
352            }
353            if !prob.is_finite() || !(0.0..=1.0).contains(&prob) {
354                return Err(XlogError::Kernel(format!(
355                    "count-lift GPU evaluator returned invalid probability {}",
356                    prob
357                )));
358            }
359            let log_prob = if prob == 0.0 {
360                f64::NEG_INFINITY
361            } else {
362                prob.ln()
363            };
364            query_probs.push(QueryProbability {
365                atom: query.atom.clone(),
366                log_prob,
367                prob,
368            });
369        }
370        Ok(ExactResult {
371            log_z_e: 0.0,
372            query_probs,
373        })
374    }
375}
376
377/// What a CNF variable stands for, in the order of the gradient vectors.
378#[derive(Debug, Clone)]
379pub enum ProbVarInfo {
380    /// A plain probabilistic fact: one atom, one probability. `prob` is
381    /// exactly the Bernoulli weight `w_true` stored in the GPU weight table
382    /// for this variable, so `p*(1-p)` is the correct Jacobian for
383    /// `grad_true`/`grad_false` at this slot.
384    Fact {
385        /// Ground atom controlled by this Bernoulli variable.
386        atom: GroundAtom,
387        /// Bernoulli probability assigned to the true branch.
388        prob: f64,
389    },
390    /// One Bernoulli decision of an annotated disjunction's chain.
391    Choice {
392        /// Declared heads of the whole disjunction with their *marginal*
393        /// probabilities (context/display only — see `prob` below for the
394        /// Jacobian-correct parameter of this specific chain variable).
395        choices: Arc<[(GroundAtom, f64)]>,
396        /// Index of this chain variable's head within `choices`.
397        choice_index: usize,
398        /// The *conditional* Bernoulli parameter actually assigned to this
399        /// CNF variable's weight (`p_i / (1 - sum of earlier heads'
400        /// probabilities)`), i.e. the same value stored in
401        /// `provenance::Provenance::choice_probs` and used to build the GPU
402        /// weight table for this variable. `prob*(1-prob)` — using *this*
403        /// `prob`, not `choices[choice_index].1` — is the correct Jacobian
404        /// for `grad_true`/`grad_false` at this slot; the two are generally
405        /// different values.
406        prob: f64,
407    },
408    /// A variable introduced by compilation that is not a source of randomness.
409    Other,
410}
411
412#[cfg(feature = "host-io")]
413#[derive(Debug, Clone, Copy)]
414struct FactWeightChange {
415    entry_index: usize,
416    var: u32,
417    old_prob: f64,
418    new_prob: f64,
419    evidence: Option<bool>,
420}
421
422#[cfg(feature = "host-io")]
423fn fact_log_weights(prob: f64, evidence: Option<bool>) -> (f64, f64) {
424    let mut log_true = prob.ln();
425    let mut log_false = (1.0 - prob).ln();
426    match evidence {
427        Some(true) => log_false = f64::NEG_INFINITY,
428        Some(false) => log_true = f64::NEG_INFINITY,
429        None => {}
430    }
431    (log_true, log_false)
432}
433
434#[derive(Clone)]
435/// Reusable exact-inference program backed by a certified GPU circuit.
436pub struct ExactDdnnfProgram {
437    gpu: Option<Arc<GpuExactState>>,
438    #[cfg(feature = "host-io")]
439    count_lift_gpu: Option<Arc<GpuCountLiftState>>,
440    queries: Vec<QuerySpec>,
441    #[cfg(feature = "host-io")]
442    random_vars: Option<Arc<DeviceRandomVarList>>,
443    max_var: u32,
444    #[cfg(feature = "host-io")]
445    origin: ExactProgramOrigin,
446    /// Latest circuit compilation profile (populated on cache miss when profiling).
447    last_compile_profile: Option<CircuitCompileProfile>,
448    /// Sparse storage for what each CNF variable stands for: only variables that
449    /// were actually assigned to a probabilistic fact or annotated-disjunction
450    /// choice are present, as `(var, info)` pairs sorted by `var`. The sort is
451    /// by `var` (not construction order) so entries are laid out in the same
452    /// order `prob_var_map()` materializes them in, which makes the vector
453    /// itself directly inspectable/debuggable as a CNF-var-indexed sequence.
454    /// `var` may repeat (a leaf and a choice can be assigned the same CNF
455    /// variable in principle); when it does, the *last* matching entry wins
456    /// during materialization (see the ordering note on the `sort_by_key` call
457    /// in `compile_provenance_with_gpu`, which documents which entry that is).
458    /// CNF variables are 1-indexed. Call `prob_var_map()` to materialize the
459    /// dense, `grad_true`/`grad_false`-aligned view on demand. Populated only when
460    /// compiled with the "host-io" feature (empty otherwise); also empty when
461    /// compiled through the GPU count-lift fast path, since that path never
462    /// builds a CNF encoding (see `uses_gpu_native_count_lift()` and
463    /// `prob_var_map()` below — an empty map there does not mean the program
464    /// has no probabilistic facts).
465    prob_var_entries: Vec<(u32, ProbVarInfo)>,
466    /// Fixed evidence assignments keyed by CNF variable; mutable fact updates
467    /// consult this map to preserve the compile-time evidence mask.
468    #[cfg(feature = "host-io")]
469    fixed_evidence_by_var: BTreeMap<u32, bool>,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub(crate) enum ExactProgramOrigin {
474    Source,
475    Program,
476}
477
478impl ExactDdnnfProgram {
479    /// Parses source and compiles a certified exact program with default GPU settings.
480    pub fn compile_source(source: &str) -> Result<Self> {
481        let provenance = extract_from_source(source)?;
482        Self::compile_provenance_with_gpu(
483            provenance,
484            GpuConfig::default(),
485            ExactProgramOrigin::Source,
486        )
487    }
488
489    /// Parses source and compiles a certified exact program with explicit GPU settings.
490    pub fn compile_source_with_gpu(source: &str, config: GpuConfig) -> Result<Self> {
491        let provenance = extract_from_source(source)?;
492        Self::compile_provenance_with_gpu(provenance, config, ExactProgramOrigin::Source)
493    }
494
495    /// Compile an already parsed program with the requested GPU configuration.
496    ///
497    /// Imports must already be resolved and merged. This method does not load
498    /// unresolved `use` declarations from the filesystem.
499    pub fn compile_from_program(program: &Program, config: GpuConfig) -> Result<Self> {
500        let provenance = extract_from_program(program)?;
501        Self::compile_provenance_with_gpu(provenance, config, ExactProgramOrigin::Program)
502    }
503
504    #[cfg(feature = "host-io")]
505    pub(crate) fn origin(&self) -> ExactProgramOrigin {
506        self.origin
507    }
508
509    /// Returns whether evaluation uses the certified GPU circuit backend.
510    pub fn uses_gpu_production_backend(&self) -> bool {
511        self.gpu.is_some()
512    }
513
514    /// Get the latest circuit compilation profile (populated when XLOG_WARMUP_PROFILE=1).
515    pub fn last_compile_profile(&self) -> Option<&CircuitCompileProfile> {
516        self.last_compile_profile.as_ref()
517    }
518
519    /// Materializes a dense vector describing what each CNF variable stands for.
520    ///
521    /// The returned vector's length is the CNF encoder's variable *capacity*
522    /// (`3 * number of PIR nodes` at compile time, see `compilation/gpu_cnf.rs`)
523    /// — **not** the number of CNF variables actually in use, and not the
524    /// number of random variables in the program. Slot `v` describes CNF
525    /// variable `v` directly when `v` was assigned; slot `0` is always unused
526    /// padding (CNF variables are 1-indexed), and so is any other slot with no
527    /// variable assigned to it — those padding slots are indistinguishable
528    /// from `ProbVarInfo::Other`. Do not treat `len()` of the result as a
529    /// variable count or a random-variable count; use
530    /// [`Self::random_var_indices`] for that ([`Self::num_vars`] returns this
531    /// same capacity, not a count, so it is not a substitute here).
532    ///
533    /// What *is* guaranteed is alignment with `evaluate_gpu_with_grads`'s
534    /// `grad_true`/`grad_false` vectors, which are allocated with the same
535    /// capacity: `prob_var_map()[v]` and `grad_true[v]` name and value the
536    /// same variable `v`.
537    ///
538    /// Rebuilds the dense vector on every call from the sparse
539    /// `prob_var_entries` storage that actually lives for the lifetime of the
540    /// program.
541    ///
542    /// On the GPU count-lift fast path (count aggregates without evidence or
543    /// disjunctions — see [`Self::uses_gpu_native_count_lift`]), no CNF
544    /// encoding is ever built, so this returns an **empty** vector even for
545    /// programs that do have probabilistic facts. Callers that need to
546    /// enumerate a program's probabilistic facts must check
547    /// `uses_gpu_native_count_lift()` first and treat an empty map from that
548    /// path as "mapping unavailable", not as "no random variables".
549    pub fn prob_var_map(&self) -> Vec<ProbVarInfo> {
550        let capacity = if self.max_var == 0 {
551            0
552        } else {
553            self.max_var as usize + 1
554        };
555        let mut dense = vec![ProbVarInfo::Other; capacity];
556        for (var, info) in &self.prob_var_entries {
557            debug_assert!(
558                (*var as usize) < capacity,
559                "prob_var_entries contains CNF var {} but capacity is only {} \
560                 (max_var {}); entries must never exceed the encoder's own \
561                 variable capacity",
562                var,
563                capacity,
564                self.max_var
565            );
566            if let Some(slot) = dense.get_mut(*var as usize) {
567                *slot = info.clone();
568            }
569        }
570        dense
571    }
572
573    #[cfg(feature = "host-io")]
574    pub(crate) fn checked_prob_var_map(&self) -> Result<Vec<ProbVarInfo>> {
575        self.ensure_usable()?;
576        Ok(self.prob_var_map())
577    }
578
579    /// Atomically replace probabilities for independent probabilistic facts.
580    ///
581    /// The complete batch is validated before any device write. Annotated-
582    /// disjunction choices and compiler-introduced variables are deliberately
583    /// immutable through this surface because changing them requires additional
584    /// normalization or has no source probability at all.
585    #[cfg(feature = "host-io")]
586    pub(crate) fn set_fact_probabilities(&mut self, updates: &BTreeMap<u32, f64>) -> Result<()> {
587        self.set_fact_probabilities_with_device_failures(updates, None, None)
588    }
589
590    #[cfg(feature = "host-io")]
591    fn set_fact_probabilities_with_device_failures(
592        &mut self,
593        updates: &BTreeMap<u32, f64>,
594        fail_after_successful_writes: Option<usize>,
595        fail_after_successful_rollback_writes: Option<usize>,
596    ) -> Result<()> {
597        if self.count_lift_gpu.is_some() {
598            return Err(XlogError::UnsupportedEpistemicConstruct {
599                construct: "mutable exact fact probabilities".to_string(),
600                context: "GPU count-lift exact programs do not expose CNF fact variables"
601                    .to_string(),
602            });
603        }
604        let state = self.gpu_state()?;
605        state.ensure_usable()?;
606        let dense = self.prob_var_map();
607        let mut changes = Vec::with_capacity(updates.len());
608        for (&var, &new_prob) in updates {
609            if var == 0 {
610                return Err(XlogError::Compilation(
611                    "Cannot update CNF variable 0: exact variables are 1-indexed".to_string(),
612                ));
613            }
614            if var > self.max_var || var as usize >= dense.len() {
615                return Err(XlogError::Compilation(format!(
616                    "Cannot update CNF variable {var}: valid range is 1..={}",
617                    self.max_var
618                )));
619            }
620            if !new_prob.is_finite() || !(0.0..=1.0).contains(&new_prob) {
621                return Err(XlogError::Compilation(format!(
622                    "Probability for CNF variable {var} must be finite and within [0, 1], got {new_prob}"
623                )));
624            }
625            match &dense[var as usize] {
626                ProbVarInfo::Fact { prob, .. } => {
627                    let entry_index = self
628                        .prob_var_entries
629                        .iter()
630                        .rposition(|(entry_var, info)| {
631                            *entry_var == var && matches!(info, ProbVarInfo::Fact { .. })
632                        })
633                        .ok_or_else(|| {
634                            XlogError::Compilation(format!(
635                                "CNF variable {var} fact metadata is unavailable"
636                            ))
637                        })?;
638                    changes.push(FactWeightChange {
639                        entry_index,
640                        var,
641                        old_prob: *prob,
642                        new_prob,
643                        evidence: self.fixed_evidence_by_var.get(&var).copied(),
644                    });
645                }
646                ProbVarInfo::Choice { .. } => {
647                    return Err(XlogError::UnsupportedEpistemicConstruct {
648                        construct: "mutable exact fact probabilities".to_string(),
649                        context: format!(
650                            "CNF variable {var} is an annotated-disjunction choice; only independent probabilistic facts are mutable"
651                        ),
652                    });
653                }
654                ProbVarInfo::Other => {
655                    return Err(XlogError::UnsupportedEpistemicConstruct {
656                        construct: "mutable exact fact probabilities".to_string(),
657                        context: format!(
658                            "CNF variable {var} is compiler-introduced or unmapped; only independent probabilistic facts are mutable"
659                        ),
660                    });
661                }
662            }
663        }
664
665        if changes.is_empty() {
666            return Ok(());
667        }
668
669        let mut cache = state
670            .cache
671            .lock()
672            .unwrap_or_else(|poisoned| poisoned.into_inner());
673        let var_stride = cache.var_stride()? as usize;
674        let slot_start = state.handle().slot_index() as usize * var_stride;
675        let provider = state.provider.clone();
676        let mut successful_writes = 0usize;
677        let write_result: Result<()> = (|| {
678            for change in &changes {
679                let index = slot_start.checked_add(change.var as usize).ok_or_else(|| {
680                    XlogError::Compilation("fact weight index overflow".to_string())
681                })?;
682                let (new_log_true, new_log_false) =
683                    fact_log_weights(change.new_prob, change.evidence);
684                {
685                    let (log_true, _) = cache.var_log_weights_mut();
686                    let mut destination = log_true.slice_mut(index..index + 1);
687                    provider.htod_sync_copy_into_tracked(&[new_log_true], &mut destination)?;
688                }
689                successful_writes += 1;
690                if fail_after_successful_writes == Some(successful_writes) {
691                    return Err(XlogError::Kernel(
692                        "injected fact probability device-write failure".to_string(),
693                    ));
694                }
695                {
696                    let (_, log_false) = cache.var_log_weights_mut();
697                    let mut destination = log_false.slice_mut(index..index + 1);
698                    provider.htod_sync_copy_into_tracked(&[new_log_false], &mut destination)?;
699                }
700                successful_writes += 1;
701                if fail_after_successful_writes == Some(successful_writes) {
702                    return Err(XlogError::Kernel(
703                        "injected fact probability device-write failure".to_string(),
704                    ));
705                }
706            }
707            Ok(())
708        })();
709
710        if let Err(write_error) = write_result {
711            let mut successful_rollback_writes = 0usize;
712            let rollback_result: Result<()> = (|| {
713                for change in &changes {
714                    let index = slot_start.checked_add(change.var as usize).ok_or_else(|| {
715                        XlogError::Compilation("fact weight rollback index overflow".to_string())
716                    })?;
717                    let (old_log_true, old_log_false) =
718                        fact_log_weights(change.old_prob, change.evidence);
719                    {
720                        let (log_true, _) = cache.var_log_weights_mut();
721                        let mut destination = log_true.slice_mut(index..index + 1);
722                        provider.htod_sync_copy_into_tracked(&[old_log_true], &mut destination)?;
723                    }
724                    successful_rollback_writes += 1;
725                    if fail_after_successful_rollback_writes == Some(successful_rollback_writes) {
726                        return Err(XlogError::Kernel(
727                            "injected fact probability rollback failure".to_string(),
728                        ));
729                    }
730                    {
731                        let (_, log_false) = cache.var_log_weights_mut();
732                        let mut destination = log_false.slice_mut(index..index + 1);
733                        provider.htod_sync_copy_into_tracked(&[old_log_false], &mut destination)?;
734                    }
735                    successful_rollback_writes += 1;
736                    if fail_after_successful_rollback_writes == Some(successful_rollback_writes) {
737                        return Err(XlogError::Kernel(
738                            "injected fact probability rollback failure".to_string(),
739                        ));
740                    }
741                }
742                Ok(())
743            })();
744            if let Err(rollback_error) = rollback_result {
745                let combined = format!(
746                    "Fact probability device update failed ({write_error}); rollback also failed ({rollback_error})"
747                );
748                state.invalidate(combined.clone());
749                return Err(XlogError::Kernel(combined));
750            }
751            return Err(write_error);
752        }
753
754        for change in changes {
755            match &mut self.prob_var_entries[change.entry_index].1 {
756                ProbVarInfo::Fact { prob, .. } => *prob = change.new_prob,
757                _ => unreachable!("validated fact metadata changed while holding exclusive state"),
758            }
759        }
760        Ok(())
761    }
762
763    #[cfg(all(test, feature = "host-io"))]
764    pub(crate) fn set_fact_probabilities_with_device_failures_for_test(
765        &mut self,
766        updates: &BTreeMap<u32, f64>,
767        fail_after_successful_writes: Option<usize>,
768        fail_after_successful_rollback_writes: Option<usize>,
769    ) -> Result<()> {
770        self.set_fact_probabilities_with_device_failures(
771            updates,
772            fail_after_successful_writes,
773            fail_after_successful_rollback_writes,
774        )
775    }
776
777    #[cfg(feature = "host-io")]
778    pub(crate) fn ensure_usable(&self) -> Result<()> {
779        if let Some(state) = &self.gpu {
780            state.ensure_usable()?;
781        }
782        Ok(())
783    }
784
785    #[doc(hidden)]
786    #[cfg(feature = "host-io")]
787    /// Returns whether compilation selected the exact GPU aggregate-lifting backend.
788    pub fn uses_gpu_native_count_lift(&self) -> bool {
789        self.count_lift_gpu.is_some()
790    }
791
792    #[cfg(feature = "host-io")]
793    /// Evaluates all compiled queries conditioned on the program evidence.
794    pub fn evaluate(&self) -> Result<ExactResult> {
795        if let Some(count_lift_gpu) = &self.count_lift_gpu {
796            return count_lift_gpu.evaluate();
797        }
798
799        // `gpu` is `None` only when compilation found an empty PIR root set
800        // (no probabilistic leaves and no derivations reach any query), so
801        // every query atom is unprovable and P = 0 is the correct semantics —
802        // this is NOT a missing-GPU fallback; a real circuit with an
803        // unavailable GPU fails at compile time instead.
804        if self.gpu.is_none() {
805            let mut query_probs: Vec<QueryProbability> = Vec::with_capacity(self.queries.len());
806            for query in &self.queries {
807                query_probs.push(QueryProbability {
808                    atom: query.atom.clone(),
809                    log_prob: f64::NEG_INFINITY,
810                    prob: 0.0,
811                });
812            }
813            return Ok(ExactResult {
814                log_z_e: 0.0,
815                query_probs,
816            });
817        }
818
819        let log_z_e = self.eval_log_z_gpu(None)?;
820        if log_z_e.is_infinite() && log_z_e.is_sign_negative() {
821            return Err(XlogError::Execution(
822                "Exact inference error: evidence is inconsistent (P(E)=0)".to_string(),
823            ));
824        }
825
826        let mut query_probs: Vec<QueryProbability> = Vec::with_capacity(self.queries.len());
827        for query in &self.queries {
828            let (log_prob, prob) = match query.var {
829                None => (f64::NEG_INFINITY, 0.0),
830                Some(var) => {
831                    let log_z_eq = self.eval_log_z_gpu(Some(var))?;
832                    let log_prob = log_z_eq - log_z_e;
833                    let mut prob = if log_prob.is_infinite() && log_prob.is_sign_negative() {
834                        0.0
835                    } else {
836                        log_prob.exp()
837                    };
838                    if prob.is_nan() {
839                        return Err(XlogError::Execution(
840                            "Exact inference error: NaN probability encountered".to_string(),
841                        ));
842                    }
843                    prob = prob.clamp(0.0, 1.0);
844                    (log_prob, prob)
845                }
846            };
847
848            query_probs.push(QueryProbability {
849                atom: query.atom.clone(),
850                log_prob,
851                prob,
852            });
853        }
854
855        Ok(ExactResult {
856            log_z_e,
857            query_probs,
858        })
859    }
860
861    /// Returns the CNF encoder's variable *capacity* (`max_var + 1`), i.e. the
862    /// same quantity as `prob_var_map().len()` — **not** the number of CNF
863    /// variables actually assigned, and not the number of random variables in
864    /// the program (most CNF variables are auxiliary Tseitin variables with
865    /// no probabilistic meaning). Use [`Self::random_var_indices`] to count or
866    /// enumerate random variables instead.
867    pub fn num_vars(&self) -> usize {
868        if self.max_var == 0 {
869            0
870        } else {
871            (self.max_var as usize) + 1
872        }
873    }
874
875    /// Returns the indices of random (probabilistic) variables in order.
876    ///
877    /// Random variables are those with non-trivial weights (not (0.0, 0.0)).
878    /// These correspond to annotated disjunctions in the source program.
879    /// The order matches the order variables were assigned during CNF encoding.
880    #[cfg(feature = "host-io")]
881    pub fn random_var_indices(&self) -> Vec<u32> {
882        let Some(state) = self.gpu.as_ref() else {
883            return Vec::new();
884        };
885        let Some(random_vars) = self.random_vars.as_ref() else {
886            return Vec::new();
887        };
888        if random_vars.is_empty() {
889            return Vec::new();
890        }
891        let count = random_vars.count() as usize;
892        let mut host = vec![0u32; count];
893        let view = random_vars.list().slice(0..count);
894        if let Err(e) = state
895            .provider()
896            .device()
897            .inner()
898            .dtoh_sync_copy_into(&view, &mut host)
899        {
900            eprintln!("Failed to read random var list: {}", e);
901            return Vec::new();
902        }
903        host
904    }
905
906    /// CNF variable id for the `idx`-th query formula (DIMACS, 1-based), if present.
907    pub(crate) fn query_var(&self, idx: usize) -> Option<u32> {
908        self.queries.get(idx).and_then(|q| q.var)
909    }
910
911    /// GPU neural fast-path: compute NLL gradients w.r.t. probability tensors (no host reads).
912    ///
913    /// This implements the design in `docs/design/2026-01-22-gpu-native-compilation-design.md` §5.3:
914    /// - Fill AD conditional-chain log-weights from device-resident `p[label]`.
915    /// - Run XGCF forward+backward on GPU.
916    /// - Scatter gradients back into probability-space via the correct chain rule (uses both grad_true + grad_false).
917    ///
918    /// The output gradient buffers are updated in-place:
919    /// - Base run: `out = dlogZ_base/dp`
920    /// - Query-forced run: `out -= dlogZ_query/dp`
921    ///   Result: `out = dL/dp` for `L = -log P(query | evidence)` (NLL).
922    pub fn neural_backward_nll_buffers(
923        &self,
924        slots: &GpuWeightSlots,
925        query_idx: usize,
926        probs: &[CudaBuffer],
927        out_grads: &mut [CudaBuffer],
928        cfg: NeuralFastPathConfig,
929    ) -> Result<()> {
930        self.neural_backward_nll_buffers_inner(slots, query_idx, probs, out_grads, cfg, None, true)
931    }
932
933    /// Same as [`Self::neural_backward_nll_buffers`], but also returns the device-resident scalar NLL loss:
934    /// `L = -log P(query | evidence)`.
935    ///
936    /// The returned slice has length 1 and is written on GPU (no device->host reads).
937    pub fn neural_backward_nll_buffers_with_device_loss(
938        &self,
939        slots: &GpuWeightSlots,
940        query_idx: usize,
941        probs: &[CudaBuffer],
942        out_grads: &mut [CudaBuffer],
943        cfg: NeuralFastPathConfig,
944        expected_true: bool,
945    ) -> Result<TrackedCudaSlice<f64>> {
946        let state = self.gpu_state()?;
947        state.ensure_usable()?;
948        let mut loss = state.provider.memory().alloc::<f64>(1)?;
949        self.neural_backward_nll_buffers_inner(
950            slots,
951            query_idx,
952            probs,
953            out_grads,
954            cfg,
955            Some(&mut loss),
956            expected_true,
957        )?;
958        Ok(loss)
959    }
960
961    /// Batched variant of [`Self::neural_backward_nll_buffers_with_device_loss`].
962    ///
963    /// Computes NLL gradients for `batch` queries that share one compiled circuit
964    /// template and returns a device-resident vector of `batch` scalar losses.
965    ///
966    /// On circuits that require free-variable correction, this falls back to the
967    /// existing per-query path for correctness.
968    pub fn neural_backward_nll_buffers_batch_with_device_loss(
969        &self,
970        slots: &GpuWeightSlots,
971        query_indices: &[usize],
972        probs_batch: &[Vec<CudaBuffer>],
973        out_grads_batch: &mut [Vec<CudaBuffer>],
974        cfg: NeuralFastPathConfig,
975        expected_true: bool,
976    ) -> Result<TrackedCudaSlice<f64>> {
977        let batch = query_indices.len();
978        if batch == 0 {
979            return Err(XlogError::Execution(
980                "Neural fast-path batch error: empty query batch".to_string(),
981            ));
982        }
983        if probs_batch.len() != batch || out_grads_batch.len() != batch {
984            return Err(XlogError::Compilation(format!(
985                "Neural fast-path batch error: query/prob/grad batch mismatch ({}/{}/{})",
986                batch,
987                probs_batch.len(),
988                out_grads_batch.len()
989            )));
990        }
991
992        let state = self.gpu_state()?;
993        let batch_u32 = u32::try_from(batch).map_err(|_| {
994            XlogError::Compilation("Neural fast-path batch size exceeds u32".to_string())
995        })?;
996        let device = state.provider.device().inner();
997
998        // Fallback for circuits that currently require per-query free-var correction.
999        {
1000            let cache = state
1001                .cache
1002                .lock()
1003                .unwrap_or_else(|poisoned| poisoned.into_inner());
1004            if cache.has_any_free_var_mask() {
1005                drop(cache);
1006                let mut losses = state.provider.memory().alloc::<f64>(batch)?;
1007                for q in 0..batch {
1008                    let loss_q = self.neural_backward_nll_buffers_with_device_loss(
1009                        slots,
1010                        query_indices[q],
1011                        &probs_batch[q],
1012                        &mut out_grads_batch[q],
1013                        cfg,
1014                        expected_true,
1015                    )?;
1016                    let mut dst = losses.slice_mut(q..(q + 1));
1017                    device.dtod_copy(&loss_q, &mut dst).map_err(|e| {
1018                        XlogError::Kernel(format!(
1019                            "Failed to copy fallback batch loss to output: {}",
1020                            e
1021                        ))
1022                    })?;
1023                }
1024                return Ok(losses);
1025            }
1026        }
1027
1028        let fill = device
1029            .get_func(NEURAL_MODULE, neural_kernels::NEURAL_FILL_AD_CHAIN_F32)
1030            .ok_or_else(|| {
1031                XlogError::Kernel("neural_fill_ad_chain_f32 kernel not found".to_string())
1032            })?;
1033        let scatter = device
1034            .get_func(
1035                NEURAL_MODULE,
1036                neural_kernels::NEURAL_SCATTER_AD_CHAIN_GRADS_F32,
1037            )
1038            .ok_or_else(|| {
1039                XlogError::Kernel("neural_scatter_ad_chain_grads_f32 kernel not found".to_string())
1040            })?;
1041        let binary_f64 = device
1042            .get_func(ARITH_MODULE, arith_kernels::ARITH_BINARY_F64)
1043            .ok_or_else(|| XlogError::Kernel("arith_binary_f64 kernel not found".to_string()))?;
1044        let apply_query_false_batched = device
1045            .get_func(
1046                WEIGHTS_MODULE,
1047                weights_kernels::WEIGHTS_APPLY_QUERY_VARS_FALSE_BATCHED,
1048            )
1049            .ok_or_else(|| {
1050                XlogError::Kernel(
1051                    "weights_apply_query_vars_false_batched kernel not found".to_string(),
1052                )
1053            })?;
1054        let apply_query_true_batched = device
1055            .get_func(
1056                WEIGHTS_MODULE,
1057                weights_kernels::WEIGHTS_APPLY_QUERY_VARS_TRUE_BATCHED,
1058            )
1059            .ok_or_else(|| {
1060                XlogError::Kernel(
1061                    "weights_apply_query_vars_true_batched kernel not found".to_string(),
1062                )
1063            })?;
1064
1065        let mut cache = state
1066            .cache
1067            .lock()
1068            .unwrap_or_else(|poisoned| poisoned.into_inner());
1069        let var_stride = cache.var_stride()?;
1070        let var_stride_usize = var_stride as usize;
1071        let node_stride = cache.node_stride();
1072        let node_stride_usize = node_stride as usize;
1073
1074        let mut var_log_true_batch = state
1075            .provider
1076            .memory()
1077            .alloc::<f64>(batch * var_stride_usize)?;
1078        let mut var_log_false_batch = state
1079            .provider
1080            .memory()
1081            .alloc::<f64>(batch * var_stride_usize)?;
1082        cache.copy_slot_weights_to_batch(
1083            state.handle(),
1084            &mut var_log_true_batch,
1085            &mut var_log_false_batch,
1086            batch_u32,
1087        )?;
1088
1089        let mut values_batch = state
1090            .provider
1091            .memory()
1092            .alloc::<f64>(batch * node_stride_usize)?;
1093        let mut adj_batch = state
1094            .provider
1095            .memory()
1096            .alloc::<f64>(batch * node_stride_usize)?;
1097        let mut grad_true_batch = state
1098            .provider
1099            .memory()
1100            .alloc::<f64>(batch * var_stride_usize)?;
1101        let mut grad_false_batch = state
1102            .provider
1103            .memory()
1104            .alloc::<f64>(batch * var_stride_usize)?;
1105        let mut base_roots = state.provider.memory().alloc::<f64>(batch)?;
1106        let mut query_roots = state.provider.memory().alloc::<f64>(batch)?;
1107        let mut losses = state.provider.memory().alloc::<f64>(batch)?;
1108        let mut force_saved = state.provider.memory().alloc::<f64>(batch)?;
1109
1110        let mut query_vars_host: Vec<u32> = Vec::with_capacity(batch);
1111
1112        // Fill per-query var weight rows from device-resident probability tensors.
1113        for q in 0..batch {
1114            if probs_batch[q].len() != out_grads_batch[q].len() {
1115                return Err(XlogError::Compilation(format!(
1116                    "Neural fast-path batch error: probs len {} != out_grads len {} for query {}",
1117                    probs_batch[q].len(),
1118                    out_grads_batch[q].len(),
1119                    q
1120                )));
1121            }
1122            if probs_batch[q].len() != slots.num_groups_usize() {
1123                return Err(XlogError::Compilation(format!(
1124                    "Neural fast-path batch error: expected {} groups, got {} for query {}",
1125                    slots.num_groups_usize(),
1126                    probs_batch[q].len(),
1127                    q
1128                )));
1129            }
1130
1131            let query_var = self.query_var(query_indices[q]).ok_or_else(|| {
1132                XlogError::Execution(format!(
1133                    "Neural fast-path batch error: query {} has no CNF var",
1134                    query_indices[q]
1135                ))
1136            })?;
1137            if query_var == 0 || query_var > self.max_var {
1138                return Err(XlogError::Compilation(format!(
1139                    "Neural fast-path batch error: query var {} out of bounds (max_var={})",
1140                    query_var, self.max_var
1141                )));
1142            }
1143            query_vars_host.push(query_var);
1144
1145            let row_start = q
1146                .checked_mul(var_stride_usize)
1147                .ok_or_else(|| XlogError::Compilation("Neural batch row overflow".to_string()))?;
1148            let row_end = row_start + var_stride_usize;
1149
1150            for (g, prob_buf) in probs_batch[q].iter().enumerate() {
1151                if prob_buf.arity() != 1 {
1152                    return Err(XlogError::Compilation(
1153                        "Neural fast-path expects 1-column prob buffers".to_string(),
1154                    ));
1155                }
1156                let ty = prob_buf.schema().column_type(0).ok_or_else(|| {
1157                    XlogError::Compilation("Missing prob buffer schema".to_string())
1158                })?;
1159                if ty != ScalarType::F32 {
1160                    return Err(XlogError::Compilation(format!(
1161                        "Neural fast-path expects prob dtype F32, got {:?}",
1162                        ty
1163                    )));
1164                }
1165
1166                let slot_vars = slots.group_slot_cnf_var(g)?;
1167                let labels = neural_slot_count_u32(slot_vars.len())?;
1168                if prob_buf.num_rows() != labels as u64 {
1169                    return Err(XlogError::Compilation(format!(
1170                        "Neural fast-path prob rows {} != labels {}",
1171                        prob_buf.num_rows(),
1172                        labels
1173                    )));
1174                }
1175                if out_grads_batch[q][g].num_rows() != labels as u64 {
1176                    return Err(XlogError::Compilation(format!(
1177                        "Neural fast-path grad rows {} != labels {}",
1178                        out_grads_batch[q][g].num_rows(),
1179                        labels
1180                    )));
1181                }
1182
1183                let prob_col = prob_buf.column(0).ok_or_else(|| {
1184                    XlogError::Compilation("Neural fast-path missing prob column".to_string())
1185                })?;
1186                let mut q_true = var_log_true_batch.slice_mut(row_start..row_end);
1187                let mut q_false = var_log_false_batch.slice_mut(row_start..row_end);
1188
1189                // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1190                unsafe {
1191                    fill.clone().launch(
1192                        LaunchConfig {
1193                            grid_dim: (1, 1, 1),
1194                            block_dim: (1, 1, 1),
1195                            shared_mem_bytes: 0,
1196                        },
1197                        (
1198                            prob_col,
1199                            labels,
1200                            &slot_vars,
1201                            cfg.eps,
1202                            cfg.min_p,
1203                            &mut q_true,
1204                            &mut q_false,
1205                        ),
1206                    )
1207                }
1208                .map_err(|e| {
1209                    XlogError::Kernel(format!("neural_fill_ad_chain_f32 failed: {}", e))
1210                })?;
1211            }
1212        }
1213
1214        // Base pass (all queries): grads = dlogZ_base/dp, roots = logZ_base.
1215        cache.eval_grads_inplace_fused_batched(
1216            state.handle(),
1217            &var_log_true_batch,
1218            &var_log_false_batch,
1219            &mut values_batch,
1220            &mut adj_batch,
1221            &mut grad_true_batch,
1222            &mut grad_false_batch,
1223            batch_u32,
1224        )?;
1225        cache.copy_root_batched_from_values(
1226            state.handle(),
1227            &values_batch,
1228            &mut base_roots,
1229            batch_u32,
1230        )?;
1231
1232        // Scatter base gradients into output buffers.
1233        for q in 0..batch {
1234            let row_start = q
1235                .checked_mul(var_stride_usize)
1236                .ok_or_else(|| XlogError::Compilation("Neural batch row overflow".to_string()))?;
1237            let row_end = row_start + var_stride_usize;
1238            let q_grad_true = grad_true_batch.slice(row_start..row_end);
1239            let q_grad_false = grad_false_batch.slice(row_start..row_end);
1240
1241            for (g, prob_buf) in probs_batch[q].iter().enumerate() {
1242                let slot_vars = slots.group_slot_cnf_var(g)?;
1243                let labels = neural_slot_count_u32(slot_vars.len())?;
1244                let prob_col = prob_buf.column(0).ok_or_else(|| {
1245                    XlogError::Compilation("Neural fast-path missing prob column".to_string())
1246                })?;
1247                let out_col = out_grads_batch[q][g]
1248                    .columns_mut()
1249                    .get_mut(0)
1250                    .ok_or_else(|| XlogError::Compilation("Missing grad column".to_string()))?;
1251
1252                let shared_bytes: u32 = 3u64
1253                    .checked_mul(labels as u64)
1254                    .and_then(|n| n.checked_mul(std::mem::size_of::<f64>() as u64))
1255                    .and_then(|n| u32::try_from(n).ok())
1256                    .ok_or_else(|| {
1257                        XlogError::Kernel("Neural scatter shared memory overflow".to_string())
1258                    })?;
1259
1260                // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1261                unsafe {
1262                    scatter.clone().launch(
1263                        LaunchConfig {
1264                            grid_dim: (1, 1, 1),
1265                            block_dim: (1, 1, 1),
1266                            shared_mem_bytes: shared_bytes,
1267                        },
1268                        (
1269                            prob_col,
1270                            labels,
1271                            &slot_vars,
1272                            cfg.eps,
1273                            cfg.min_p,
1274                            &q_grad_true,
1275                            &q_grad_false,
1276                            0u8,
1277                            out_col,
1278                        ),
1279                    )
1280                }
1281                .map_err(|e| XlogError::Kernel(format!("neural_scatter (base) failed: {}", e)))?;
1282            }
1283        }
1284
1285        // Reuse the device-resident query-var batch (uploaded once and cached),
1286        // so a warm training loop performs no per-step tracked host transfer here.
1287        let query_vars = state.cached_query_var_batch(query_vars_host)?;
1288        let force_grid = checked_launch_grid_u32("gpu exact batched query force", batch_u32, 256)?;
1289        if force_grid != 0 {
1290            if expected_true {
1291                // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1292                unsafe {
1293                    apply_query_false_batched.clone().launch(
1294                        LaunchConfig {
1295                            grid_dim: (force_grid, 1, 1),
1296                            block_dim: (256, 1, 1),
1297                            shared_mem_bytes: 0,
1298                        },
1299                        (
1300                            query_vars.as_ref(),
1301                            batch_u32,
1302                            self.max_var,
1303                            var_stride,
1304                            &mut var_log_false_batch,
1305                            &mut force_saved,
1306                        ),
1307                    )
1308                }
1309                .map_err(|e| {
1310                    XlogError::Kernel(format!(
1311                        "weights_apply_query_vars_false_batched failed: {}",
1312                        e
1313                    ))
1314                })?;
1315            } else {
1316                // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1317                unsafe {
1318                    apply_query_true_batched.clone().launch(
1319                        LaunchConfig {
1320                            grid_dim: (force_grid, 1, 1),
1321                            block_dim: (256, 1, 1),
1322                            shared_mem_bytes: 0,
1323                        },
1324                        (
1325                            query_vars.as_ref(),
1326                            batch_u32,
1327                            self.max_var,
1328                            var_stride,
1329                            &mut var_log_true_batch,
1330                            &mut force_saved,
1331                        ),
1332                    )
1333                }
1334                .map_err(|e| {
1335                    XlogError::Kernel(format!(
1336                        "weights_apply_query_vars_true_batched failed: {}",
1337                        e
1338                    ))
1339                })?;
1340            }
1341        }
1342
1343        // Query-forced pass (all queries): grads = dlogZ_query/dp, roots = logZ_query.
1344        cache.eval_grads_inplace_fused_batched(
1345            state.handle(),
1346            &var_log_true_batch,
1347            &var_log_false_batch,
1348            &mut values_batch,
1349            &mut adj_batch,
1350            &mut grad_true_batch,
1351            &mut grad_false_batch,
1352            batch_u32,
1353        )?;
1354        cache.copy_root_batched_from_values(
1355            state.handle(),
1356            &values_batch,
1357            &mut query_roots,
1358            batch_u32,
1359        )?;
1360
1361        let loss_grid = checked_launch_grid_u32("gpu exact batched query loss", batch_u32, 256)?;
1362        if loss_grid != 0 {
1363            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1364            unsafe {
1365                binary_f64.clone().launch(
1366                    LaunchConfig {
1367                        grid_dim: (loss_grid, 1, 1),
1368                        block_dim: (256, 1, 1),
1369                        shared_mem_bytes: 0,
1370                    },
1371                    (&base_roots, &query_roots, batch_u32, 1u8, &mut losses),
1372                )
1373            }
1374            .map_err(|e| XlogError::Kernel(format!("Failed to compute batched NLL loss: {}", e)))?;
1375        }
1376
1377        // Scatter query gradients with subtract mode.
1378        for q in 0..batch {
1379            let row_start = q
1380                .checked_mul(var_stride_usize)
1381                .ok_or_else(|| XlogError::Compilation("Neural batch row overflow".to_string()))?;
1382            let row_end = row_start + var_stride_usize;
1383            let q_grad_true = grad_true_batch.slice(row_start..row_end);
1384            let q_grad_false = grad_false_batch.slice(row_start..row_end);
1385
1386            for (g, prob_buf) in probs_batch[q].iter().enumerate() {
1387                let slot_vars = slots.group_slot_cnf_var(g)?;
1388                let labels = neural_slot_count_u32(slot_vars.len())?;
1389                let prob_col = prob_buf.column(0).ok_or_else(|| {
1390                    XlogError::Compilation("Neural fast-path missing prob column".to_string())
1391                })?;
1392                let out_col = out_grads_batch[q][g]
1393                    .columns_mut()
1394                    .get_mut(0)
1395                    .ok_or_else(|| XlogError::Compilation("Missing grad column".to_string()))?;
1396
1397                let shared_bytes: u32 = 3u64
1398                    .checked_mul(labels as u64)
1399                    .and_then(|n| n.checked_mul(std::mem::size_of::<f64>() as u64))
1400                    .and_then(|n| u32::try_from(n).ok())
1401                    .ok_or_else(|| {
1402                        XlogError::Kernel("Neural scatter shared memory overflow".to_string())
1403                    })?;
1404
1405                // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1406                unsafe {
1407                    scatter.clone().launch(
1408                        LaunchConfig {
1409                            grid_dim: (1, 1, 1),
1410                            block_dim: (1, 1, 1),
1411                            shared_mem_bytes: shared_bytes,
1412                        },
1413                        (
1414                            prob_col,
1415                            labels,
1416                            &slot_vars,
1417                            cfg.eps,
1418                            cfg.min_p,
1419                            &q_grad_true,
1420                            &q_grad_false,
1421                            1u8,
1422                            out_col,
1423                        ),
1424                    )
1425                }
1426                .map_err(|e| XlogError::Kernel(format!("neural_scatter (query) failed: {}", e)))?;
1427            }
1428        }
1429
1430        Ok(losses)
1431    }
1432
1433    #[expect(
1434        clippy::too_many_arguments,
1435        reason = "neural backpropagation keeps the weight slots, query, probability and gradient buffers, execution config, optional loss, and target label explicit"
1436    )]
1437    fn neural_backward_nll_buffers_inner(
1438        &self,
1439        slots: &GpuWeightSlots,
1440        query_idx: usize,
1441        probs: &[CudaBuffer],
1442        out_grads: &mut [CudaBuffer],
1443        cfg: NeuralFastPathConfig,
1444        out_loss: Option<&mut TrackedCudaSlice<f64>>,
1445        expected_true: bool,
1446    ) -> Result<()> {
1447        if self.gpu.is_none() {
1448            return Err(XlogError::Execution(
1449                "Neural fast-path error: program has no compiled circuit".to_string(),
1450            ));
1451        }
1452
1453        let query_var = self.query_var(query_idx).ok_or_else(|| {
1454            XlogError::Execution(format!(
1455                "Neural fast-path error: query {} has no CNF var",
1456                query_idx
1457            ))
1458        })?;
1459
1460        if probs.len() != out_grads.len() {
1461            return Err(XlogError::Compilation(format!(
1462                "Neural fast-path error: probs len {} != out_grads len {}",
1463                probs.len(),
1464                out_grads.len()
1465            )));
1466        }
1467        if probs.len() != slots.num_groups_usize() {
1468            return Err(XlogError::Compilation(format!(
1469                "Neural fast-path error: expected {} groups, got {}",
1470                slots.num_groups_usize(),
1471                probs.len()
1472            )));
1473        }
1474
1475        let state = self.gpu_state()?;
1476        let device = state.provider.device().inner();
1477
1478        let fill = device
1479            .get_func(NEURAL_MODULE, neural_kernels::NEURAL_FILL_AD_CHAIN_F32)
1480            .ok_or_else(|| {
1481                XlogError::Kernel("neural_fill_ad_chain_f32 kernel not found".to_string())
1482            })?;
1483        let scatter = device
1484            .get_func(
1485                NEURAL_MODULE,
1486                neural_kernels::NEURAL_SCATTER_AD_CHAIN_GRADS_F32,
1487            )
1488            .ok_or_else(|| {
1489                XlogError::Kernel("neural_scatter_ad_chain_grads_f32 kernel not found".to_string())
1490            })?;
1491        let binary_f64 = device
1492            .get_func(ARITH_MODULE, arith_kernels::ARITH_BINARY_F64)
1493            .ok_or_else(|| XlogError::Kernel("arith_binary_f64 kernel not found".to_string()))?;
1494
1495        let mut cache = state
1496            .cache
1497            .lock()
1498            .unwrap_or_else(|poisoned| poisoned.into_inner());
1499
1500        let root_idx = state.handle().root() as usize;
1501
1502        // If the caller requested the scalar loss, keep the base logZ on device so we can compute
1503        // loss = logZ_base - logZ_query without any host reads.
1504        let mut base_log_z: Option<TrackedCudaSlice<f64>> = if out_loss.is_some() {
1505            Some(state.provider.memory().alloc::<f64>(1)?)
1506        } else {
1507            None
1508        };
1509
1510        // 1) Update AD chain weights from device-resident p[label].
1511        for (g, prob_buf) in probs.iter().enumerate() {
1512            if prob_buf.arity() != 1 {
1513                return Err(XlogError::Compilation(
1514                    "Neural fast-path expects 1-column prob buffers".to_string(),
1515                ));
1516            }
1517            let ty = prob_buf
1518                .schema()
1519                .column_type(0)
1520                .ok_or_else(|| XlogError::Compilation("Missing prob buffer schema".to_string()))?;
1521            if ty != ScalarType::F32 {
1522                return Err(XlogError::Compilation(format!(
1523                    "Neural fast-path expects prob dtype F32, got {:?}",
1524                    ty
1525                )));
1526            }
1527
1528            let slot_vars = slots.group_slot_cnf_var(g)?;
1529            let labels = neural_slot_count_u32(slot_vars.len())?;
1530
1531            if prob_buf.num_rows() != labels as u64 {
1532                return Err(XlogError::Compilation(format!(
1533                    "Neural fast-path prob rows {} != labels {}",
1534                    prob_buf.num_rows(),
1535                    labels
1536                )));
1537            }
1538
1539            let prob_col = prob_buf.column(0).ok_or_else(|| {
1540                XlogError::Compilation("Neural fast-path missing prob column".to_string())
1541            })?;
1542
1543            let (var_log_true, var_log_false) = cache.var_log_weights_mut();
1544
1545            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1546            unsafe {
1547                fill.clone().launch(
1548                    LaunchConfig {
1549                        grid_dim: (1, 1, 1),
1550                        block_dim: (1, 1, 1),
1551                        shared_mem_bytes: 0,
1552                    },
1553                    (
1554                        prob_col,
1555                        labels,
1556                        &slot_vars,
1557                        cfg.eps,
1558                        cfg.min_p,
1559                        var_log_true,
1560                        var_log_false,
1561                    ),
1562                )
1563            }
1564            .map_err(|e| XlogError::Kernel(format!("neural_fill_ad_chain_f32 failed: {}", e)))?;
1565        }
1566
1567        // 2) Base run: out = dlogZ_base/dp
1568        cache.eval_grads_inplace_fused(state.handle())?;
1569        if let Some(base) = base_log_z.as_mut() {
1570            let root_view = cache.values().slice(root_idx..(root_idx + 1));
1571            device.dtod_copy(&root_view, base).map_err(|e| {
1572                XlogError::Kernel(format!("Failed to copy base logZ on GPU: {}", e))
1573            })?;
1574        }
1575        for (g, prob_buf) in probs.iter().enumerate() {
1576            let slot_vars = slots.group_slot_cnf_var(g)?;
1577            let labels = neural_slot_count_u32(slot_vars.len())?;
1578
1579            let out_buf = out_grads.get_mut(g).ok_or_else(|| {
1580                XlogError::Compilation("Neural fast-path missing output grad buffer".to_string())
1581            })?;
1582            if out_buf.arity() != 1 {
1583                return Err(XlogError::Compilation(
1584                    "Neural fast-path expects 1-column grad buffers".to_string(),
1585                ));
1586            }
1587            let out_ty = out_buf
1588                .schema()
1589                .column_type(0)
1590                .ok_or_else(|| XlogError::Compilation("Missing grad buffer schema".to_string()))?;
1591            if out_ty != ScalarType::F32 {
1592                return Err(XlogError::Compilation(format!(
1593                    "Neural fast-path expects grad dtype F32, got {:?}",
1594                    out_ty
1595                )));
1596            }
1597            if out_buf.num_rows() != labels as u64 {
1598                return Err(XlogError::Compilation(format!(
1599                    "Neural fast-path grad rows {} != labels {}",
1600                    out_buf.num_rows(),
1601                    labels
1602                )));
1603            }
1604
1605            let prob_col = prob_buf.column(0).ok_or_else(|| {
1606                XlogError::Compilation("Neural fast-path missing prob column".to_string())
1607            })?;
1608            let out_col = out_buf
1609                .columns_mut()
1610                .get_mut(0)
1611                .ok_or_else(|| XlogError::Compilation("Missing grad column".to_string()))?;
1612
1613            let shared_bytes: u32 = 3u64
1614                .checked_mul(labels as u64)
1615                .and_then(|n| n.checked_mul(std::mem::size_of::<f64>() as u64))
1616                .and_then(|n| u32::try_from(n).ok())
1617                .ok_or_else(|| {
1618                    XlogError::Kernel("Neural scatter shared memory overflow".to_string())
1619                })?;
1620
1621            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1622            unsafe {
1623                scatter.clone().launch(
1624                    LaunchConfig {
1625                        grid_dim: (1, 1, 1),
1626                        block_dim: (1, 1, 1),
1627                        shared_mem_bytes: shared_bytes,
1628                    },
1629                    (
1630                        prob_col,
1631                        labels,
1632                        &slot_vars,
1633                        cfg.eps,
1634                        cfg.min_p,
1635                        cache.grad_true(),
1636                        cache.grad_false(),
1637                        0u8,
1638                        out_col,
1639                    ),
1640                )
1641            }
1642            .map_err(|e| XlogError::Kernel(format!("neural_scatter (base) failed: {}", e)))?;
1643        }
1644
1645        // 3) Query run: out -= dlogZ_query/dp
1646        if query_var == 0 || query_var > self.max_var {
1647            return Err(XlogError::Compilation(format!(
1648                "Neural fast-path error: query var {} out of bounds (max_var={})",
1649                query_var, self.max_var
1650            )));
1651        }
1652
1653        let mut restore = state.provider.memory().alloc::<f64>(1)?;
1654        if expected_true {
1655            {
1656                let (_, var_log_false) = cache.var_log_weights_mut();
1657                force_query_var_false(state.provider(), var_log_false, query_var, &mut restore)?;
1658            }
1659        } else {
1660            {
1661                let (var_log_true, _) = cache.var_log_weights_mut();
1662                force_query_var_true(state.provider(), var_log_true, query_var, &mut restore)?;
1663            }
1664        }
1665
1666        cache.eval_grads_inplace_fused(state.handle())?;
1667        if let Some(out) = out_loss {
1668            let base = base_log_z
1669                .as_ref()
1670                .expect("base_log_z allocated when out_loss requested");
1671            let root_view = cache.values().slice(root_idx..(root_idx + 1));
1672            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1673            unsafe {
1674                binary_f64.clone().launch(
1675                    LaunchConfig {
1676                        grid_dim: (1, 1, 1),
1677                        block_dim: (1, 1, 1),
1678                        shared_mem_bytes: 0,
1679                    },
1680                    (base, &root_view, 1u32, 1u8, out),
1681                )
1682            }
1683            .map_err(|e| XlogError::Kernel(format!("Failed to compute NLL loss on GPU: {}", e)))?;
1684        }
1685        for (g, prob_buf) in probs.iter().enumerate() {
1686            let slot_vars = slots.group_slot_cnf_var(g)?;
1687            let labels = neural_slot_count_u32(slot_vars.len())?;
1688
1689            let prob_col = prob_buf.column(0).ok_or_else(|| {
1690                XlogError::Compilation("Neural fast-path missing prob column".to_string())
1691            })?;
1692            let out_col = out_grads[g]
1693                .columns_mut()
1694                .get_mut(0)
1695                .ok_or_else(|| XlogError::Compilation("Missing grad column".to_string()))?;
1696
1697            let shared_bytes: u32 = 3u64
1698                .checked_mul(labels as u64)
1699                .and_then(|n| n.checked_mul(std::mem::size_of::<f64>() as u64))
1700                .and_then(|n| u32::try_from(n).ok())
1701                .ok_or_else(|| {
1702                    XlogError::Kernel("Neural scatter shared memory overflow".to_string())
1703                })?;
1704
1705            // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
1706            unsafe {
1707                scatter.clone().launch(
1708                    LaunchConfig {
1709                        grid_dim: (1, 1, 1),
1710                        block_dim: (1, 1, 1),
1711                        shared_mem_bytes: shared_bytes,
1712                    },
1713                    (
1714                        prob_col,
1715                        labels,
1716                        &slot_vars,
1717                        cfg.eps,
1718                        cfg.min_p,
1719                        cache.grad_true(),
1720                        cache.grad_false(),
1721                        1u8,
1722                        out_col,
1723                    ),
1724                )
1725            }
1726            .map_err(|e| XlogError::Kernel(format!("neural_scatter (query) failed: {}", e)))?;
1727        }
1728        if expected_true {
1729            {
1730                let (_, var_log_false) = cache.var_log_weights_mut();
1731                restore_query_var_false(state.provider(), var_log_false, query_var, &restore)?;
1732            }
1733        } else {
1734            {
1735                let (var_log_true, _) = cache.var_log_weights_mut();
1736                restore_query_var_true(state.provider(), var_log_true, query_var, &restore)?;
1737            }
1738        }
1739
1740        Ok(())
1741    }
1742
1743    #[cfg(feature = "host-io")]
1744    /// Evaluates all queries and returns literal-log-weight gradients.
1745    pub fn evaluate_gpu_with_grads(&self) -> Result<ExactResultWithGrads> {
1746        if self.gpu.is_none() {
1747            if self.count_lift_gpu.is_some() {
1748                return Err(XlogError::UnsupportedEpistemicConstruct {
1749                    construct: "GPU exact gradient evaluation".to_string(),
1750                    context: "GPU count-lift exact backend does not expose gradient evaluation; \
1751                              gradient production paths require a compiled GPU-native Decision-DNNF exact backend"
1752                        .to_string(),
1753                });
1754            }
1755            return Ok(ExactResultWithGrads {
1756                log_z_e: 0.0,
1757                query_grads: Vec::new(),
1758            });
1759        }
1760        self.ensure_usable()?;
1761
1762        let weights_len = if self.max_var == 0 {
1763            0
1764        } else {
1765            (self.max_var as usize) + 1
1766        };
1767
1768        let (log_z_e, grad_true_e, grad_false_e) = self.eval_log_z_and_grads_gpu_cached(None)?;
1769
1770        if log_z_e.is_infinite() && log_z_e.is_sign_negative() {
1771            return Err(XlogError::Execution(
1772                "Exact inference error: evidence is inconsistent (P(E)=0)".to_string(),
1773            ));
1774        }
1775
1776        let mut query_grads: Vec<QueryGradients> = Vec::with_capacity(self.queries.len());
1777
1778        for query in &self.queries {
1779            let Some(var) = query.var else {
1780                query_grads.push(QueryGradients {
1781                    atom: query.atom.clone(),
1782                    log_prob: f64::NEG_INFINITY,
1783                    prob: 0.0,
1784                    grad_true: vec![0.0; weights_len],
1785                    grad_false: vec![0.0; weights_len],
1786                });
1787                continue;
1788            };
1789
1790            let idx = var as usize;
1791            if idx >= weights_len {
1792                return Err(XlogError::Compilation(format!(
1793                    "Exact inference error: query var {} out of bounds (len={})",
1794                    var, weights_len
1795                )));
1796            }
1797
1798            let (log_z_eq, grad_true_eq, grad_false_eq) =
1799                self.eval_log_z_and_grads_gpu_cached(Some(var))?;
1800
1801            let log_prob = log_z_eq - log_z_e;
1802            let mut prob = if log_prob.is_infinite() && log_prob.is_sign_negative() {
1803                0.0
1804            } else {
1805                log_prob.exp()
1806            };
1807            if prob.is_nan() {
1808                return Err(XlogError::Execution(
1809                    "Exact inference error: NaN probability encountered".to_string(),
1810                ));
1811            }
1812            prob = prob.clamp(0.0, 1.0);
1813
1814            if grad_true_eq.len() != grad_true_e.len() || grad_false_eq.len() != grad_false_e.len()
1815            {
1816                return Err(XlogError::Execution(
1817                    "Exact inference error: gradient length mismatch".to_string(),
1818                ));
1819            }
1820
1821            let mut grad_true: Vec<f64> = grad_true_eq;
1822            let mut grad_false: Vec<f64> = grad_false_eq;
1823            for i in 0..grad_true.len() {
1824                grad_true[i] -= grad_true_e[i];
1825                grad_false[i] -= grad_false_e[i];
1826            }
1827
1828            query_grads.push(QueryGradients {
1829                atom: query.atom.clone(),
1830                log_prob,
1831                prob,
1832                grad_true,
1833                grad_false,
1834            });
1835        }
1836
1837        Ok(ExactResultWithGrads {
1838            log_z_e,
1839            query_grads,
1840        })
1841    }
1842
1843    fn compile_provenance_with_gpu(
1844        provenance: Provenance,
1845        config: GpuConfig,
1846        _origin: ExactProgramOrigin,
1847    ) -> Result<Self> {
1848        if config.memory_bytes == 0 {
1849            return Err(XlogError::Kernel(
1850                "GPU memory budget must be non-zero".to_string(),
1851            ));
1852        }
1853
1854        let provenance = if config.decision_order_hint {
1855            crate::decision_order::apply_decision_order_hint(provenance)
1856        } else {
1857            provenance
1858        };
1859
1860        let mut roots_set: HashSet<crate::pir::PirNodeId> = HashSet::new();
1861
1862        let mut evidence_formulas: Vec<(crate::pir::PirNodeId, bool, GroundAtom)> = Vec::new();
1863        for (atom, value) in validated_evidence_entries(&provenance)? {
1864            let formula = provenance.query_formula(&atom.predicate, &atom.args);
1865            match formula {
1866                Some(id) => {
1867                    roots_set.insert(id);
1868                    evidence_formulas.push((id, value, atom.clone()));
1869                }
1870                None => {
1871                    if value {
1872                        return Err(XlogError::Execution(format!(
1873                            "Exact inference error: evidence atom is never derivable: {}",
1874                            display_atom(atom)
1875                        )));
1876                    }
1877                }
1878            }
1879        }
1880
1881        let mut queries: Vec<QuerySpec> = Vec::new();
1882        #[cfg(feature = "host-io")]
1883        let mut query_nodes: Vec<(usize, crate::pir::PirNodeId)> = Vec::new();
1884        for atom in &provenance.queries {
1885            let formula = provenance.query_formula(&atom.predicate, &atom.args);
1886            if let Some(id) = formula {
1887                roots_set.insert(id);
1888                #[cfg(feature = "host-io")]
1889                {
1890                    query_nodes.push((queries.len(), id));
1891                }
1892            }
1893            queries.push(QuerySpec {
1894                #[cfg(feature = "host-io")]
1895                atom: atom.clone(),
1896                var: None,
1897            });
1898        }
1899
1900        // Ensure ALL probabilistic variable nodes (Decision, Lit, NegLit) are reachable
1901        // so they get CNF variables. This is required for the template/neural fast-path
1902        // where GpuWeightSlots expects one CNF variable per ChoiceVarId/LeafId.
1903        for (idx, node) in provenance.pir.nodes().iter().enumerate() {
1904            match node {
1905                crate::pir::PirNode::Decision { .. }
1906                | crate::pir::PirNode::Lit { .. }
1907                | crate::pir::PirNode::NegLit { .. } => {
1908                    roots_set.insert(crate::pir::PirNodeId::from_u32(idx as u32));
1909                }
1910                _ => {}
1911            }
1912        }
1913
1914        let mut roots: Vec<crate::pir::PirNodeId> = roots_set.into_iter().collect();
1915        roots.sort();
1916
1917        if roots.is_empty() {
1918            return Ok(Self {
1919                gpu: None,
1920                #[cfg(feature = "host-io")]
1921                count_lift_gpu: None,
1922                queries,
1923                #[cfg(feature = "host-io")]
1924                random_vars: None,
1925                max_var: 0,
1926                #[cfg(feature = "host-io")]
1927                origin: _origin,
1928                last_compile_profile: None,
1929                prob_var_entries: Vec::new(),
1930                #[cfg(feature = "host-io")]
1931                fixed_evidence_by_var: BTreeMap::new(),
1932            });
1933        }
1934
1935        #[cfg(feature = "host-io")]
1936        if let Some(count_lift_gpu) = try_build_count_lift_gpu_state(&provenance, &queries, config)?
1937        {
1938            // No CNF encoding is built on this path (count aggregates are
1939            // evaluated by a dedicated GPU kernel instead), so there is no
1940            // leaf_var/choice_var table to derive a variable map from. Leave
1941            // `prob_var_entries` empty and `max_var` at 0 — callers must use
1942            // `uses_gpu_native_count_lift()` to tell this apart from "no random
1943            // variables in the program" (see the doc on `prob_var_map()`).
1944            return Ok(Self {
1945                gpu: None,
1946                count_lift_gpu: Some(count_lift_gpu),
1947                queries,
1948                random_vars: None,
1949                max_var: 0,
1950                origin: _origin,
1951                last_compile_profile: None,
1952                prob_var_entries: Vec::new(),
1953                #[cfg(feature = "host-io")]
1954                fixed_evidence_by_var: BTreeMap::new(),
1955            });
1956        }
1957
1958        let provider = Arc::new(
1959            xlog_cuda::CudaProviderBuilder::new(
1960                config.device_ordinal,
1961                MemoryBudget::with_limit(config.memory_bytes),
1962            )
1963            .build()?,
1964        );
1965
1966        let canonical_cnf_hash = crate::cnf::canonical_pir_hash(&provenance.pir, &roots)?;
1967        let gpu_pir = GpuPirGraph::from_host(&provenance.pir, &provider)?;
1968        let gpu_roots = GpuPirRoots::from_host(&roots, &provider)?;
1969        let encoding = encode_cnf_gpu(&gpu_pir, &gpu_roots, &provider)?;
1970        if encoding.vars.max_var != encoding.cnf.var_cap {
1971            return Err(XlogError::Compilation(format!(
1972                "Exact inference error: CNF var_cap {} != vars.max_var {}",
1973                encoding.cnf.var_cap, encoding.vars.max_var
1974            )));
1975        }
1976
1977        // Which probabilistic fact (or choice) each CNF variable stands for, stored
1978        // sparsely as `(var, info)` pairs (see the doc on `prob_var_entries`).
1979        // `leaf_var`/`choice_var` are GPU-resident dense tables keyed by
1980        // LeafId/ChoiceVarId; a value of 0 means the leaf/choice was not reachable
1981        // from the compiled roots.
1982        #[cfg(feature = "host-io")]
1983        let prob_var_entries = {
1984            let mut leaf_var_host = vec![0u32; encoding.vars.leaf_var.len()];
1985            provider
1986                .device()
1987                .inner()
1988                .dtoh_sync_copy_into(&encoding.vars.leaf_var, &mut leaf_var_host)
1989                .map_err(|e| XlogError::Kernel(format!("Failed to read leaf_var table: {}", e)))?;
1990            let mut choice_var_host = vec![0u32; encoding.vars.choice_var.len()];
1991            provider
1992                .device()
1993                .inner()
1994                .dtoh_sync_copy_into(&encoding.vars.choice_var, &mut choice_var_host)
1995                .map_err(|e| {
1996                    XlogError::Kernel(format!("Failed to read choice_var table: {}", e))
1997                })?;
1998
1999            let mut entries: Vec<(u32, ProbVarInfo)> = Vec::new();
2000            for (leaf_idx, &var) in leaf_var_host.iter().enumerate() {
2001                if var == 0 {
2002                    continue;
2003                }
2004                let leaf = crate::pir::LeafId::new(leaf_idx as u32);
2005                if let (Some(atom), Some(prob)) = (
2006                    provenance.leaf_atoms.get(&leaf),
2007                    provenance.leaf_probs.get(&leaf),
2008                ) {
2009                    entries.push((
2010                        var,
2011                        ProbVarInfo::Fact {
2012                            atom: atom.clone(),
2013                            prob: *prob,
2014                        },
2015                    ));
2016                }
2017            }
2018            for (choice_idx, &var) in choice_var_host.iter().enumerate() {
2019                if var == 0 {
2020                    continue;
2021                }
2022                let choice = crate::pir::ChoiceVarId::new(choice_idx as u32);
2023                // The map must carry the *conditional* Bernoulli parameter that was
2024                // actually assigned to this CNF variable's weight (`choice_probs`),
2025                // not the disjunction's declared marginal probabilities
2026                // (`ChoiceSource::choices`), which only serve as display context.
2027                // See ProbVarInfo::Choice::prob's doc for why the two differ.
2028                match (
2029                    provenance.choice_sources.get(&choice),
2030                    provenance.choice_probs.get(&choice),
2031                ) {
2032                    (Some(source), Some(&(cond_true, _cond_false))) => {
2033                        entries.push((
2034                            var,
2035                            ProbVarInfo::Choice {
2036                                choices: source.choices.clone(),
2037                                choice_index: source.choice_index,
2038                                prob: cond_true,
2039                            },
2040                        ));
2041                    }
2042                    _ => {
2043                        // `choice_sources` and `choice_probs` are populated in
2044                        // lock-step in `provenance.rs` (see the choice-handling
2045                        // arm around lines 708-717) and remapped in lock-step in
2046                        // `decision_order.rs` (lines 115-123); a `choice_var_host`
2047                        // entry pointing at a `ChoiceVarId` missing from either map
2048                        // means that invariant broke upstream. Silently falling
2049                        // back to `ProbVarInfo::Other` would make a real
2050                        // compilation bug look like "this CNF variable is just an
2051                        // auxiliary Tseitin variable" to every caller of
2052                        // `prob_var_map()`, so fail loudly instead.
2053                        return Err(XlogError::Compilation(format!(
2054                            "Exact inference error: choice_sources/choice_probs are out of \
2055                             sync for {:?} (CNF var {var}); expected both maps to contain \
2056                             this ChoiceVarId",
2057                            choice
2058                        )));
2059                    }
2060                }
2061            }
2062            // `entries` is built leaf-first, then choice-second (the two loops
2063            // above), so on a `var` collision the leaf entry comes first and the
2064            // choice entry comes second. `sort_by_key` is STABLE, so it preserves
2065            // that relative order; `prob_var_map()`'s materialization loop then
2066            // overwrites earlier entries with later ones for the same slot
2067            // (last-write-wins), so choice wins over leaf on collision. Do not
2068            // change this to `sort_unstable_by_key`: it makes no such ordering
2069            // guarantee and would silently flip which entry wins.
2070            entries.sort_by_key(|(var, _)| *var);
2071            entries
2072        };
2073        #[cfg(not(feature = "host-io"))]
2074        let prob_var_entries: Vec<(u32, ProbVarInfo)> = Vec::new();
2075
2076        let (leaf_probs_host, choice_true_host, choice_false_host) =
2077            build_weight_sources(&provenance)?;
2078
2079        let leaf_probs = upload_f64(&provider, &leaf_probs_host)?;
2080        let choice_true = upload_f64(&provider, &choice_true_host)?;
2081        let choice_false = upload_f64(&provider, &choice_false_host)?;
2082
2083        let evidence_by_var = if evidence_formulas.is_empty() {
2084            let mut evidence = provider
2085                .memory()
2086                .alloc::<u8>((encoding.vars.max_var as usize) + 1)?;
2087            provider
2088                .device()
2089                .inner()
2090                .memset_zeros(&mut evidence)
2091                .map_err(|e| XlogError::Kernel(format!("Failed to zero evidence buffer: {}", e)))?;
2092            evidence
2093        } else {
2094            let mut nodes: Vec<u32> = Vec::with_capacity(evidence_formulas.len());
2095            let mut vals: Vec<u8> = Vec::with_capacity(evidence_formulas.len());
2096            for (node, value, _atom) in &evidence_formulas {
2097                nodes.push(node.as_u32());
2098                vals.push(if *value { 1u8 } else { 2u8 });
2099            }
2100            let evidence_nodes = upload_u32(&provider, &nodes)?;
2101            let evidence_vals = upload_u8(&provider, &vals)?;
2102            build_evidence_by_var_gpu(
2103                &encoding.vars.node_var,
2104                &evidence_nodes,
2105                &evidence_vals,
2106                encoding.vars.max_var,
2107                &provider,
2108            )?
2109        };
2110
2111        #[cfg(feature = "host-io")]
2112        let fixed_evidence_by_var = {
2113            if evidence_formulas.is_empty() {
2114                BTreeMap::new()
2115            } else {
2116                let evidence_nodes = evidence_formulas
2117                    .iter()
2118                    .map(|(node, _, _)| node.as_u32())
2119                    .collect::<Vec<_>>();
2120                let evidence_vars = map_nodes_to_vars_gpu(
2121                    &encoding.vars.node_var,
2122                    &upload_u32(&provider, &evidence_nodes)?,
2123                    encoding.vars.max_var,
2124                    &provider,
2125                )?;
2126                let mut vars_host = vec![0u32; evidence_vars.len()];
2127                provider
2128                    .device()
2129                    .inner()
2130                    .dtoh_sync_copy_into(&evidence_vars, &mut vars_host)
2131                    .map_err(|error| {
2132                        XlogError::Kernel(format!(
2133                            "Failed to read exact evidence CNF variables: {error}"
2134                        ))
2135                    })?;
2136                let mut assignments = BTreeMap::new();
2137                for (var, (_, value, _)) in vars_host.into_iter().zip(&evidence_formulas) {
2138                    if let Some(previous) = assignments.insert(var, *value) {
2139                        if previous != *value {
2140                            return Err(XlogError::Compilation(format!(
2141                                "Conflicting exact evidence assignments for CNF variable {var}"
2142                            )));
2143                        }
2144                    }
2145                }
2146                assignments
2147            }
2148        };
2149
2150        let weights = build_weights_gpu(
2151            &encoding.vars,
2152            &leaf_probs,
2153            &choice_true,
2154            &choice_false,
2155            &evidence_by_var,
2156            &provider,
2157        )?;
2158        let random_var_count = leaf_probs_host
2159            .len()
2160            .checked_add(choice_true_host.len())
2161            .ok_or_else(|| XlogError::Compilation("random var count overflow".to_string()))?;
2162        let random_var_count = u32::try_from(random_var_count)
2163            .map_err(|_| XlogError::Compilation("random var count exceeds u32".to_string()))?;
2164        let num_leaf_probs = u32::try_from(leaf_probs_host.len())
2165            .map_err(|_| XlogError::Compilation("leaf_probs count exceeds u32".to_string()))?;
2166        let num_choice_probs = u32::try_from(choice_true_host.len())
2167            .map_err(|_| XlogError::Compilation("choice_probs count exceeds u32".to_string()))?;
2168        let (random_var_list, actual_random_var_count) = collect_random_vars_device(
2169            &provider,
2170            &encoding.vars,
2171            num_leaf_probs,
2172            num_choice_probs,
2173            random_var_count,
2174        )?;
2175        let random_vars =
2176            DeviceRandomVarList::from_device(random_var_list, actual_random_var_count)?;
2177
2178        let compile_config = default_compile_config(&encoding.cnf, config.memory_bytes)?;
2179        let cache_config = default_cache_config(&encoding.cnf, &compile_config)?;
2180
2181        let mut cache = GpuCircuitCache::new(&provider, cache_config)?;
2182        let compilation_ledger = Arc::new(CircuitCompilationLedger::new());
2183        let (handle, compile_profile) = compile_gpu_d4_and_verify_cached_with_ledger(
2184            &encoding.cnf,
2185            &encoding.decision_var_limit,
2186            &provider,
2187            &compile_config,
2188            &mut cache,
2189            &random_vars,
2190            CircuitCompilationContext {
2191                canonical_cnf_hash: Some(canonical_cnf_hash),
2192                ledger: compilation_ledger.as_ref(),
2193            },
2194        )?;
2195        cache.store_weights(&handle, &weights.log_true, &weights.log_false)?;
2196
2197        #[cfg(feature = "host-io")]
2198        if !query_nodes.is_empty() {
2199            let mut node_ids: Vec<u32> = Vec::with_capacity(query_nodes.len());
2200            for (_idx, node) in &query_nodes {
2201                node_ids.push(node.as_u32());
2202            }
2203            let node_ids_device = upload_u32(&provider, &node_ids)?;
2204            let vars_device = map_nodes_to_vars_gpu(
2205                &encoding.vars.node_var,
2206                &node_ids_device,
2207                encoding.vars.max_var,
2208                &provider,
2209            )?;
2210
2211            let mut vars_host = vec![0u32; vars_device.len()];
2212            provider
2213                .device()
2214                .inner()
2215                .dtoh_sync_copy_into(&vars_device, &mut vars_host)
2216                .map_err(|e| XlogError::Kernel(format!("Failed to read query vars: {}", e)))?;
2217
2218            for (i, (query_idx, _)) in query_nodes.iter().enumerate() {
2219                let var = vars_host[i];
2220                queries[*query_idx].var = Some(var);
2221            }
2222        }
2223
2224        #[cfg(feature = "host-io")]
2225        let state = GpuExactState::new(provider, cache, handle, compilation_ledger)?;
2226        #[cfg(not(feature = "host-io"))]
2227        let state = GpuExactState::new(provider, cache, handle);
2228
2229        Ok(Self {
2230            gpu: Some(Arc::new(state)),
2231            #[cfg(feature = "host-io")]
2232            count_lift_gpu: None,
2233            queries,
2234            #[cfg(feature = "host-io")]
2235            random_vars: Some(Arc::new(random_vars)),
2236            max_var: encoding.vars.max_var,
2237            #[cfg(feature = "host-io")]
2238            origin: _origin,
2239            last_compile_profile: compile_profile,
2240            prob_var_entries,
2241            #[cfg(feature = "host-io")]
2242            fixed_evidence_by_var,
2243        })
2244    }
2245
2246    #[cfg(feature = "host-io")]
2247    fn eval_log_z_gpu(&self, query_true: Option<u32>) -> Result<f64> {
2248        let state = self.gpu_state()?;
2249        state.ensure_usable()?;
2250        let mut cache = state
2251            .cache
2252            .lock()
2253            .unwrap_or_else(|poisoned| poisoned.into_inner());
2254
2255        if let Some(var) = query_true {
2256            if var == 0 || var > self.max_var {
2257                return Err(XlogError::Compilation(format!(
2258                    "Exact inference error: query var {} out of bounds (max_var={})",
2259                    var, self.max_var
2260                )));
2261            }
2262        }
2263
2264        let mut restore = None;
2265        if let Some(var) = query_true {
2266            let mut buf = state.provider.memory().alloc::<f64>(1)?;
2267            {
2268                let (_, var_log_false) = cache.var_log_weights_mut();
2269                force_query_var_false(state.provider(), var_log_false, var, &mut buf)?;
2270            }
2271            restore = Some((var, buf));
2272        }
2273
2274        let mut out_log_z = state.provider.memory().alloc::<f64>(1)?;
2275        let eval_result = cache.eval_log_wmc_device_inplace(state.handle(), &mut out_log_z);
2276
2277        if let Some((var, buf)) = restore {
2278            let (_, var_log_false) = cache.var_log_weights_mut();
2279            let restore_result =
2280                restore_query_var_false(state.provider(), var_log_false, var, &buf);
2281            if let Err(err) = eval_result {
2282                restore_result?;
2283                return Err(err);
2284            }
2285            restore_result?;
2286        } else {
2287            eval_result?;
2288        }
2289
2290        let mut host = [0.0f64];
2291        state
2292            .provider
2293            .device()
2294            .inner()
2295            .dtoh_sync_copy_into(&out_log_z, &mut host)
2296            .map_err(|e| XlogError::Kernel(format!("Failed to read logZ: {}", e)))?;
2297        validate_circuit_value(host[0])
2298    }
2299
2300    fn gpu_state(&self) -> Result<Arc<GpuExactState>> {
2301        self.gpu.clone().ok_or_else(|| {
2302            XlogError::Execution(
2303                "Exact inference GPU error: program has no compiled circuit".to_string(),
2304            )
2305        })
2306    }
2307
2308    #[cfg(feature = "host-io")]
2309    pub(crate) fn circuit_witness(&self) -> Result<ExactCircuitWitness> {
2310        let state = self.gpu_state()?;
2311        state.ensure_usable()?;
2312        Ok(state.compilation_witness())
2313    }
2314
2315    #[cfg(feature = "host-io")]
2316    fn eval_log_z_and_grads_gpu_cached(
2317        &self,
2318        query_true: Option<u32>,
2319    ) -> Result<(f64, Vec<f64>, Vec<f64>)> {
2320        let state = self.gpu_state()?;
2321        state.ensure_usable()?;
2322        let mut cache = state
2323            .cache
2324            .lock()
2325            .unwrap_or_else(|poisoned| poisoned.into_inner());
2326
2327        if let Some(var) = query_true {
2328            if var == 0 || var > self.max_var {
2329                return Err(XlogError::Compilation(format!(
2330                    "Exact inference error: query var {} out of bounds (max_var={})",
2331                    var, self.max_var
2332                )));
2333            }
2334        }
2335
2336        let mut restore = None;
2337        if let Some(var) = query_true {
2338            let mut buf = state.provider.memory().alloc::<f64>(1)?;
2339            {
2340                let (_, var_log_false) = cache.var_log_weights_mut();
2341                force_query_var_false(state.provider(), var_log_false, var, &mut buf)?;
2342            }
2343            restore = Some((var, buf));
2344        }
2345
2346        let eval_result = cache.eval_grads_inplace(state.handle());
2347
2348        if let Some((var, buf)) = restore {
2349            let (_, var_log_false) = cache.var_log_weights_mut();
2350            let restore_result =
2351                restore_query_var_false(state.provider(), var_log_false, var, &buf);
2352            if let Err(err) = eval_result {
2353                restore_result?;
2354                return Err(err);
2355            }
2356            restore_result?;
2357        } else {
2358            eval_result?;
2359        }
2360
2361        let weights_len = if self.max_var == 0 {
2362            0
2363        } else {
2364            (self.max_var as usize) + 1
2365        };
2366
2367        let device = state.provider.device().inner();
2368        let mut host_grad_true: Vec<f64> = vec![0.0; weights_len];
2369        let mut host_grad_false: Vec<f64> = vec![0.0; weights_len];
2370
2371        let root_idx = state.handle().root() as usize;
2372        let root_view = cache.values().slice(root_idx..(root_idx + 1));
2373        let mut log_z = [0.0_f64];
2374        device
2375            .dtoh_sync_copy_into(&root_view, &mut log_z)
2376            .map_err(|e| XlogError::Kernel(format!("Failed to read logZ: {}", e)))?;
2377        let log_z = validate_circuit_value(log_z[0])?;
2378
2379        // Gradient buffers are multi-slot: [slot0_var0..slot0_varN, slot1_var0..].
2380        // Slice into the correct slot to download only this circuit's gradients.
2381        let var_stride = cache.var_stride()? as usize;
2382        let slot = state.handle().slot_index() as usize;
2383        let grad_start = slot * var_stride;
2384        let grad_end = grad_start + weights_len;
2385        let grad_true_slot = cache.grad_true().slice(grad_start..grad_end);
2386        let grad_false_slot = cache.grad_false().slice(grad_start..grad_end);
2387        device
2388            .dtoh_sync_copy_into(&grad_true_slot, &mut host_grad_true)
2389            .map_err(|e| XlogError::Kernel(format!("Failed to download grad_true: {}", e)))?;
2390        device
2391            .dtoh_sync_copy_into(&grad_false_slot, &mut host_grad_false)
2392            .map_err(|e| XlogError::Kernel(format!("Failed to download grad_false: {}", e)))?;
2393        validate_circuit_gradient_values(&host_grad_true, &host_grad_false)?;
2394
2395        Ok((log_z, host_grad_true, host_grad_false))
2396    }
2397}
2398
2399#[cfg(feature = "host-io")]
2400fn try_build_count_lift_gpu_state(
2401    provenance: &Provenance,
2402    queries: &[QuerySpec],
2403    config: GpuConfig,
2404) -> Result<Option<Arc<GpuCountLiftState>>> {
2405    if queries.is_empty() || !provenance.evidence.is_empty() || !provenance.choice_probs.is_empty()
2406    {
2407        return Ok(None);
2408    }
2409
2410    let fired_count_predicates: HashSet<&str> = provenance
2411        .aggregate_lifting
2412        .iter()
2413        .filter(|entry| {
2414            entry.status == AggregateLiftStatus::Fired
2415                && entry.operator.as_str() == "count"
2416                && entry.deterministic_rows == 0
2417        })
2418        .map(|entry| entry.predicate.as_str())
2419        .collect();
2420    if fired_count_predicates.is_empty() {
2421        return Ok(None);
2422    }
2423    if queries
2424        .iter()
2425        .any(|query| !fired_count_predicates.contains(query.atom.predicate.as_str()))
2426    {
2427        return Ok(None);
2428    }
2429
2430    let provider = Arc::new(
2431        xlog_cuda::CudaProviderBuilder::new(
2432            config.device_ordinal,
2433            MemoryBudget::with_limit(config.memory_bytes),
2434        )
2435        .build()?,
2436    );
2437    let mut gpu_queries = Vec::with_capacity(queries.len());
2438    for query in queries {
2439        let target_count = match count_lift_query_target(query)? {
2440            Some(target) => target,
2441            None => return Ok(None),
2442        };
2443        let root = match provenance.query_formula(&query.atom.predicate, &query.atom.args) {
2444            Some(root) => root,
2445            None => return Ok(None),
2446        };
2447        let mut leaves = HashSet::new();
2448        collect_count_lift_leaves(provenance, root, &mut leaves)?;
2449        if leaves.is_empty() || leaves.len() > 64 {
2450            return Ok(None);
2451        }
2452        if target_count > leaves.len() as u32 {
2453            return Ok(None);
2454        }
2455        let mut leaves: Vec<_> = leaves.into_iter().collect();
2456        leaves.sort_by_key(|leaf| leaf.as_u32());
2457        let mut leaf_probs_host = Vec::with_capacity(leaves.len());
2458        for leaf in leaves {
2459            let p = *provenance.leaf_probs.get(&leaf).ok_or_else(|| {
2460                XlogError::Compilation(format!(
2461                    "Count-lift GPU evaluator missing probability for leaf {}",
2462                    leaf.as_u32()
2463                ))
2464            })?;
2465            leaf_probs_host.push(p);
2466        }
2467        let leaf_count = u32::try_from(leaf_probs_host.len())
2468            .map_err(|_| XlogError::Compilation("count-lift leaf count exceeds u32".to_string()))?;
2469        let leaf_probs = upload_f64(&provider, &leaf_probs_host)?;
2470        gpu_queries.push(GpuCountLiftQuery {
2471            atom: query.atom.clone(),
2472            target_count,
2473            leaf_count,
2474            leaf_probs,
2475        });
2476    }
2477    Ok(Some(Arc::new(GpuCountLiftState::new(
2478        provider,
2479        gpu_queries,
2480    ))))
2481}
2482
2483#[cfg(feature = "host-io")]
2484fn count_lift_query_target(query: &QuerySpec) -> Result<Option<u32>> {
2485    match query.atom.args.last() {
2486        Some(Value::I64(value)) if *value >= 0 => u32::try_from(*value)
2487            .map(Some)
2488            .map_err(|_| XlogError::Compilation("count-lift target exceeds u32".to_string())),
2489        _ => Ok(None),
2490    }
2491}
2492
2493#[cfg(feature = "host-io")]
2494fn collect_count_lift_leaves(
2495    provenance: &Provenance,
2496    node: crate::pir::PirNodeId,
2497    leaves: &mut HashSet<crate::pir::LeafId>,
2498) -> Result<()> {
2499    let pir_node = provenance.pir.node(node).ok_or_else(|| {
2500        XlogError::Compilation(format!(
2501            "Count-lift GPU evaluator saw invalid PIR node {}",
2502            node.as_u32()
2503        ))
2504    })?;
2505    match pir_node {
2506        crate::pir::PirNode::Const(_) => Ok(()),
2507        crate::pir::PirNode::Lit { leaf } | crate::pir::PirNode::NegLit { leaf } => {
2508            leaves.insert(*leaf);
2509            Ok(())
2510        }
2511        crate::pir::PirNode::And { children } | crate::pir::PirNode::Or { children } => {
2512            for child in children {
2513                collect_count_lift_leaves(provenance, *child, leaves)?;
2514            }
2515            Ok(())
2516        }
2517        crate::pir::PirNode::Decision { .. } => Err(XlogError::Compilation(
2518            "Count-lift GPU evaluator does not support annotated-disjunction choices".to_string(),
2519        )),
2520    }
2521}
2522
2523fn force_query_var_false(
2524    provider: &Arc<CudaKernelProvider>,
2525    log_false: &mut TrackedCudaSlice<f64>,
2526    var: u32,
2527    restore: &mut TrackedCudaSlice<f64>,
2528) -> Result<()> {
2529    let device = provider.device().inner();
2530    let func = device
2531        .get_func(WEIGHTS_MODULE, weights_kernels::WEIGHTS_FORCE_VAR_FALSE)
2532        .ok_or_else(|| XlogError::Kernel("weights_force_var_false kernel not found".to_string()))?;
2533    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2534    unsafe {
2535        func.clone().launch(
2536            LaunchConfig {
2537                grid_dim: (1, 1, 1),
2538                block_dim: (1, 1, 1),
2539                shared_mem_bytes: 0,
2540            },
2541            (var, log_false, restore),
2542        )
2543    }
2544    .map_err(|e| XlogError::Kernel(format!("weights_force_var_false failed: {}", e)))?;
2545    Ok(())
2546}
2547
2548fn restore_query_var_false(
2549    provider: &Arc<CudaKernelProvider>,
2550    log_false: &mut TrackedCudaSlice<f64>,
2551    var: u32,
2552    restore: &TrackedCudaSlice<f64>,
2553) -> Result<()> {
2554    let device = provider.device().inner();
2555    let func = device
2556        .get_func(WEIGHTS_MODULE, weights_kernels::WEIGHTS_RESTORE_VAR_FALSE)
2557        .ok_or_else(|| {
2558            XlogError::Kernel("weights_restore_var_false kernel not found".to_string())
2559        })?;
2560    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2561    unsafe {
2562        func.clone().launch(
2563            LaunchConfig {
2564                grid_dim: (1, 1, 1),
2565                block_dim: (1, 1, 1),
2566                shared_mem_bytes: 0,
2567            },
2568            (var, log_false, restore),
2569        )
2570    }
2571    .map_err(|e| XlogError::Kernel(format!("weights_restore_var_false failed: {}", e)))?;
2572    Ok(())
2573}
2574
2575fn force_query_var_true(
2576    provider: &Arc<CudaKernelProvider>,
2577    log_true: &mut TrackedCudaSlice<f64>,
2578    var: u32,
2579    restore: &mut TrackedCudaSlice<f64>,
2580) -> Result<()> {
2581    let device = provider.device().inner();
2582    let func = device
2583        .get_func(WEIGHTS_MODULE, weights_kernels::WEIGHTS_FORCE_VAR_TRUE)
2584        .ok_or_else(|| XlogError::Kernel("weights_force_var_true kernel not found".to_string()))?;
2585    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2586    unsafe {
2587        func.clone().launch(
2588            LaunchConfig {
2589                grid_dim: (1, 1, 1),
2590                block_dim: (1, 1, 1),
2591                shared_mem_bytes: 0,
2592            },
2593            (var, log_true, restore),
2594        )
2595    }
2596    .map_err(|e| XlogError::Kernel(format!("weights_force_var_true failed: {}", e)))?;
2597    Ok(())
2598}
2599
2600fn restore_query_var_true(
2601    provider: &Arc<CudaKernelProvider>,
2602    log_true: &mut TrackedCudaSlice<f64>,
2603    var: u32,
2604    restore: &TrackedCudaSlice<f64>,
2605) -> Result<()> {
2606    let device = provider.device().inner();
2607    let func = device
2608        .get_func(WEIGHTS_MODULE, weights_kernels::WEIGHTS_RESTORE_VAR_TRUE)
2609        .ok_or_else(|| {
2610            XlogError::Kernel("weights_restore_var_true kernel not found".to_string())
2611        })?;
2612    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2613    unsafe {
2614        func.clone().launch(
2615            LaunchConfig {
2616                grid_dim: (1, 1, 1),
2617                block_dim: (1, 1, 1),
2618                shared_mem_bytes: 0,
2619            },
2620            (var, log_true, restore),
2621        )
2622    }
2623    .map_err(|e| XlogError::Kernel(format!("weights_restore_var_true failed: {}", e)))?;
2624    Ok(())
2625}
2626
2627pub(crate) fn default_compile_config(
2628    cnf: &xlog_solve::GpuCnf,
2629    memory_bytes: u64,
2630) -> Result<GpuCompileConfig> {
2631    // Must match the default GPU-native Decision-DNNF compiler configuration expected
2632    // by the Python training paths.
2633    // Sizing is conservative and strictly bounded by `GpuCompileConfig::{smooth_node_cap,smooth_edge_cap}`.
2634    let frontier_depth: u16 = 6;
2635
2636    let var_cap = cnf.var_cap.max(1);
2637    let trail_bytes_per_item = (var_cap as u64)
2638        .checked_add(1)
2639        .and_then(|v| v.checked_mul(std::mem::size_of::<i32>() as u64))
2640        .ok_or_else(|| XlogError::Compilation("trail size overflow".to_string()))?;
2641    let denom = trail_bytes_per_item
2642        .checked_mul(8)
2643        .ok_or_else(|| XlogError::Compilation("trail memory denominator overflow".to_string()))?;
2644    if memory_bytes
2645        < denom.checked_mul(8).ok_or_else(|| {
2646            XlogError::Compilation("minimum frontier memory requirement overflow".to_string())
2647        })?
2648    {
2649        return Err(XlogError::Compilation(format!(
2650            "memory budget {} cannot hold the minimum GPU-native Decision-DNNF frontier allocation",
2651            memory_bytes
2652        )));
2653    }
2654    let max_items_by_trail = memory_bytes / denom;
2655    let max_frontier_items = max_items_by_trail.min(4096).min(u64::from(u32::MAX)) as u32;
2656
2657    // The GPU-native Decision-DNNF compiler emits one leaf circuit per frontier item;
2658    // caps must scale with the maximum frontier size (up to 2^frontier_depth,
2659    // bounded by max_frontier_items).
2660    let frontier_cap_factor = (1u64
2661        .checked_shl(frontier_depth as u32)
2662        .unwrap_or(u64::from(u32::MAX)))
2663    .min(u64::from(max_frontier_items)) as u32;
2664
2665    let per_item_nodes = cnf
2666        .var_cap
2667        .checked_mul(5)
2668        .ok_or_else(|| XlogError::Compilation("smooth_node_cap overflow".to_string()))?
2669        .max(1024);
2670    let smooth_node_cap = per_item_nodes
2671        .checked_mul(frontier_cap_factor)
2672        .ok_or_else(|| XlogError::Compilation("smooth_node_cap overflow".to_string()))?;
2673
2674    // Edge capacity scales with node capacity; AND/OR fanout grows edges but stays within a small
2675    // multiple of nodes for the compiler's frontier emission patterns.
2676    let mut smooth_edge_cap = smooth_node_cap
2677        .checked_mul(2)
2678        .ok_or_else(|| XlogError::Compilation("smooth_edge_cap overflow".to_string()))?;
2679    if smooth_edge_cap < max_frontier_items {
2680        smooth_edge_cap = max_frontier_items;
2681    }
2682
2683    // The verifier's UNSAT certificate (resolution trace) can be large even when the source CNF
2684    // is small, because equivalence checking builds CNF(C) with many Tseitin variables/clauses.
2685    // Allocate a larger share of the budget to the GPU CDCL arenas to avoid deterministic
2686    // overflow errors in production verifier paths.
2687    let mut cdcl_learned_bytes = memory_bytes / 8;
2688    if cdcl_learned_bytes < 4 * 1024 * 1024 {
2689        cdcl_learned_bytes = 4 * 1024 * 1024;
2690    }
2691
2692    let config = GpuCompileConfig {
2693        frontier_depth,
2694        max_frontier_items,
2695        max_depth: 128,
2696        smooth_node_cap,
2697        smooth_edge_cap,
2698        cdcl_restart_interval: 64,
2699        cdcl_learned_bytes,
2700        cdcl_conflict_budget: None,
2701        incremental_verify: false,
2702    };
2703    Ok(config)
2704}
2705
2706pub(crate) fn default_cache_config(
2707    cnf: &xlog_solve::GpuCnf,
2708    compile: &GpuCompileConfig,
2709) -> Result<GpuCircuitCacheConfig> {
2710    if compile.smooth_node_cap == 0 || compile.smooth_edge_cap == 0 {
2711        return Err(XlogError::Compilation(
2712            "GPU cache config requires non-zero smoothing caps".to_string(),
2713        ));
2714    }
2715    Ok(GpuCircuitCacheConfig {
2716        num_slots: 4, // Hold 4 circuit templates; power-of-2 hash table.
2717        table_size: 8,
2718        node_cap: compile.smooth_node_cap,
2719        edge_cap: compile.smooth_edge_cap,
2720        level_cap: compile.smooth_node_cap,
2721        var_cap: cnf.var_cap,
2722    })
2723}
2724
2725pub(crate) fn build_weight_sources(
2726    provenance: &Provenance,
2727) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>)> {
2728    let max_leaf = provenance.leaf_probs.keys().map(|leaf| leaf.as_u32()).max();
2729    let leaf_len = max_leaf.map(|v| v as usize + 1).unwrap_or(0);
2730    let mut leaf_probs = vec![0.0f64; leaf_len];
2731    let mut leaf_seen = vec![false; leaf_len];
2732    for (leaf, p) in &provenance.leaf_probs {
2733        let idx = leaf.as_u32() as usize;
2734        if idx >= leaf_len {
2735            return Err(XlogError::Compilation(
2736                "leaf probability index out of bounds".to_string(),
2737            ));
2738        }
2739        leaf_probs[idx] = *p;
2740        leaf_seen[idx] = true;
2741    }
2742    if let Some((idx, _)) = leaf_seen.iter().enumerate().find(|(_, seen)| !**seen) {
2743        return Err(XlogError::Compilation(format!(
2744            "missing probability for leaf {}",
2745            idx
2746        )));
2747    }
2748
2749    let max_choice = provenance
2750        .choice_probs
2751        .keys()
2752        .map(|choice| choice.as_u32())
2753        .max();
2754    let choice_len = max_choice.map(|v| v as usize + 1).unwrap_or(0);
2755    let mut choice_true = vec![0.0f64; choice_len];
2756    let mut choice_false = vec![0.0f64; choice_len];
2757    let mut choice_seen = vec![false; choice_len];
2758    for (choice, (pt, pf)) in &provenance.choice_probs {
2759        let idx = choice.as_u32() as usize;
2760        if idx >= choice_len {
2761            return Err(XlogError::Compilation(
2762                "choice probability index out of bounds".to_string(),
2763            ));
2764        }
2765        choice_true[idx] = *pt;
2766        choice_false[idx] = *pf;
2767        choice_seen[idx] = true;
2768    }
2769    if let Some((idx, _)) = choice_seen.iter().enumerate().find(|(_, seen)| !**seen) {
2770        return Err(XlogError::Compilation(format!(
2771            "missing probability for choice {}",
2772            idx
2773        )));
2774    }
2775
2776    Ok((leaf_probs, choice_true, choice_false))
2777}
2778
2779pub(crate) fn upload_u32(
2780    provider: &Arc<CudaKernelProvider>,
2781    host: &[u32],
2782) -> Result<TrackedCudaSlice<u32>> {
2783    let memory = provider.memory();
2784    let mut buf = memory.alloc::<u32>(host.len())?;
2785    provider
2786        .htod_sync_copy_into_tracked(host, &mut buf)
2787        .map_err(|e| XlogError::Kernel(format!("Failed to upload u32 buffer: {}", e)))?;
2788    Ok(buf)
2789}
2790
2791pub(crate) fn upload_u8(
2792    provider: &Arc<CudaKernelProvider>,
2793    host: &[u8],
2794) -> Result<TrackedCudaSlice<u8>> {
2795    let memory = provider.memory();
2796    let mut buf = memory.alloc::<u8>(host.len())?;
2797    provider
2798        .htod_sync_copy_into_tracked(host, &mut buf)
2799        .map_err(|e| XlogError::Kernel(format!("Failed to upload u8 buffer: {}", e)))?;
2800    Ok(buf)
2801}
2802
2803pub(crate) fn upload_f64(
2804    provider: &Arc<CudaKernelProvider>,
2805    host: &[f64],
2806) -> Result<TrackedCudaSlice<f64>> {
2807    let memory = provider.memory();
2808    let mut buf = memory.alloc::<f64>(host.len())?;
2809    provider
2810        .htod_sync_copy_into_tracked(host, &mut buf)
2811        .map_err(|e| XlogError::Kernel(format!("Failed to upload f64 buffer: {}", e)))?;
2812    Ok(buf)
2813}
2814
2815fn capture_compact_count_device(
2816    provider: &Arc<CudaKernelProvider>,
2817    prefix_sum: &TrackedCudaSlice<u32>,
2818    mask: &TrackedCudaSlice<u8>,
2819    n: u32,
2820) -> Result<TrackedCudaSlice<u32>> {
2821    let mut out = provider.memory().alloc::<u32>(1)?;
2822    let device = provider.device().inner();
2823    let capture_fn = device
2824        .get_func(FILTER_MODULE, filter_kernels::CAPTURE_COMPACT_COUNT)
2825        .ok_or_else(|| XlogError::Kernel("capture_compact_count kernel not found".to_string()))?;
2826    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2827    unsafe {
2828        capture_fn.clone().launch(
2829            LaunchConfig {
2830                grid_dim: (1, 1, 1),
2831                block_dim: (1, 1, 1),
2832                shared_mem_bytes: 0,
2833            },
2834            (prefix_sum, mask, n, &mut out),
2835        )
2836    }
2837    .map_err(|e| XlogError::Kernel(format!("capture_compact_count failed: {}", e)))?;
2838    Ok(out)
2839}
2840
2841pub(crate) fn collect_random_vars_device(
2842    provider: &Arc<CudaKernelProvider>,
2843    vars: &GpuCnfVarTables,
2844    num_leaf_probs: u32,
2845    num_choice_probs: u32,
2846    _expected_count: u32,
2847) -> Result<(TrackedCudaSlice<u32>, u32)> {
2848    let device = provider.device().inner();
2849    let memory = provider.memory();
2850
2851    let mask_len = vars
2852        .max_var
2853        .checked_add(1)
2854        .ok_or_else(|| XlogError::Compilation("random var mask_len overflow".to_string()))?;
2855    let mask_len_usize = usize::try_from(mask_len)
2856        .map_err(|_| XlogError::Compilation("random var mask_len exceeds usize".to_string()))?;
2857
2858    let mut mask = memory.alloc::<u8>(mask_len_usize)?;
2859    device
2860        .memset_zeros(&mut mask)
2861        .map_err(|e| XlogError::Kernel(format!("Failed to zero random var mask: {}", e)))?;
2862
2863    let mut iota = memory.alloc::<u32>(mask_len_usize)?;
2864    let fill_iota = device
2865        .get_func(FILTER_MODULE, filter_kernels::FILL_U32_IOTA)
2866        .ok_or_else(|| XlogError::Kernel("fill_u32_iota kernel not found".to_string()))?;
2867    let block_size = 256u32;
2868    let grid = checked_launch_grid_u32("fill random-var iota", mask_len, block_size)?;
2869    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2870    unsafe {
2871        fill_iota.clone().launch(
2872            LaunchConfig {
2873                grid_dim: (grid, 1, 1),
2874                block_dim: (block_size, 1, 1),
2875                shared_mem_bytes: 0,
2876            },
2877            (&mut iota, mask_len, 0u32),
2878        )
2879    }
2880    .map_err(|e| XlogError::Kernel(format!("fill_u32_iota failed: {}", e)))?;
2881
2882    // Only iterate over the probabilistic entries — leaf_var and choice_var are allocated
2883    // to num_nodes but only the first num_leaf_probs / num_choice_probs entries correspond
2884    // to variables with actual probabilities. Non-probabilistic PIR leaf nodes also get
2885    // CNF variables but must NOT be marked as random.
2886    let leaf_len = num_leaf_probs;
2887    let choice_len = num_choice_probs;
2888
2889    let mark_kernel = device
2890        .get_func(FILTER_MODULE, filter_kernels::MARK_RANDOM_VARS)
2891        .ok_or_else(|| XlogError::Kernel("mark_random_vars kernel not found".to_string()))?;
2892    let mark_n = leaf_len.max(choice_len);
2893    if mark_n > 0 {
2894        let grid = checked_launch_grid_u32("mark random vars", mark_n, block_size)?;
2895        // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2896        unsafe {
2897            mark_kernel.clone().launch(
2898                LaunchConfig {
2899                    grid_dim: (grid, 1, 1),
2900                    block_dim: (block_size, 1, 1),
2901                    shared_mem_bytes: 0,
2902                },
2903                (
2904                    &vars.leaf_var,
2905                    &vars.choice_var,
2906                    leaf_len,
2907                    choice_len,
2908                    &mut mask,
2909                    mask_len,
2910                ),
2911            )
2912        }
2913        .map_err(|e| XlogError::Kernel(format!("mark_random_vars failed: {}", e)))?;
2914    }
2915
2916    let prefix_sum = provider.scan_u8_mask_device(&mask, mask_len)?;
2917    let count_device = capture_compact_count_device(provider, &prefix_sum, &mask, mask_len)?;
2918
2919    // Read the actual random var count from device (the GPU scan result is authoritative).
2920    // The host-side expected_count can be wrong when some ChoiceVarIds are unreachable
2921    // from query/evidence roots and don't get assigned CNF variables.
2922    let actual_count = {
2923        let mut buf = vec![0u32; 1];
2924        device
2925            .dtoh_sync_copy_into(&count_device, &mut buf)
2926            .map_err(|e| XlogError::Kernel(format!("dtoh count_device failed: {}", e)))?;
2927        buf[0]
2928    };
2929
2930    if actual_count == 0 {
2931        // No random variables in the circuit — return empty list.
2932        let out = provider.memory().alloc::<u32>(0)?;
2933        return Ok((out, 0));
2934    }
2935
2936    let mut out = memory.alloc::<u32>(mask_len_usize)?;
2937    let compact_fn = device
2938        .get_func(FILTER_MODULE, filter_kernels::COMPACT_U32_BY_MASK)
2939        .ok_or_else(|| XlogError::Kernel("compact_u32_by_mask kernel not found".to_string()))?;
2940    // SAFETY: kernel arguments match the PTX signature; device buffers were allocated with sufficient size
2941    unsafe {
2942        compact_fn.clone().launch(
2943            LaunchConfig {
2944                grid_dim: (grid, 1, 1),
2945                block_dim: (block_size, 1, 1),
2946                shared_mem_bytes: 0,
2947            },
2948            (&iota, &mask, &prefix_sum, mask_len, &mut out),
2949        )
2950    }
2951    .map_err(|e| XlogError::Kernel(format!("compact_u32_by_mask failed: {}", e)))?;
2952
2953    Ok((out, actual_count))
2954}
2955
2956fn display_atom(atom: &GroundAtom) -> String {
2957    if atom.args.is_empty() {
2958        format!("{}()", atom.predicate)
2959    } else {
2960        format!("{}({} args)", atom.predicate, atom.args.len())
2961    }
2962}
2963
2964pub(crate) fn validated_evidence_entries(
2965    provenance: &Provenance,
2966) -> Result<Vec<(&GroundAtom, bool)>> {
2967    let mut evidence_atoms: HashMap<GroundAtom, bool> = HashMap::new();
2968    let mut entries = Vec::with_capacity(provenance.evidence.len());
2969    for (atom, value) in &provenance.evidence {
2970        let canonical_atom = provenance.canonical_atom(atom)?;
2971        if let Some(previous) = evidence_atoms.insert(canonical_atom, *value) {
2972            if previous != *value {
2973                return Err(XlogError::Execution(format!(
2974                    "Exact inference error: conflicting evidence for {}",
2975                    display_atom(atom)
2976                )));
2977            }
2978            continue;
2979        }
2980        entries.push((atom, *value));
2981    }
2982    Ok(entries)
2983}
2984
2985#[cfg(all(test, feature = "host-io"))]
2986mod tests {
2987    use super::*;
2988    use xlog_cuda::CudaDevice;
2989
2990    #[test]
2991    fn fact_weight_updates_match_compile_kernel_and_preserve_fixed_evidence() {
2992        let tiny = 1e-17;
2993        let (log_true, log_false) = fact_log_weights(tiny, None);
2994        assert_eq!(log_true.to_bits(), tiny.ln().to_bits());
2995        assert_eq!(log_false.to_bits(), (1.0 - tiny).ln().to_bits());
2996
2997        let (fixed_true, impossible_false) = fact_log_weights(0.25, Some(true));
2998        assert_eq!(fixed_true.to_bits(), 0.25f64.ln().to_bits());
2999        assert_eq!(impossible_false, f64::NEG_INFINITY);
3000
3001        let (impossible_true, fixed_false) = fact_log_weights(0.25, Some(false));
3002        assert_eq!(impossible_true, f64::NEG_INFINITY);
3003        assert_eq!(fixed_false.to_bits(), (1.0f64 - 0.25).ln().to_bits());
3004    }
3005
3006    #[test]
3007    fn exact_evidence_entries_deduplicate_schema_equivalent_values() {
3008        let provenance = extract_from_source(
3009            "0.5::gate(\"alpha\").\n\
3010             evidence(gate(\"alpha\"), true).\n\
3011             evidence(gate(alpha), true).\n\
3012             query(gate(\"alpha\")).\n",
3013        )
3014        .expect("extract equivalent symbol evidence");
3015
3016        let evidence = validated_evidence_entries(&provenance).expect("validate evidence");
3017        assert_eq!(evidence.len(), 1);
3018        assert!(evidence[0].1);
3019    }
3020
3021    #[test]
3022    fn test_exact_negation_probability() {
3023        let _gpu_guard = crate::test_gpu_lock::lock();
3024        if CudaDevice::new(0).is_err() {
3025            eprintln!("Skipping test: CUDA runtime unavailable");
3026            return;
3027        }
3028        // 0.3::rain(). dry() :- not rain().
3029        // P(dry) = P(not rain) = 1 - 0.3 = 0.7
3030        let source = r#"
30310.3::rain().
3032dry() :- not rain().
3033query(dry()).
3034"#;
3035
3036        let program = ExactDdnnfProgram::compile_source(source).unwrap();
3037        let result = program.evaluate().unwrap();
3038
3039        assert_eq!(result.query_probs.len(), 1);
3040        let dry_prob = result.query_probs[0].prob;
3041        assert!(
3042            (dry_prob - 0.7).abs() < 1e-6,
3043            "P(dry) should be 0.7, got {}",
3044            dry_prob
3045        );
3046    }
3047
3048    #[test]
3049    fn test_exact_multi_layer_negation() {
3050        let _gpu_guard = crate::test_gpu_lock::lock();
3051        if CudaDevice::new(0).is_err() {
3052            eprintln!("Skipping test: CUDA runtime unavailable");
3053            return;
3054        }
3055        // 0.4::c(). b() :- not c(). a() :- not b().
3056        // P(b) = P(not c) = 0.6
3057        // P(a) = P(not b) = 0.4
3058        let source = r#"
30590.4::c().
3060b() :- not c().
3061a() :- not b().
3062query(a()).
3063"#;
3064
3065        let program = ExactDdnnfProgram::compile_source(source).unwrap();
3066        let result = program.evaluate().unwrap();
3067
3068        assert_eq!(result.query_probs.len(), 1);
3069        let a_prob = result.query_probs[0].prob;
3070        assert!(
3071            (a_prob - 0.4).abs() < 1e-6,
3072            "P(a) should be 0.4, got {}",
3073            a_prob
3074        );
3075    }
3076
3077    #[test]
3078    fn test_eval_log_z_changes_for_sprinkler_given_wet() {
3079        let _gpu_guard = crate::test_gpu_lock::lock();
3080        if CudaDevice::new(0).is_err() {
3081            eprintln!("Skipping test: CUDA runtime unavailable");
3082            return;
3083        }
3084
3085        let source = r#"
30860.7::rain().
30870.2::sprinkler().
3088wet() :- rain().
3089wet() :- sprinkler().
3090evidence(wet(), true).
3091query(rain()).
3092query(sprinkler()).
3093"#;
3094
3095        let program = ExactDdnnfProgram::compile_source(source).unwrap();
3096        let log_z_e = program.eval_log_z_gpu(None).unwrap();
3097        let sprinkler_var = program.query_var(1).unwrap();
3098        let log_z_eq = program.eval_log_z_gpu(Some(sprinkler_var)).unwrap();
3099
3100        let state = program.gpu_state().unwrap();
3101        let mut cache = state
3102            .cache
3103            .lock()
3104            .unwrap_or_else(|poisoned| poisoned.into_inner());
3105        let (_, var_log_false) = cache.var_log_weights_mut();
3106
3107        let mut before = [0.0f64];
3108        let view = var_log_false.slice(sprinkler_var as usize..(sprinkler_var as usize + 1));
3109        state
3110            .provider
3111            .device()
3112            .inner()
3113            .dtoh_sync_copy_into(&view, &mut before)
3114            .unwrap();
3115
3116        let mut restore = state.provider.memory().alloc::<f64>(1).unwrap();
3117        force_query_var_false(state.provider(), var_log_false, sprinkler_var, &mut restore)
3118            .unwrap();
3119
3120        let mut after = [0.0f64];
3121        let view_after = var_log_false.slice(sprinkler_var as usize..(sprinkler_var as usize + 1));
3122        state
3123            .provider
3124            .device()
3125            .inner()
3126            .dtoh_sync_copy_into(&view_after, &mut after)
3127            .unwrap();
3128
3129        restore_query_var_false(state.provider(), var_log_false, sprinkler_var, &restore).unwrap();
3130
3131        assert!(
3132            before[0].is_finite(),
3133            "expected finite log_false before forcing"
3134        );
3135        assert!(
3136            after[0].is_infinite() && after[0].is_sign_negative(),
3137            "expected -inf log_false after forcing, got {}",
3138            after[0]
3139        );
3140        assert!(
3141            log_z_eq < log_z_e,
3142            "conditioning on sprinkler should reduce logZ (log_z_e={}, log_z_eq={})",
3143            log_z_e,
3144            log_z_eq
3145        );
3146    }
3147
3148    #[test]
3149    fn cached_gradient_readback_rejects_non_finite_device_output() {
3150        let _gpu_guard = crate::test_gpu_lock::lock();
3151        match CudaDevice::new(0) {
3152            Ok(_) => {}
3153            Err(error) if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") => {
3154                panic!("XLOG_REQUIRE_CUDA=1 but CUDA runtime initialization failed: {error}")
3155            }
3156            Err(error) => {
3157                eprintln!("Skipping test: CUDA runtime unavailable: {error}");
3158                return;
3159            }
3160        }
3161
3162        let program = ExactDdnnfProgram::compile_source(
3163            "0.5::rain().\n\
3164             query(rain()).\n",
3165        )
3166        .unwrap();
3167        let rain_var = program.query_var(0).unwrap() as usize;
3168        let state = program.gpu_state().unwrap();
3169
3170        {
3171            let mut cache = state
3172                .cache
3173                .lock()
3174                .unwrap_or_else(|poisoned| poisoned.into_inner());
3175            let var_stride = cache.var_stride().unwrap() as usize;
3176            let slot_start = state.handle().slot_index() as usize * var_stride;
3177            let (var_log_true, _) = cache.var_log_weights_mut();
3178            let mut rain_true =
3179                var_log_true.slice_mut((slot_start + rain_var)..(slot_start + rain_var + 1));
3180            state
3181                .provider
3182                .htod_sync_copy_into_tracked(&[f64::INFINITY], &mut rain_true)
3183                .unwrap();
3184        }
3185
3186        let error = program
3187            .eval_log_z_and_grads_gpu_cached(None)
3188            .expect_err("non-finite downloaded gradients must be rejected");
3189        assert!(
3190            matches!(error, XlogError::Compilation(ref message) if message.contains("gradient") && message.contains("non-finite")),
3191            "unexpected error: {error}"
3192        );
3193    }
3194
3195    #[test]
3196    fn fact_probability_update_rolls_back_metadata_weights_and_fixed_evidence() {
3197        let _gpu_guard = crate::test_gpu_lock::lock();
3198        match CudaDevice::new(0) {
3199            Ok(_) => {}
3200            Err(error) if std::env::var("XLOG_REQUIRE_CUDA").as_deref() == Ok("1") => {
3201                panic!("XLOG_REQUIRE_CUDA=1 but CUDA runtime initialization failed: {error}")
3202            }
3203            Err(error) => {
3204                eprintln!("Skipping test: CUDA runtime unavailable: {error}");
3205                return;
3206            }
3207        }
3208
3209        let mut program = ExactDdnnfProgram::compile_source(
3210            "0.5::rain().\n\
3211             evidence(rain(), true).\n\
3212             query(rain()).\n",
3213        )
3214        .unwrap();
3215        let rain_var = program
3216            .prob_var_map()
3217            .iter()
3218            .enumerate()
3219            .find_map(|(var, info)| {
3220                matches!(info, ProbVarInfo::Fact { atom, .. } if atom.predicate == "rain")
3221                    .then_some(var as u32)
3222            })
3223            .unwrap();
3224        let before = program.evaluate().unwrap();
3225        assert_eq!(before.query_probs.len(), 1);
3226        assert!((before.query_probs[0].prob - 1.0).abs() < 1e-12);
3227
3228        let error = program
3229            .set_fact_probabilities_with_device_failures(
3230                &BTreeMap::from([(rain_var, 0.9)]),
3231                Some(1),
3232                None,
3233            )
3234            .expect_err("failure after the first device write must be reported");
3235        assert!(error.to_string().contains("injected"));
3236
3237        assert!(matches!(
3238            &program.prob_var_map()[rain_var as usize],
3239            ProbVarInfo::Fact { prob, .. } if (*prob - 0.5).abs() < 1e-12
3240        ));
3241        let after = program.evaluate().unwrap();
3242        assert!((after.log_z_e - before.log_z_e).abs() < 1e-12);
3243        assert_eq!(after.query_probs.len(), 1);
3244        assert!((after.query_probs[0].prob - 1.0).abs() < 1e-12);
3245        assert!((after.query_probs[0].prob - before.query_probs[0].prob).abs() < 1e-12);
3246        assert!((after.query_probs[0].log_prob - before.query_probs[0].log_prob).abs() < 1e-12);
3247    }
3248}