Skip to main content

pyxlog/
lib.rs

1//! Python bindings for XLOG via PyO3.
2#![allow(missing_docs)] // PyO3 #[pyclass] / #[pymethods] generate pub items without docs
3#![allow(
4    clippy::large_enum_variant,
5    clippy::needless_range_loop,
6    clippy::too_many_arguments,
7    clippy::type_complexity
8)]
9
10use std::collections::{HashMap, HashSet};
11use std::os::raw::{c_char, c_void};
12use std::sync::Arc;
13
14use pyo3::exceptions::{PyBufferError, PyMemoryError, PyRuntimeError, PyValueError};
15use pyo3::prelude::*;
16use pyo3::types::{PyDict, PyList};
17
18use xlog_core::{MemoryBudget, Schema};
19use xlog_cuda::{
20    device_runtime::{
21        AsyncCudaResource, DeviceMemoryResource, GlobalDeviceBudget, StreamPool, XlogDeviceRuntime,
22    },
23    CudaBuffer, CudaDevice, CudaKernelProvider, DlpackManagedTensor, GpuMemoryManager,
24};
25#[cfg(feature = "arrow-device-import")]
26use xlog_cuda::{ArrowDeviceArray, ArrowDeviceArrayOwned};
27use xlog_gpu::logic as gpu_logic;
28use xlog_logic::ast::ProbEngine;
29use xlog_neural::{NetworkRegistry, TensorSourceRegistry};
30use xlog_prob::exact::GpuConfig;
31
32use xlog_core::RelId;
33use xlog_ir::ExecutionPlan;
34use xlog_logic::ast::Program as AstProgram;
35use xlog_runtime::{Executor, RelationStore};
36
37mod neural_registry;
38use neural_registry::NeuralPredicateRegistry;
39mod diagnostic_serialization;
40mod dlpack;
41mod epistemic;
42mod ilp;
43mod ilp_exact;
44mod ilp_gpu;
45mod joint_carrier;
46mod logic;
47mod neural;
48mod program;
49mod relation_metadata;
50mod training;
51mod types;
52pub(crate) use diagnostic_serialization::{pack_query_proof_traces, pack_rule_provenance};
53pub(crate) use program::{
54    CachedCircuit, CompiledProbProgram, HardFilter, InputSource, JoinPlan, NeuralGroup,
55    QuerySignature,
56};
57use relation_metadata::RelationMetadataStore;
58
59const DLPACK_CAPSULE_NAME: &[u8] = b"dltensor\0";
60const USED_DLPACK_CAPSULE_NAME: &[u8] = b"used_dltensor\0";
61// Every pyxlog DLPack import is consumed on CudaDevice's legacy default
62// stream. The Python Array API reserves integer 1 for that CUDA stream.
63const DLPACK_CUDA_LEGACY_DEFAULT_STREAM: i64 = 1;
64
65#[cfg(feature = "arrow-device-import")]
66const ARROW_DEVICE_ARRAY_CAPSULE_NAME: &[u8] = b"arrow_device_array\0";
67#[cfg(feature = "arrow-device-import")]
68const USED_ARROW_DEVICE_ARRAY_CAPSULE_NAME: &[u8] = b"used_arrow_device_array\0";
69
70unsafe extern "C" fn dlpack_capsule_destructor(capsule: *mut pyo3::ffi::PyObject) {
71    if capsule.is_null() {
72        return;
73    }
74
75    let valid =
76        pyo3::ffi::PyCapsule_IsValid(capsule, DLPACK_CAPSULE_NAME.as_ptr() as *const c_char);
77    if valid == 0 {
78        return;
79    }
80
81    let ptr =
82        pyo3::ffi::PyCapsule_GetPointer(capsule, DLPACK_CAPSULE_NAME.as_ptr() as *const c_char);
83    if ptr.is_null() {
84        pyo3::ffi::PyErr_Clear();
85        return;
86    }
87
88    let managed = ptr as *mut xlog_cuda::DLManagedTensor;
89    drop(DlpackManagedTensor::from_raw(managed));
90}
91
92pub(crate) fn dlpack_capsule_from_tensor(
93    py: Python<'_>,
94    tensor: DlpackManagedTensor,
95) -> PyResult<Py<PyAny>> {
96    let raw = tensor.into_raw();
97    let ptr = raw as *mut c_void;
98    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
99    let capsule = unsafe {
100        pyo3::ffi::PyCapsule_New(
101            ptr,
102            DLPACK_CAPSULE_NAME.as_ptr() as *const c_char,
103            Some(dlpack_capsule_destructor),
104        )
105    };
106    if capsule.is_null() {
107        // SAFETY: the pointer is a valid owned Python object pointer returned by the C API
108        unsafe {
109            drop(DlpackManagedTensor::from_raw(raw));
110        }
111        return Err(PyRuntimeError::new_err("Failed to create DLPack capsule"));
112    }
113    // SAFETY: capsule is a non-null owned pointer returned by PyCapsule_New; PyO3 takes ownership
114    let obj: Py<PyAny> = unsafe { Bound::from_owned_ptr(py, capsule) }.unbind();
115    Ok(obj)
116}
117
118#[cfg(feature = "arrow-device-import")]
119unsafe extern "C" fn arrow_device_array_capsule_destructor(capsule: *mut pyo3::ffi::PyObject) {
120    if capsule.is_null() {
121        return;
122    }
123
124    let valid = pyo3::ffi::PyCapsule_IsValid(
125        capsule,
126        ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
127    );
128    if valid == 0 {
129        return;
130    }
131
132    let ptr = pyo3::ffi::PyCapsule_GetPointer(
133        capsule,
134        ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
135    );
136    if ptr.is_null() {
137        pyo3::ffi::PyErr_Clear();
138        return;
139    }
140
141    drop(ArrowDeviceArrayOwned::from_raw(
142        ptr as *mut ArrowDeviceArray,
143    ));
144}
145
146#[cfg(feature = "arrow-device-import")]
147pub(crate) fn arrow_device_capsule_from_device_array(
148    py: Python<'_>,
149    device_array: ArrowDeviceArrayOwned,
150) -> PyResult<Py<PyAny>> {
151    let raw = device_array.into_raw();
152    let ptr = raw as *mut c_void;
153    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
154    let capsule = unsafe {
155        pyo3::ffi::PyCapsule_New(
156            ptr,
157            ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
158            Some(arrow_device_array_capsule_destructor),
159        )
160    };
161    if capsule.is_null() {
162        // SAFETY: the pointer is a valid owned Python object pointer returned by the C API
163        unsafe {
164            drop(ArrowDeviceArrayOwned::from_raw(raw));
165        }
166        return Err(PyRuntimeError::new_err(
167            "Failed to create Arrow device array capsule",
168        ));
169    }
170    // SAFETY: capsule is a non-null owned pointer returned by PyCapsule_New; PyO3 takes ownership
171    let obj: Py<PyAny> = unsafe { Bound::from_owned_ptr(py, capsule) }.unbind();
172    Ok(obj)
173}
174
175#[cfg(feature = "arrow-device-import")]
176pub(crate) fn arrow_device_from_py(obj: &Bound<'_, PyAny>) -> PyResult<ArrowDeviceArrayOwned> {
177    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
178    if unsafe {
179        pyo3::ffi::PyCapsule_IsValid(
180            obj.as_ptr(),
181            ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
182        )
183    } == 0
184    {
185        return Err(PyValueError::new_err(
186            "Expected an Arrow device array capsule (arrow_device_array)",
187        ));
188    }
189
190    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
191    let ptr = unsafe {
192        pyo3::ffi::PyCapsule_GetPointer(
193            obj.as_ptr(),
194            ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
195        )
196    };
197    if ptr.is_null() {
198        return Err(PyRuntimeError::new_err(
199            "Failed to get Arrow device array pointer",
200        ));
201    }
202
203    // Mark consumed so the capsule destructor doesn't free the pointer we now own.
204    // SAFETY: capsule is valid (checked above); renaming marks it consumed so the destructor skips cleanup
205    let rc = unsafe {
206        pyo3::ffi::PyCapsule_SetName(
207            obj.as_ptr(),
208            USED_ARROW_DEVICE_ARRAY_CAPSULE_NAME.as_ptr() as *const c_char,
209        )
210    };
211    if rc != 0 {
212        return Err(PyRuntimeError::new_err(
213            "Failed to mark Arrow device array capsule as consumed",
214        ));
215    }
216
217    // SAFETY: ptr is non-null (checked above) and points to an ArrowDeviceArray matching the Arrow C Data Interface layout
218    Ok(unsafe { ArrowDeviceArrayOwned::from_raw(ptr as *mut ArrowDeviceArray) })
219}
220
221pub(crate) fn provider_from_config(config: GpuConfig) -> xlog_core::Result<CudaKernelProvider> {
222    let device = Arc::new(CudaDevice::new(config.device_ordinal)?);
223    let stream_pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
224    let async_resource: Box<dyn DeviceMemoryResource + Send + Sync> =
225        Box::new(AsyncCudaResource::new(
226            Arc::clone(&device),
227            config.device_ordinal as u32,
228            Arc::clone(&stream_pool),
229        ));
230    let budget_limit = usize::try_from(config.memory_bytes).unwrap_or(usize::MAX);
231    let budgeted: Box<dyn DeviceMemoryResource + Send + Sync> =
232        Box::new(GlobalDeviceBudget::new(async_resource, budget_limit));
233    let runtime = Arc::new(XlogDeviceRuntime::with_resource(
234        Arc::clone(&device),
235        config.device_ordinal as u32,
236        stream_pool,
237        budgeted,
238    ));
239    let memory = Arc::new(GpuMemoryManager::with_runtime(
240        device.clone(),
241        MemoryBudget::with_limit(config.memory_bytes),
242        runtime,
243    ));
244    CudaKernelProvider::with_runtime(device, memory)
245}
246
247pub(crate) fn enforce_call_memory_limit(
248    provider: &Arc<CudaKernelProvider>,
249    memory_mb: Option<u64>,
250) -> PyResult<()> {
251    let Some(memory_mb) = memory_mb else {
252        return Ok(());
253    };
254    if memory_mb == 0 {
255        return Err(PyValueError::new_err("memory_mb must be > 0"));
256    }
257    let memory_limit_bytes = memory_mb.saturating_mul(1024 * 1024);
258    let allocated_bytes = provider.memory().allocated_bytes();
259    if allocated_bytes > memory_limit_bytes {
260        return Err(PyMemoryError::new_err(format!(
261            "per-call memory limit exceeded before evaluation: allocated_bytes={} memory_limit_bytes={}",
262            allocated_bytes, memory_limit_bytes
263        )));
264    }
265    Ok(())
266}
267
268pub(crate) fn provider_memory_stats(
269    py: Python<'_>,
270    provider: &Arc<CudaKernelProvider>,
271) -> PyResult<Py<PyAny>> {
272    let dict = PyDict::new(py);
273    let memory = provider.memory();
274    dict.set_item("allocated_bytes", memory.allocated_bytes())?;
275    dict.set_item("memory_limit_bytes", memory.budget().device_bytes)?;
276    dict.set_item("peak_memory_bytes", memory.peak_bytes())?;
277    dict.set_item("status", "available")?;
278    Ok(dict.into())
279}
280
281pub(crate) fn parse_prob_engine_override(s: &str) -> PyResult<ProbEngine> {
282    let v = s.trim().to_ascii_lowercase();
283    match v.as_str() {
284        "exact_ddnnf" | "exact" | "ddnnf" => Ok(ProbEngine::ExactDdnnf),
285        "mc" => Ok(ProbEngine::Mc),
286        other => Err(PyValueError::new_err(format!(
287            "Unknown prob_engine '{}'; expected 'exact_ddnnf' or 'mc'",
288            other
289        ))),
290    }
291}
292
293pub(crate) fn dlpack_from_py(obj: &Bound<'_, PyAny>) -> PyResult<DlpackManagedTensor> {
294    let py = obj.py();
295
296    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
297    let capsule_obj: Bound<'_, PyAny> = if unsafe {
298        pyo3::ffi::PyCapsule_IsValid(obj.as_ptr(), DLPACK_CAPSULE_NAME.as_ptr() as *const c_char)
299    } != 0
300    {
301        obj.clone()
302    } else if obj.hasattr("__dlpack__")? {
303        let (device_type, device_id): (i32, i32) =
304            obj.call_method0("__dlpack_device__")?.extract()?;
305        if device_type != xlog_cuda::dlpack::K_DLCUDA {
306            return Err(PyBufferError::new_err(format!(
307                "Unsupported DLPack producer device type {device_type} (device {device_id}); \
308                 XLOG requires CUDA device memory (kDLCUDA=2)"
309            )));
310        }
311        // Passing the consumer stream makes the producer order any pending
312        // non-default-stream writes before XLOG reads the tensor. A raw
313        // capsule cannot negotiate synchronization and must already be ready
314        // for the legacy default stream when supplied by the caller.
315        let kwargs = PyDict::new(py);
316        kwargs.set_item("stream", DLPACK_CUDA_LEGACY_DEFAULT_STREAM)?;
317        obj.call_method("__dlpack__", (), Some(&kwargs))?
318    } else {
319        return Err(PyValueError::new_err(
320            "Expected a DLPack capsule or an object with __dlpack__",
321        ));
322    };
323
324    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
325    if unsafe {
326        pyo3::ffi::PyCapsule_IsValid(
327            capsule_obj.as_ptr(),
328            DLPACK_CAPSULE_NAME.as_ptr() as *const c_char,
329        )
330    } == 0
331    {
332        return Err(PyValueError::new_err("Invalid DLPack capsule"));
333    }
334
335    // SAFETY: capsule validity was checked immediately before this call; pointer lifetime is managed by the capsule
336    let ptr = unsafe {
337        pyo3::ffi::PyCapsule_GetPointer(
338            capsule_obj.as_ptr(),
339            DLPACK_CAPSULE_NAME.as_ptr() as *const c_char,
340        )
341    };
342    if ptr.is_null() {
343        return Err(PyRuntimeError::new_err("Failed to get DLPack pointer"));
344    }
345
346    // SAFETY: capsule is valid (checked above); renaming marks it consumed so the destructor skips cleanup
347    let rc = unsafe {
348        pyo3::ffi::PyCapsule_SetName(
349            capsule_obj.as_ptr(),
350            USED_DLPACK_CAPSULE_NAME.as_ptr() as *const c_char,
351        )
352    };
353    if rc != 0 {
354        return Err(PyRuntimeError::new_err(
355            "Failed to mark DLPack capsule as consumed",
356        ));
357    }
358
359    // SAFETY: ptr is non-null (checked above) and points to a DLManagedTensor matching the DLPack specification layout
360    Ok(unsafe { DlpackManagedTensor::from_raw(ptr as *mut xlog_cuda::DLManagedTensor) })
361}
362
363#[pyfunction]
364fn dlpack_is_cuda(obj: &Bound<'_, PyAny>) -> PyResult<bool> {
365    // SAFETY: capsule validity is checked before reading the DLPack header. This
366    // does not consume the capsule; ownership remains with its destructor.
367    if unsafe {
368        pyo3::ffi::PyCapsule_IsValid(obj.as_ptr(), DLPACK_CAPSULE_NAME.as_ptr() as *const c_char)
369    } == 0
370    {
371        return Err(PyValueError::new_err(
372            "Expected a DLPack capsule (dltensor)",
373        ));
374    }
375
376    // SAFETY: capsule validity was checked immediately before this call.
377    let ptr = unsafe {
378        pyo3::ffi::PyCapsule_GetPointer(obj.as_ptr(), DLPACK_CAPSULE_NAME.as_ptr() as *const c_char)
379    };
380    if ptr.is_null() {
381        return Err(PyRuntimeError::new_err("Failed to get DLPack pointer"));
382    }
383
384    // SAFETY: ptr is non-null and points to a DLManagedTensor owned by the capsule.
385    let managed = unsafe { &*(ptr as *const xlog_cuda::DLManagedTensor) };
386    Ok(managed.dl_tensor.device.device_type == xlog_cuda::dlpack::K_DLCUDA)
387}
388
389#[pyclass(name = "DifferentiableProofTraceMap")]
390pub struct PyDifferentiableProofTraceMap {
391    inner: xlog_logic::DifferentiableProofTraceMap,
392}
393
394fn pack_differentiable_proof_trace(
395    py: Python<'_>,
396    trace: &xlog_logic::ProofTrace,
397) -> PyResult<Py<PyAny>> {
398    let dict = PyDict::new(py);
399    dict.set_item("proof_id", trace.proof_id)?;
400    dict.set_item("answer_key", &trace.answer_key)?;
401    dict.set_item("clause_id", &trace.clause_id)?;
402    dict.set_item("support_atoms", &trace.support_atoms)?;
403    dict.set_item("weight", trace.weight)?;
404    dict.set_item("gradient", trace.gradient)?;
405    Ok(dict.into())
406}
407
408#[pymethods]
409impl PyDifferentiableProofTraceMap {
410    #[new]
411    fn new() -> Self {
412        Self {
413            inner: xlog_logic::DifferentiableProofTraceMap::new(),
414        }
415    }
416
417    fn insert(
418        &mut self,
419        answer_key: String,
420        clause_id: String,
421        support_atoms: Vec<String>,
422        initial_weight: f64,
423    ) -> PyResult<u64> {
424        if !initial_weight.is_finite() {
425            return Err(PyValueError::new_err(
426                "initial_weight must be a finite float",
427            ));
428        }
429        Ok(self.inner.insert(xlog_logic::ProofTraceSpec {
430            answer_key,
431            clause_id,
432            support_atoms,
433            initial_weight,
434        }))
435    }
436
437    fn trace(&self, py: Python<'_>, proof_id: u64) -> PyResult<Option<Py<PyAny>>> {
438        self.inner
439            .trace(proof_id)
440            .map(|trace| pack_differentiable_proof_trace(py, trace))
441            .transpose()
442    }
443
444    fn traces(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
445        let list = PyList::empty(py);
446        for trace in self.inner.traces() {
447            list.append(pack_differentiable_proof_trace(py, trace)?)?;
448        }
449        Ok(list.into())
450    }
451
452    fn accumulate_binary_logistic_gradients(
453        &mut self,
454        targets: Vec<(String, f64)>,
455    ) -> PyResult<f64> {
456        if targets.iter().any(|(_, target)| !target.is_finite()) {
457            return Err(PyValueError::new_err("targets must be finite floats"));
458        }
459        Ok(self.inner.accumulate_binary_logistic_gradients(&targets))
460    }
461
462    fn apply_gradients(&mut self, learning_rate: f64) -> PyResult<()> {
463        if !learning_rate.is_finite() || learning_rate < 0.0 {
464            return Err(PyValueError::new_err(
465                "learning_rate must be a finite non-negative float",
466            ));
467        }
468        self.inner.apply_gradients(learning_rate);
469        Ok(())
470    }
471}
472
473#[pyclass]
474pub struct Program;
475
476#[pyclass]
477pub struct CompiledProgram {
478    pub(crate) program: CompiledProbProgram,
479    pub(crate) output_provider: Arc<CudaKernelProvider>,
480    /// Registry for neural networks
481    pub(crate) network_registry: NetworkRegistry,
482    /// Registry for neural predicate metadata (predicate -> network/labels)
483    pub(crate) neural_registry: NeuralPredicateRegistry,
484    /// Names of neural networks declared in the program (from nn() declarations)
485    pub(crate) declared_networks: HashSet<String>,
486    /// Map from network name to form: true = embedding, false = classification
487    pub(crate) declared_network_forms: HashMap<String, bool>,
488    /// Registry for tensor data sources (images, embeddings, etc.)
489    pub(crate) tensor_sources: TensorSourceRegistry,
490    /// Name of the Stage-B existential-join domain tensor source, as supplied by
491    /// the Python driver (single source of truth). `None` until a domain source is
492    /// registered; read by the join forward to resolve `DomainRow`/`ConstDummy`
493    /// groups instead of an engine-side hardcoded name.
494    pub(crate) domain_source: Option<String>,
495    /// Which ROW of the join-domain tensor holds which domain CONSTANT, as stated by
496    /// the Python driver: `domain_ids[j]` is the constant whose feature vector is row
497    /// `j`. This is the SINGLE source of truth for "constant -> feature row" — the
498    /// torch-side mixture and this circuit look the row up in the same list, so they
499    /// cannot drift apart.
500    ///
501    /// The ids arrive WITH the tensor (`register_domain_tensor_source` requires them), so
502    /// there is no "no ids registered" state to fall back from. The fallback that used to
503    /// live here — row = the constant's position in the relation's materialized domain —
504    /// is the defect this map exists to close: it reads a different row from the torch
505    /// path for any domain that is not exactly `0..D-1`, silently, and no test that
506    /// stays inside that coincidence can see it. Keeping it as a reachable branch would
507    /// leave the same silent-wrong mode one call away.
508    ///
509    /// Empty until a domain source is registered — and that state IS reachable from a
510    /// caller mistake, not only from an internal bug: a join signature is compiled for
511    /// every rule defining the train head (`joint_candidate_eligibility`), so a program
512    /// whose driver forgot `domain_inputs` reaches this map while it is still empty. The
513    /// lookup then fails with "constant N is not in domain_ids", which is true but points
514    /// at the wrong parameter, so the Python driver checks `domain_inputs` for every join
515    /// candidate BEFORE it asks for eligibility. A lookup miss here is therefore a real
516    /// error to surface (a joined constant with no id), never something to paper over.
517    pub(crate) domain_ids: Vec<i64>,
518    /// Original program source (for dynamic query compilation)
519    pub(crate) _source: String,
520    /// Parsed program AST (for signature analysis)
521    pub(crate) ast: xlog_logic::ast::Program,
522    /// GPU configuration
523    pub(crate) _gpu_config: GpuConfig,
524    /// Probabilistic inference engine
525    pub(crate) _prob_engine: ProbEngine,
526    /// Cache of analyzed query signatures.
527    pub(crate) query_signature_cache: HashMap<String, QuerySignature>,
528    /// Cache of compiled circuits by template signature
529    pub(crate) circuit_cache: HashMap<String, CachedCircuit>,
530    /// Number of circuit-template cache hits observed by neural training paths.
531    pub(crate) circuit_cache_hits: usize,
532    /// Number of circuit-template cache misses observed by neural training paths.
533    pub(crate) circuit_cache_misses: usize,
534    /// Number of times the template compilation path executed.
535    pub(crate) template_compile_count: usize,
536    /// When true, batch queries sharing the same circuit template in training.
537    pub(crate) batch_queries: bool,
538    /// Latest circuit compilation profile (populated on cache miss when profiling).
539    pub(crate) last_compile_profile: Option<xlog_prob::compilation::CircuitCompileProfile>,
540}
541
542#[pyclass]
543pub struct LogicProgram;
544
545#[pyclass]
546pub struct CompiledLogicProgram {
547    pub(crate) program: Arc<gpu_logic::LogicProgram>,
548    pub(crate) provider: Arc<CudaKernelProvider>,
549}
550
551/// A fixed accepted-evidence exact circuit with mutable independent fact priors.
552///
553/// Clones share one serialized native state. Updating or evaluating one clone
554/// excludes concurrent work on the same circuit and is visible to every clone.
555#[pyclass(skip_from_py_object)]
556#[derive(Clone)]
557pub struct CompiledConditionedProgram {
558    #[cfg(feature = "host-io")]
559    pub(crate) program: xlog_prob::epistemic_production::PreparedConditionedProgram,
560    #[cfg(feature = "host-io")]
561    pub(crate) result_provider: Arc<CudaKernelProvider>,
562}
563
564#[pyclass]
565pub struct LogicRelationSession {
566    pub(crate) program: Arc<gpu_logic::LogicProgram>,
567    pub(crate) provider: Arc<CudaKernelProvider>,
568    pub(crate) relation_store: RelationStore,
569    pub(crate) evaluation_store: Option<gpu_logic::LogicMaterializedStore>,
570    pub(crate) session_runtime: Option<gpu_logic::LogicSessionRuntime>,
571    pub(crate) last_delta_stats: Option<LogicDeltaStats>,
572    pub(crate) relation_callbacks: Vec<RelationChangeCallback>,
573    pub(crate) next_relation_callback_id: u64,
574    pub(crate) relation_generations: HashMap<String, u64>,
575    pub(crate) relation_metadata: RelationMetadataStore,
576}
577
578pub(crate) struct RelationChangeCallback {
579    pub id: u64,
580    pub callback: Py<PyAny>,
581}
582
583#[derive(Clone, Debug)]
584pub(crate) struct LogicDeltaStats {
585    pub input_delta_count: usize,
586    pub changed_relations: usize,
587    pub changed_relation_names: Vec<String>,
588    pub insert_rows: u64,
589    pub delete_rows: u64,
590    pub has_deletes: bool,
591    pub affected_sccs: usize,
592    pub recomputed_sccs: usize,
593    pub incremental_sccs: usize,
594    pub coalesced_insert_rows: u64,
595    pub coalesced_delete_rows: u64,
596    pub canceled_rows: u64,
597    pub equivalent_to_full_recompute: Option<bool>,
598    pub planner_telemetry: gpu_logic::DeltaPlannerTelemetry,
599    pub debug_trace: Vec<String>,
600}
601
602#[pyclass]
603pub struct LogicQueryResult {
604    #[pyo3(get)]
605    pub relation_name: String,
606    #[pyo3(get)]
607    pub columns: Vec<String>,
608    #[pyo3(get)]
609    pub sort_labels: Vec<String>,
610    #[pyo3(get)]
611    pub tensors: Vec<Py<PyAny>>,
612    #[pyo3(get)]
613    pub num_rows: usize,
614    #[pyo3(get)]
615    pub is_true: bool,
616}
617
618#[pyclass]
619pub struct LogicEvalResult {
620    #[pyo3(get)]
621    pub queries: Vec<Py<LogicQueryResult>>,
622}
623
624#[pyclass]
625pub struct IlpTaggedCreditDeviceResult {
626    #[pyo3(get)]
627    pub fact_row_offsets: Py<PyAny>,
628    #[pyo3(get)]
629    pub entry_indices: Py<PyAny>,
630    #[pyo3(get)]
631    pub entry_i: Py<PyAny>,
632    #[pyo3(get)]
633    pub entry_j: Py<PyAny>,
634    #[pyo3(get)]
635    pub entry_k: Py<PyAny>,
636}
637
638#[pyclass]
639pub struct McDeviceEvalResult {
640    /// Per-query satisfying-sample counts. DLPack int32 tensor on CUDA.
641    #[pyo3(get)]
642    pub query_counts: Py<PyAny>,
643    /// Evidence satisfying-sample count. DLPack int32 tensor with shape [1] on CUDA.
644    #[pyo3(get)]
645    pub evidence_count: Py<PyAny>,
646    #[pyo3(get)]
647    pub total_samples: usize,
648    #[pyo3(get)]
649    pub seed: u64,
650    #[pyo3(get)]
651    pub confidence: f64,
652    #[pyo3(get)]
653    pub nonmonotone_semantics: String,
654    #[pyo3(get)]
655    pub nonmonotone_sccs: usize,
656    #[pyo3(get)]
657    pub nonmonotone_cycles: usize,
658    #[pyo3(get)]
659    pub nonmonotone_iteration_limit_hits: usize,
660    #[pyo3(get)]
661    pub sampling_method: String,
662    #[pyo3(get)]
663    pub resident_no_host_certified: bool,
664    #[pyo3(get)]
665    pub resident_no_host_policy_result: String,
666    #[pyo3(get)]
667    pub resident_no_host_tracked_dtoh_calls: u64,
668    #[pyo3(get)]
669    pub resident_no_host_tracked_htod_calls: u64,
670    #[pyo3(get)]
671    pub resident_no_host_host_loop_iterations: u64,
672    #[pyo3(get)]
673    pub resident_no_host_per_sample_host_launches: u64,
674    #[pyo3(get)]
675    pub resident_no_host_untracked_metadata_reads: u64,
676    #[pyo3(get)]
677    pub resident_no_host_engine_launches: u64,
678    #[pyo3(get)]
679    pub resident_no_host_host_fixpoint_iterations: u64,
680    #[pyo3(get)]
681    pub resident_no_host_per_operator_host_allocations: u64,
682}
683
684#[pyclass]
685pub struct EvalResult {
686    #[pyo3(get)]
687    pub atoms: Vec<String>,
688    #[pyo3(get)]
689    pub prob: Py<PyAny>,
690    #[pyo3(get)]
691    pub log_prob: Py<PyAny>,
692    #[pyo3(get)]
693    pub num_vars: usize,
694    /// Exact log-evidence `log Z_E` (natural log). `None` for Monte Carlo results.
695    #[pyo3(get)]
696    pub log_z_e: Option<f64>,
697    #[pyo3(get)]
698    pub grad_true: Option<Vec<Py<PyAny>>>,
699    #[pyo3(get)]
700    pub grad_false: Option<Vec<Py<PyAny>>>,
701    #[pyo3(get)]
702    pub approx: bool,
703    #[pyo3(get)]
704    pub stderr: Option<Py<PyAny>>,
705    #[pyo3(get)]
706    pub ci_low: Option<Py<PyAny>>,
707    #[pyo3(get)]
708    pub ci_high: Option<Py<PyAny>>,
709    #[pyo3(get)]
710    pub samples: Option<usize>,
711    #[pyo3(get)]
712    pub evidence_samples: Option<usize>,
713    #[pyo3(get)]
714    pub seed: Option<u64>,
715    #[pyo3(get)]
716    pub confidence: Option<f64>,
717    #[pyo3(get)]
718    pub nonmonotone_semantics: Option<String>,
719    #[pyo3(get)]
720    pub nonmonotone_sccs: Option<usize>,
721    #[pyo3(get)]
722    pub nonmonotone_cycles: Option<usize>,
723    #[pyo3(get)]
724    pub nonmonotone_iteration_limit_hits: Option<usize>,
725    #[pyo3(get)]
726    pub sampling_method: Option<String>,
727    /// MC only: which engine produced the result — `"gpu-resident"` for the
728    /// resident megakernel engine, `"cpu-oracle"` for the explicitly opted-in
729    /// CPU oracle. `None` for exact inference.
730    #[pyo3(get)]
731    pub mc_engine: Option<String>,
732}
733
734/// Exact probabilities conditioned on an accepted epistemic world view.
735///
736/// `prob`/`log_prob` are DLPack capsules over device memory, like `EvalResult`.
737/// `trace` carries the production-path counters of the epistemic->probability
738/// adapter: they are the evidence that conditioning actually happened on the GPU.
739///
740/// `log_z_e` is log P(evidence): the exact log-probability of the conditioned
741/// evidence under the probabilistic program's distribution, computed by weighted
742/// model counting over the compiled circuit. Query probabilities are
743/// `exp(log_z_eq - log_z_e)`. When the conditioned atoms are independent root
744/// facts it coincides with the log of the product of their priors, but that is a
745/// special case, not the definition: evidence on a derived atom, on atoms sharing
746/// an ancestor, or negated evidence all diverge from the product form.
747#[pyclass]
748pub struct EpistemicEvalResult {
749    #[pyo3(get)]
750    pub atoms: Vec<String>,
751    #[pyo3(get)]
752    pub prob: Py<PyAny>,
753    #[pyo3(get)]
754    pub log_prob: Py<PyAny>,
755    #[pyo3(get)]
756    pub log_z_e: f64,
757    #[pyo3(get)]
758    pub trace: Py<PyAny>,
759}
760
761/// Summary of one accepted epistemic GPU execution.
762///
763/// `accepted_world_views == 0` means the program ran but nothing was accepted.
764/// `evaluate_conditioned` on that same program RAISES `RuntimeError` rather than
765/// returning an unconditioned result — this method is the non-raising way to detect
766/// the state. `know_operator_count`/`possible_operator_count` are plan-level
767/// censuses and stay non-zero even when nothing is accepted.
768#[pyclass]
769pub struct EpistemicEvidence {
770    #[pyo3(get)]
771    pub epistemic_mode: String,
772    #[pyo3(get)]
773    pub know_operator_count: usize,
774    #[pyo3(get)]
775    pub possible_operator_count: usize,
776    #[pyo3(get)]
777    pub accepted_candidates: usize,
778    #[pyo3(get)]
779    pub rejected_candidates: usize,
780    #[pyo3(get)]
781    pub accepted_world_views: usize,
782    #[pyo3(get)]
783    pub final_output_rows: usize,
784}
785
786// =========================================================================
787// Training Infrastructure
788// =========================================================================
789
790/// Statistics for a single training epoch.
791// Result-only class: nothing extracts it back from Python, so the
792// Clone-derived automatic `FromPyObject` is explicitly skipped.
793#[pyclass(skip_from_py_object)]
794#[derive(Clone)]
795pub struct EpochStats {
796    /// Average loss across all batches in the epoch
797    #[pyo3(get)]
798    pub avg_loss: f64,
799    /// Number of batches processed
800    #[pyo3(get)]
801    pub num_batches: usize,
802    /// Total number of queries processed
803    #[pyo3(get)]
804    pub total_queries: usize,
805}
806
807/// Training history tracking loss over epochs and batches.
808// Result-only class: nothing extracts it back from Python, so the
809// Clone-derived automatic `FromPyObject` is explicitly skipped.
810#[pyclass(skip_from_py_object)]
811#[derive(Clone)]
812pub struct TrainingHistory {
813    /// Loss at the end of each epoch
814    #[pyo3(get)]
815    pub epoch_losses: Vec<f64>,
816    /// Wall-clock time (seconds) for each epoch
817    #[pyo3(get)]
818    pub epoch_times: Vec<f64>,
819    /// Loss for each batch across all epochs
820    #[pyo3(get)]
821    pub batch_losses: Vec<f64>,
822    /// True if training was stopped early due to validation loss plateau.
823    #[pyo3(get)]
824    pub stopped_early: bool,
825}
826
827#[pyclass]
828pub struct IlpProgramFactory;
829
830#[pyclass]
831pub struct CompiledIlpProgram {
832    pub(crate) base_source: String,
833    pub(crate) _learnable_source: String,
834    pub(crate) ast: AstProgram,
835    pub(crate) executor: Executor,
836    pub(crate) provider: Arc<CudaKernelProvider>,
837    pub(crate) plan: ExecutionPlan,
838    pub(crate) rel_index: Vec<(RelId, String)>,
839    pub(crate) schemas: HashMap<String, Schema>,
840    pub(crate) left_keys: Vec<usize>,
841    pub(crate) right_keys: Vec<usize>,
842    pub(crate) head_projection: Vec<usize>,
843    pub(crate) compiled_schema_size: usize,
844    pub(crate) head_rel_name: String,
845    pub(crate) max_active_rules: usize,
846    pub(crate) candidate_map: Option<HashMap<(u32, u32, u32), u32>>,
847    pub(crate) candidate_order: Option<Vec<(u32, u32, u32)>>,
848    pub(crate) relation_overrides: HashMap<String, CudaBuffer>,
849    /// Maximum bytes for per-chunk temp allocations (masks, prefix sums,
850    /// chunk-local COO scratch). The final merged COO buffer is exact-NNZ
851    /// sized and may exceed this budget. Default: 16 MB.
852    pub(crate) coo_chunk_budget: u64,
853    /// When true, raise instead of falling back to chunked COO path.
854    /// Use in zero-D2H benchmarks and CI gates. Default: false.
855    pub(crate) strict_zero_dtoh: bool,
856    /// Per-phase wall-clock of the compile that built this program (ms).
857    pub(crate) compile_timing: Vec<(&'static str, f64)>,
858}
859
860#[pymodule]
861#[pyo3(name = "_native")]
862fn pyxlog(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
863    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
864    m.add_class::<Program>()?;
865    m.add_class::<CompiledProgram>()?;
866    m.add_class::<LogicProgram>()?;
867    m.add_class::<CompiledLogicProgram>()?;
868    m.add_class::<CompiledConditionedProgram>()?;
869    m.add_class::<LogicRelationSession>()?;
870    m.add_class::<relation_metadata::RelationEvidence>()?;
871    m.add_class::<LogicQueryResult>()?;
872    m.add_class::<LogicEvalResult>()?;
873    m.add_class::<McDeviceEvalResult>()?;
874    m.add_class::<EvalResult>()?;
875    m.add_class::<EpistemicEvalResult>()?;
876    m.add_class::<EpistemicEvidence>()?;
877    // Training infrastructure
878    m.add_class::<PyDifferentiableProofTraceMap>()?;
879    m.add_class::<EpochStats>()?;
880    m.add_class::<TrainingHistory>()?;
881    // ILP bindings
882    m.add_class::<IlpProgramFactory>()?;
883    m.add_class::<CompiledIlpProgram>()?;
884    m.add_class::<IlpTaggedCreditDeviceResult>()?;
885    m.add_function(wrap_pyfunction!(training::train_model, m)?)?;
886    m.add_function(wrap_pyfunction!(training::train_model_tensor, m)?)?;
887    m.add_function(wrap_pyfunction!(dlpack::dlpack_roundtrip, m)?)?;
888    m.add_function(wrap_pyfunction!(dlpack_is_cuda, m)?)?;
889    #[cfg(feature = "arrow-device-import")]
890    m.add_function(wrap_pyfunction!(dlpack::export_arrow_device, m)?)?;
891    #[cfg(feature = "arrow-device-import")]
892    m.add_function(wrap_pyfunction!(dlpack::import_arrow_device, m)?)?;
893    // Joint constraint carrier bindings
894    m.add_class::<joint_carrier::JointConstraintCarrier>()?;
895    m.add(
896        "CarrierRefused",
897        _py.get_type::<joint_carrier::CarrierRefused>(),
898    )?;
899    m.add(
900        "SolverResourceExhausted",
901        _py.get_type::<joint_carrier::SolverResourceExhausted>(),
902    )?;
903    m.add(
904        "RelationMetadataError",
905        _py.get_type::<relation_metadata::RelationMetadataError>(),
906    )?;
907    m.add("SOLVER_ABI_IDENTITY", xlog_cuda::SOLVER_ABI_IDENTITY)?;
908    Ok(())
909}