Skip to main content

xlog_prob/mc/
resident.rs

1//! GPU-resident Datalog/MC execution engine.
2//!
3//! This module replaces the host-orchestrated per-sample MC loop with a single
4//! device megakernel (`mc_resident_engine`) that evaluates *all* Monte Carlo
5//! worlds to a device-side fixpoint and counts query/evidence satisfaction with
6//! **zero host interaction inside the measured region**: no host loop over
7//! samples, no per-sample host kernel sequencing, no host metadata reads
8//! (tracked or untracked).
9//!
10//! Supported fragment (bounded-domain positive Datalog), checked structurally —
11//! never by predicate name:
12//!   * predicate arity <= [`MAX_ARITY`];
13//!   * rule body length <= [`MAX_BODY`] positive literals (no negation,
14//!     comparison, arithmetic, epistemic, aggregate, or univ literals);
15//!   * <= [`MAX_VARS`] distinct variables per rule;
16//!   * all terms are variables or ground constants (no lists/compounds/functors);
17//!   * bounded Herbrand universe: `domain^arity` slots, with the total universe
18//!     and per-predicate domain capped so one world fits one CUDA block.
19//!   * positive joins are lowered to device rule records and evaluated from a
20//!     world-segmented sparse column arena (`slot`, `arg0`, `arg1`) with
21//!     device row counters and static device offsets; the dense bitset is only
22//!     a device-side membership/dedup index.
23//!   * worst-case sparse/WCOJ arena bounds are checked before device allocation
24//!     when `XLOG_MC_RESIDENT_MEMORY_BUDGET_BYTES` is set.
25//!
26//! Anything outside the fragment is rejected **before execution** with a typed
27//! [`ResidentRejection`] (fail-closed; no CPU fallback).
28
29use std::collections::BTreeMap;
30use std::sync::Arc;
31
32use cudarc::driver::LaunchConfig;
33use xlog_core::{Result, XlogError};
34use xlog_cuda::memory::TrackedCudaSlice;
35use xlog_cuda::provider::{mc_resident_kernels, MC_RESIDENT_MODULE};
36use xlog_cuda::{CudaKernelProvider, LaunchAsync};
37use xlog_logic::ast::{Atom, BodyLiteral, Term};
38
39use super::{McEvalConfig, McProgram, McSamplingMethod};
40use crate::provenance::{GroundAtom, Value};
41
42/// Maximum supported predicate arity.
43pub(super) const MAX_ARITY: usize = 3;
44/// Maximum supported rule body length (positive literals).
45pub(super) const MAX_BODY: usize = 3;
46/// Maximum supported distinct variables per rule.
47pub(super) const MAX_VARS: usize = 8;
48/// Maximum supported bounded-universe slot count (one world must fit one block's
49/// working set comfortably).
50pub(super) const MAX_UNIVERSE: usize = 1 << 16;
51/// Maximum supported domain size (distinct constants).
52pub(super) const MAX_DOMAIN: usize = 256;
53/// u32 width of one device atom record: base, arity, arg0, arg1, arg2, stride0.
54const ATOM_REC: usize = 6;
55/// u32 width of one device rule record: n_body, n_vars, domain, head, body0..body2.
56const RULE_REC: usize = 3 + 4 * ATOM_REC;
57/// Device encoding flag marking an arg spec as a bound constant.
58const CONST_FLAG: u32 = 0x8000_0000;
59const RESIDENT_BUDGET_ENV: &str = "XLOG_MC_RESIDENT_MEMORY_BUDGET_BYTES";
60const RESIDENT_BLOCKS_PER_WORLD_ENV: &str = "XLOG_MC_RESIDENT_BLOCKS_PER_WORLD";
61
62/// Kind of a fail-closed rejection of an MC program by the resident engine.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ResidentRejectKind {
65    /// A body literal uses negation.
66    Negation,
67    /// A body literal is epistemic (`know`/`possible`).
68    EpistemicLiteral,
69    /// A body literal is a comparison / arithmetic / univ (non-relational).
70    NonRelationalLiteral,
71    /// A predicate arity exceeds [`MAX_ARITY`].
72    ArityTooHigh,
73    /// A rule body has more than [`MAX_BODY`] literals.
74    BodyTooLong,
75    /// A rule uses more than [`MAX_VARS`] distinct variables.
76    TooManyVars,
77    /// A term is not a variable or ground constant (list/compound/functor/agg).
78    UnboundedTerm,
79    /// The bounded domain exceeds [`MAX_DOMAIN`].
80    DomainTooLarge,
81    /// The bounded universe exceeds [`MAX_UNIVERSE`].
82    UniverseTooLarge,
83    /// A predicate appears with inconsistent arity.
84    InconsistentArity,
85    /// Annotated disjunctions are not yet supported by the resident engine.
86    AnnotatedDisjunctionUnsupported,
87}
88
89impl ResidentRejectKind {
90    /// Returns the stable machine-readable rejection name.
91    pub fn as_str(self) -> &'static str {
92        match self {
93            ResidentRejectKind::Negation => "negation",
94            ResidentRejectKind::EpistemicLiteral => "epistemic_literal",
95            ResidentRejectKind::NonRelationalLiteral => "non_relational_literal",
96            ResidentRejectKind::ArityTooHigh => "arity_too_high",
97            ResidentRejectKind::BodyTooLong => "body_too_long",
98            ResidentRejectKind::TooManyVars => "too_many_vars",
99            ResidentRejectKind::UnboundedTerm => "unbounded_term",
100            ResidentRejectKind::DomainTooLarge => "domain_too_large",
101            ResidentRejectKind::UniverseTooLarge => "universe_too_large",
102            ResidentRejectKind::InconsistentArity => "inconsistent_arity",
103            ResidentRejectKind::AnnotatedDisjunctionUnsupported => {
104                "annotated_disjunction_unsupported"
105            }
106        }
107    }
108}
109
110/// A typed fail-closed rejection: which rule was violated, the offending
111/// construct, and the surrounding context (for diagnostics).
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ResidentRejection {
114    /// Rejection category.
115    pub kind: ResidentRejectKind,
116    /// Unsupported or unbounded program construct.
117    pub construct: String,
118    /// Source context explaining where the construct occurred.
119    pub context: String,
120}
121
122impl ResidentRejection {
123    fn err(
124        kind: ResidentRejectKind,
125        construct: impl Into<String>,
126        context: impl Into<String>,
127    ) -> Self {
128        ResidentRejection {
129            kind,
130            construct: construct.into(),
131            context: context.into(),
132        }
133    }
134
135    /// Convert to the engine's unified error type while preserving the kind +
136    /// construct + context in the message for callers that only see `XlogError`.
137    pub fn into_error(self) -> XlogError {
138        XlogError::Compilation(format!(
139            "resident MC engine rejected program [kind={}] construct=`{}` context=`{}`",
140            self.kind.as_str(),
141            self.construct,
142            self.context
143        ))
144    }
145}
146
147/// Canonical key for a ground constant, unifying `Term` literals and `Value`s so
148/// the same constant in a rule and in a fact map to one domain index.
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
150enum ConstKey {
151    Int(i64),
152    Sym(u32),
153    Str(String),
154    FloatBits(u64),
155}
156
157impl ConstKey {
158    fn from_value(v: &Value) -> ConstKey {
159        match v {
160            Value::I64(i) => ConstKey::Int(*i),
161            Value::Symbol(s) => ConstKey::Sym(*s),
162            Value::String(s) => ConstKey::Str(s.clone()),
163            Value::F64(bits) => ConstKey::FloatBits(*bits),
164        }
165    }
166
167    /// Map a rule term to either a constant key or a variable name.
168    fn from_term(t: &Term) -> std::result::Result<TermClass, ResidentRejection> {
169        match t {
170            Term::Variable(name) => Ok(TermClass::Var(name.clone())),
171            Term::Integer(i) => Ok(TermClass::Const(ConstKey::Int(*i))),
172            Term::Symbol(s) => Ok(TermClass::Const(ConstKey::Sym(*s))),
173            Term::String(s) => Ok(TermClass::Const(ConstKey::Str(s.clone()))),
174            Term::Float(f) => Ok(TermClass::Const(ConstKey::FloatBits(f.to_bits()))),
175            other => Err(ResidentRejection::err(
176                ResidentRejectKind::UnboundedTerm,
177                format!("{:?}", other),
178                "rule term must be a variable or ground constant",
179            )),
180        }
181    }
182}
183
184enum TermClass {
185    Var(String),
186    Const(ConstKey),
187}
188
189/// No-host-interaction instrumentation for the measured engine region.
190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
191pub struct McNoHostStats {
192    /// Tracked data-plane host-to-device calls inside the measured region.
193    pub tracked_htod_calls: u64,
194    /// Tracked data-plane device-to-host calls inside the measured region.
195    pub tracked_dtoh_calls: u64,
196    /// Untracked control-plane metadata reads inside the measured region.
197    pub untracked_metadata_reads: u64,
198    /// Number of kernel launches the engine issued inside the measured region.
199    pub engine_launches: u64,
200    /// Number of host-side per-sample loop iterations inside the measured region
201    /// (structurally zero: the engine has no host sample loop).
202    pub host_loop_iterations: u64,
203    /// Number of per-sample host launches inside the measured region
204    /// (structurally zero: one global launch covers all worlds).
205    pub per_sample_host_launches: u64,
206    /// Number of host-side fixpoint-loop iterations inside the measured region
207    /// (structurally zero: recursion converges device-side). Required by the
208    /// WCOJ world-batched acceptance contract.
209    pub host_fixpoint_iterations: u64,
210    /// Number of device allocations issued inside the measured region (must be
211    /// zero: every arena is pre-allocated before the region). Backed by the
212    /// memory manager's `alloc_count` snapshot.
213    pub per_operator_host_allocations: u64,
214}
215
216impl McNoHostStats {
217    /// True iff the measured region had **no host interaction**: no tracked
218    /// transfers, no untracked metadata reads, no host sample loop, no
219    /// per-sample host launches, no host fixpoint loop, no in-region device
220    /// allocations. (A single global engine launch is permitted and is *not*
221    /// per-sample; see [`Self::engine_launches`].)
222    pub fn is_no_host(&self) -> bool {
223        self.tracked_htod_calls == 0
224            && self.tracked_dtoh_calls == 0
225            && self.untracked_metadata_reads == 0
226            && self.host_loop_iterations == 0
227            && self.per_sample_host_launches == 0
228            && self.host_fixpoint_iterations == 0
229            && self.per_operator_host_allocations == 0
230    }
231}
232
233/// Device-resident result of a GPU-resident MC run. Counts stay on device; the
234/// caller decides whether/when to download them (after the measured region).
235pub struct McResidentResult {
236    /// Device count of accepted worlds satisfying each query.
237    pub query_counts: TrackedCudaSlice<u32>,
238    /// Device count of worlds satisfying all evidence.
239    pub evidence_count: TrackedCudaSlice<u32>,
240    /// Per-world fixpoint iteration count (device-resident).
241    pub iter_trace: TrackedCudaSlice<u32>,
242    /// Final sparse row count per world (device-resident).
243    pub sparse_final_row_counts: TrackedCudaSlice<u32>,
244    /// World-segment offsets for the sparse arena, populated by the resident
245    /// kernel (device-resident).
246    pub sparse_offsets: TrackedCudaSlice<u32>,
247    /// Per-world resident status flags, populated by the resident kernel:
248    /// `[postflight_summary(3); converged_flags; sparse_overflow_flags;
249    /// block_participation; changed_flags; global_continue]`.
250    pub resident_status_flags: TrackedCudaSlice<u32>,
251    /// Number of sampled worlds.
252    pub total_samples: usize,
253    /// Random seed used for sampling.
254    pub seed: u64,
255    /// Requested confidence level for reported intervals.
256    pub confidence: f64,
257    /// Evidence-sampling strategy used by the resident engine.
258    pub sampling_method: McSamplingMethod,
259    /// Number of query atoms (== `query_counts.len()`).
260    pub num_queries: usize,
261    /// Transfer, launch, and host-loop measurements for the resident region.
262    pub no_host: McNoHostStats,
263}
264
265/// Compiled, device-uploadable plan for the resident engine.
266#[derive(Debug, Clone)]
267pub struct ResidentPlan {
268    /// Number of possible grounded predicate slots per world.
269    pub universe_size: u32,
270    /// Number of constants in the bounded grounding domain.
271    pub domain_size: u32,
272    /// Maximum resident fixpoint iterations per world.
273    pub max_iters: u32,
274    edb_slots: Vec<u32>,
275    pf_slot: Vec<u32>,
276    pf_var: Vec<u32>,
277    rule_data: Vec<u32>,
278    num_rules: u32,
279    q_slot: Vec<u32>,
280    ev_slot: Vec<u32>,
281    ev_expected: Vec<u8>,
282    ad_data: Vec<u32>,
283    num_ads: u32,
284    /// Number of independent Bernoulli variables sampled per world.
285    pub num_vars: usize,
286    bernoulli_probs: Vec<f32>,
287}
288
289const RESIDENT_POSTFLIGHT_WORDS: usize = 3;
290const RESIDENT_WORLD_STATUS_SECTIONS: usize = 4;
291const RESIDENT_CONTROL_WORDS: usize = 1;
292
293fn resident_status_words(num_worlds: u32) -> usize {
294    (num_worlds as usize)
295        .saturating_mul(RESIDENT_WORLD_STATUS_SECTIONS)
296        .saturating_add(RESIDENT_POSTFLIGHT_WORDS)
297        .saturating_add(RESIDENT_CONTROL_WORDS)
298        .max(1)
299}
300
301fn validate_resident_postflight(
302    summary: &[u32],
303    total_worlds: u32,
304    iteration_limit: u32,
305    sparse_cap: usize,
306) -> Result<()> {
307    let [unconverged_worlds, overflowed_worlds, minimum_required_sparse_rows] = summary else {
308        return Err(XlogError::Kernel(format!(
309            "resident postflight summary length {}, expected {}",
310            summary.len(),
311            RESIDENT_POSTFLIGHT_WORDS
312        )));
313    };
314
315    if *overflowed_worlds > 0 {
316        let minimum_required = sparse_cap.saturating_add(1) as u64;
317        return Err(XlogError::CapacityExceeded {
318            context: format!(
319                "resident Monte Carlo sparse relation arena ({} of {} worlds overflowed)",
320                overflowed_worlds, total_worlds
321            ),
322            required: u64::from(*minimum_required_sparse_rows).max(minimum_required),
323            limit: sparse_cap as u64,
324            unit: "rows per world".to_string(),
325        });
326    }
327
328    if *unconverged_worlds > 0 {
329        return Err(XlogError::ConvergenceFailure {
330            context: "resident Monte Carlo fixpoint".to_string(),
331            unconverged_worlds: u64::from(*unconverged_worlds),
332            total_worlds: u64::from(total_worlds),
333            iteration_limit: u64::from(iteration_limit),
334        });
335    }
336
337    Ok(())
338}
339
340fn resident_memory_budget_bytes() -> Result<Option<u64>> {
341    match std::env::var(RESIDENT_BUDGET_ENV) {
342        Ok(raw) => raw.parse::<u64>().map(Some).map_err(|e| {
343            XlogError::Execution(format!("invalid {RESIDENT_BUDGET_ENV} value `{raw}`: {e}"))
344        }),
345        Err(std::env::VarError::NotPresent) => Ok(None),
346        Err(e) => Err(XlogError::Execution(format!(
347            "invalid {RESIDENT_BUDGET_ENV}: {e}"
348        ))),
349    }
350}
351
352fn resident_blocks_per_world() -> Result<u32> {
353    match std::env::var(RESIDENT_BLOCKS_PER_WORLD_ENV) {
354        Ok(raw) => {
355            let blocks = raw.parse::<u32>().map_err(|e| {
356                XlogError::Execution(format!(
357                    "invalid {RESIDENT_BLOCKS_PER_WORLD_ENV} value `{raw}`: {e}"
358                ))
359            })?;
360            if blocks == 0 {
361                return Err(XlogError::Execution(format!(
362                    "invalid {RESIDENT_BLOCKS_PER_WORLD_ENV} value `{raw}`: must be >= 1"
363                )));
364            }
365            Ok(blocks)
366        }
367        Err(std::env::VarError::NotPresent) => Ok(1),
368        Err(e) => Err(XlogError::Execution(format!(
369            "invalid {RESIDENT_BLOCKS_PER_WORLD_ENV}: {e}"
370        ))),
371    }
372}
373
374fn sat_mul(a: u64, b: u64) -> u64 {
375    a.saturating_mul(b)
376}
377
378fn sat_pow(mut base: u64, mut exp: u32) -> u64 {
379    let mut acc = 1u64;
380    while exp > 0 {
381        if exp & 1 == 1 {
382            acc = sat_mul(acc, base);
383        }
384        exp >>= 1;
385        if exp > 0 {
386            base = sat_mul(base, base);
387        }
388    }
389    acc
390}
391
392fn estimate_resident_bound_bytes(plan: &ResidentPlan, num_worlds: u32) -> u64 {
393    let worlds = num_worlds.max(1) as u64;
394    let vars = plan.num_vars.max(1) as u64;
395    let universe = plan.universe_size.max(1) as u64;
396    let meta_words = plan
397        .edb_slots
398        .len()
399        .saturating_add(plan.pf_slot.len())
400        .saturating_add(plan.pf_var.len())
401        .saturating_add(plan.rule_data.len())
402        .saturating_add(plan.q_slot.len())
403        .saturating_add(plan.ev_slot.len())
404        .saturating_add(plan.ev_expected.len())
405        .saturating_add(plan.ad_data.len())
406        .saturating_add(18);
407
408    let sparse_cap = universe;
409    let setup_bytes = sat_mul(2, vars)
410        .saturating_add(sat_mul(worlds, vars))
411        .saturating_add(sat_mul(sat_mul(sat_mul(worlds, 2), universe), 4))
412        .saturating_add(sat_mul(sat_mul(sat_mul(worlds, 2), sparse_cap), 16))
413        .saturating_add(sat_mul(sat_mul(worlds, 2), 4))
414        .saturating_add(sat_mul(worlds, 4))
415        .saturating_add(sat_mul(worlds.saturating_add(1), 4))
416        .saturating_add(sat_mul(worlds.saturating_mul(4).saturating_add(4), 4))
417        .saturating_add(sat_mul(meta_words as u64, 4))
418        .saturating_add(sat_mul(plan.q_slot.len().max(1) as u64, 4))
419        .saturating_add(4)
420        .saturating_add(sat_mul(worlds, 4));
421
422    let mut sparse_join_bytes = 0u64;
423    for rule in plan.rule_data.as_chunks::<RULE_REC>().0 {
424        let n_body = rule[0];
425        if n_body < 2 {
426            continue;
427        }
428        let n_vars = rule[1];
429        let assignments = sat_pow(plan.domain_size.max(1) as u64, n_vars);
430        let row_words = (n_body as u64).saturating_add(1);
431        sparse_join_bytes =
432            sparse_join_bytes.max(sat_mul(sat_mul(worlds, assignments), row_words * 4));
433    }
434
435    setup_bytes.saturating_add(sparse_join_bytes)
436}
437
438struct PredInfo {
439    arity: usize,
440    base: u32,
441}
442
443struct Universe {
444    domain: BTreeMap<ConstKey, u32>,
445    preds: BTreeMap<String, PredInfo>,
446    domain_size: u32,
447}
448
449impl Universe {
450    fn stride0(&self, arity: usize) -> u32 {
451        if arity >= 2 {
452            self.domain_size.pow((arity - 1) as u32)
453        } else {
454            1
455        }
456    }
457
458    fn arg_stride(&self, arity: usize, arg_idx: usize) -> u32 {
459        if arity <= arg_idx + 1 {
460            1
461        } else {
462            self.domain_size.pow((arity - arg_idx - 1) as u32)
463        }
464    }
465
466    /// Slot id of a ground atom (predicate + constant args).
467    fn ground_slot(&self, atom: &GroundAtom) -> std::result::Result<u32, ResidentRejection> {
468        let info = self.preds.get(&atom.predicate).ok_or_else(|| {
469            ResidentRejection::err(
470                ResidentRejectKind::InconsistentArity,
471                atom.predicate.clone(),
472                "ground atom references unknown predicate",
473            )
474        })?;
475        if atom.args.len() != info.arity {
476            return Err(ResidentRejection::err(
477                ResidentRejectKind::InconsistentArity,
478                atom.predicate.clone(),
479                format!("expected arity {} got {}", info.arity, atom.args.len()),
480            ));
481        }
482        let mut slot = info.base;
483        for (i, v) in atom.args.iter().enumerate() {
484            let key = ConstKey::from_value(v);
485            let idx = *self.domain.get(&key).ok_or_else(|| {
486                ResidentRejection::err(
487                    ResidentRejectKind::UnboundedTerm,
488                    format!("{:?}", v),
489                    "ground constant absent from bounded domain",
490                )
491            })?;
492            slot += idx * self.arg_stride(info.arity, i);
493        }
494        Ok(slot)
495    }
496}
497
498/// Compile an [`McProgram`] into a resident-engine plan, or return a typed
499/// fail-closed rejection for anything outside the supported fragment.
500pub fn compile_resident_plan(
501    mc: &McProgram,
502) -> std::result::Result<ResidentPlan, ResidentRejection> {
503    let program = &mc.program;
504
505    // --- 1. Gather predicate arities (consistency-checked). ---
506    let mut arities: BTreeMap<String, usize> = BTreeMap::new();
507    let mut note_pred = |pred: &str, arity: usize| -> std::result::Result<(), ResidentRejection> {
508        if arity > MAX_ARITY {
509            return Err(ResidentRejection::err(
510                ResidentRejectKind::ArityTooHigh,
511                pred.to_string(),
512                format!("arity {} exceeds max {}", arity, MAX_ARITY),
513            ));
514        }
515        match arities.get(pred) {
516            Some(&existing) if existing != arity => Err(ResidentRejection::err(
517                ResidentRejectKind::InconsistentArity,
518                pred.to_string(),
519                format!("arity {} vs {}", existing, arity),
520            )),
521            _ => {
522                arities.insert(pred.to_string(), arity);
523                Ok(())
524            }
525        }
526    };
527
528    // --- 2. Collect constants for the bounded domain. ---
529    let mut domain: BTreeMap<ConstKey, u32> = BTreeMap::new();
530    let mut note_const = |key: ConstKey, domain: &mut BTreeMap<ConstKey, u32>| {
531        let next = domain.len() as u32;
532        domain.entry(key).or_insert(next);
533    };
534
535    // Deterministic facts.
536    for fact in program.facts() {
537        note_pred(&fact.head.predicate, fact.head.terms.len())?;
538        for t in &fact.head.terms {
539            match ConstKey::from_term(t)? {
540                TermClass::Const(k) => note_const(k, &mut domain),
541                TermClass::Var(_) => {
542                    return Err(ResidentRejection::err(
543                        ResidentRejectKind::UnboundedTerm,
544                        fact.head.predicate.clone(),
545                        "fact head contains a variable",
546                    ))
547                }
548            }
549        }
550    }
551    // Prob facts (ground).
552    for pf in &mc.prob_facts {
553        note_pred(&pf.atom.predicate, pf.atom.args.len())?;
554        for v in &pf.atom.args {
555            note_const(ConstKey::from_value(v), &mut domain);
556        }
557    }
558    // Queries + evidence (ground).
559    for q in &mc.queries {
560        note_pred(&q.predicate, q.args.len())?;
561        for v in &q.args {
562            note_const(ConstKey::from_value(v), &mut domain);
563        }
564    }
565    for (e, _) in &mc.evidence {
566        note_pred(&e.predicate, e.args.len())?;
567        for v in &e.args {
568            note_const(ConstKey::from_value(v), &mut domain);
569        }
570    }
571    // Annotated-disjunction choice atoms (ground).
572    for ad in &mc.annotated_disjunctions {
573        for atom in &ad.choices {
574            note_pred(&atom.predicate, atom.args.len())?;
575            for v in &atom.args {
576                note_const(ConstKey::from_value(v), &mut domain);
577            }
578        }
579    }
580    // Rules: collect predicates + constants (variables ranged over the domain).
581    for rule in &program.rules {
582        if rule.is_fact() {
583            continue;
584        }
585        note_pred(&rule.head.predicate, rule.head.terms.len())?;
586        collect_atom_consts(&rule.head, &mut domain, &mut note_const)?;
587        if rule.body.len() > MAX_BODY {
588            return Err(ResidentRejection::err(
589                ResidentRejectKind::BodyTooLong,
590                rule.head.predicate.clone(),
591                format!("body length {} exceeds max {}", rule.body.len(), MAX_BODY),
592            ));
593        }
594        for lit in &rule.body {
595            let atom = classify_body_literal(lit, &rule.head.predicate)?;
596            note_pred(&atom.predicate, atom.terms.len())?;
597            collect_atom_consts(atom, &mut domain, &mut note_const)?;
598        }
599    }
600
601    if domain.len() > MAX_DOMAIN {
602        return Err(ResidentRejection::err(
603            ResidentRejectKind::DomainTooLarge,
604            format!("{} constants", domain.len()),
605            format!("domain exceeds max {}", MAX_DOMAIN),
606        ));
607    }
608    let domain_size = domain.len() as u32;
609
610    // --- 3. Assign predicate slot blocks (deterministic order). ---
611    let mut preds: BTreeMap<String, PredInfo> = BTreeMap::new();
612    let mut base: u64 = 0;
613    for (pred, &arity) in &arities {
614        let slot_count: u64 = if arity == 0 {
615            1
616        } else {
617            (domain_size as u64).pow(arity as u32)
618        };
619        preds.insert(
620            pred.clone(),
621            PredInfo {
622                arity,
623                base: base as u32,
624            },
625        );
626        base += slot_count;
627        if base > MAX_UNIVERSE as u64 {
628            return Err(ResidentRejection::err(
629                ResidentRejectKind::UniverseTooLarge,
630                format!("{} slots", base),
631                format!("universe exceeds max {}", MAX_UNIVERSE),
632            ));
633        }
634    }
635    let universe_size = base as u32;
636
637    let universe = Universe {
638        domain,
639        preds,
640        domain_size,
641    };
642
643    // --- 4. Lower EDB facts, prob facts, queries, evidence to slots. ---
644    let mut edb_slots = Vec::new();
645    for fact in program.facts() {
646        let ga = ground_atom_from_atom(&fact.head)?;
647        edb_slots.push(universe.ground_slot(&ga)?);
648    }
649    let mut pf_slot = Vec::new();
650    let mut pf_var = Vec::new();
651    for pf in &mc.prob_facts {
652        pf_slot.push(universe.ground_slot(&pf.atom)?);
653        pf_var.push(pf.var_idx as u32);
654    }
655    let mut q_slot = Vec::new();
656    for q in &mc.queries {
657        q_slot.push(universe.ground_slot(q)?);
658    }
659    let mut ev_slot = Vec::new();
660    let mut ev_expected = Vec::new();
661    for (e, v) in &mc.evidence {
662        ev_slot.push(universe.ground_slot(e)?);
663        ev_expected.push(if *v { 1u8 } else { 0u8 });
664    }
665
666    // --- 5. Lower rules to device records. ---
667    let mut rule_data = Vec::new();
668    let mut num_rules = 0u32;
669    for rule in &program.rules {
670        if rule.is_fact() {
671            continue;
672        }
673        let rec = lower_rule(rule, &universe)?;
674        rule_data.extend_from_slice(&rec);
675        num_rules += 1;
676    }
677
678    // --- 6. Lower annotated disjunctions to device records. ---
679    // Each record: [n_choices, n_dvars, dvar_0.., slot_0..]. The kernel walks the
680    // conditional-Bernoulli chain (first firing decision wins; residual = last
681    // choice or "none").
682    let mut ad_data: Vec<u32> = Vec::new();
683    let mut num_ads = 0u32;
684    for ad in &mc.annotated_disjunctions {
685        let n_choices = ad.choices.len() as u32;
686        let n_dvars = ad.decision_vars.len() as u32;
687        ad_data.push(n_choices);
688        ad_data.push(n_dvars);
689        for &dv in &ad.decision_vars {
690            ad_data.push(u32::try_from(dv).map_err(|_| {
691                ResidentRejection::err(
692                    ResidentRejectKind::UnboundedTerm,
693                    "decision_var",
694                    "AD decision var index exceeds u32",
695                )
696            })?);
697        }
698        for atom in &ad.choices {
699            ad_data.push(universe.ground_slot(atom)?);
700        }
701        num_ads += 1;
702    }
703
704    // Iteration cap: at most `universe_size` distinct atoms can be derived, so a
705    // monotone fixpoint must converge within that many passes. +1 for the final
706    // no-change confirmation pass.
707    let max_iters = universe_size.saturating_add(1).max(1);
708
709    Ok(ResidentPlan {
710        universe_size,
711        domain_size,
712        max_iters,
713        edb_slots,
714        pf_slot,
715        pf_var,
716        rule_data,
717        num_rules,
718        q_slot,
719        ev_slot,
720        ev_expected,
721        ad_data,
722        num_ads,
723        num_vars: mc.bernoulli_probs.len(),
724        bernoulli_probs: mc.bernoulli_probs.clone(),
725    })
726}
727
728fn collect_atom_consts<F: FnMut(ConstKey, &mut BTreeMap<ConstKey, u32>)>(
729    atom: &Atom,
730    domain: &mut BTreeMap<ConstKey, u32>,
731    note_const: &mut F,
732) -> std::result::Result<(), ResidentRejection> {
733    if atom.terms.len() > MAX_ARITY {
734        return Err(ResidentRejection::err(
735            ResidentRejectKind::ArityTooHigh,
736            atom.predicate.clone(),
737            format!("arity {} exceeds max {}", atom.terms.len(), MAX_ARITY),
738        ));
739    }
740    for t in &atom.terms {
741        if let TermClass::Const(k) = ConstKey::from_term(t)? {
742            note_const(k, domain);
743        }
744    }
745    Ok(())
746}
747
748/// Classify a body literal: only positive relational atoms are supported.
749fn classify_body_literal<'a>(
750    lit: &'a BodyLiteral,
751    rule_ctx: &str,
752) -> std::result::Result<&'a Atom, ResidentRejection> {
753    match lit {
754        BodyLiteral::Positive(a) => Ok(a),
755        BodyLiteral::Negated(a) => Err(ResidentRejection::err(
756            ResidentRejectKind::Negation,
757            a.predicate.clone(),
758            format!("negated literal in rule for `{}`", rule_ctx),
759        )),
760        BodyLiteral::Epistemic(l) => Err(ResidentRejection::err(
761            ResidentRejectKind::EpistemicLiteral,
762            l.atom.predicate.clone(),
763            format!("epistemic literal in rule for `{}`", rule_ctx),
764        )),
765        BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {
766            Err(ResidentRejection::err(
767                ResidentRejectKind::NonRelationalLiteral,
768                "comparison/is/univ",
769                format!("non-relational literal in rule for `{}`", rule_ctx),
770            ))
771        }
772    }
773}
774
775/// Lower one rule to its fixed-width device record.
776fn lower_rule(
777    rule: &xlog_logic::ast::Rule,
778    universe: &Universe,
779) -> std::result::Result<Vec<u32>, ResidentRejection> {
780    // Assign variable ids by first occurrence across head + body.
781    let mut var_ids: BTreeMap<String, u32> = BTreeMap::new();
782    let assign_var = |name: &str,
783                      var_ids: &mut BTreeMap<String, u32>|
784     -> std::result::Result<u32, ResidentRejection> {
785        if let Some(&id) = var_ids.get(name) {
786            return Ok(id);
787        }
788        let id = var_ids.len() as u32;
789        if id as usize >= MAX_VARS {
790            return Err(ResidentRejection::err(
791                ResidentRejectKind::TooManyVars,
792                rule.head.predicate.clone(),
793                format!("more than {} distinct variables", MAX_VARS),
794            ));
795        }
796        var_ids.insert(name.to_string(), id);
797        Ok(id)
798    };
799
800    // First pass: assign var ids deterministically (head then body).
801    let body_atoms: Vec<&Atom> = {
802        let mut v = Vec::new();
803        for lit in &rule.body {
804            v.push(classify_body_literal(lit, &rule.head.predicate)?);
805        }
806        v
807    };
808    for t in &rule.head.terms {
809        if let TermClass::Var(name) = ConstKey::from_term(t)? {
810            assign_var(&name, &mut var_ids)?;
811        }
812    }
813    for atom in &body_atoms {
814        for t in &atom.terms {
815            if let TermClass::Var(name) = ConstKey::from_term(t)? {
816                assign_var(&name, &mut var_ids)?;
817            }
818        }
819    }
820    let n_vars = var_ids.len() as u32;
821
822    let encode_atom = |atom: &Atom| -> std::result::Result<[u32; ATOM_REC], ResidentRejection> {
823        let info = universe.preds.get(&atom.predicate).ok_or_else(|| {
824            ResidentRejection::err(
825                ResidentRejectKind::InconsistentArity,
826                atom.predicate.clone(),
827                "rule atom references unknown predicate",
828            )
829        })?;
830        let arity = info.arity as u32;
831        let mut rec = [0u32; ATOM_REC];
832        rec[0] = info.base;
833        rec[1] = arity;
834        rec[5] = universe.stride0(info.arity);
835        for (i, t) in atom.terms.iter().enumerate() {
836            let spec = match ConstKey::from_term(t)? {
837                TermClass::Var(name) => *var_ids.get(&name).expect("var assigned above"),
838                TermClass::Const(k) => {
839                    let idx = *universe.domain.get(&k).ok_or_else(|| {
840                        ResidentRejection::err(
841                            ResidentRejectKind::UnboundedTerm,
842                            format!("{:?}", k),
843                            "rule constant absent from bounded domain",
844                        )
845                    })?;
846                    CONST_FLAG | idx
847                }
848            };
849            rec[2 + i] = spec;
850        }
851        Ok(rec)
852    };
853
854    let mut rec = vec![0u32; RULE_REC];
855    rec[0] = body_atoms.len() as u32;
856    rec[1] = n_vars;
857    rec[2] = universe.domain_size;
858    let head_rec = encode_atom(&rule.head)?;
859    rec[3..3 + ATOM_REC].copy_from_slice(&head_rec);
860    for (bi, atom) in body_atoms.iter().enumerate() {
861        let a = encode_atom(atom)?;
862        let off = 3 + ATOM_REC + bi * ATOM_REC;
863        rec[off..off + ATOM_REC].copy_from_slice(&a);
864    }
865    Ok(rec)
866}
867
868fn ground_atom_from_atom(atom: &Atom) -> std::result::Result<GroundAtom, ResidentRejection> {
869    let mut args = Vec::with_capacity(atom.terms.len());
870    for t in &atom.terms {
871        let v = match t {
872            Term::Integer(i) => Value::I64(*i),
873            Term::Symbol(s) => Value::Symbol(*s),
874            Term::String(s) => Value::String(s.clone()),
875            Term::Float(f) => Value::F64(f.to_bits()),
876            other => {
877                return Err(ResidentRejection::err(
878                    ResidentRejectKind::UnboundedTerm,
879                    format!("{:?}", other),
880                    "fact term must be a ground constant",
881                ))
882            }
883        };
884        args.push(v);
885    }
886    Ok(GroundAtom {
887        predicate: atom.predicate.clone(),
888        args,
889    })
890}
891
892impl McProgram {
893    /// Evaluate this program with the GPU-resident engine. Returns device-resident
894    /// counts plus no-host instrumentation for the measured region. Fails closed
895    /// (typed [`ResidentRejection`] wrapped into [`XlogError`]) for programs
896    /// outside the supported fragment.
897    pub fn evaluate_resident_with_provider(
898        &self,
899        cfg: McEvalConfig,
900        provider: Arc<CudaKernelProvider>,
901    ) -> Result<McResidentResult> {
902        cfg.validate()?;
903        let plan = compile_resident_plan(self).map_err(ResidentRejection::into_error)?;
904        let sparse_cap = plan.universe_size.max(1) as usize;
905        run_resident(&plan, &cfg, self, provider, sparse_cap)
906    }
907
908    /// Convenience: evaluate with a fresh provider.
909    pub fn evaluate_resident(&self, cfg: McEvalConfig) -> Result<McResidentResult> {
910        let provider = Arc::new(self.provider()?);
911        self.evaluate_resident_with_provider(cfg, provider)
912    }
913}
914
915fn run_resident(
916    plan: &ResidentPlan,
917    cfg: &McEvalConfig,
918    mc: &McProgram,
919    provider: Arc<CudaKernelProvider>,
920    sparse_cap: usize,
921) -> Result<McResidentResult> {
922    let (method, forcing) = mc.resolve_sampling_method(cfg.sampling_method)?;
923    let num_worlds = u32::try_from(cfg.samples)
924        .map_err(|_| XlogError::Execution("MC samples exceed u32::MAX".to_string()))?;
925    let blocks_per_world = resident_blocks_per_world()?;
926    let num_vars = plan.num_vars;
927
928    if let Some(budget_bytes) = resident_memory_budget_bytes()? {
929        let bound_bytes = estimate_resident_bound_bytes(plan, num_worlds);
930        if bound_bytes > budget_bytes {
931            return Err(XlogError::ResourceExhausted {
932                context: format!(
933                    "resident_resource_budget operator=sparse_wcoj bound_bytes={bound_bytes} budget_bytes={budget_bytes}"
934                ),
935                estimated_bytes: bound_bytes,
936                budget_bytes,
937            });
938        }
939    }
940
941    // ---------------- Static setup (BEFORE the measured region) ----------------
942    // All device allocations, the seeded sample matrix, force arrays, and every
943    // plan array are uploaded here. Allocation syncs; nothing below the measured
944    // marker may transfer or read back.
945    let dev = provider.device();
946
947    // Sample matrix (device-resident), forced for clamped evidence.
948    let mut d_force_mask = provider.memory().alloc::<u8>(num_vars.max(1))?;
949    let mut d_forced_value = provider.memory().alloc::<u8>(num_vars.max(1))?;
950    if method == McSamplingMethod::EvidenceClamping && num_vars > 0 {
951        provider.htod_sync_copy_into_tracked(&forcing.force_mask, &mut d_force_mask)?;
952        provider.htod_sync_copy_into_tracked(&forcing.forced_value, &mut d_forced_value)?;
953    } else {
954        dev.inner()
955            .memset_zeros(&mut d_force_mask)
956            .map_err(|e| XlogError::Kernel(format!("zero force_mask: {e}")))?;
957        dev.inner()
958            .memset_zeros(&mut d_forced_value)
959            .map_err(|e| XlogError::Kernel(format!("zero forced_value: {e}")))?;
960    }
961    let samples_device = if num_vars == 0 || cfg.samples == 0 {
962        provider.memory().alloc::<u8>(1)?
963    } else {
964        provider.sample_bernoulli_matrix_device(
965            &plan.bernoulli_probs,
966            cfg.samples,
967            cfg.seed,
968            &d_force_mask.slice(..),
969            &d_forced_value.slice(..),
970        )?
971    };
972
973    // Working dense membership index [num_worlds * 2 * U], pre-zeroed. This is a
974    // device-side dedup/index sidecar for the sparse columnar relation arena.
975    let u = plan.universe_size.max(1) as usize;
976    let rel_len = (num_worlds as usize)
977        .saturating_mul(u)
978        .saturating_mul(2)
979        .max(1);
980    let mut d_rel = provider.memory().alloc::<u32>(rel_len)?;
981    dev.inner()
982        .memset_zeros(&mut d_rel)
983        .map_err(|e| XlogError::Kernel(format!("zero rel: {e}")))?;
984
985    // Sparse world-segmented columnar relation arena. Production capacity is
986    // the static universe bound per world/buffer; the explicit parameter keeps
987    // the capacity invariant testable without changing the public API.
988    if sparse_cap == 0 {
989        return Err(XlogError::CapacityExceeded {
990            context: "resident Monte Carlo sparse relation arena".to_string(),
991            required: 1,
992            limit: 0,
993            unit: "rows per world".to_string(),
994        });
995    }
996    let sparse_len = (num_worlds as usize)
997        .saturating_mul(2)
998        .saturating_mul(sparse_cap)
999        .max(1);
1000    // Four contiguous u32 columns: slot | arg0 | arg1 | arg2.
1001    let mut d_sparse_columns = provider
1002        .memory()
1003        .alloc::<u32>(sparse_len.saturating_mul(4).max(1))?;
1004    let mut d_sparse_counts = provider
1005        .memory()
1006        .alloc::<u32>((num_worlds as usize).saturating_mul(2).max(1))?;
1007    let mut d_sparse_final_counts = provider
1008        .memory()
1009        .alloc::<u32>((num_worlds as usize).max(1))?;
1010    let mut d_sparse_offsets = provider
1011        .memory()
1012        .alloc::<u32>((num_worlds as usize).saturating_add(1).max(1))?;
1013    let mut d_resident_status_flags = provider
1014        .memory()
1015        .alloc::<u32>(resident_status_words(num_worlds))?;
1016    dev.inner()
1017        .memset_zeros(&mut d_sparse_columns)
1018        .map_err(|e| XlogError::Kernel(format!("zero sparse_columns: {e}")))?;
1019    dev.inner()
1020        .memset_zeros(&mut d_sparse_counts)
1021        .map_err(|e| XlogError::Kernel(format!("zero sparse_counts: {e}")))?;
1022    dev.inner()
1023        .memset_zeros(&mut d_sparse_final_counts)
1024        .map_err(|e| XlogError::Kernel(format!("zero sparse_final_counts: {e}")))?;
1025    dev.inner()
1026        .memset_zeros(&mut d_sparse_offsets)
1027        .map_err(|e| XlogError::Kernel(format!("zero sparse_offsets: {e}")))?;
1028    dev.inner()
1029        .memset_zeros(&mut d_resident_status_flags)
1030        .map_err(|e| XlogError::Kernel(format!("zero resident_status_flags: {e}")))?;
1031
1032    // Pack every read-only plan array into one contiguous `meta` u32 buffer and
1033    // record offsets in the `cfg` header. This keeps the kernel under the launch
1034    // arg-arity limit (7 params) and uses one HtoD per buffer.
1035    let q_count = plan.q_slot.len();
1036    let ev_expected_u32: Vec<u32> = plan.ev_expected.iter().map(|&b| b as u32).collect();
1037
1038    let mut meta: Vec<u32> = Vec::new();
1039    let push_meta = |data: &[u32], meta: &mut Vec<u32>| -> u32 {
1040        let off = meta.len() as u32;
1041        meta.extend_from_slice(data);
1042        off
1043    };
1044    let edb_off = push_meta(&plan.edb_slots, &mut meta);
1045    let pf_slot_off = push_meta(&plan.pf_slot, &mut meta);
1046    let pf_var_off = push_meta(&plan.pf_var, &mut meta);
1047    let rules_off = push_meta(&plan.rule_data, &mut meta);
1048    let q_off = push_meta(&plan.q_slot, &mut meta);
1049    let ev_slot_off = push_meta(&plan.ev_slot, &mut meta);
1050    let ev_exp_off = push_meta(&ev_expected_u32, &mut meta);
1051    let ad_off = push_meta(&plan.ad_data, &mut meta);
1052
1053    // cfg header, indices mirror the `CFG_*` defines in mc_resident.cu.
1054    let cfg_host: [u32; 19] = [
1055        num_worlds,
1056        plan.universe_size,
1057        num_vars as u32,
1058        plan.max_iters,
1059        edb_off,
1060        plan.edb_slots.len() as u32,
1061        pf_slot_off,
1062        pf_var_off,
1063        plan.pf_slot.len() as u32,
1064        rules_off,
1065        plan.num_rules,
1066        q_off,
1067        q_count as u32,
1068        ev_slot_off,
1069        ev_exp_off,
1070        plan.ev_slot.len() as u32,
1071        ad_off,
1072        plan.num_ads,
1073        blocks_per_world,
1074    ];
1075
1076    let mut d_cfg = provider.memory().alloc::<u32>(cfg_host.len())?;
1077    provider.htod_sync_copy_into_tracked(&cfg_host, &mut d_cfg)?;
1078    let mut d_meta = provider.memory().alloc::<u32>(meta.len().max(1))?;
1079    if !meta.is_empty() {
1080        provider.htod_sync_copy_into_tracked(&meta, &mut d_meta)?;
1081    }
1082
1083    let mut d_query_counts = provider.memory().alloc::<u32>(q_count.max(1))?;
1084    dev.inner()
1085        .memset_zeros(&mut d_query_counts)
1086        .map_err(|e| XlogError::Kernel(format!("zero query_counts: {e}")))?;
1087    let mut d_evidence_count = provider.memory().alloc::<u32>(1)?;
1088    dev.inner()
1089        .memset_zeros(&mut d_evidence_count)
1090        .map_err(|e| XlogError::Kernel(format!("zero evidence_count: {e}")))?;
1091    let mut d_iter_trace = provider.memory().alloc::<u32>(num_worlds.max(1) as usize)?;
1092    dev.inner()
1093        .memset_zeros(&mut d_iter_trace)
1094        .map_err(|e| XlogError::Kernel(format!("zero iter_trace: {e}")))?;
1095
1096    let engine_fn = dev
1097        .inner()
1098        .get_func(MC_RESIDENT_MODULE, mc_resident_kernels::MC_RESIDENT_ENGINE)
1099        .ok_or_else(|| XlogError::Kernel("mc_resident_engine kernel not found".to_string()))?;
1100
1101    // Ensure all setup work is complete before we start measuring.
1102    dev.synchronize()?;
1103
1104    // ---------------- Measured region (ZERO host interaction) ----------------
1105    let pre = provider.host_transfer_stats();
1106    let pre_untracked = provider.untracked_metadata_dtoh_count();
1107    let pre_allocs = provider.memory().alloc_count();
1108    let mut engine_launches = 0u64;
1109
1110    let block_dim = 128u32;
1111    let grid_dim = num_worlds
1112        .max(1)
1113        .checked_mul(blocks_per_world)
1114        .ok_or_else(|| {
1115            XlogError::Execution(format!(
1116                "resident grid overflow: worlds={num_worlds} blocks_per_world={blocks_per_world}"
1117            ))
1118        })?;
1119    let launch_cfg = LaunchConfig {
1120        grid_dim: (grid_dim, 1, 1),
1121        block_dim: (block_dim, 1, 1),
1122        shared_mem_bytes: 0,
1123    };
1124    // SAFETY: argument list matches the `mc_resident_engine` PTX signature; every
1125    // device buffer was allocated with sufficient length above.
1126    unsafe {
1127        let args = (
1128            &d_cfg,
1129            &d_meta,
1130            &mut d_rel,
1131            &samples_device,
1132            &mut d_query_counts,
1133            &mut d_evidence_count,
1134            &mut d_iter_trace,
1135            &mut d_sparse_columns,
1136            &mut d_sparse_counts,
1137            &mut d_sparse_final_counts,
1138            &mut d_sparse_offsets,
1139            &mut d_resident_status_flags,
1140            sparse_cap as u32,
1141        );
1142        if blocks_per_world == 1 {
1143            engine_fn
1144                .launch(launch_cfg, args)
1145                .map_err(|e| XlogError::Kernel(format!("mc_resident_engine launch failed: {e}")))?;
1146        } else {
1147            engine_fn
1148                .launch_cooperative(launch_cfg, args)
1149                .map_err(|e| {
1150                    XlogError::Kernel(format!("mc_resident_engine cooperative launch failed: {e}"))
1151                })?;
1152        }
1153    }
1154    engine_launches += 1;
1155    dev.synchronize()?;
1156
1157    let post = provider.host_transfer_stats();
1158    let post_untracked = provider.untracked_metadata_dtoh_count();
1159    let post_allocs = provider.memory().alloc_count();
1160    // ---------------- End measured region ----------------
1161
1162    let no_host = McNoHostStats {
1163        tracked_htod_calls: post.htod_calls.saturating_sub(pre.htod_calls),
1164        tracked_dtoh_calls: post.dtoh_calls.saturating_sub(pre.dtoh_calls),
1165        untracked_metadata_reads: post_untracked.saturating_sub(pre_untracked),
1166        engine_launches,
1167        host_loop_iterations: 0,
1168        // The dense engine has no host fixpoint loop (convergence is device-side
1169        // inside the megakernel) and allocates every arena before the region.
1170        host_fixpoint_iterations: 0,
1171        per_operator_host_allocations: post_allocs.saturating_sub(pre_allocs),
1172        per_sample_host_launches: 0,
1173    };
1174
1175    // Authoritative device-written status is read only after the measured
1176    // zero-host region. Invalid worlds were excluded from device-side counts;
1177    // no result may escape until this bounded metadata check succeeds.
1178    let postflight = provider
1179        .dtoh_small_metadata_untracked(&d_resident_status_flags, RESIDENT_POSTFLIGHT_WORDS)?;
1180    validate_resident_postflight(&postflight, num_worlds, plan.max_iters, sparse_cap)?;
1181
1182    Ok(McResidentResult {
1183        query_counts: d_query_counts,
1184        evidence_count: d_evidence_count,
1185        iter_trace: d_iter_trace,
1186        sparse_final_row_counts: d_sparse_final_counts,
1187        sparse_offsets: d_sparse_offsets,
1188        resident_status_flags: d_resident_status_flags,
1189        total_samples: cfg.samples,
1190        seed: cfg.seed,
1191        confidence: cfg.confidence,
1192        sampling_method: method,
1193        num_queries: q_count,
1194        no_host,
1195    })
1196}
1197
1198#[cfg(all(test, feature = "host-io"))]
1199mod tests {
1200    use super::*;
1201
1202    #[test]
1203    fn resident_plan_deduplicates_schema_equivalent_evidence() {
1204        let program = McProgram::compile_source(
1205            "0.5::gate(\"alpha\").\n\
1206             evidence(gate(\"alpha\"), true).\n\
1207             evidence(gate(alpha), true).\n\
1208             query(gate(\"alpha\")).\n",
1209        )
1210        .expect("compile equivalent symbol evidence");
1211        let plan = compile_resident_plan(&program).expect("compile resident plan");
1212
1213        assert_eq!(plan.ev_slot.len(), 1);
1214        assert_eq!(plan.ev_expected, [1]);
1215    }
1216
1217    #[test]
1218    fn resident_postflight_rejects_unconverged_worlds() {
1219        let err = validate_resident_postflight(&[3, 0, 0], 128, 7, 64)
1220            .expect_err("unconverged worlds must reject the result");
1221        assert!(matches!(
1222            err,
1223            XlogError::ConvergenceFailure {
1224                unconverged_worlds: 3,
1225                total_worlds: 128,
1226                iteration_limit: 7,
1227                ..
1228            }
1229        ));
1230    }
1231
1232    #[test]
1233    fn resident_postflight_rejects_sparse_capacity_overflow() {
1234        let err = validate_resident_postflight(&[0, 2, 65], 128, 7, 64)
1235            .expect_err("overflowed worlds must reject the result");
1236        assert!(matches!(
1237            err,
1238            XlogError::CapacityExceeded {
1239                required: 65,
1240                limit: 64,
1241                ..
1242            }
1243        ));
1244    }
1245
1246    #[test]
1247    #[ignore = "requires a CUDA device"]
1248    fn resident_kernel_nonconvergence_is_authoritative() {
1249        let program = McProgram::compile_source(
1250            "start().\n\
1251             first() :- start().\n\
1252             second() :- first().\n\
1253             query(second()).\n",
1254        )
1255        .expect("compile recursive chain");
1256        let mut plan = compile_resident_plan(&program).expect("compile resident plan");
1257        plan.max_iters = 1;
1258        let cfg = McEvalConfig {
1259            samples: 8,
1260            ..McEvalConfig::default()
1261        };
1262        let provider = Arc::new(program.provider().expect("CUDA provider"));
1263        let sparse_cap = plan.universe_size.max(1) as usize;
1264
1265        let err = match run_resident(&plan, &cfg, &program, provider, sparse_cap) {
1266            Ok(_) => panic!("unconverged resident run returned counts"),
1267            Err(err) => err,
1268        };
1269        assert!(matches!(
1270            err,
1271            XlogError::ConvergenceFailure {
1272                unconverged_worlds: 8,
1273                total_worlds: 8,
1274                iteration_limit: 1,
1275                ..
1276            }
1277        ));
1278    }
1279
1280    #[test]
1281    #[ignore = "requires a CUDA device"]
1282    fn resident_kernel_sparse_overflow_is_authoritative() {
1283        let program = McProgram::compile_source(
1284            "first().\n\
1285             second().\n\
1286             query(first()).\n",
1287        )
1288        .expect("compile two-fact program");
1289        let plan = compile_resident_plan(&program).expect("compile resident plan");
1290        let cfg = McEvalConfig {
1291            samples: 8,
1292            ..McEvalConfig::default()
1293        };
1294        let provider = Arc::new(program.provider().expect("CUDA provider"));
1295
1296        let err = match run_resident(&plan, &cfg, &program, provider, 1) {
1297            Ok(_) => panic!("overflowed resident run returned counts"),
1298            Err(err) => err,
1299        };
1300        assert!(matches!(
1301            err,
1302            XlogError::CapacityExceeded {
1303                required: 2,
1304                limit: 1,
1305                ..
1306            }
1307        ));
1308    }
1309}