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