Skip to main content

xlog_prob/compilation/
mod.rs

1//! GPU-native knowledge compilation.
2//!
3//! This module is the home of GPU-native compilation + verification utilities.
4//!
5//! Production correctness requires the GPU CDCL equivalence verifier (see `validation`).
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Instant;
10
11use cudarc::driver::DeviceSlice;
12use xlog_core::{Result, XlogError};
13use xlog_cuda::memory::TrackedCudaSlice;
14use xlog_cuda::CudaKernelProvider;
15use xlog_solve::{GpuCdclConfig, GpuCnf};
16
17use crate::compilation::gpu_cache::{GpuCircuitCache, GpuCircuitCacheHandle};
18use crate::gpu::{GpuCircuitBuilder, GpuCircuitLayout, GpuXgcf};
19
20pub mod disk_cache;
21pub mod gpu_cache;
22pub mod gpu_cnf;
23pub mod gpu_d4;
24pub mod gpu_pir;
25pub mod gpu_pir_intern;
26pub mod gpu_weights;
27pub mod validation;
28
29pub use gpu_cnf::{encode_cnf_gpu, GpuCnfEncoding, GpuCnfVarTables};
30pub use gpu_d4::GpuCompileConfig;
31pub use gpu_pir::{GpuPirGraph, GpuPirRoots, PIR_AND, PIR_LIT, PIR_NEG_LIT, PIR_OR};
32// PIR_CONST and PIR_DECISION are used within gpu_pir.rs and gpu_pir_intern.rs
33// via direct module paths; no crate-level re-export needed.
34pub use gpu_pir_intern::{GpuPirInterner, PirBatch};
35pub use gpu_weights::GpuWeights;
36pub use gpu_weights::{
37    apply_query_vars_device, build_evidence_by_var_gpu, build_weights_gpu, map_nodes_to_vars_gpu,
38    restore_query_vars_device,
39};
40pub use validation::{
41    build_equivalence_queries_gpu, validate_equivalence_gpu, validate_equivalence_gpu_gated,
42    GpuEquivalenceConfig, GpuEquivalenceQueries,
43};
44// check_equivalence_gpu and check_equivalence_gpu_gated are called only
45// within validation.rs itself; no crate-level re-export needed.
46
47/// Per-stage compilation timing (populated only when XLOG_WARMUP_PROFILE=1).
48#[derive(Debug, Clone, Default)]
49pub struct CircuitCompileProfile {
50    pub cnf_hash_sec: f64,
51    pub d4_compile_sec: f64,
52    pub verify_sec: f64,
53    pub smooth_sec: f64,
54    pub cache_store_sec: f64,
55    pub free_var_mask_sec: f64,
56    pub gpu_cache_hit: bool,
57    pub disk_cache_hit: bool,
58    /// BFS frontier item count after `frontier_depth` expansion steps
59    /// (0 on cache hits or when profiling is disabled).
60    pub frontier_items: u32,
61}
62
63/// Monotonic event ledger for one exact-circuit materialization path.
64///
65/// Mutation is private to this module so counts can only change at the GPU
66/// compiler, verified disk-restore, GPU-cache-hit, and successful
67/// materialization boundaries below. The exact state retains this ledger and
68/// snapshots it around every prepared evaluation.
69#[derive(Debug, Default)]
70pub(crate) struct CircuitCompilationLedger {
71    compiler_invocations: AtomicU64,
72    materializations: AtomicU64,
73    disk_cache_restores: AtomicU64,
74    gpu_cache_hits: AtomicU64,
75}
76
77#[cfg(feature = "host-io")]
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub(crate) struct CircuitCompilationSnapshot {
80    pub(crate) compiler_invocations: u64,
81    pub(crate) materializations: u64,
82    pub(crate) disk_cache_restores: u64,
83    pub(crate) gpu_cache_hits: u64,
84}
85
86impl CircuitCompilationLedger {
87    pub(crate) fn new() -> Self {
88        Self::default()
89    }
90
91    #[cfg(feature = "host-io")]
92    pub(crate) fn snapshot(&self) -> CircuitCompilationSnapshot {
93        CircuitCompilationSnapshot {
94            compiler_invocations: self.compiler_invocations.load(Ordering::Acquire),
95            materializations: self.materializations.load(Ordering::Acquire),
96            disk_cache_restores: self.disk_cache_restores.load(Ordering::Acquire),
97            gpu_cache_hits: self.gpu_cache_hits.load(Ordering::Acquire),
98        }
99    }
100
101    fn record_compiler_invocation(&self) -> Result<()> {
102        increment_circuit_event(
103            &self.compiler_invocations,
104            "GPU circuit compiler invocation",
105        )
106    }
107
108    fn record_materialization(&self) -> Result<()> {
109        increment_circuit_event(&self.materializations, "exact circuit materialization")
110    }
111
112    fn record_disk_cache_restore(&self) -> Result<()> {
113        increment_circuit_event(&self.disk_cache_restores, "verified disk-cache restoration")
114    }
115
116    fn record_gpu_cache_hit(&self) -> Result<()> {
117        increment_circuit_event(&self.gpu_cache_hits, "GPU circuit-cache hit")
118    }
119}
120
121/// Cache identity and authoritative event ledger for one compilation attempt.
122pub(crate) struct CircuitCompilationContext<'a> {
123    pub(crate) canonical_cnf_hash: Option<u64>,
124    pub(crate) ledger: &'a CircuitCompilationLedger,
125}
126
127fn increment_circuit_event(counter: &AtomicU64, event: &str) -> Result<()> {
128    counter
129        .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
130            value.checked_add(1)
131        })
132        .map(|_| ())
133        .map_err(|_| XlogError::Compilation(format!("{event} counter overflowed")))
134}
135
136pub(crate) fn warmup_profiling_enabled() -> bool {
137    std::env::var("XLOG_WARMUP_PROFILE")
138        .map(|v| v == "1")
139        .unwrap_or(false)
140}
141
142/// Device-resident random-variable list for GPU smoothing.
143pub struct DeviceRandomVarList {
144    list: TrackedCudaSlice<u32>,
145    count: u32,
146}
147
148impl DeviceRandomVarList {
149    pub fn from_device(list: TrackedCudaSlice<u32>, count: u32) -> Result<Self> {
150        let len = u32::try_from(list.len()).map_err(|_| {
151            XlogError::Compilation("DeviceRandomVarList: list length exceeds u32".to_string())
152        })?;
153        if count > len {
154            return Err(XlogError::Compilation(format!(
155                "DeviceRandomVarList: count {} exceeds list len {}",
156                count, len
157            )));
158        }
159        Ok(Self { list, count })
160    }
161
162    pub fn from_host(provider: &CudaKernelProvider, host: &[u32]) -> Result<Self> {
163        let memory = provider.memory();
164        let mut list = memory.alloc::<u32>(host.len())?;
165        if !host.is_empty() {
166            provider
167                .htod_sync_copy_into_tracked(host, &mut list)
168                .map_err(|e| {
169                    XlogError::Kernel(format!("DeviceRandomVarList upload failed: {}", e))
170                })?;
171        }
172        let count = u32::try_from(host.len()).map_err(|_| {
173            XlogError::Compilation("DeviceRandomVarList: host len exceeds u32".to_string())
174        })?;
175        Ok(Self { list, count })
176    }
177
178    pub fn is_empty(&self) -> bool {
179        self.count == 0
180    }
181
182    pub fn count(&self) -> u32 {
183        self.count
184    }
185
186    pub fn list(&self) -> &TrackedCudaSlice<u32> {
187        &self.list
188    }
189}
190
191fn upload_disk_artifact_for_verification(
192    artifact: &disk_cache::CircuitArtifact,
193    provider: &Arc<CudaKernelProvider>,
194) -> Result<GpuXgcf> {
195    let memory = provider.memory().clone();
196
197    macro_rules! upload {
198        ($field:expr, $ty:ty, $name:literal) => {{
199            let mut device = memory.alloc::<$ty>($field.len())?;
200            provider
201                .htod_sync_copy_into_tracked($field, &mut device)
202                .map_err(|e| {
203                    XlogError::Kernel(format!("disk cache verify upload {} failed: {}", $name, e))
204                })?;
205            device
206        }};
207    }
208
209    let builder = GpuCircuitBuilder {
210        node_type: upload!(&artifact.node_type, u8, "node_type"),
211        child_offsets: upload!(&artifact.child_offsets, u32, "child_offsets"),
212        child_indices: upload!(&artifact.child_indices, u32, "child_indices"),
213        lit: upload!(&artifact.lit, i32, "lit"),
214        decision_var: upload!(&artifact.decision_var, u32, "decision_var"),
215        decision_child_false: upload!(&artifact.decision_child_false, u32, "decision_child_false"),
216        decision_child_true: upload!(&artifact.decision_child_true, u32, "decision_child_true"),
217    };
218    let layout = GpuCircuitLayout {
219        num_nodes: artifact.num_nodes,
220        num_edges: artifact.num_edges,
221        num_levels: artifact.num_levels,
222        level_offsets: upload!(&artifact.level_offsets, u32, "level_offsets"),
223        level_nodes: upload!(&artifact.level_nodes, u32, "level_nodes"),
224        root: artifact.root,
225        max_var: artifact.max_var,
226        num_nodes_device: None,
227        num_edges_device: None,
228    };
229
230    GpuXgcf::from_device(builder, layout, provider)
231}
232
233/// Compile CNF on GPU, then verify equivalence with GPU CDCL.
234pub fn compile_gpu_d4_and_verify(
235    cnf: &GpuCnf,
236    decision_var_limit: &TrackedCudaSlice<u32>,
237    provider: &Arc<CudaKernelProvider>,
238    config: &GpuCompileConfig,
239) -> Result<GpuXgcf> {
240    if config.cdcl_conflict_budget.is_some() {
241        return Err(XlogError::Compilation(
242            "cdcl_conflict_budget is not supported by the GPU CDCL verifier".to_string(),
243        ));
244    }
245    // Size guard BEFORE compile — the D4 compile itself can crash with a
246    // context-poisoning launch failure on a large CNF, earlier than the verify.
247    validation::check_verify_size_bound(cnf, "compile_gpu_d4_and_verify")?;
248    let circuit = gpu_d4::compile_gpu_d4(cnf, provider, config)?;
249    let cdcl = cdcl_config_from_compile(config)?;
250    validate_equivalence_gpu(
251        cnf,
252        decision_var_limit,
253        &circuit,
254        provider,
255        GpuEquivalenceConfig {
256            cdcl,
257            reuse_workspace: config.incremental_verify,
258        },
259    )?;
260    Ok(circuit)
261}
262
263/// Compile CNF on GPU, cache the circuit, then verify equivalence with GPU CDCL.
264///
265/// `canonical_cnf_hash`: a process-independent hash of the PIR structure, used as
266/// the `cnf_hash` in the disk cache key. Computed via [`crate::cnf::canonical_pir_hash`].
267/// If `None`, disk caching is skipped.
268pub fn compile_gpu_d4_and_verify_cached(
269    cnf: &GpuCnf,
270    decision_var_limit: &TrackedCudaSlice<u32>,
271    provider: &Arc<CudaKernelProvider>,
272    config: &GpuCompileConfig,
273    cache: &mut GpuCircuitCache,
274    random_vars: &DeviceRandomVarList,
275    canonical_cnf_hash: Option<u64>,
276) -> Result<(GpuCircuitCacheHandle, Option<CircuitCompileProfile>)> {
277    let ledger = CircuitCompilationLedger::new();
278    compile_gpu_d4_and_verify_cached_with_ledger(
279        cnf,
280        decision_var_limit,
281        provider,
282        config,
283        cache,
284        random_vars,
285        CircuitCompilationContext {
286            canonical_cnf_hash,
287            ledger: &ledger,
288        },
289    )
290}
291
292pub(crate) fn compile_gpu_d4_and_verify_cached_with_ledger(
293    cnf: &GpuCnf,
294    decision_var_limit: &TrackedCudaSlice<u32>,
295    provider: &Arc<CudaKernelProvider>,
296    config: &GpuCompileConfig,
297    cache: &mut GpuCircuitCache,
298    random_vars: &DeviceRandomVarList,
299    context: CircuitCompilationContext<'_>,
300) -> Result<(GpuCircuitCacheHandle, Option<CircuitCompileProfile>)> {
301    let CircuitCompilationContext {
302        canonical_cnf_hash,
303        ledger,
304    } = context;
305    if config.cdcl_conflict_budget.is_some() {
306        return Err(XlogError::Compilation(
307            "cdcl_conflict_budget is not supported by the GPU CDCL verifier".to_string(),
308        ));
309    }
310    // Size guard BEFORE compile (the D4 compile can crash earlier than
311    // the verify on a large CNF).
312    validation::check_verify_size_bound(cnf, "compile_gpu_d4_and_verify_cached")?;
313
314    let profiling = warmup_profiling_enabled();
315    let mut profile = CircuitCompileProfile::default();
316
317    // --- CNF hash stage ---
318    #[cfg(debug_assertions)]
319    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: hash_cnf_gpu");
320    let t_hash = if profiling {
321        Some(Instant::now())
322    } else {
323        None
324    };
325    let key = gpu_cache::hash_cnf_gpu(cnf, provider)?;
326    if let Some(t0) = t_hash {
327        provider
328            .device()
329            .synchronize()
330            .map_err(|e| XlogError::Kernel(format!("sync after hash_cnf_gpu: {}", e)))?;
331        profile.cnf_hash_sec = t0.elapsed().as_secs_f64();
332    }
333    #[cfg(debug_assertions)]
334    {
335        if !profiling {
336            provider
337                .device()
338                .synchronize()
339                .map_err(|e| XlogError::Kernel(format!("sync after hash_cnf_gpu failed: {}", e)))?;
340        }
341    }
342    #[cfg(debug_assertions)]
343    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: lookup_or_insert_device");
344    let lookup = cache.lookup_or_insert_device(&key)?;
345    let mut handle = lookup.into_handle()?;
346
347    // --- Disk cache check (only on GPU cache miss) ---
348    //
349    // D→H copy compile_needed to decide whether we need to compile at all.
350    // If compile_needed == 0, the GPU cache already has the circuit (GPU cache hit).
351    // If compile_needed == 1, we check the disk cache before falling through to the
352    // GPU-native Decision-DNNF compiler.
353    let compile_needed_host: Vec<u32> = provider
354        .device()
355        .inner()
356        .dtoh_sync_copy(handle.compile_needed_device())
357        .map_err(|e| XlogError::Kernel(format!("dtoh compile_needed: {}", e)))?;
358    let compile_needed = compile_needed_host[0];
359
360    // GPU cache hit — short-circuit the entire compile pipeline.
361    if compile_needed == 0 {
362        profile.gpu_cache_hit = true;
363        ledger.record_gpu_cache_hit()?;
364        ledger.record_materialization()?;
365        let out_profile = if profiling { Some(profile) } else { None };
366        return Ok((handle, out_profile));
367    }
368
369    // Build the disk cache key (we know compile_needed == 1 at this point).
370    // The canonical PIR hash keeps semantically stable cache identity, while the encoded CNF
371    // hash guards symmetric conditioned programs whose PIR shape is identical but whose CNF is not.
372    let cache_key = if compile_needed == 1 {
373        if let Some(canonical_hash) = canonical_cnf_hash {
374            let config_hash = hash_compile_config(config);
375            let random_vars_hash = hash_random_vars(random_vars, provider)?;
376            let gpu_cnf_hash = read_gpu_cnf_hash(&key, provider)?;
377            let cnf_hash = combine_disk_cnf_hash(canonical_hash, gpu_cnf_hash);
378            let sm = detect_compute_capability(provider)?;
379            Some(disk_cache::CircuitCacheKey {
380                cnf_hash,
381                config_hash,
382                random_vars_hash,
383                sm,
384            })
385        } else {
386            None
387        }
388    } else {
389        None
390    };
391
392    // Check disk cache on GPU cache miss
393    if let Some(ref disk_key) = cache_key {
394        #[cfg(debug_assertions)]
395        eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: checking disk cache");
396        if let Ok(Some(artifact)) = disk_cache::read_artifact(disk_key) {
397            #[cfg(debug_assertions)]
398            eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: disk cache hit");
399            // Stale-artifact guard: the canonical PIR hash keeps semantically-stable
400            // cache identity, so an entry written by an earlier engine whose ENCODING
401            // differed (different var universe) can be served for the same key. A
402            // cached circuit referencing vars beyond the freshly-encoded CNF is
403            // staleness, not an engine bug — evict and fall through to a fresh
404            // compile (which overwrites the entry). Fail-closed equivalence checking
405            // stays authoritative for freshly-compiled circuits below.
406            if artifact.max_var > cnf.var_cap {
407                #[cfg(debug_assertions)]
408                eprintln!(
409                    "[xlog-prob] compile_gpu_d4_and_verify_cached: stale disk artifact \
410                     (max_var {} > cnf var_cap {}), evicting",
411                    artifact.max_var, cnf.var_cap
412                );
413                disk_cache::evict_artifact(disk_key);
414            } else {
415                let circuit = upload_disk_artifact_for_verification(&artifact, provider)?;
416                let verifier_decision_var_limit = if random_vars.is_empty() {
417                    &cnf.num_vars
418                } else {
419                    decision_var_limit
420                };
421                let cdcl = cdcl_config_from_compile(config)?;
422                let t_verify = if profiling {
423                    Some(Instant::now())
424                } else {
425                    None
426                };
427                match validate_equivalence_gpu_gated(
428                    cnf,
429                    verifier_decision_var_limit,
430                    &circuit,
431                    provider,
432                    GpuEquivalenceConfig {
433                        cdcl,
434                        reuse_workspace: config.incremental_verify,
435                    },
436                    handle.compile_needed_device(),
437                ) {
438                    Ok(()) => {
439                        if let Some(t0) = t_verify {
440                            provider.device().synchronize().map_err(|e| {
441                                XlogError::Kernel(format!("sync after disk cache verify: {}", e))
442                            })?;
443                            profile.verify_sec = t0.elapsed().as_secs_f64();
444                        }
445                        cache.restore_from_host_arrays(&mut handle, &artifact)?;
446                        provider.device().synchronize().map_err(|e| {
447                            XlogError::Kernel(format!("sync after disk cache restore: {}", e))
448                        })?;
449                        profile.disk_cache_hit = true;
450                        ledger.record_disk_cache_restore()?;
451                        ledger.record_materialization()?;
452                        let out_profile = if profiling { Some(profile) } else { None };
453                        return Ok((handle, out_profile));
454                    }
455                    Err(_stale) => {
456                        // A cached artifact failing equivalence against the current
457                        // CNF is a stale entry (fresh-compile verification below
458                        // remains fail-closed). Evict and recompile.
459                        #[cfg(debug_assertions)]
460                        eprintln!(
461                            "[xlog-prob] compile_gpu_d4_and_verify_cached: cached circuit \
462                             failed equivalence against current CNF ({_stale}), evicting"
463                        );
464                        disk_cache::evict_artifact(disk_key);
465                    }
466                }
467            }
468        }
469        #[cfg(debug_assertions)]
470        eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: disk cache miss");
471    }
472
473    let d4_config = d4_config_for_smoothing(config, random_vars.count())?;
474
475    // --- GPU-native Decision-DNNF compile stage ---
476    #[cfg(debug_assertions)]
477    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: compile_gpu_d4_gated");
478    let t_d4 = if profiling {
479        Some(Instant::now())
480    } else {
481        None
482    };
483    ledger.record_compiler_invocation()?;
484    let (circuit_base, frontier_items) = gpu_d4::compile_gpu_d4_gated_with_stats(
485        cnf,
486        provider,
487        &d4_config,
488        handle.compile_needed_device(),
489    )?;
490    profile.frontier_items = frontier_items;
491    if let Some(t0) = t_d4 {
492        provider
493            .device()
494            .synchronize()
495            .map_err(|e| XlogError::Kernel(format!("sync after d4 compile: {}", e)))?;
496        profile.d4_compile_sec = t0.elapsed().as_secs_f64();
497    }
498    #[cfg(debug_assertions)]
499    {
500        if !profiling {
501            provider.device().synchronize().map_err(|e| {
502                XlogError::Kernel(format!("sync after compile_gpu_d4_gated failed: {}", e))
503            })?;
504        }
505    }
506    if circuit_base.num_nodes() == 0 || circuit_base.num_levels() == 0 {
507        // Defensive: the GPU-native Decision-DNNF compiler returned an empty circuit
508        // (the primary GPU cache hit is handled by the compile_needed == 0 early return
509        // above; this catches degenerate CNFs).
510        ledger.record_materialization()?;
511        let out_profile = if profiling { Some(profile) } else { None };
512        return Ok((handle, out_profile));
513    }
514
515    // --- Verify equivalence stage ---
516    //
517    // Verify equivalence on the *base* circuit (pre-smoothing) to keep the verifier CNFs minimal.
518    //
519    // `encode_cnf_gpu` sets `decision_var_limit` to the end of the leaf+choice var range. For
520    // deterministic programs with no probabilistic vars, this range is empty (limit=0). In that
521    // case, the verifier must still be able to branch, so fall back to `cnf.num_vars` (all CNF
522    // vars are semantically meaningful when there is no probabilistic decision set).
523    let verifier_decision_var_limit = if random_vars.is_empty() {
524        &cnf.num_vars
525    } else {
526        decision_var_limit
527    };
528    let cdcl = cdcl_config_from_compile(config)?;
529    #[cfg(debug_assertions)]
530    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: validate_equivalence_gpu_gated");
531    let t_verify = if profiling {
532        Some(Instant::now())
533    } else {
534        None
535    };
536    validate_equivalence_gpu_gated(
537        cnf,
538        verifier_decision_var_limit,
539        &circuit_base,
540        provider,
541        GpuEquivalenceConfig {
542            cdcl,
543            reuse_workspace: config.incremental_verify,
544        },
545        handle.compile_needed_device(),
546    )?;
547    if let Some(t0) = t_verify {
548        provider
549            .device()
550            .synchronize()
551            .map_err(|e| XlogError::Kernel(format!("sync after verify: {}", e)))?;
552        profile.verify_sec = t0.elapsed().as_secs_f64();
553    }
554    #[cfg(debug_assertions)]
555    {
556        if !profiling {
557            provider.device().synchronize().map_err(|e| {
558                XlogError::Kernel(format!(
559                    "sync after validate_equivalence_gpu_gated failed: {}",
560                    e
561                ))
562            })?;
563        }
564    }
565
566    // --- Smoothing stage ---
567    //
568    // Smoothing is evaluation-only (WMC/grad correctness); it is semantics-preserving and does not
569    // need to participate in the equivalence check.
570    let t_smooth = if profiling {
571        Some(Instant::now())
572    } else {
573        None
574    };
575    let circuit_eval = if random_vars.is_empty() {
576        circuit_base
577    } else {
578        #[cfg(debug_assertions)]
579        eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: smooth_random_vars_device");
580        let smoothed = circuit_base.smooth_random_vars_device(
581            provider,
582            random_vars.list(),
583            random_vars.count(),
584            config.smooth_node_cap,
585            config.smooth_edge_cap,
586        )?;
587        #[cfg(debug_assertions)]
588        {
589            if !profiling {
590                provider.device().synchronize().map_err(|e| {
591                    XlogError::Kernel(format!(
592                        "sync after smooth_random_vars_device failed: {}",
593                        e
594                    ))
595                })?;
596            }
597        }
598        smoothed
599    };
600    if let Some(t0) = t_smooth {
601        provider
602            .device()
603            .synchronize()
604            .map_err(|e| XlogError::Kernel(format!("sync after smooth: {}", e)))?;
605        profile.smooth_sec = t0.elapsed().as_secs_f64();
606    }
607
608    // --- Cache store stage ---
609    #[cfg(debug_assertions)]
610    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: store_from_xgcf");
611    let t_store = if profiling {
612        Some(Instant::now())
613    } else {
614        None
615    };
616    cache.store_from_xgcf(&mut handle, &circuit_eval)?;
617    if let Some(t0) = t_store {
618        provider
619            .device()
620            .synchronize()
621            .map_err(|e| XlogError::Kernel(format!("sync after cache store: {}", e)))?;
622        profile.cache_store_sec = t0.elapsed().as_secs_f64();
623    }
624    #[cfg(debug_assertions)]
625    {
626        if !profiling {
627            provider.device().synchronize().map_err(|e| {
628                XlogError::Kernel(format!("sync after store_from_xgcf failed: {}", e))
629            })?;
630        }
631    }
632
633    // --- Free-var mask stage ---
634    #[cfg(debug_assertions)]
635    eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: compute_free_var_mask_gpu_gated");
636    let t_fvm = if profiling {
637        Some(Instant::now())
638    } else {
639        None
640    };
641    let free_var_mask = gpu_d4::compute_free_var_mask_gpu_gated(
642        cnf,
643        &circuit_eval,
644        provider,
645        handle.compile_needed_device(),
646    )?;
647    #[cfg(debug_assertions)]
648    {
649        if !profiling {
650            provider.device().synchronize().map_err(|e| {
651                XlogError::Kernel(format!(
652                    "sync after compute_free_var_mask_gpu_gated failed: {}",
653                    e
654                ))
655            })?;
656        }
657    }
658    // Only enable free-var correction if there are actual free variables.
659    // When the mask is all-zero (common for smoothed d-DNNF circuits),
660    // skipping this keeps has_free_var_mask[slot]=false, which avoids unnecessary
661    // free-var correction kernel launches on every subsequent eval.
662    let mask_host: Vec<u8> = provider
663        .device()
664        .inner()
665        .dtoh_sync_copy(&free_var_mask)
666        .map_err(|e| XlogError::Kernel(format!("Failed to read free_var_mask: {}", e)))?;
667    let has_free_vars = mask_host.iter().any(|&b| b != 0);
668    #[cfg(debug_assertions)]
669    eprintln!(
670        "[xlog-prob] free_var_mask: {} free vars, batched eval {}",
671        if has_free_vars { "has" } else { "no" },
672        if has_free_vars { "DISABLED" } else { "ENABLED" },
673    );
674    if has_free_vars {
675        cache.store_free_var_mask(&handle, &free_var_mask)?;
676        #[cfg(debug_assertions)]
677        {
678            if !profiling {
679                provider.device().synchronize().map_err(|e| {
680                    XlogError::Kernel(format!("sync after store_free_var_mask failed: {}", e))
681                })?;
682            }
683        }
684    }
685    if let Some(t0) = t_fvm {
686        provider
687            .device()
688            .synchronize()
689            .map_err(|e| XlogError::Kernel(format!("sync after free_var_mask: {}", e)))?;
690        profile.free_var_mask_sec = t0.elapsed().as_secs_f64();
691    }
692
693    // --- Disk cache write (opportunistic) ---
694    //
695    // After a successful compilation, write the artifact to disk for next warm start.
696    // Errors are silently ignored — the disk cache is best-effort.
697    if let Some(ref disk_key) = cache_key {
698        if let Ok(artifact) = cache.build_artifact_from_device(&handle, provider) {
699            let _ = disk_cache::write_artifact(disk_key, &artifact);
700            #[cfg(debug_assertions)]
701            eprintln!("[xlog-prob] compile_gpu_d4_and_verify_cached: wrote disk cache artifact");
702        }
703    }
704
705    ledger.record_materialization()?;
706    let out_profile = if profiling { Some(profile) } else { None };
707    Ok((handle, out_profile))
708}
709
710fn d4_config_for_smoothing(
711    config: &GpuCompileConfig,
712    random_var_count: u32,
713) -> Result<GpuCompileConfig> {
714    if random_var_count == 0 {
715        return Ok(*config);
716    }
717    let headroom = 2u32
718        .checked_add(random_var_count)
719        .ok_or_else(|| XlogError::Compilation("smooth headroom overflow".to_string()))?;
720    if config.smooth_node_cap <= headroom {
721        return Err(XlogError::Compilation(format!(
722            "GpuCompileConfig smooth_node_cap {} too small for smoothing headroom {}",
723            config.smooth_node_cap, headroom
724        )));
725    }
726    let base_cap = config
727        .smooth_node_cap
728        .checked_sub(headroom)
729        .ok_or_else(|| XlogError::Compilation("smooth node cap underflow".to_string()))?;
730    if base_cap < 3 {
731        return Err(XlogError::Compilation(
732            "GpuCompileConfig smooth_node_cap leaves <3 base nodes".to_string(),
733        ));
734    }
735    let mut out = *config;
736    out.smooth_node_cap = base_cap;
737    Ok(out)
738}
739
740fn cdcl_config_from_compile(config: &GpuCompileConfig) -> Result<GpuCdclConfig> {
741    if config.cdcl_restart_interval == 0 {
742        return Err(XlogError::Compilation(
743            "cdcl_restart_interval must be > 0".to_string(),
744        ));
745    }
746    if config.cdcl_learned_bytes == 0 {
747        return Err(XlogError::Compilation(
748            "cdcl_learned_bytes must be > 0".to_string(),
749        ));
750    }
751
752    // Deterministic sizing: assume average learned clause length = 4.
753    const AVG_LEN: u64 = 4;
754    const META_BYTES_PER_CLAUSE: u64 = 24; // offsets + lbd + activity + flags + proof offsets (rounded up)
755    const PROOF_BYTES_PER_CLAUSE: u64 = 8 + (8 * AVG_LEN); // (conflict, steps) + 2*u32 per lit
756    const LIT_BYTES_PER_CLAUSE: u64 = 4 * AVG_LEN;
757
758    let bytes_per_clause = META_BYTES_PER_CLAUSE
759        .checked_add(PROOF_BYTES_PER_CLAUSE)
760        .and_then(|v| v.checked_add(LIT_BYTES_PER_CLAUSE))
761        .ok_or_else(|| XlogError::Compilation("cdcl bytes per clause overflow".to_string()))?;
762
763    let max_clauses = config
764        .cdcl_learned_bytes
765        .checked_div(bytes_per_clause)
766        .ok_or_else(|| XlogError::Compilation("cdcl_learned_bytes div overflow".to_string()))?;
767    if max_clauses == 0 {
768        return Err(XlogError::Compilation(
769            "cdcl_learned_bytes too small for learned clause arena".to_string(),
770        ));
771    }
772
773    let max_lits = max_clauses
774        .checked_mul(AVG_LEN)
775        .ok_or_else(|| XlogError::Compilation("max_learned_lits overflow".to_string()))?;
776    let max_proof_u32 = max_clauses
777        .checked_mul(2 + 2 * AVG_LEN)
778        .ok_or_else(|| XlogError::Compilation("max_proof_u32 overflow".to_string()))?;
779
780    let max_learned_clauses = u32::try_from(max_clauses)
781        .map_err(|_| XlogError::Compilation("max_learned_clauses exceeds u32::MAX".to_string()))?;
782    let max_learned_lits = u32::try_from(max_lits)
783        .map_err(|_| XlogError::Compilation("max_learned_lits exceeds u32::MAX".to_string()))?;
784    let max_proof_u32 = u32::try_from(max_proof_u32)
785        .map_err(|_| XlogError::Compilation("max_proof_u32 exceeds u32::MAX".to_string()))?;
786
787    let reduce_interval = config
788        .cdcl_restart_interval
789        .checked_mul(20)
790        .ok_or_else(|| XlogError::Compilation("cdcl reduce_interval overflow".to_string()))?;
791
792    let mut gpu_cdcl = GpuCdclConfig::default();
793    gpu_cdcl.max_learned_clauses = max_learned_clauses;
794    gpu_cdcl.max_learned_lits = max_learned_lits;
795    gpu_cdcl.max_proof_u32 = max_proof_u32;
796    gpu_cdcl.restart_base = config.cdcl_restart_interval;
797    gpu_cdcl.reduce_interval = reduce_interval;
798    // Fail-closed conflict budget. Treewidth-hard equivalence verifies can
799    // spin past the GPU watchdog and crash with a context-poisoning launch
800    // failure; a finite budget makes them decline (VerifyBudgetExceeded) before
801    // that. Default 0 = unlimited (no behavior change). A recommended value is a
802    // calibration follow-up (between a completing verify's conflict count and
803    // the watchdog boundary); operators opt in via XLOG_D4_VERIFY_MAX_CONFLICTS.
804    gpu_cdcl.max_conflicts = verify_max_conflicts();
805    Ok(gpu_cdcl)
806}
807
808/// Per-verify conflict budget, read once from
809/// `XLOG_D4_VERIFY_MAX_CONFLICTS`. Default 0 = unlimited.
810fn verify_max_conflicts() -> u32 {
811    static BUDGET: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
812    *BUDGET.get_or_init(|| {
813        std::env::var("XLOG_D4_VERIFY_MAX_CONFLICTS")
814            .ok()
815            .and_then(|v| v.trim().parse::<u32>().ok())
816            .unwrap_or(0)
817    })
818}
819
820// ---------------------------------------------------------------------------
821// Disk cache helpers
822// ---------------------------------------------------------------------------
823
824/// FNV-1a 64-bit hash — deterministic across processes and Rust versions.
825/// Matches the FNV-1a algorithm used in the GPU hash kernel (kernels/cache.cu).
826fn fnv1a_u64(bytes: &[u8]) -> u64 {
827    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
828    const FNV_PRIME: u64 = 0x100000001b3;
829    let mut h = FNV_OFFSET;
830    for &b in bytes {
831        h ^= b as u64;
832        h = h.wrapping_mul(FNV_PRIME);
833    }
834    h
835}
836
837fn combine_disk_cnf_hash(canonical_hash: u64, gpu_cnf_hash: u64) -> u64 {
838    let mut buf = Vec::with_capacity(16);
839    buf.extend_from_slice(&canonical_hash.to_le_bytes());
840    buf.extend_from_slice(&gpu_cnf_hash.to_le_bytes());
841    fnv1a_u64(&buf)
842}
843
844fn read_gpu_cnf_hash(
845    hash: &TrackedCudaSlice<u64>,
846    provider: &Arc<CudaKernelProvider>,
847) -> Result<u64> {
848    let host: Vec<u64> = provider
849        .device()
850        .inner()
851        .dtoh_sync_copy(hash)
852        .map_err(|e| XlogError::Kernel(format!("dtoh GPU CNF hash for disk cache key: {}", e)))?;
853    host.first()
854        .copied()
855        .ok_or_else(|| XlogError::Kernel("empty GPU CNF hash for disk cache key".to_string()))
856}
857
858/// Hash the compile config fields that affect circuit topology output.
859fn hash_compile_config(config: &GpuCompileConfig) -> u64 {
860    let mut buf = Vec::new();
861    buf.extend_from_slice(&config.frontier_depth.to_le_bytes());
862    buf.extend_from_slice(&config.max_frontier_items.to_le_bytes());
863    buf.extend_from_slice(&config.max_depth.to_le_bytes());
864    buf.extend_from_slice(&config.smooth_node_cap.to_le_bytes());
865    buf.extend_from_slice(&config.smooth_edge_cap.to_le_bytes());
866    // CDCL verifier params do not affect the compiled circuit topology,
867    // but we include them for safety so a verifier config change invalidates the cache.
868    buf.extend_from_slice(&config.cdcl_restart_interval.to_le_bytes());
869    buf.extend_from_slice(&config.cdcl_learned_bytes.to_le_bytes());
870    fnv1a_u64(&buf)
871}
872
873/// Hash the random variable list (D→H copy + hash).
874fn hash_random_vars(
875    random_vars: &DeviceRandomVarList,
876    provider: &Arc<CudaKernelProvider>,
877) -> Result<u64> {
878    let count = random_vars.count();
879    let mut buf = Vec::new();
880    buf.extend_from_slice(&count.to_le_bytes());
881    if count > 0 {
882        let host: Vec<u32> = provider
883            .device()
884            .inner()
885            .dtoh_sync_copy(random_vars.list())
886            .map_err(|e| {
887                XlogError::Kernel(format!("dtoh random_vars for disk cache hash: {}", e))
888            })?;
889        // Hash only the valid elements (count may be less than the allocation).
890        for &v in &host[..count as usize] {
891            buf.extend_from_slice(&v.to_le_bytes());
892        }
893    }
894    Ok(fnv1a_u64(&buf))
895}
896
897/// Query the device compute capability and encode as `major * 10 + minor` (e.g. 89 for sm_89).
898fn detect_compute_capability(provider: &Arc<CudaKernelProvider>) -> Result<u32> {
899    use cudarc::driver::sys::CUdevice_attribute;
900
901    let device = provider.device().inner();
902    let major = device
903        .attribute(CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR)
904        .map_err(|e| {
905            XlogError::Kernel(format!("Failed to query compute capability major: {}", e))
906        })?;
907    let minor = device
908        .attribute(CUdevice_attribute::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR)
909        .map_err(|e| {
910            XlogError::Kernel(format!("Failed to query compute capability minor: {}", e))
911        })?;
912    let major_u32: u32 = major.try_into().map_err(|_| {
913        XlogError::Kernel(format!(
914            "compute capability major {} cannot be converted to u32",
915            major
916        ))
917    })?;
918    let minor_u32: u32 = minor.try_into().map_err(|_| {
919        XlogError::Kernel(format!(
920            "compute capability minor {} cannot be converted to u32",
921            minor
922        ))
923    })?;
924    Ok(major_u32 * 10 + minor_u32)
925}