Skip to main content

xlog_prob/mc/
mod.rs

1//! Approximate probabilistic inference via Monte Carlo sampling.
2//!
3//! This engine samples probabilistic facts / annotated disjunction decisions on the GPU and
4//! evaluates the deterministic core in each sampled world.
5//!
6//! For programs with non-monotone recursion (cycles through `not` and/or aggregates),
7//! Monte Carlo evaluation requires the user to opt into the approximate probabilistic
8//! engine. The deterministic evaluation uses a bounded, cycle-aware semantics:
9//!
10//! - If an SCC reaches a fixpoint under synchronous iteration, that fixpoint is used.
11//! - If the SCC enters a cycle, the interpretation is the intersection of all states in the cycle
12//!   (skeptical, invariant tuples only). This avoids parity/oscillation dependence on iteration
13//!   count while remaining fully deterministic and explicit.
14
15mod buffers;
16mod evidence;
17mod resident;
18mod results;
19
20pub use evidence::{EvidenceForcing, ForceabilityReason};
21pub use resident::{
22    compile_resident_plan, McNoHostStats, McResidentResult, ResidentPlan, ResidentRejectKind,
23    ResidentRejection,
24};
25
26use std::collections::BTreeMap;
27#[cfg(feature = "host-io")]
28use std::collections::{HashMap, HashSet};
29use std::sync::Arc;
30
31#[cfg(feature = "host-io")]
32use cudarc::driver::DeviceSlice;
33#[cfg(feature = "host-io")]
34use xlog_core::Schema;
35use xlog_core::{MemoryBudget, Result, XlogError};
36use xlog_cuda::memory::TrackedCudaSlice;
37use xlog_cuda::{CudaDevice, CudaKernelProvider, GpuMemoryManager};
38#[cfg(feature = "host-io")]
39use xlog_logic::ast::{BodyLiteral, Rule};
40use xlog_logic::ast::{Directives, Evidence, ProbMethod, ProbQuery, Program};
41
42use crate::exact::GpuConfig;
43#[cfg(feature = "host-io")]
44use crate::provenance::Value;
45use crate::provenance::{
46    atom_key_from_ground_atom, canonicalize_probabilistic_program,
47    presentation_atom_from_canonical, GroundAtom,
48};
49
50/// Sampling method for Monte Carlo inference.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum McSamplingMethod {
53    /// Sample from prior, discard worlds where evidence is not satisfied.
54    Rejection,
55    /// Force evidence variables in the sampler; every sample counts.
56    EvidenceClamping,
57}
58
59impl McSamplingMethod {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            McSamplingMethod::Rejection => "rejection",
63            McSamplingMethod::EvidenceClamping => "evidence_clamping",
64        }
65    }
66}
67
68impl From<ProbMethod> for McSamplingMethod {
69    fn from(value: ProbMethod) -> Self {
70        match value {
71            ProbMethod::Rejection => Self::Rejection,
72            ProbMethod::EvidenceClamping => Self::EvidenceClamping,
73        }
74    }
75}
76
77/// Strategy for counting evidence-satisfied samples in the MC loop.
78///
79/// In `QueriesOnly` mode (used with evidence clamping), evidence is
80/// guaranteed to hold in every sample, so we skip the truth-kernel's
81/// evidence check and evidence-side buffer allocations.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum McCountStrategy {
84    /// Full path: check both queries and evidence each sample.
85    QueriesAndEvidence,
86    /// Clamped path: evidence is always satisfied; only accumulate query flags.
87    QueriesOnly,
88}
89
90impl McCountStrategy {
91    /// Derive the count strategy from the chosen sampling method.
92    pub fn from_method(method: McSamplingMethod) -> Self {
93        match method {
94            McSamplingMethod::Rejection => Self::QueriesAndEvidence,
95            McSamplingMethod::EvidenceClamping => Self::QueriesOnly,
96        }
97    }
98}
99
100/// Breakdown of time spent in each phase of MC evaluation.
101/// Gate with `XLOG_MC_PROFILE=1` to print at the end of evaluation.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
103pub struct McTimingBreakdown {
104    pub sampler_us: u64,
105    pub sample_reset_us: u64,
106    pub sample_build_us: u64,
107    pub eval_us: u64,
108    pub count_us: u64,
109}
110
111impl McTimingBreakdown {
112    pub fn total_us(&self) -> u64 {
113        self.sampler_us
114            .saturating_add(self.sample_reset_us)
115            .saturating_add(self.sample_build_us)
116            .saturating_add(self.eval_us)
117            .saturating_add(self.count_us)
118    }
119}
120
121/// Bounded semantics for non-monotone SCC evaluation inside MC sampling.
122pub const NONMONOTONE_SEMANTICS: &str = "Synchronous iteration per SCC; if a fixpoint is reached, use it; if a cycle is detected, use the intersection of all states in the cycle (skeptical tuples only); if the iteration budget is exceeded, use the intersection across all visited states (conservative).";
123
124#[derive(Debug, Clone)]
125#[non_exhaustive]
126/// Configuration for Monte Carlo probabilistic inference.
127///
128/// Use [`McEvalConfig::default()`] as a starting point and then update the
129/// individual fields you need.
130pub struct McEvalConfig {
131    /// Number of Monte Carlo samples.
132    pub samples: usize,
133    /// RNG seed (deterministic).
134    pub seed: u64,
135    /// Two-sided confidence level in (0,1) (e.g., 0.95).
136    pub confidence: f64,
137    /// Maximum SCC iteration steps for non-monotone cycle detection.
138    pub max_nonmonotone_iterations: usize,
139    /// Sampling method override. `None` = auto-select (EvidenceClamping when forceable, Rejection otherwise).
140    pub sampling_method: Option<McSamplingMethod>,
141    /// Allow the host CPU oracle ([`McProgram::evaluate_cpu`]) when the
142    /// resident GPU engine rejects the program. Default `false`: a rejected
143    /// program fails closed with the typed rejection instead of silently
144    /// running on the CPU. When set, the oracle result is labeled
145    /// [`McEngine::CpuOracle`] and must never serve as GPU-native evidence.
146    pub allow_cpu_oracle_fallback: bool,
147}
148
149impl Default for McEvalConfig {
150    fn default() -> Self {
151        Self {
152            samples: 10000,
153            seed: 0,
154            confidence: 0.95,
155            max_nonmonotone_iterations: 1024,
156            sampling_method: None,
157            allow_cpu_oracle_fallback: false,
158        }
159    }
160}
161
162impl McEvalConfig {
163    pub fn from_directives(directives: &Directives) -> Result<Self> {
164        let mut cfg = Self::default();
165        if let Some(samples) = directives.prob_samples {
166            cfg.samples = samples;
167        }
168        if let Some(seed) = directives.prob_seed {
169            cfg.seed = seed;
170        }
171        if let Some(confidence) = directives.prob_confidence {
172            cfg.confidence = confidence;
173        }
174        if let Some(iterations) = directives.prob_max_nonmonotone_iterations {
175            cfg.max_nonmonotone_iterations = iterations;
176        }
177        cfg.sampling_method = directives.prob_method.map(McSamplingMethod::from);
178        cfg.validate()?;
179        Ok(cfg)
180    }
181
182    pub fn validate(&self) -> Result<()> {
183        if self.samples == 0 {
184            return Err(XlogError::Compilation(
185                "MC inference requires samples > 0".to_string(),
186            ));
187        }
188        if !(0.0 < self.confidence && self.confidence < 1.0) || self.confidence.is_nan() {
189            return Err(XlogError::Compilation(format!(
190                "MC inference requires 0 < confidence < 1, got {}",
191                self.confidence
192            )));
193        }
194        if self.max_nonmonotone_iterations == 0 {
195            return Err(XlogError::Compilation(
196                "MC inference requires max_nonmonotone_iterations > 0".to_string(),
197            ));
198        }
199        Ok(())
200    }
201}
202
203#[derive(Debug, Clone)]
204pub struct McQueryEstimate {
205    pub atom: GroundAtom,
206    pub prob: f64,
207    pub log_prob: f64,
208    pub stderr: f64,
209    pub ci_low: f64,
210    pub ci_high: f64,
211}
212
213/// Which engine produced an [`McResult`].
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum McEngine {
216    /// GPU-resident megakernel engine — the production MC path.
217    GpuResident,
218    /// Host CPU oracle ([`McProgram::evaluate_cpu`]) — explicit opt-in only
219    /// (see [`McEvalConfig::allow_cpu_oracle_fallback`]); never valid as
220    /// GPU-native or zero-host evidence.
221    CpuOracle,
222}
223
224impl McEngine {
225    /// Stable string form for result metadata surfaces (CLI JSON, pyxlog).
226    pub fn as_str(&self) -> &'static str {
227        match self {
228            McEngine::GpuResident => "gpu-resident",
229            McEngine::CpuOracle => "cpu-oracle",
230        }
231    }
232}
233
234#[derive(Debug, Clone)]
235pub struct McResult {
236    pub total_samples: usize,
237    pub evidence_samples: usize,
238    pub seed: u64,
239    pub confidence: f64,
240    pub query_estimates: Vec<McQueryEstimate>,
241    pub nonmonotone_sccs: usize,
242    pub nonmonotone_cycles: usize,
243    pub nonmonotone_iteration_limit_hits: usize,
244    pub sampling_method: McSamplingMethod,
245    /// Engine that produced this result; CPU-oracle results are reachable
246    /// only through explicit opt-in and are labeled so downstream consumers
247    /// can never mistake them for GPU-resident evidence.
248    pub engine: McEngine,
249}
250
251/// **Legacy back-compat surface** — tracked (data-plane) host<->device transfer
252/// deltas measured around the MC measured region.
253///
254/// This struct dates from the predecessor `a894aab4` engine, which removed only
255/// *tracked* data-plane transfers from a still-host-orchestrated loop. The
256/// current resident megakernel engine has a *stronger* property — **no host
257/// interaction at all** in the measured region — so the authoritative contract
258/// now lives in [`McNoHostStats`] (`McResidentResult::no_host`), which also
259/// counts untracked metadata reads, host fixpoint iterations, and in-region
260/// device allocations. `McDeviceResult` retains this field for API
261/// back-compatibility; for the resident engine its tracked-call fields are zero.
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
263pub struct McHotLoopTransfers {
264    /// Tracked host-to-device calls in the measured region.
265    pub htod_calls: u64,
266    /// Tracked device-to-host calls in the measured region.
267    pub dtoh_calls: u64,
268    /// Tracked host-to-device bytes in the measured region.
269    pub htod_bytes: u64,
270    /// Tracked device-to-host bytes in the measured region.
271    pub dtoh_bytes: u64,
272}
273
274impl McHotLoopTransfers {
275    /// True when no tracked host/device transfer occurred in the measured region.
276    pub fn is_zero(&self) -> bool {
277        self.htod_calls == 0 && self.dtoh_calls == 0
278    }
279}
280
281/// Device-resident Monte Carlo result counts.
282pub struct McDeviceResult {
283    pub query_counts: TrackedCudaSlice<u32>,
284    pub evidence_count: TrackedCudaSlice<u32>,
285    pub total_samples: usize,
286    pub seed: u64,
287    pub confidence: f64,
288    pub nonmonotone_sccs: usize,
289    pub nonmonotone_cycles: usize,
290    pub nonmonotone_iteration_limit_hits: usize,
291    pub sampling_method: McSamplingMethod,
292    /// Legacy back-compat field: tracked transfers measured around the resident
293    /// engine's measured region (zero). The authoritative no-host contract is
294    /// [`McNoHostStats`] on [`McResidentResult`]; see [`McHotLoopTransfers`].
295    pub hot_loop_transfers: McHotLoopTransfers,
296    /// Authoritative no-host counters for the resident engine measured region.
297    pub no_host: McNoHostStats,
298}
299
300#[derive(Debug, Clone)]
301pub(super) struct ProbFactSpec {
302    pub(super) var_idx: usize,
303    pub(super) atom: GroundAtom,
304}
305
306#[derive(Debug, Clone)]
307pub(super) struct AdSpec {
308    pub(super) decision_vars: Vec<usize>,
309    pub(super) choices: Vec<GroundAtom>,
310    #[cfg_attr(not(feature = "host-io"), allow(dead_code))]
311    pub(super) has_none: bool,
312}
313
314#[cfg(feature = "host-io")]
315#[derive(Debug, Clone, Default, PartialEq, Eq)]
316pub(super) struct Relation {
317    pub(super) tuples: HashSet<Vec<Value>>,
318}
319
320#[cfg(feature = "host-io")]
321impl Relation {
322    pub(super) fn insert_tuple(&mut self, tuple: Vec<Value>) {
323        self.tuples.insert(tuple);
324    }
325
326    pub(super) fn contains(&self, tuple: &[Value]) -> bool {
327        self.tuples.contains(tuple)
328    }
329
330    pub(super) fn is_empty(&self) -> bool {
331        self.tuples.is_empty()
332    }
333}
334
335#[cfg(feature = "host-io")]
336#[derive(Debug, Clone)]
337pub(super) enum SccKind {
338    MonotoneNonRecursive,
339    MonotoneRecursive,
340    NonMonotone,
341}
342
343#[cfg(feature = "host-io")]
344#[derive(Debug, Clone)]
345pub(super) struct SccPlan {
346    pub(super) predicates: Vec<String>,
347    pub(super) rules: Vec<Rule>,
348    pub(super) kind: SccKind,
349}
350
351#[cfg_attr(not(feature = "host-io"), allow(dead_code))]
352#[derive(Debug, Clone, Default)]
353pub(super) struct EvalStats {
354    pub(super) nonmonotone_sccs: usize,
355    pub(super) nonmonotone_cycles: usize,
356    pub(super) nonmonotone_iteration_limit_hits: usize,
357}
358
359#[derive(Clone)]
360pub struct McProgram {
361    pub(super) gpu_config: GpuConfig,
362    pub(super) program: Program,
363    #[cfg(feature = "host-io")]
364    pub(super) base_store: HashMap<String, Relation>,
365    #[cfg(feature = "host-io")]
366    pub(super) scc_plans: Vec<SccPlan>,
367    #[cfg(feature = "host-io")]
368    pub(super) arithmetic_schemas: HashMap<String, Schema>,
369    pub(super) queries: Vec<GroundAtom>,
370    #[cfg_attr(not(feature = "host-io"), allow(dead_code))]
371    query_presentations: Vec<GroundAtom>,
372    pub(super) evidence: Vec<(GroundAtom, bool)>,
373    pub(super) bernoulli_probs: Vec<f32>,
374    pub(super) prob_facts: Vec<ProbFactSpec>,
375    pub(super) annotated_disjunctions: Vec<AdSpec>,
376}
377
378impl McProgram {
379    pub fn compile_source(source: &str) -> Result<Self> {
380        let program = xlog_logic::parse_program(source)?;
381        Self::compile_from_program(&program, GpuConfig::default())
382    }
383
384    pub fn compile_source_with_gpu(source: &str, config: GpuConfig) -> Result<Self> {
385        let program = xlog_logic::parse_program(source)?;
386        Self::compile_from_program(&program, config)
387    }
388
389    /// Compile an already parsed program with the requested GPU configuration.
390    ///
391    /// Imports must already be resolved and merged. This method does not load
392    /// unresolved `use` declarations from the filesystem.
393    pub fn compile_from_program(program: &Program, config: GpuConfig) -> Result<Self> {
394        let mut compiled = Self::compile_program(program)?;
395        compiled.gpu_config = config;
396        Ok(compiled)
397    }
398
399    pub fn num_vars(&self) -> usize {
400        self.bernoulli_probs.len()
401    }
402
403    /// Host-facing MC evaluation: runs the resident megakernel engine
404    /// ([`Self::evaluate_gpu_device_with_provider`]) and then **materializes the
405    /// result on the host** by downloading the final query/evidence counts
406    /// *after* the measured region. The download is a host-result
407    /// materialization, not part of the measured region — the no-host property
408    /// belongs to the resident engine, not to this convenience wrapper. Use
409    /// [`Self::evaluate_gpu_device`] when you want device-resident counts with no
410    /// host download at all.
411    ///
412    /// **Fail-closed contract:** if the resident engine rejects the program
413    /// (negation, aggregates, unbounded terms, ...), this returns the typed
414    /// rejection error. The CPU oracle is reachable only via the explicit
415    /// [`McEvalConfig::allow_cpu_oracle_fallback`] opt-in and its result is
416    /// labeled [`McEngine::CpuOracle`].
417    #[cfg(feature = "host-io")]
418    pub fn evaluate(&self, cfg: McEvalConfig) -> Result<McResult> {
419        let provider = Arc::new(self.provider()?);
420        let cfg_clone = cfg.clone();
421        let device_result =
422            match self.evaluate_gpu_device_with_provider(cfg_clone, provider.clone()) {
423                Ok(result) => result,
424                Err(XlogError::Compilation(message))
425                    if message.starts_with("resident MC engine rejected program") =>
426                {
427                    // Fail closed by default: the CPU oracle is an explicit,
428                    // labeled opt-in (`allow_cpu_oracle_fallback`), never a
429                    // silent substitute for the resident GPU engine.
430                    if cfg.allow_cpu_oracle_fallback {
431                        return self.evaluate_cpu(cfg);
432                    }
433                    return Err(XlogError::Compilation(format!(
434                        "{message}; MC fail-closed: set \
435                         McEvalConfig::allow_cpu_oracle_fallback to explicitly \
436                         run the labeled CPU oracle instead"
437                    )));
438                }
439                Err(err) => return Err(err),
440            };
441
442        let mut host_counts = vec![0u32; device_result.query_counts.len()];
443        if !host_counts.is_empty() {
444            provider
445                .device()
446                .inner()
447                .dtoh_sync_copy_into(&device_result.query_counts, &mut host_counts)
448                .map_err(|e| {
449                    XlogError::Kernel(format!("Failed to download MC query counts: {}", e))
450                })?;
451        }
452
453        let mut host_evidence = [0u32];
454        provider
455            .device()
456            .inner()
457            .dtoh_sync_copy_into(&device_result.evidence_count, &mut host_evidence)
458            .map_err(|e| {
459                XlogError::Kernel(format!("Failed to download MC evidence count: {}", e))
460            })?;
461
462        let evidence_samples = if self.evidence.is_empty() {
463            cfg.samples
464        } else {
465            host_evidence[0] as usize
466        };
467
468        if device_result.sampling_method != McSamplingMethod::EvidenceClamping
469            && !self.evidence.is_empty()
470            && evidence_samples == 0
471        {
472            return Err(XlogError::Execution(format!(
473                "MC inference error: evidence was never satisfied across {} samples (seed={})",
474                cfg.samples, cfg.seed
475            )));
476        }
477
478        let z = results::normal_quantile(0.5 + cfg.confidence / 2.0);
479        let mut query_estimates: Vec<McQueryEstimate> = Vec::with_capacity(self.queries.len());
480        for (i, atom) in self.query_presentations.iter().enumerate() {
481            let k = host_counts.get(i).copied().unwrap_or(0) as usize;
482            let (p, stderr, ci_low, ci_high) = results::binomial_estimate(k, evidence_samples, z);
483            let log_prob = if p == 0.0 { f64::NEG_INFINITY } else { p.ln() };
484
485            query_estimates.push(McQueryEstimate {
486                atom: atom.clone(),
487                prob: p,
488                log_prob,
489                stderr,
490                ci_low,
491                ci_high,
492            });
493        }
494
495        Ok(McResult {
496            total_samples: cfg.samples,
497            evidence_samples,
498            seed: cfg.seed,
499            confidence: cfg.confidence,
500            query_estimates,
501            nonmonotone_sccs: device_result.nonmonotone_sccs,
502            nonmonotone_cycles: device_result.nonmonotone_cycles,
503            engine: McEngine::GpuResident,
504            nonmonotone_iteration_limit_hits: device_result.nonmonotone_iteration_limit_hits,
505            sampling_method: device_result.sampling_method,
506        })
507    }
508
509    /// CPU **oracle / debug** MC path. Downloads the full sampled-bit matrix to
510    /// the host and evaluates every sampled world on a host relation store.
511    ///
512    /// This is intentionally *not* GPU-native: it performs a large DtoH of the
513    /// sample matrix and runs the deterministic core on the CPU. It exists solely
514    /// as a deterministic, seed-matched oracle for validating the GPU-native
515    /// device counts (the GPU sampler is shared, so for the same program/seed the
516    /// two paths see identical samples). It must **never** be used as zero-host /
517    /// GPU-native release evidence, and the acceptance matrix excludes it and the
518    /// tests that call it (`tests/gpu_mc_vs_cpu.rs`, `tests/mc.rs`).
519    #[cfg(feature = "host-io")]
520    pub fn evaluate_cpu(&self, cfg: McEvalConfig) -> Result<McResult> {
521        if cfg.samples == 0 {
522            return Err(XlogError::Execution(
523                "MC inference requires samples > 0".to_string(),
524            ));
525        }
526        if !(0.0 < cfg.confidence && cfg.confidence < 1.0) || cfg.confidence.is_nan() {
527            return Err(XlogError::Execution(format!(
528                "MC inference requires 0 < confidence < 1, got {}",
529                cfg.confidence
530            )));
531        }
532        if cfg.max_nonmonotone_iterations == 0 {
533            return Err(XlogError::Execution(
534                "MC inference requires max_nonmonotone_iterations > 0".to_string(),
535            ));
536        }
537
538        let (method, forcing) = self.resolve_sampling_method(cfg.sampling_method)?;
539        let is_clamped = method == McSamplingMethod::EvidenceClamping;
540
541        let mut n_evidence: usize = 0;
542        let mut n_query_true: Vec<usize> = vec![0; self.queries.len()];
543        let mut stats = EvalStats::default();
544
545        let num_vars = self.bernoulli_probs.len();
546        let samples_matrix: Vec<u8> = if num_vars == 0 {
547            Vec::new()
548        } else {
549            let total = num_vars
550                .checked_mul(cfg.samples)
551                .ok_or_else(|| XlogError::Execution("MC sample matrix overflow".to_string()))?;
552            let provider = Arc::new(self.provider()?);
553
554            // Allocate force arrays: upload actual forcing data in clamped mode, zero-fill otherwise
555            let mut d_force_mask = provider.memory().alloc::<u8>(num_vars.max(1))?;
556            let mut d_forced_value = provider.memory().alloc::<u8>(num_vars.max(1))?;
557            if is_clamped {
558                provider
559                    .htod_sync_copy_into_tracked(&forcing.force_mask, &mut d_force_mask)
560                    .map_err(|e| {
561                        XlogError::Kernel(format!("Failed to upload force_mask: {}", e))
562                    })?;
563                provider
564                    .htod_sync_copy_into_tracked(&forcing.forced_value, &mut d_forced_value)
565                    .map_err(|e| {
566                        XlogError::Kernel(format!("Failed to upload forced_value: {}", e))
567                    })?;
568            } else {
569                provider
570                    .device()
571                    .inner()
572                    .memset_zeros(&mut d_force_mask)
573                    .map_err(|e| XlogError::Kernel(format!("Failed to zero force_mask: {}", e)))?;
574                provider
575                    .device()
576                    .inner()
577                    .memset_zeros(&mut d_forced_value)
578                    .map_err(|e| {
579                        XlogError::Kernel(format!("Failed to zero forced_value: {}", e))
580                    })?;
581            }
582
583            let samples_device = provider.sample_bernoulli_matrix_device(
584                &self.bernoulli_probs,
585                cfg.samples,
586                cfg.seed,
587                &d_force_mask.slice(..),
588                &d_forced_value.slice(..),
589            )?;
590            let mut host = vec![0u8; total];
591            if !host.is_empty() {
592                provider
593                    .device()
594                    .inner()
595                    .dtoh_sync_copy_into(&samples_device, &mut host)
596                    .map_err(|e| {
597                        XlogError::Kernel(format!("Failed to download MC samples: {}", e))
598                    })?;
599            }
600            host
601        };
602
603        for sample_idx in 0..cfg.samples {
604            let sample_bits = if num_vars == 0 {
605                &[][..]
606            } else {
607                let start = sample_idx * num_vars;
608                let end = start + num_vars;
609                &samples_matrix[start..end]
610            };
611
612            let mut store = self.base_store.clone();
613            self.apply_sample_facts(&mut store, sample_bits)?;
614
615            let sample_stats = results::evaluate_program_inplace(
616                &self.scc_plans,
617                &mut store,
618                cfg.max_nonmonotone_iterations,
619                &self.arithmetic_schemas,
620            )?;
621            stats.nonmonotone_sccs += sample_stats.nonmonotone_sccs;
622            stats.nonmonotone_cycles += sample_stats.nonmonotone_cycles;
623            stats.nonmonotone_iteration_limit_hits += sample_stats.nonmonotone_iteration_limit_hits;
624
625            // In clamped mode, skip evidence check — all samples count
626            if !is_clamped && !results::evidence_satisfied(&store, &self.evidence) {
627                continue;
628            }
629
630            n_evidence += 1;
631            for (i, q) in self.queries.iter().enumerate() {
632                if results::atom_holds(&store, q) {
633                    n_query_true[i] += 1;
634                }
635            }
636        }
637
638        if !is_clamped && !self.evidence.is_empty() && n_evidence == 0 {
639            return Err(XlogError::Execution(format!(
640                "MC inference error: evidence was never satisfied across {} samples (seed={})",
641                cfg.samples, cfg.seed
642            )));
643        }
644
645        // If there is no evidence (or clamped mode), treat all samples as evidence-satisfying.
646        let denom = if self.evidence.is_empty() || is_clamped {
647            cfg.samples
648        } else {
649            n_evidence
650        };
651
652        let z = results::normal_quantile(0.5 + cfg.confidence / 2.0);
653
654        let mut query_estimates: Vec<McQueryEstimate> = Vec::with_capacity(self.queries.len());
655        for (i, atom) in self.query_presentations.iter().enumerate() {
656            let k = n_query_true[i];
657            let (p, stderr, ci_low, ci_high) = results::binomial_estimate(k, denom, z);
658            let log_prob = if p == 0.0 { f64::NEG_INFINITY } else { p.ln() };
659
660            query_estimates.push(McQueryEstimate {
661                atom: atom.clone(),
662                prob: p,
663                log_prob,
664                stderr,
665                ci_low,
666                ci_high,
667            });
668        }
669
670        Ok(McResult {
671            total_samples: cfg.samples,
672            evidence_samples: denom,
673            seed: cfg.seed,
674            confidence: cfg.confidence,
675            query_estimates,
676            nonmonotone_sccs: stats.nonmonotone_sccs,
677            nonmonotone_cycles: stats.nonmonotone_cycles,
678            nonmonotone_iteration_limit_hits: stats.nonmonotone_iteration_limit_hits,
679            sampling_method: method,
680            engine: McEngine::CpuOracle,
681        })
682    }
683
684    /// Alias for [`Self::evaluate`]: GPU device evaluation followed by host-result
685    /// materialization (final-count download after the measured region). The
686    /// `_gpu` suffix denotes that the *compute* runs on the GPU — it does **not**
687    /// imply a zero-host result, since it returns a host [`McResult`]. For the
688    /// device-resident, no-host-download API use [`Self::evaluate_gpu_device`].
689    #[cfg(feature = "host-io")]
690    pub fn evaluate_gpu(&self, cfg: McEvalConfig) -> Result<McResult> {
691        self.evaluate(cfg)
692    }
693
694    /// GPU-native device-resident MC evaluation via the resident megakernel
695    /// engine ([`resident`]). Returns [`McDeviceResult`] with counts left on the
696    /// device (no host download). The engine evaluates all worlds in a single
697    /// launch with **no host interaction in the measured region** (no host
698    /// sample loop, no per-sample/per-operator host launches or allocations, no
699    /// tracked transfers, and no untracked metadata reads); see
700    /// [`McResidentResult::no_host`] / [`McNoHostStats::is_no_host`] for the full
701    /// measured contract.
702    pub fn evaluate_gpu_device(&self, cfg: McEvalConfig) -> Result<McDeviceResult> {
703        let provider = Arc::new(self.provider()?);
704        self.evaluate_gpu_device_with_provider(cfg, provider)
705    }
706
707    /// GPU-native device-resident MC evaluation using a caller-supplied provider
708    /// (enables provider/buffer reuse across calls). Static setup (arena
709    /// allocation, plan upload, sampling) happens before the measured region;
710    /// the measured region is a single resident-engine launch with **zero host
711    /// interaction** — no host loop over samples or fixpoint iterations, no
712    /// per-sample/per-operator host launches or device allocations, no tracked
713    /// HtoD/DtoH transfers, and no untracked metadata reads. The full contract is
714    /// measured by [`McNoHostStats`] (`McResidentResult::no_host`) and gated by
715    /// `tests/mc_resident.rs`. Counts remain device-resident; the caller decides
716    /// whether/when to download them.
717    pub fn evaluate_gpu_device_with_provider(
718        &self,
719        cfg: McEvalConfig,
720        provider: Arc<CudaKernelProvider>,
721    ) -> Result<McDeviceResult> {
722        // The GPU-resident megakernel engine is the sole MC execution path: there
723        // is no host-orchestrated per-sample fallback. It evaluates ALL worlds in
724        // a single launch with zero host interaction in the measured region, then
725        // returns device-resident counts. Programs outside the supported bounded
726        // fragment fail closed (typed `ResidentRejection`).
727        let r = self.evaluate_resident_with_provider(cfg, provider)?;
728        let no_host = r.no_host;
729        Ok(McDeviceResult {
730            query_counts: r.query_counts,
731            evidence_count: r.evidence_count,
732            total_samples: r.total_samples,
733            seed: r.seed,
734            confidence: r.confidence,
735            // The resident engine accepts only bounded positive Datalog and
736            // device-evaluates its fixpoint; legacy non-monotone SCC bookkeeping
737            // is not part of this engine's reported state.
738            nonmonotone_sccs: 0,
739            nonmonotone_cycles: 0,
740            nonmonotone_iteration_limit_hits: 0,
741            sampling_method: r.sampling_method,
742            // Back-compat surface: `hot_loop_transfers` reports the resident
743            // engine's tracked transfers (zero). Richer no-host evidence (untracked
744            // reads, fixpoint/alloc counts) lives in `McResidentResult::no_host`.
745            hot_loop_transfers: McHotLoopTransfers {
746                htod_calls: no_host.tracked_htod_calls,
747                dtoh_calls: no_host.tracked_dtoh_calls,
748                htod_bytes: 0,
749                dtoh_bytes: 0,
750            },
751            no_host,
752        })
753    }
754
755    fn compile_program(program: &Program) -> Result<Self> {
756        let source_program = program;
757        let arithmetic_schemas = crate::provenance::arithmetic_schemas(program)?;
758        let canonical_program = canonicalize_probabilistic_program(program, &arithmetic_schemas)?;
759        let program = &canonical_program;
760
761        let mut queries: Vec<GroundAtom> = Vec::new();
762        let mut query_presentations: Vec<GroundAtom> = Vec::new();
763        for (ProbQuery { atom }, ProbQuery { atom: source_atom }) in program
764            .prob_queries
765            .iter()
766            .zip(&source_program.prob_queries)
767        {
768            queries.push(atom_key_from_ground_atom(atom)?);
769            query_presentations.push(presentation_atom_from_canonical(
770                source_atom,
771                atom,
772                &arithmetic_schemas,
773            )?);
774        }
775
776        let mut evidence: Vec<(GroundAtom, bool)> = Vec::new();
777        let mut evidence_values: BTreeMap<GroundAtom, bool> = BTreeMap::new();
778        for Evidence { atom, value } in &program.evidence {
779            let atom = atom_key_from_ground_atom(atom)?;
780            match evidence_values.entry(atom.clone()) {
781                std::collections::btree_map::Entry::Occupied(previous)
782                    if previous.get() != value =>
783                {
784                    return Err(XlogError::Execution(format!(
785                        "Monte Carlo inference error: conflicting evidence for {}",
786                        atom.predicate
787                    )));
788                }
789                std::collections::btree_map::Entry::Occupied(_) => continue,
790                std::collections::btree_map::Entry::Vacant(entry) => {
791                    entry.insert(*value);
792                    evidence.push((atom, *value));
793                }
794            }
795        }
796
797        let mut prob_facts = program.prob_facts.clone();
798        buffers::extend_prob_facts_with_coin(program, &mut prob_facts)?;
799        let (bernoulli_probs, prob_facts, annotated_disjunctions) =
800            buffers::compile_sampling_plan(&prob_facts, &program.annotated_disjunctions)?;
801
802        #[cfg(feature = "host-io")]
803        let mut base_store: HashMap<String, Relation> = HashMap::new();
804
805        // Deterministic facts.
806        #[cfg(feature = "host-io")]
807        {
808            for fact in program.facts() {
809                let atom = atom_key_from_ground_atom(&fact.head)?;
810                base_store
811                    .entry(atom.predicate.clone())
812                    .or_default()
813                    .insert_tuple(atom.args);
814            }
815        }
816
817        // Ensure relations exist for all referenced predicates so evaluation treats missing as empty,
818        // but never errors due to an unknown predicate (CPU eval path only).
819        #[cfg(feature = "host-io")]
820        {
821            let mut referenced: HashSet<String> = HashSet::new();
822            for rule in &program.rules {
823                referenced.insert(rule.head.predicate.clone());
824                for lit in &rule.body {
825                    match lit {
826                        BodyLiteral::Positive(a) | BodyLiteral::Negated(a) => {
827                            referenced.insert(a.predicate.clone());
828                        }
829                        BodyLiteral::Epistemic(lit) => {
830                            referenced.insert(lit.atom.predicate.clone());
831                        }
832                        BodyLiteral::Comparison(_)
833                        | BodyLiteral::IsExpr(_)
834                        | BodyLiteral::Univ(_) => {}
835                    }
836                }
837            }
838            for pf in &program.prob_facts {
839                referenced.insert(pf.atom.predicate.clone());
840            }
841            for ad in &program.annotated_disjunctions {
842                for pf in &ad.choices {
843                    referenced.insert(pf.atom.predicate.clone());
844                }
845            }
846            for q in &queries {
847                referenced.insert(q.predicate.clone());
848            }
849            for (e, _) in &evidence {
850                referenced.insert(e.predicate.clone());
851            }
852            for pred in referenced {
853                base_store.entry(pred).or_default();
854            }
855        }
856
857        #[cfg(feature = "host-io")]
858        let scc_plans = results::build_scc_plans(program)?;
859        Ok(Self {
860            gpu_config: GpuConfig::default(),
861            program: program.clone(),
862            #[cfg(feature = "host-io")]
863            base_store,
864            #[cfg(feature = "host-io")]
865            scc_plans,
866            #[cfg(feature = "host-io")]
867            arithmetic_schemas,
868            queries,
869            query_presentations,
870            evidence,
871            bernoulli_probs,
872            prob_facts,
873            annotated_disjunctions,
874        })
875    }
876
877    fn provider(&self) -> Result<CudaKernelProvider> {
878        if self.gpu_config.memory_bytes == 0 {
879            return Err(XlogError::Kernel(
880                "GPU memory budget must be non-zero".to_string(),
881            ));
882        }
883
884        let device = Arc::new(CudaDevice::new(self.gpu_config.device_ordinal)?);
885        let memory = Arc::new(GpuMemoryManager::new(
886            device.clone(),
887            MemoryBudget::with_limit(self.gpu_config.memory_bytes),
888        ));
889        CudaKernelProvider::new(device, memory)
890    }
891}