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