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::{
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 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#[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#[derive(Debug, Clone, Copy)]
149#[non_exhaustive]
150pub struct GpuConfig {
151 pub device_ordinal: usize,
153 pub memory_bytes: u64,
155 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, 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 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#[derive(Debug, Clone)]
362pub enum ProbVarInfo {
363 Fact { atom: GroundAtom, prob: f64 },
368 Choice {
370 choices: Arc<[(GroundAtom, f64)]>,
374 choice_index: usize,
376 prob: f64,
385 },
386 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)] gpu_config: GpuConfig,
425 last_compile_profile: Option<CircuitCompileProfile>,
427 prob_var_entries: Vec<(u32, ProbVarInfo)>,
445 #[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 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)] 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 pub fn last_compile_profile(&self) -> Option<&CircuitCompileProfile> {
497 self.last_compile_profile.as_ref()
498 }
499
500 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 #[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 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 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 #[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 pub(crate) fn query_var(&self, idx: usize) -> Option<u32> {
887 self.queries.get(idx).and_then(|q| q.var)
888 }
889
890 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 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 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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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.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 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 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 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 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 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 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 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 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 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, 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 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 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 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 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 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 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 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 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 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}