Skip to main content

pyxlog/
ilp.rs

1// ---------------------------------------------------------------------------
2// ILP (Inductive Logic Programming) Python bindings
3// ---------------------------------------------------------------------------
4
5use std::collections::HashMap as StdHashMap;
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8use std::time::Instant;
9
10use pyo3::exceptions::{PyRuntimeError, PyValueError};
11use pyo3::prelude::*;
12use pyo3::types::{PyDict, PySequence};
13
14use xlog_core::{RelId, ScalarType, Schema};
15use xlog_cuda::{CudaKernelProvider, JoinType};
16use xlog_ir::{ExecutionPlan, RirNode};
17use xlog_logic::ast::{Program as AstProgram, Term, TypeRef};
18use xlog_logic::ground_term_encoding::append_ground_term_bytes;
19use xlog_prob::exact::GpuConfig;
20use xlog_runtime::ilp_registry::IlpMask;
21use xlog_runtime::{read_device_row_count, Executor};
22
23use xlog_cuda::type_seam::GpuScalar;
24
25use super::{
26    dlpack_capsule_from_tensor, dlpack_from_py, ilp_gpu, provider_from_config, types,
27    CompiledIlpProgram, IlpProgramFactory, IlpTaggedCreditDeviceResult,
28};
29
30// ---------------------------------------------------------------------------
31// Helper functions
32// ---------------------------------------------------------------------------
33
34struct RelationExampleGroup {
35    relation: String,
36    query_buf: xlog_cuda::CudaBuffer,
37    num_rows: u32,
38}
39
40fn type_ref_name(typ: &TypeRef) -> String {
41    match typ {
42        TypeRef::Scalar(scalar) => types::scalar_type_name(scalar),
43        TypeRef::Domain(name) => name.clone(),
44        TypeRef::List(inner) => format!("list<{}>", type_ref_name(inner)),
45        TypeRef::Term => "term".to_string(),
46        TypeRef::Compound => "compound".to_string(),
47        TypeRef::PredRef => "predref".to_string(),
48    }
49}
50
51fn collect_dlpack_columns(
52    dlpack_columns: &Bound<'_, PyAny>,
53    err_msg: &str,
54) -> PyResult<Vec<xlog_cuda::DlpackManagedTensor>> {
55    let seq = dlpack_columns
56        .cast::<PySequence>()
57        .map_err(|_| PyValueError::new_err(err_msg.to_string()))?;
58    let mut tensors = Vec::with_capacity(seq.len()?);
59    for item in seq.try_iter()? {
60        tensors.push(dlpack_from_py(&item?)?);
61    }
62    Ok(tensors)
63}
64
65/// Allocate zero-initialized loss (scalar) and grad (num_cands) on GPU and
66/// export both as DLPack capsules. Shared by both f32 and f64 paths.
67fn build_zero_typed<T>(
68    provider: &CudaKernelProvider,
69    py: Python<'_>,
70    num_cands: u32,
71    scalar_type: ScalarType,
72) -> PyResult<(Py<PyAny>, Py<PyAny>)>
73where
74    T: GpuScalar + cudarc::driver::ValidAsZeroBits,
75{
76    let mut d_grad = provider
77        .memory()
78        .alloc::<T>(num_cands as usize)
79        .map_err(|e| types::gpu_err("alloc grad", e))?;
80    if num_cands > 0 {
81        provider
82            .device()
83            .inner()
84            .memset_zeros(&mut d_grad)
85            .map_err(|e| types::gpu_err("zero grad", e))?;
86    }
87    let mut d_loss = provider
88        .memory()
89        .alloc::<T>(1)
90        .map_err(|e| types::gpu_err("alloc loss", e))?;
91    provider
92        .device()
93        .inner()
94        .memset_zeros(&mut d_loss)
95        .map_err(|e| types::gpu_err("zero loss", e))?;
96    ilp_gpu::export_loss_grad_device(provider, py, d_loss, d_grad, num_cands, scalar_type)
97}
98
99fn export_device_bool_tensor(
100    provider: &CudaKernelProvider,
101    py: Python<'_>,
102    values: xlog_cuda::memory::TrackedCudaSlice<u8>,
103    rows: usize,
104) -> PyResult<Py<PyAny>> {
105    let rows_u32 = u32::try_from(rows)
106        .map_err(|_| PyValueError::new_err(format!("Row count {} exceeds u32::MAX", rows)))?;
107
108    let mut d_num_rows = provider.memory().alloc::<u32>(1).map_err(types::xlog_err)?;
109    provider
110        .device()
111        .inner()
112        .htod_sync_copy_into(&[rows_u32], &mut d_num_rows)
113        .map_err(types::xlog_err)?;
114
115    let buffer = xlog_cuda::CudaBuffer::from_columns(
116        vec![values.into_bytes().into()],
117        rows as u64,
118        d_num_rows,
119        Schema::new(vec![("col0".to_string(), ScalarType::Bool)]),
120    );
121    let tensor = provider
122        .to_dlpack_table(buffer)
123        .column(0)
124        .map_err(types::xlog_err)?;
125    dlpack_capsule_from_tensor(py, tensor)
126}
127
128fn export_device_u32_tensor_as_i32(
129    provider: &CudaKernelProvider,
130    py: Python<'_>,
131    values: xlog_cuda::memory::TrackedCudaSlice<u32>,
132    rows: usize,
133) -> PyResult<Py<PyAny>> {
134    let rows_u32 = u32::try_from(rows)
135        .map_err(|_| PyValueError::new_err(format!("Row count {} exceeds u32::MAX", rows)))?;
136
137    let mut d_num_rows = provider.memory().alloc::<u32>(1).map_err(types::xlog_err)?;
138    provider
139        .device()
140        .inner()
141        .htod_sync_copy_into(&[rows_u32], &mut d_num_rows)
142        .map_err(types::xlog_err)?;
143
144    // PyTorch does not support unsigned 32-bit DLPack tensors; export as i32.
145    let buffer = xlog_cuda::CudaBuffer::from_columns(
146        vec![values.into_bytes().into()],
147        rows as u64,
148        d_num_rows,
149        Schema::new(vec![("col0".to_string(), ScalarType::I32)]),
150    );
151    let tensor = provider
152        .to_dlpack_table(buffer)
153        .column(0)
154        .map_err(types::xlog_err)?;
155    dlpack_capsule_from_tensor(py, tensor)
156}
157
158fn empty_tagged_credit_device_result(
159    provider: &CudaKernelProvider,
160    py: Python<'_>,
161    num_facts: usize,
162) -> PyResult<IlpTaggedCreditDeviceResult> {
163    let mut d_row_offsets = provider
164        .memory()
165        .alloc::<u32>(num_facts + 1)
166        .map_err(types::xlog_err)?;
167    provider
168        .device()
169        .inner()
170        .memset_zeros(&mut d_row_offsets)
171        .map_err(types::xlog_err)?;
172    let d_empty_indices = provider.memory().alloc::<u32>(0).map_err(types::xlog_err)?;
173    let d_empty_i = provider.memory().alloc::<u32>(0).map_err(types::xlog_err)?;
174    let d_empty_j = provider.memory().alloc::<u32>(0).map_err(types::xlog_err)?;
175    let d_empty_k = provider.memory().alloc::<u32>(0).map_err(types::xlog_err)?;
176
177    Ok(IlpTaggedCreditDeviceResult {
178        fact_row_offsets: export_device_u32_tensor_as_i32(
179            provider,
180            py,
181            d_row_offsets,
182            num_facts + 1,
183        )?,
184        entry_indices: export_device_u32_tensor_as_i32(provider, py, d_empty_indices, 0)?,
185        entry_i: export_device_u32_tensor_as_i32(provider, py, d_empty_i, 0)?,
186        entry_j: export_device_u32_tensor_as_i32(provider, py, d_empty_j, 0)?,
187        entry_k: export_device_u32_tensor_as_i32(provider, py, d_empty_k, 0)?,
188    })
189}
190
191/// Pack `i64` fact values into typed byte columns according to schema.
192/// Returns one `Vec<u8>` per column with correctly-encoded LE bytes.
193/// Rejects F32/F64 columns (not supported in batch APIs).
194pub(crate) fn pack_i64_columns_typed(
195    relation: &str,
196    facts: &[Vec<i64>],
197    schema: &Schema,
198) -> PyResult<Vec<Vec<u8>>> {
199    let arity = schema.arity();
200    for (idx, fact) in facts.iter().enumerate() {
201        if fact.len() != arity {
202            return Err(PyValueError::new_err(format!(
203                "Relation '{}': fact {} has {} values, expected {}",
204                relation,
205                idx,
206                fact.len(),
207                arity,
208            )));
209        }
210    }
211
212    let mut columns: Vec<Vec<u8>> = (0..arity)
213        .map(|col_idx| {
214            let elem_size = schema
215                .column_type(col_idx)
216                .map(|t| t.size_bytes())
217                .unwrap_or(4);
218            Vec::with_capacity(facts.len() * elem_size)
219        })
220        .collect();
221
222    for fact in facts {
223        for (col_idx, &val) in fact.iter().enumerate() {
224            let col_type = schema.column_type(col_idx);
225            let col = &mut columns[col_idx];
226            match col_type {
227                Some(ScalarType::U32) => {
228                    let v = u32::try_from(val).map_err(|_| {
229                        PyValueError::new_err(format!(
230                            "Relation '{}' column {} (U32): value {} out of range [0, {}]",
231                            relation,
232                            col_idx,
233                            val,
234                            u32::MAX,
235                        ))
236                    })?;
237                    col.extend_from_slice(&v.to_le_bytes());
238                }
239                Some(ScalarType::I32) => {
240                    let v = i32::try_from(val).map_err(|_| {
241                        PyValueError::new_err(format!(
242                            "Relation '{}' column {} (I32): value {} out of range [{}, {}]",
243                            relation,
244                            col_idx,
245                            val,
246                            i32::MIN,
247                            i32::MAX,
248                        ))
249                    })?;
250                    col.extend_from_slice(&v.to_le_bytes());
251                }
252                Some(ScalarType::U64) => {
253                    let v = u64::try_from(val).map_err(|_| PyValueError::new_err(format!(
254                        "Relation '{}' column {} (U64): value {} is negative; U64 requires non-negative values",
255                        relation, col_idx, val,
256                    )))?;
257                    col.extend_from_slice(&v.to_le_bytes());
258                }
259                Some(ScalarType::I64) => {
260                    col.extend_from_slice(&val.to_le_bytes());
261                }
262                Some(ScalarType::Bool) => match val {
263                    0 => col.push(0u8),
264                    1 => col.push(1u8),
265                    _ => {
266                        return Err(PyValueError::new_err(format!(
267                            "Relation '{}' column {} (Bool): value {} not in {{0, 1}}",
268                            relation, col_idx, val,
269                        )))
270                    }
271                },
272                Some(ScalarType::Symbol) => {
273                    let v = u32::try_from(val).map_err(|_| {
274                        PyValueError::new_err(format!(
275                            "Relation '{}' column {} (Symbol): value {} out of range [0, {}]",
276                            relation,
277                            col_idx,
278                            val,
279                            u32::MAX,
280                        ))
281                    })?;
282                    col.extend_from_slice(&v.to_le_bytes());
283                }
284                Some(ScalarType::F32) => {
285                    return Err(PyValueError::new_err(format!(
286                        "Relation '{}' column {} (F32): float columns not supported in batch APIs",
287                        relation, col_idx,
288                    )));
289                }
290                Some(ScalarType::F64) => {
291                    return Err(PyValueError::new_err(format!(
292                        "Relation '{}' column {} (F64): float columns not supported in batch APIs",
293                        relation, col_idx,
294                    )));
295                }
296                None => {
297                    return Err(PyValueError::new_err(format!(
298                        "Relation '{}' column {}: no type in schema",
299                        relation, col_idx,
300                    )));
301                }
302            }
303        }
304    }
305
306    Ok(columns)
307}
308
309pub(crate) fn load_facts_into_store(
310    ast: &AstProgram,
311    provider: &CudaKernelProvider,
312    executor: &mut Executor,
313    schemas: &HashMap<String, Schema>,
314) -> xlog_core::Result<()> {
315    use xlog_core::XlogError;
316    let mut rows_by_pred: HashMap<&str, Vec<&[Term]>> = HashMap::new();
317    for fact in ast.facts() {
318        rows_by_pred
319            .entry(fact.head.predicate.as_str())
320            .or_default()
321            .push(&fact.head.terms);
322    }
323
324    for (pred, rows) in rows_by_pred {
325        let schema = schemas.get(pred).ok_or_else(|| {
326            XlogError::Execution(format!("Missing schema for fact predicate {}", pred))
327        })?;
328
329        if rows.iter().any(|r| r.len() != schema.arity()) {
330            return Err(XlogError::Execution(format!(
331                "Fact arity mismatch for {} (expected {})",
332                pred,
333                schema.arity()
334            )));
335        }
336
337        let mut columns: Vec<Vec<u8>> = vec![Vec::new(); schema.arity()];
338        for row in &rows {
339            for (col_idx, term) in row.iter().enumerate() {
340                let typ = schema.column_type(col_idx).ok_or_else(|| {
341                    XlogError::Execution(format!("Missing type for col {}", col_idx))
342                })?;
343                append_ground_term_bytes(&mut columns[col_idx], term, typ).map_err(|error| {
344                    xlog_core::XlogError::Execution(format!(
345                        "Failed to encode fact for predicate {pred} at column {col_idx}: {error}"
346                    ))
347                })?;
348            }
349        }
350
351        let slices: Vec<&[u8]> = columns.iter().map(|c| c.as_slice()).collect();
352        let fact_buf = provider.create_buffer_from_slices(&slices, schema.clone())?;
353
354        let existing = executor.store().get(pred).ok_or_else(|| {
355            XlogError::Execution(format!(
356                "Missing base relation {} while loading facts",
357                pred
358            ))
359        })?;
360        let merged = provider.union(existing, &fact_buf)?;
361        executor.store_mut().put(pred, merged);
362    }
363    Ok(())
364}
365
366/// Extracted TensorMaskedJoin metadata from the execution plan.
367struct TmjMeta {
368    left_keys: Vec<usize>,
369    right_keys: Vec<usize>,
370    head_projection: Vec<usize>,
371    schema_size: usize,
372    head_rel_name: String,
373}
374
375fn walk_tmj(node: &RirNode, target_mask: Option<&str>) -> Option<TmjMeta> {
376    match node {
377        RirNode::TensorMaskedJoin {
378            mask_name,
379            left_keys,
380            right_keys,
381            head_projection,
382            schema_size,
383            head_rel_name,
384            ..
385        } => {
386            if target_mask.is_none() || target_mask == Some(mask_name.as_str()) {
387                Some(TmjMeta {
388                    left_keys: left_keys.clone(),
389                    right_keys: right_keys.clone(),
390                    head_projection: head_projection.clone(),
391                    schema_size: *schema_size,
392                    head_rel_name: head_rel_name.clone(),
393                })
394            } else {
395                None
396            }
397        }
398        RirNode::Fixpoint {
399            base, recursive, ..
400        } => walk_tmj(base, target_mask).or_else(|| walk_tmj(recursive, target_mask)),
401        RirNode::Union { inputs } => inputs.iter().find_map(|n| walk_tmj(n, target_mask)),
402        RirNode::Filter { input, .. }
403        | RirNode::Project { input, .. }
404        | RirNode::Distinct { input, .. }
405        | RirNode::GroupBy { input, .. } => walk_tmj(input, target_mask),
406        RirNode::Join { left, right, .. } | RirNode::Diff { left, right } => {
407            walk_tmj(left, target_mask).or_else(|| walk_tmj(right, target_mask))
408        }
409        // Descend into the fallback so a TMJ wrapped beneath a promoted
410        // MultiWayJoin is still discoverable. The promoter does not currently
411        // wrap TMJ-bearing trees, but the explicit arm documents the contract
412        // instead of relying on the catch-all.
413        RirNode::MultiWayJoin { fallback, .. } => walk_tmj(fallback, target_mask),
414        RirNode::ChainJoin { fallback, .. } => walk_tmj(fallback, target_mask),
415        _ => None,
416    }
417}
418
419fn extract_tmj_meta(plan: &ExecutionPlan) -> TmjMeta {
420    extract_tmj_meta_for_mask(plan, None)
421}
422
423fn extract_tmj_meta_for_mask(plan: &ExecutionPlan, mask_name: Option<&str>) -> TmjMeta {
424    for scc_rules in &plan.rules_by_scc {
425        for rule in scc_rules {
426            if let Some(meta) = walk_tmj(&rule.body, mask_name) {
427                return meta;
428            }
429        }
430    }
431    TmjMeta {
432        left_keys: vec![],
433        right_keys: vec![],
434        head_projection: vec![],
435        schema_size: 0,
436        head_rel_name: String::new(),
437    }
438}
439
440fn strip_learnable_declarations(source: &str) -> String {
441    source
442        .lines()
443        .filter(|line| !line.trim_start().starts_with("learnable("))
444        .collect::<Vec<_>>()
445        .join("\n")
446}
447
448fn extract_learnable_declarations(source: &str) -> String {
449    source
450        .lines()
451        .filter(|line| line.trim_start().starts_with("learnable("))
452        .collect::<Vec<_>>()
453        .join("\n")
454}
455
456// ---------------------------------------------------------------------------
457// IlpProgramFactory
458// ---------------------------------------------------------------------------
459
460#[pymethods]
461impl IlpProgramFactory {
462    #[staticmethod]
463    #[pyo3(signature = (source, device=0, memory_mb=512, max_active_rules=None))]
464    pub fn compile(
465        source: &str,
466        device: usize,
467        memory_mb: u64,
468        max_active_rules: Option<usize>,
469    ) -> PyResult<CompiledIlpProgram> {
470        // Validate max_active_rules range
471        if let Some(max) = max_active_rules {
472            if !(16..=128).contains(&max) {
473                return Err(PyValueError::new_err(format!(
474                    "max_active_rules must be between 16 and 128, got {}",
475                    max
476                )));
477            }
478        }
479
480        // The frontend runs before the provider is built, so an unparsable
481        // source still fails with a ValueError without initialising CUDA.
482        build_ilp_program(
483            source,
484            max_active_rules,
485            ProviderFor::New { device, memory_mb },
486        )
487    }
488}
489
490/// Where a program being built gets its CUDA provider from.
491enum ProviderFor {
492    /// Build a new provider (and pay for a fresh CUDA context).
493    New { device: usize, memory_mb: u64 },
494    /// Share an existing program's provider.
495    Shared(Arc<CudaKernelProvider>),
496}
497
498fn ms_since(t: Instant) -> f64 {
499    t.elapsed().as_secs_f64() * 1000.0
500}
501
502/// Shared body of [`IlpProgramFactory::compile`] and
503/// [`CompiledIlpProgram::compile_variant`].
504fn build_ilp_program(
505    source: &str,
506    max_active_rules: Option<usize>,
507    provider_for: ProviderFor,
508) -> PyResult<CompiledIlpProgram> {
509    let mut timing: Vec<(&'static str, f64)> = Vec::new();
510    let t = Instant::now();
511    let ast = xlog_logic::parse_program(source).map_err(types::val_err)?;
512
513    let base_source = strip_learnable_declarations(source);
514    let learnable_source = extract_learnable_declarations(source);
515
516    let mut compiler = xlog_logic::Compiler::new();
517    if let Some(max) = max_active_rules {
518        compiler.set_max_active_rules(max);
519    }
520    let plan = compiler.compile_program(&ast).map_err(types::xlog_err)?;
521
522    let mut rel_index: Vec<(RelId, String)> = compiler
523        .rel_ids()
524        .iter()
525        .map(|(name, id)| (*id, name.clone()))
526        .collect();
527    rel_index.sort_by_key(|(id, _)| id.0);
528    let schemas = compiler.schemas().clone();
529    timing.push(("frontend", ms_since(t)));
530
531    let t = Instant::now();
532    let provider = match provider_for {
533        ProviderFor::New { device, memory_mb } => {
534            let mut config = GpuConfig::default();
535            config.device_ordinal = device;
536            config.memory_bytes = memory_mb * 1024 * 1024;
537            let provider = Arc::new(provider_from_config(config).map_err(types::xlog_err)?);
538            timing.push(("provider", ms_since(t)));
539            provider
540        }
541        ProviderFor::Shared(provider) => provider,
542    };
543
544    let t = Instant::now();
545    let mut executor = Executor::new(provider.clone());
546
547    for (name, rel_id) in compiler.rel_ids() {
548        executor.register_relation(*rel_id, name);
549    }
550
551    for (name, schema) in &schemas {
552        let empty = provider
553            .create_empty_buffer(schema.clone())
554            .map_err(types::xlog_err)?;
555        executor.store_mut().put(name, empty);
556    }
557
558    load_facts_into_store(&ast, &provider, &mut executor, &schemas).map_err(types::xlog_err)?;
559    timing.push(("facts", ms_since(t)));
560
561    let t = Instant::now();
562    executor.execute_plan(&plan).map_err(types::xlog_err)?;
563    timing.push(("execute", ms_since(t)));
564
565    let tmj = extract_tmj_meta(&plan);
566
567    let active_rules = max_active_rules.unwrap_or(32);
568
569    Ok(CompiledIlpProgram {
570        base_source,
571        _learnable_source: learnable_source,
572        ast,
573        executor,
574        provider,
575        plan,
576        rel_index,
577        schemas,
578        left_keys: tmj.left_keys,
579        right_keys: tmj.right_keys,
580        head_projection: tmj.head_projection,
581        compiled_schema_size: tmj.schema_size,
582        head_rel_name: tmj.head_rel_name,
583        max_active_rules: active_rules,
584        candidate_map: None,
585        candidate_order: None,
586        relation_overrides: HashMap::new(),
587        coo_chunk_budget: 16 * 1024 * 1024,
588        strict_zero_dtoh: false,
589        compile_timing: timing,
590    })
591}
592
593// ---------------------------------------------------------------------------
594// CompiledIlpProgram — #[pymethods] block
595// ---------------------------------------------------------------------------
596
597#[pymethods]
598impl CompiledIlpProgram {
599    /// Compile `source` on this program's CUDA provider instead of a new one.
600    ///
601    /// The result is a separate program with its own relation store, in the
602    /// state `IlpProgramFactory.compile(source, ...)` would produce — the
603    /// frontend runs again, the facts are loaded again and the plan is
604    /// executed once. What is skipped is only the per-compile CUDA provider
605    /// setup (a fresh context, allocator and memory pool). Intended for ILP
606    /// loops that compile many one-rule variants of one program (hold-out
607    /// folds, candidate scans).
608    ///
609    /// Because the provider is shared, so are its device-memory budget
610    /// (`memory_mb` given to the base compile covers this program and every
611    /// variant alive at the same time), its streams and its host-transfer
612    /// counters — `variant.fact_exists()` bumps the counter
613    /// `base.d2h_transfer_count()` reads. Relation overrides uploaded to the
614    /// base with `put_relation` are NOT carried over: the variant sees only
615    /// the facts in `source`. The variant is compiled with the same
616    /// `max_active_rules` as this program.
617    pub fn compile_variant(&self, py: Python<'_>, source: &str) -> PyResult<CompiledIlpProgram> {
618        py.detach(|| {
619            build_ilp_program(
620                source,
621                Some(self.max_active_rules),
622                ProviderFor::Shared(Arc::clone(&self.provider)),
623            )
624        })
625    }
626
627    /// Wall-clock breakdown of the compile that produced this program, in
628    /// milliseconds per phase (`provider`, `frontend`, `facts`, `execute`).
629    /// A variant has no `provider` entry. Phase names are diagnostic and may
630    /// change; do not build on the exact set.
631    pub fn compile_timing_ms(&self) -> StdHashMap<String, f64> {
632        self.compile_timing
633            .iter()
634            .map(|(k, v)| ((*k).to_string(), *v))
635            .collect()
636    }
637
638    /// Upload candidate (i,j,k) -> index mapping. Called once per attempt.
639    pub fn set_candidate_map(&mut self, candidates: Vec<(u32, u32, u32)>) -> PyResult<()> {
640        let mut map = HashMap::with_capacity(candidates.len());
641        for (cidx, &(i, j, k)) in candidates.iter().enumerate() {
642            map.insert((i, j, k), cidx as u32);
643        }
644        self.candidate_map = Some(map);
645        self.candidate_order = Some(candidates);
646        Ok(())
647    }
648
649    /// Length of current candidate map (0 if not set).
650    pub fn candidate_map_len(&self) -> usize {
651        self.candidate_map.as_ref().map_or(0, |m| m.len())
652    }
653
654    pub fn debug_ilp_mask_kind(&self, name: String) -> Option<String> {
655        self.executor.ilp_registry().get_mask(&name).map(|mask| {
656            match mask {
657                IlpMask::Dense { .. } => "dense",
658                IlpMask::Sparse { .. } => "sparse_host",
659                IlpMask::SparseDevice { .. } => "sparse_device",
660            }
661            .to_string()
662        })
663    }
664
665    /// Set the per-chunk temp allocation budget in bytes. The final merged
666    /// COO buffer is exact-NNZ sized and may exceed this budget. Default: 16 MB.
667    pub fn set_coo_chunk_budget(&mut self, bytes: u64) {
668        self.coo_chunk_budget = bytes;
669    }
670
671    /// Deprecated: use `set_coo_chunk_budget`. Kept for one release cycle.
672    pub fn set_coo_memory_cap(&mut self, bytes: u64) {
673        self.coo_chunk_budget = bytes;
674    }
675
676    /// Enable strict zero-D2H mode. When true, raises RuntimeError instead
677    /// of falling back to the chunked COO path (which uses D2H transfers).
678    /// Use for zero-D2H benchmarks and CI gates.
679    pub fn set_strict_zero_dtoh(&mut self, strict: bool) {
680        self.strict_zero_dtoh = strict;
681    }
682
683    /// Upload a named relation as DLPack tensor columns into the ILP program store.
684    ///
685    /// This enables tensor-native ILP data upload: compile a schema-only source
686    /// (predicate declarations only, no facts), then upload GPU tensor data via
687    /// DLPack without host materialization.
688    pub fn put_relation(
689        &mut self,
690        name: String,
691        dlpack_columns: &Bound<'_, PyAny>,
692    ) -> PyResult<()> {
693        if name.starts_with("__") {
694            return Err(PyValueError::new_err(format!(
695                "Relation {} is internal and cannot be stored in a compiled ILP program",
696                name
697            )));
698        }
699        let schema = self.schemas.get(&name).ok_or_else(|| {
700            PyValueError::new_err(format!(
701                "Unknown relation {} (not present in compiled schemas)",
702                name
703            ))
704        })?;
705        let tensors = collect_dlpack_columns(
706            dlpack_columns,
707            &format!("Relation {} must be a sequence of DLPack columns", name),
708        )?;
709        let buffer = self
710            .provider
711            .from_dlpack_tensors_with_schema(schema.clone(), tensors)
712            .map_err(types::xlog_err)?;
713        let live_buffer = self
714            .provider
715            .clone_buffer(&buffer)
716            .map_err(types::xlog_err)?;
717        self.relation_overrides.insert(name.clone(), buffer);
718        self.executor.put_relation(&name, live_buffer);
719        Ok(())
720    }
721
722    /// GPU-resident ILP loss + gradient computation.
723    ///
724    /// Builds a sparse CSR structure from retained per-entry membership masks
725    /// and launches forward (credit gather + NLL loss) and backward (gradient
726    /// scatter) CUDA kernels.  Returns `(loss_dlpack, grad_dlpack)` where both
727    /// are GPU-resident tensors exported via DLPack.
728    ///
729    /// # Arguments
730    /// * `positives` — list of `(relation, [col_values])` positive examples
731    /// * `negatives` — list of `(relation, [col_values])` negative examples
732    /// * `cand_probs_obj` — DLPack/PyTorch tensor of candidate probabilities on GPU
733    pub fn compute_ilp_loss_grad_gpu<'py>(
734        &mut self,
735        py: Python<'py>,
736        positives: Vec<(String, Vec<i64>)>,
737        negatives: Vec<(String, Vec<i64>)>,
738        cand_probs_obj: &Bound<'py, PyAny>,
739    ) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
740        // Validate inputs and import candidate probabilities via DLPack.
741        let candidate_map = self.candidate_map.clone().ok_or_else(|| {
742            PyRuntimeError::new_err(
743                "candidate_map not set — call set_candidate_map() before compute_ilp_loss_grad_gpu()",
744            )
745        })?;
746        let num_cands = candidate_map.len() as u32;
747
748        let managed = dlpack_from_py(cand_probs_obj)?;
749        let cand_buf = self
750            .provider
751            .from_dlpack_tensors(vec![managed])
752            .map_err(|e| types::gpu_err("DLPack import", e))?;
753
754        // Determine dtype from imported buffer
755        let cand_schema = cand_buf.schema().clone();
756        let cand_dtype = cand_schema
757            .column_type(0)
758            .ok_or_else(|| PyRuntimeError::new_err("cand_probs has no column type"))?;
759        let is_f64 = match cand_dtype {
760            ScalarType::F32 => false,
761            ScalarType::F64 => true,
762            other => {
763                return Err(PyValueError::new_err(format!(
764                    "cand_probs must be F32 or F64, got {:?}",
765                    other
766                )));
767            }
768        };
769
770        let cand_rows = read_device_row_count(&self.provider, &cand_buf)
771            .map_err(|e| types::gpu_err("row count", e))?;
772        if cand_rows != num_cands as usize {
773            return Err(PyValueError::new_err(format!(
774                "cand_probs length ({}) != candidate_map length ({})",
775                cand_rows, num_cands
776            )));
777        }
778
779        // Build the fact list and group examples by relation.
780        let num_pos = positives.len();
781        let num_neg = negatives.len();
782        let num_facts = (num_pos + num_neg) as u32;
783
784        // Build all_facts: (relation, values, is_positive, global_fact_idx)
785        struct FactInfo {
786            relation: String,
787            values: Vec<i64>,
788            is_positive: bool,
789        }
790        let mut all_facts: Vec<FactInfo> = Vec::with_capacity(num_pos + num_neg);
791        for (rel, vals) in positives {
792            all_facts.push(FactInfo {
793                relation: rel,
794                values: vals,
795                is_positive: true,
796            });
797        }
798        for (rel, vals) in negatives {
799            all_facts.push(FactInfo {
800                relation: rel,
801                values: vals,
802                is_positive: false,
803            });
804        }
805
806        // Handle empty facts edge case: return zero loss + zero grad
807        if num_facts == 0 {
808            return self.build_zero_loss_grad(py, num_cands, is_f64);
809        }
810
811        // Build is_positive array for upload
812        let is_positive_host: Vec<u8> = all_facts
813            .iter()
814            .map(|f| if f.is_positive { 1u8 } else { 0u8 })
815            .collect();
816
817        // Group facts by relation, preserving global fact_idx
818        let mut groups: HashMap<String, Vec<(u32, Vec<i64>)>> = HashMap::new();
819        for (global_idx, fact) in all_facts.iter().enumerate() {
820            groups
821                .entry(fact.relation.clone())
822                .or_default()
823                .push((global_idx as u32, fact.values.clone()));
824        }
825
826        if self.strict_zero_dtoh && self.executor.ilp_registry().has_sparse_device_mask() {
827            self.evaluate_ilp_plan(py)?;
828        }
829
830        // Get ILP tagged result
831        let tagged = self
832            .executor
833            .ilp_last_result()
834            .ok_or_else(|| PyRuntimeError::new_err("No ILP result — call evaluate() first"))?;
835
836        // Build the COO structure on the device without D2H reads.
837        //
838        // Two-pass approach over (relation, candidate) tasks:
839        //   Pass 1: compute GPU membership masks
840        //   Pass 2: scatter COO entries at host-computed offsets via device kernel
841        //
842        // The key insight is that each task's num_query is known on the host
843        // (it equals the number of facts in that relation group), so we can
844        // compute COO write offsets entirely on the host without any D2H reads.
845        // We over-allocate COO arrays at upper_bound (sum of all num_query)
846        // and fill sentinel values for unused slots.
847
848        let mut tasks: Vec<ilp_gpu::CooTask> = Vec::new();
849        let mut fact_indices_buffers: Vec<xlog_cuda::memory::TrackedCudaSlice<u32>> = Vec::new();
850
851        for (relation, facts_with_idx) in &groups {
852            let k_idx = self
853                .rel_index
854                .iter()
855                .position(|(_, name)| name == relation)
856                .ok_or_else(|| {
857                    PyValueError::new_err(format!("Relation '{}' not in ILP schema", relation))
858                })? as u32;
859
860            let relevant_entries: Vec<&xlog_runtime::ilp_registry::IlpTagEntry> = tagged
861                .entries
862                .iter()
863                .filter(|e| e.k == k_idx && e.num_rows > 0 && e.buffer.is_some())
864                .collect();
865
866            if relevant_entries.is_empty() {
867                continue;
868            }
869
870            let first_buf = relevant_entries[0]
871                .buffer
872                .as_ref()
873                .ok_or_else(|| PyRuntimeError::new_err("internal: filtered entry has no buffer"))?;
874            let arity = first_buf.arity();
875            if arity == 0 {
876                continue;
877            }
878            let schema = first_buf.schema().clone();
879
880            let fact_values: Vec<Vec<i64>> =
881                facts_with_idx.iter().map(|(_, v)| v.clone()).collect();
882            let col_bytes = pack_i64_columns_typed(relation, &fact_values, &schema)?;
883            let col_slices: Vec<&[u8]> = col_bytes.iter().map(|c| c.as_slice()).collect();
884            let query_buf = self
885                .provider
886                .create_buffer_from_slices(&col_slices, schema)
887                .map_err(|e| types::gpu_err("create_buffer", e))?;
888
889            let keys: Vec<usize> = (0..arity).collect();
890            let num_query = fact_values.len() as u32;
891
892            // Upload global fact indices for this relation group (H2D, allowed).
893            // Shared across all entries in this relation group via index.
894            let global_indices: Vec<u32> = facts_with_idx.iter().map(|(idx, _)| *idx).collect();
895            let mut d_fact_indices = self
896                .provider
897                .memory()
898                .alloc::<u32>(num_query as usize)
899                .map_err(|e| types::gpu_err("alloc fact_indices", e))?;
900            self.provider
901                .device()
902                .inner()
903                .htod_sync_copy_into(&global_indices, &mut d_fact_indices)
904                .map_err(|e| types::gpu_err("htod fact_indices", e))?;
905            let fi_idx = fact_indices_buffers.len();
906            fact_indices_buffers.push(d_fact_indices);
907
908            for entry in &relevant_entries {
909                let cidx = match candidate_map.get(&(entry.i, entry.j, entry.k)) {
910                    Some(c) => *c,
911                    None => continue,
912                };
913
914                let entry_buf = entry.buffer.as_ref().ok_or_else(|| {
915                    PyRuntimeError::new_err("internal: filtered entry has no buffer")
916                })?;
917                let d_mask = self
918                    .provider
919                    .membership_mask_device(&query_buf, entry_buf, &keys, &keys)
920                    .map_err(|e| types::gpu_err("membership_mask", e))?;
921
922                tasks.push(ilp_gpu::CooTask {
923                    cidx,
924                    num_query,
925                    d_mask,
926                    fact_indices_idx: fi_idx,
927                });
928            }
929        }
930
931        let num_tasks = tasks.len();
932        let upper_bound: u32 = tasks.iter().map(|t| t.num_query).sum();
933
934        if upper_bound == 0 || num_tasks == 0 {
935            return self.build_loss_grad_empty_coo(
936                py,
937                &is_positive_host,
938                num_facts,
939                num_cands,
940                is_f64,
941            );
942        }
943
944        // Check if COO allocation would exceed memory cap.
945        // Each COO entry uses 8 bytes (4 for fact_idx + 4 for cand_idx).
946        let coo_bytes = (upper_bound as u64) * 8;
947        let needs_chunking = coo_bytes > self.coo_chunk_budget;
948
949        // Upload is_positive once (shared across all paths, H2D allowed)
950        let mut d_is_positive = self
951            .provider
952            .memory()
953            .alloc::<u8>(num_facts as usize)
954            .map_err(|e| types::gpu_err("alloc is_positive", e))?;
955        self.provider
956            .device()
957            .inner()
958            .htod_sync_copy_into(&is_positive_host, &mut d_is_positive)
959            .map_err(|e| types::gpu_err("htod is_positive", e))?;
960
961        let cand_col = cand_buf
962            .column(0)
963            .ok_or_else(|| PyRuntimeError::new_err("cand_probs has no column"))?;
964
965        // Construct COO entries.
966        let (mut d_coo_facts, mut d_coo_cands, actual_nnz) = if !needs_chunking {
967            ilp_gpu::build_coo_single(
968                &self.provider,
969                &tasks,
970                &fact_indices_buffers,
971                num_facts,
972                num_cands,
973                upper_bound,
974            )?
975        } else {
976            match ilp_gpu::build_coo_chunked(
977                &self.provider,
978                &tasks,
979                &fact_indices_buffers,
980                num_facts,
981                self.coo_chunk_budget,
982            )? {
983                Some(result) => result,
984                None => {
985                    return self.build_loss_grad_empty_coo(
986                        py,
987                        &is_positive_host,
988                        num_facts,
989                        num_cands,
990                        is_f64,
991                    );
992                }
993            }
994        };
995
996        // Sort COO entries and build CSR row offsets on the device.
997        let d_row_offsets = ilp_gpu::sort_and_build_csr(
998            &self.provider,
999            &mut d_coo_facts,
1000            &mut d_coo_cands,
1001            actual_nnz,
1002            num_facts,
1003        )?;
1004
1005        // Run forward, backward, and device-side reduction kernels.
1006        ilp_gpu::forward_backward_reduce(
1007            &self.provider,
1008            py,
1009            ilp_gpu::ForwardBackwardInputs {
1010                row_offsets: &d_row_offsets,
1011                coo_candidates: &d_coo_cands,
1012                candidate_probabilities: cand_col,
1013                is_positive: &d_is_positive,
1014                fact_count: num_facts,
1015                candidate_count: num_cands,
1016                uses_f64: is_f64,
1017            },
1018        )
1019    }
1020
1021    /// GPU-resident ILP loss + gradient computation from relation-native
1022    /// positive/negative examples stored as DLPack column groups.
1023    ///
1024    /// `positives_by_relation` and `negatives_by_relation` must be Python dicts
1025    /// mapping relation name -> sequence of DLPack columns with schema-matching
1026    /// dtypes. No host fact tuple materialization occurs on this path.
1027    pub fn compute_ilp_loss_grad_gpu_relations<'py>(
1028        &mut self,
1029        py: Python<'py>,
1030        positives_by_relation: &Bound<'py, PyAny>,
1031        negatives_by_relation: &Bound<'py, PyAny>,
1032        cand_probs_obj: &Bound<'py, PyAny>,
1033    ) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
1034        let candidate_map = self.candidate_map.clone().ok_or_else(|| {
1035            PyRuntimeError::new_err(
1036                "candidate_map not set — call set_candidate_map() before compute_ilp_loss_grad_gpu_relations()",
1037            )
1038        })?;
1039        let num_cands = candidate_map.len() as u32;
1040
1041        let managed = dlpack_from_py(cand_probs_obj)?;
1042        let cand_buf = self
1043            .provider
1044            .from_dlpack_tensors(vec![managed])
1045            .map_err(|e| types::gpu_err("DLPack import", e))?;
1046
1047        let cand_schema = cand_buf.schema().clone();
1048        let cand_dtype = cand_schema
1049            .column_type(0)
1050            .ok_or_else(|| PyRuntimeError::new_err("cand_probs has no column type"))?;
1051        let is_f64 = match cand_dtype {
1052            ScalarType::F32 => false,
1053            ScalarType::F64 => true,
1054            other => {
1055                return Err(PyValueError::new_err(format!(
1056                    "cand_probs must be F32 or F64, got {:?}",
1057                    other
1058                )));
1059            }
1060        };
1061
1062        let cand_rows = read_device_row_count(&self.provider, &cand_buf)
1063            .map_err(|e| types::gpu_err("row count", e))?;
1064        if cand_rows != num_cands as usize {
1065            return Err(PyValueError::new_err(format!(
1066                "cand_probs length ({}) != candidate_map length ({})",
1067                cand_rows, num_cands
1068            )));
1069        }
1070
1071        let positive_groups = self.collect_relation_example_groups(
1072            positives_by_relation,
1073            "positives_by_relation must be a dict[str, sequence[dlpack]]",
1074        )?;
1075        let negative_groups = self.collect_relation_example_groups(
1076            negatives_by_relation,
1077            "negatives_by_relation must be a dict[str, sequence[dlpack]]",
1078        )?;
1079
1080        let num_pos: u32 = positive_groups.iter().map(|g| g.num_rows).sum();
1081        let num_neg: u32 = negative_groups.iter().map(|g| g.num_rows).sum();
1082        let num_facts = num_pos + num_neg;
1083
1084        if num_facts == 0 {
1085            return self.build_zero_loss_grad(py, num_cands, is_f64);
1086        }
1087
1088        let mut is_positive_host = Vec::with_capacity(num_facts as usize);
1089        is_positive_host.extend(std::iter::repeat_n(1u8, num_pos as usize));
1090        is_positive_host.extend(std::iter::repeat_n(0u8, num_neg as usize));
1091
1092        if self.strict_zero_dtoh && self.executor.ilp_registry().has_sparse_device_mask() {
1093            self.evaluate_ilp_plan(py)?;
1094        }
1095
1096        let tagged = self
1097            .executor
1098            .ilp_last_result()
1099            .ok_or_else(|| PyRuntimeError::new_err("No ILP result — call evaluate() first"))?;
1100
1101        let mut tasks: Vec<ilp_gpu::CooTask> = Vec::new();
1102        let mut fact_indices_buffers: Vec<xlog_cuda::memory::TrackedCudaSlice<u32>> = Vec::new();
1103        let mut next_global_idx: u32 = 0;
1104
1105        for group in positive_groups.iter().chain(negative_groups.iter()) {
1106            if group.num_rows == 0 {
1107                continue;
1108            }
1109            let k_idx = self
1110                .rel_index
1111                .iter()
1112                .position(|(_, name)| name == &group.relation)
1113                .ok_or_else(|| {
1114                    PyValueError::new_err(format!(
1115                        "Relation '{}' not in ILP schema",
1116                        group.relation
1117                    ))
1118                })? as u32;
1119
1120            let relevant_entries: Vec<&xlog_runtime::ilp_registry::IlpTagEntry> = tagged
1121                .entries
1122                .iter()
1123                .filter(|e| e.k == k_idx && e.num_rows > 0 && e.buffer.is_some())
1124                .collect();
1125            if relevant_entries.is_empty() {
1126                next_global_idx = next_global_idx.saturating_add(group.num_rows);
1127                continue;
1128            }
1129
1130            let arity = group.query_buf.arity();
1131            if arity == 0 {
1132                next_global_idx = next_global_idx.saturating_add(group.num_rows);
1133                continue;
1134            }
1135            let keys: Vec<usize> = (0..arity).collect();
1136
1137            let global_indices: Vec<u32> =
1138                (next_global_idx..next_global_idx + group.num_rows).collect();
1139            let mut d_fact_indices = self
1140                .provider
1141                .memory()
1142                .alloc::<u32>(group.num_rows as usize)
1143                .map_err(|e| types::gpu_err("alloc fact_indices", e))?;
1144            self.provider
1145                .device()
1146                .inner()
1147                .htod_sync_copy_into(&global_indices, &mut d_fact_indices)
1148                .map_err(|e| types::gpu_err("htod fact_indices", e))?;
1149            let fi_idx = fact_indices_buffers.len();
1150            fact_indices_buffers.push(d_fact_indices);
1151
1152            for entry in &relevant_entries {
1153                let cidx = match candidate_map.get(&(entry.i, entry.j, entry.k)) {
1154                    Some(c) => *c,
1155                    None => continue,
1156                };
1157                let entry_buf = entry.buffer.as_ref().ok_or_else(|| {
1158                    PyRuntimeError::new_err("internal: filtered entry has no buffer")
1159                })?;
1160                let d_mask = self
1161                    .provider
1162                    .membership_mask_device(&group.query_buf, entry_buf, &keys, &keys)
1163                    .map_err(|e| types::gpu_err("membership_mask", e))?;
1164                tasks.push(ilp_gpu::CooTask {
1165                    cidx,
1166                    num_query: group.num_rows,
1167                    d_mask,
1168                    fact_indices_idx: fi_idx,
1169                });
1170            }
1171
1172            next_global_idx = next_global_idx.saturating_add(group.num_rows);
1173        }
1174
1175        let num_tasks = tasks.len();
1176        let upper_bound: u32 = tasks.iter().map(|t| t.num_query).sum();
1177        if upper_bound == 0 || num_tasks == 0 {
1178            return self.build_loss_grad_empty_coo(
1179                py,
1180                &is_positive_host,
1181                num_facts,
1182                num_cands,
1183                is_f64,
1184            );
1185        }
1186
1187        let coo_bytes = (upper_bound as u64) * 8;
1188        let needs_chunking = coo_bytes > self.coo_chunk_budget;
1189
1190        let mut d_is_positive = self
1191            .provider
1192            .memory()
1193            .alloc::<u8>(num_facts as usize)
1194            .map_err(|e| types::gpu_err("alloc is_positive", e))?;
1195        self.provider
1196            .device()
1197            .inner()
1198            .htod_sync_copy_into(&is_positive_host, &mut d_is_positive)
1199            .map_err(|e| types::gpu_err("htod is_positive", e))?;
1200
1201        let cand_col = cand_buf
1202            .column(0)
1203            .ok_or_else(|| PyRuntimeError::new_err("cand_probs has no column"))?;
1204
1205        let (mut d_coo_facts, mut d_coo_cands, actual_nnz) = if !needs_chunking {
1206            ilp_gpu::build_coo_single(
1207                &self.provider,
1208                &tasks,
1209                &fact_indices_buffers,
1210                num_facts,
1211                num_cands,
1212                upper_bound,
1213            )?
1214        } else {
1215            match ilp_gpu::build_coo_chunked(
1216                &self.provider,
1217                &tasks,
1218                &fact_indices_buffers,
1219                num_facts,
1220                self.coo_chunk_budget,
1221            )? {
1222                Some(result) => result,
1223                None => {
1224                    return self.build_loss_grad_empty_coo(
1225                        py,
1226                        &is_positive_host,
1227                        num_facts,
1228                        num_cands,
1229                        is_f64,
1230                    );
1231                }
1232            }
1233        };
1234
1235        let d_row_offsets = ilp_gpu::sort_and_build_csr(
1236            &self.provider,
1237            &mut d_coo_facts,
1238            &mut d_coo_cands,
1239            actual_nnz,
1240            num_facts,
1241        )?;
1242
1243        ilp_gpu::forward_backward_reduce(
1244            &self.provider,
1245            py,
1246            ilp_gpu::ForwardBackwardInputs {
1247                row_offsets: &d_row_offsets,
1248                coo_candidates: &d_coo_cands,
1249                candidate_probabilities: cand_col,
1250                is_positive: &d_is_positive,
1251                fact_count: num_facts,
1252                candidate_count: num_cands,
1253                uses_f64: is_f64,
1254            },
1255        )
1256    }
1257
1258    pub fn set_rule_mask(
1259        &mut self,
1260        name: String,
1261        mask_hard_flat: &Bound<'_, PyAny>,
1262        mask_soft_flat: &Bound<'_, PyAny>,
1263        schema_size: usize,
1264    ) -> PyResult<()> {
1265        if self.compiled_schema_size > 0 && schema_size != self.compiled_schema_size {
1266            return Err(PyValueError::new_err(format!(
1267                "schema_size mismatch: mask has N={} but compiled program expects N={}",
1268                schema_size, self.compiled_schema_size,
1269            )));
1270        }
1271
1272        let hard_dmt = dlpack_from_py(mask_hard_flat)?;
1273        let soft_dmt = dlpack_from_py(mask_soft_flat)?;
1274
1275        let hard_buf = self
1276            .provider
1277            .from_dlpack_tensors(vec![hard_dmt])
1278            .map_err(types::xlog_err)?;
1279        let soft_buf = self
1280            .provider
1281            .from_dlpack_tensors(vec![soft_dmt])
1282            .map_err(types::xlog_err)?;
1283
1284        self.executor
1285            .ilp_registry_mut()
1286            .insert_mask(name, hard_buf, soft_buf, schema_size);
1287        Ok(())
1288    }
1289
1290    /// Sparse mask API: candidate IDs + DLPack soft probabilities (GPU tensor).
1291    ///
1292    /// `candidate_ids` must be exactly `[0..C)` where C is the candidate count
1293    /// for this mask under the provided recursion policy.
1294    ///
1295    /// `soft_probs_dlpack` is a DLPack capsule (CUDA f64 tensor) passed from PyTorch.
1296    /// Rust imports it zero-copy, downloads values (not counted by the D2H counter),
1297    /// performs deterministic top-k (desc soft value, then lower id), and
1298    /// stores a sparse IlpMask (no dense N^3 materialization).
1299    #[pyo3(signature = (name, candidate_ids, soft_probs_dlpack, budget, allow_recursive=false))]
1300    pub fn set_rule_mask_sparse(
1301        &mut self,
1302        name: String,
1303        candidate_ids: Vec<u32>,
1304        soft_probs_dlpack: &Bound<'_, PyAny>,
1305        budget: usize,
1306        allow_recursive: bool,
1307    ) -> PyResult<()> {
1308        if self.strict_zero_dtoh {
1309            return Err(PyRuntimeError::new_err(
1310                "strict_zero_dtoh forbids legacy set_rule_mask_sparse; use set_rule_mask_sparse_selected instead",
1311            ));
1312        }
1313
1314        let tmj = extract_tmj_meta_for_mask(&self.plan, Some(&name));
1315        let n = tmj.schema_size;
1316        if n == 0 {
1317            return Err(PyValueError::new_err(format!(
1318                "no learnable mask '{}' found",
1319                name
1320            )));
1321        }
1322        if self.compiled_schema_size > 0 && n != self.compiled_schema_size {
1323            return Err(PyValueError::new_err(format!(
1324                "schema_size mismatch for '{}': plan N={} compiled N={}",
1325                name, n, self.compiled_schema_size
1326            )));
1327        }
1328
1329        let expected_c = self.expected_candidate_count(&name, allow_recursive)?;
1330        if candidate_ids.len() != expected_c {
1331            return Err(PyValueError::new_err(format!(
1332                "candidate_ids length {} != expected candidate count {}",
1333                candidate_ids.len(),
1334                expected_c
1335            )));
1336        }
1337        for (idx, &cid) in candidate_ids.iter().enumerate() {
1338            if cid != idx as u32 {
1339                return Err(PyValueError::new_err(format!(
1340                    "candidate_ids must be [0..{}), got id {} at position {}",
1341                    expected_c, cid, idx
1342                )));
1343            }
1344        }
1345
1346        // Import DLPack tensor (zero-copy, stays on GPU)
1347        let soft_dmt = dlpack_from_py(soft_probs_dlpack)?;
1348        let soft_buf = self
1349            .provider
1350            .from_dlpack_tensors(vec![soft_dmt])
1351            .map_err(types::xlog_err)?;
1352
1353        // Download f64 values (control-plane, NOT tracked by D2H counter)
1354        let soft_probs = self
1355            .provider
1356            .download_column_untracked::<f64>(&soft_buf, 0)
1357            .map_err(types::xlog_err)?;
1358
1359        if soft_probs.len() != candidate_ids.len() {
1360            return Err(PyValueError::new_err(format!(
1361                "soft_probs tensor length {} != candidate_ids length {}",
1362                soft_probs.len(),
1363                candidate_ids.len()
1364            )));
1365        }
1366
1367        let candidate_triples = self.candidate_triples_for_mask(&name, allow_recursive)?;
1368        if candidate_triples.len() != expected_c {
1369            return Err(PyRuntimeError::new_err(format!(
1370                "internal candidate count mismatch: triples={} expected={}",
1371                candidate_triples.len(),
1372                expected_c
1373            )));
1374        }
1375
1376        // Convert to f32 for top-k ranking in insert_mask_from_sparse
1377        let active_soft: Vec<f32> = soft_probs.iter().map(|&v| v as f32).collect();
1378
1379        self.executor
1380            .ilp_registry_mut()
1381            .insert_mask_from_sparse(name, n, &candidate_triples, &active_soft, budget)
1382            .map_err(types::xlog_err)
1383    }
1384
1385    /// Preferred sparse mask API: caller preselects candidate IDs and passes
1386    /// only the selected subset plus aligned soft probabilities.
1387    ///
1388    /// This path avoids the full-vector soft-probability download in
1389    /// `set_rule_mask_sparse(...)`. The selected IDs are mapped directly to the
1390    /// existing candidate triples and stored in sparse-mask order.
1391    #[pyo3(signature = (name, selected_candidate_ids, selected_soft_probs_dlpack, allow_recursive=false))]
1392    pub fn set_rule_mask_sparse_selected(
1393        &mut self,
1394        name: String,
1395        selected_candidate_ids: Vec<u32>,
1396        selected_soft_probs_dlpack: &Bound<'_, PyAny>,
1397        allow_recursive: bool,
1398    ) -> PyResult<()> {
1399        if self.strict_zero_dtoh {
1400            return Err(PyRuntimeError::new_err(
1401                "strict_zero_dtoh forbids set_rule_mask_sparse_selected; use explicit compatibility export instead",
1402            ));
1403        }
1404        let tmj = extract_tmj_meta_for_mask(&self.plan, Some(&name));
1405        let n = tmj.schema_size;
1406        if n == 0 {
1407            return Err(PyValueError::new_err(format!(
1408                "no learnable mask '{}' found",
1409                name
1410            )));
1411        }
1412        if self.compiled_schema_size > 0 && n != self.compiled_schema_size {
1413            return Err(PyValueError::new_err(format!(
1414                "schema_size mismatch for '{}': plan N={} compiled N={}",
1415                name, n, self.compiled_schema_size
1416            )));
1417        }
1418
1419        let candidate_triples = self.candidate_triples_for_mask(&name, allow_recursive)?;
1420        let expected_c = candidate_triples.len();
1421
1422        let soft_dmt = dlpack_from_py(selected_soft_probs_dlpack)?;
1423        let soft_buf = self
1424            .provider
1425            .from_dlpack_tensors(vec![soft_dmt])
1426            .map_err(types::xlog_err)?;
1427        let selected_len = usize::try_from(soft_buf.num_rows())
1428            .map_err(|_| PyValueError::new_err("selected soft_probs length overflow"))?;
1429
1430        if selected_len != selected_candidate_ids.len() {
1431            return Err(PyValueError::new_err(format!(
1432                "selected soft_probs length {} != selected_candidate_ids length {}",
1433                selected_len,
1434                selected_candidate_ids.len()
1435            )));
1436        }
1437
1438        let mut seen = HashSet::with_capacity(selected_candidate_ids.len());
1439        let mut selected_entries = Vec::with_capacity(selected_candidate_ids.len());
1440        for (pos, &cid) in selected_candidate_ids.iter().enumerate() {
1441            let idx = usize::try_from(cid).map_err(|_| {
1442                PyValueError::new_err(format!("candidate id {} overflows usize", cid))
1443            })?;
1444            if idx >= expected_c {
1445                return Err(PyValueError::new_err(format!(
1446                    "selected candidate id {} out of range [0, {}) at position {}",
1447                    cid, expected_c, pos
1448                )));
1449            }
1450            if !seen.insert(cid) {
1451                return Err(PyValueError::new_err(format!(
1452                    "duplicate selected candidate id {} at position {}",
1453                    cid, pos
1454                )));
1455            }
1456            selected_entries.push(candidate_triples[idx]);
1457        }
1458
1459        self.executor
1460            .ilp_registry_mut()
1461            .insert_selected_mask(name, n, &selected_entries);
1462        Ok(())
1463    }
1464
1465    /// Strict sparse mask API: selected candidate IDs stay as a device tensor on
1466    /// the Python side. Rust resolves them against the fixed candidate order
1467    /// uploaded via `set_candidate_map(...)`.
1468    #[pyo3(signature = (name, selected_candidate_ids_dlpack, selected_soft_probs_dlpack, allow_recursive=false))]
1469    pub fn set_rule_mask_sparse_selected_device(
1470        &mut self,
1471        name: String,
1472        selected_candidate_ids_dlpack: &Bound<'_, PyAny>,
1473        selected_soft_probs_dlpack: &Bound<'_, PyAny>,
1474        allow_recursive: bool,
1475    ) -> PyResult<()> {
1476        self.set_rule_mask_sparse_selected_device_impl(
1477            name,
1478            selected_candidate_ids_dlpack,
1479            selected_soft_probs_dlpack,
1480            allow_recursive,
1481            true,
1482        )
1483    }
1484
1485    pub fn evaluate(&mut self, py: Python<'_>) -> PyResult<()> {
1486        if self.strict_zero_dtoh && self.executor.ilp_registry().has_sparse_device_mask() {
1487            return Err(PyRuntimeError::new_err(
1488                "SparseDevice evaluate() is incompatible with strict_zero_dtoh; \
1489use train_only(..., strict_gpu_native=True) or export an explicit compatibility mask first",
1490            ));
1491        }
1492        self.evaluate_ilp_plan(py)
1493    }
1494
1495    /// Reset mutable runtime state for ILP attempt reuse.
1496    ///
1497    /// Clears ILP registry (masks/tagged results), executor store,
1498    /// join index cache, stats, and profiler. Then re-registers schemas
1499    /// with empty buffers, reloads base facts from AST, and re-executes
1500    /// the plan. Preserves all immutable compile artifacts (AST, plan,
1501    /// schemas, rel_index, provider, TMJ metadata, max_active_rules).
1502    ///
1503    /// After reset, the program is in the same state as a fresh compile()
1504    /// with the same source — ready for set_rule_mask / evaluate cycles.
1505    pub fn reset_runtime(&mut self, py: Python<'_>) -> PyResult<()> {
1506        let _result: xlog_core::Result<()> = py.detach(|| {
1507            // 1. Clear all mutable state (ILP registry, store, caches, stats)
1508            self.executor.reset_for_ilp();
1509
1510            // 2. Re-register schemas with empty buffers
1511            for (name, schema) in &self.schemas {
1512                let empty = self.provider.create_empty_buffer(schema.clone())?;
1513                self.executor.store_mut().put(name, empty);
1514            }
1515
1516            // 3. Reload base facts from preserved AST
1517            load_facts_into_store(&self.ast, &self.provider, &mut self.executor, &self.schemas)?;
1518
1519            // 3b. Reapply persistent relation uploads on top of AST/base facts.
1520            self.apply_relation_overrides()?;
1521
1522            // 4. Re-execute plan (populates derived relations)
1523            self.executor.execute_plan(&self.plan)?;
1524
1525            Ok(())
1526        });
1527        _result.map_err(types::xlog_err)?;
1528
1529        // 5. Reset D2H transfer counter
1530        self.provider.reset_d2h_transfer_count();
1531
1532        Ok(())
1533    }
1534
1535    pub fn get_tagged_results(&self) -> PyResult<Vec<(u32, u32, u32, u32)>> {
1536        self.ensure_host_semantic_compat(
1537            "get_tagged_results()",
1538            "disable strict_zero_dtoh for host materialization",
1539        )?;
1540        match self.executor.ilp_last_result() {
1541            Some(result) => Ok(result
1542                .entries
1543                .iter()
1544                .map(|e| (e.i, e.j, e.k, e.num_rows))
1545                .collect()),
1546            None => Ok(Vec::new()),
1547        }
1548    }
1549
1550    pub fn fact_exists(&self, relation: &str, values: Vec<i64>) -> PyResult<bool> {
1551        self.ensure_host_semantic_compat("fact_exists()", "batch_fact_membership_device(...)")?;
1552        let buf =
1553            self.executor.store().get(relation).ok_or_else(|| {
1554                PyValueError::new_err(format!("Relation '{}' not found", relation))
1555            })?;
1556
1557        Self::fact_exists_in_buffer(&self.provider, buf, &values).map_err(types::xlog_err)
1558    }
1559
1560    /// Return all facts in the named relation as a list of int lists.
1561    #[pyo3(signature = (rel_name))]
1562    pub fn relation_facts(&self, rel_name: String) -> PyResult<Vec<Vec<i64>>> {
1563        self.ensure_host_semantic_compat(
1564            "relation_facts()",
1565            "disable strict_zero_dtoh for host materialization",
1566        )?;
1567        let buf =
1568            self.executor.store().get(&rel_name).ok_or_else(|| {
1569                PyValueError::new_err(format!("Relation '{}' not found", rel_name))
1570            })?;
1571
1572        let num_rows =
1573            read_device_row_count(&self.provider, buf).map_err(types::xlog_err)? as usize;
1574        if num_rows == 0 {
1575            return Ok(Vec::new());
1576        }
1577
1578        // Download all columns (reuse fact_exists_in_buffer pattern)
1579        let schema = buf.schema();
1580        let mut columns: Vec<Vec<i64>> = Vec::new();
1581        for col_idx in 0..buf.arity() {
1582            let col_type = schema.column_type(col_idx).ok_or_else(|| {
1583                PyRuntimeError::new_err(format!("Column {} type not found in schema", col_idx))
1584            })?;
1585            let col_i64: Vec<i64> = match col_type {
1586                ScalarType::I64 => self
1587                    .provider
1588                    .download_column::<i64>(buf, col_idx)
1589                    .map_err(types::xlog_err)?,
1590                ScalarType::I32 => self
1591                    .provider
1592                    .download_column::<i32>(buf, col_idx)
1593                    .map_err(types::xlog_err)?
1594                    .into_iter()
1595                    .map(|v| v as i64)
1596                    .collect(),
1597                ScalarType::U32 | ScalarType::Symbol => self
1598                    .provider
1599                    .download_column::<u32>(buf, col_idx)
1600                    .map_err(types::xlog_err)?
1601                    .into_iter()
1602                    .map(|v| v as i64)
1603                    .collect(),
1604                ScalarType::U64 => self
1605                    .provider
1606                    .download_column::<u64>(buf, col_idx)
1607                    .map_err(types::xlog_err)?
1608                    .into_iter()
1609                    .map(|v| v as i64)
1610                    .collect(),
1611                ScalarType::Bool => self
1612                    .provider
1613                    .download_column::<bool>(buf, col_idx)
1614                    .map_err(types::xlog_err)?
1615                    .into_iter()
1616                    .map(|v| if v { 1i64 } else { 0i64 })
1617                    .collect(),
1618                ScalarType::F32 | ScalarType::F64 => {
1619                    return Err(PyRuntimeError::new_err(format!(
1620                        "relation_facts does not support float column type {:?}",
1621                        col_type
1622                    )));
1623                }
1624            };
1625            columns.push(col_i64);
1626        }
1627
1628        Ok((0..num_rows)
1629            .map(|row_index| columns.iter().map(|column| column[row_index]).collect())
1630            .collect())
1631    }
1632
1633    /// Sample up to `max_n` derived facts for `head_rel` that are NOT in `exclude`.
1634    ///
1635    /// Returns `list[list[int]]` — each inner list is a tuple of column values.
1636    /// Uses the same column-download pattern as `relation_facts`.
1637    #[pyo3(signature = (head_rel, exclude, max_n))]
1638    pub fn sample_false_positives(
1639        &self,
1640        head_rel: String,
1641        exclude: Vec<(String, Vec<i64>)>,
1642        max_n: usize,
1643    ) -> PyResult<Vec<Vec<i64>>> {
1644        self.ensure_host_semantic_compat(
1645            "sample_false_positives()",
1646            "disable strict_zero_dtoh for host materialization",
1647        )?;
1648        // Build exclude set: only consider tuples for the requested relation
1649        let exclude_set: HashSet<Vec<i64>> = exclude
1650            .into_iter()
1651            .filter(|(rel, _)| rel == &head_rel)
1652            .map(|(_, vals)| vals)
1653            .collect();
1654
1655        // Download all facts using the same pattern as relation_facts
1656        let buf =
1657            self.executor.store().get(&head_rel).ok_or_else(|| {
1658                PyValueError::new_err(format!("Relation '{}' not found", head_rel))
1659            })?;
1660
1661        let num_rows =
1662            read_device_row_count(&self.provider, buf).map_err(types::xlog_err)? as usize;
1663        if num_rows == 0 {
1664            return Ok(Vec::new());
1665        }
1666
1667        let schema = buf.schema();
1668        let mut columns: Vec<Vec<i64>> = Vec::new();
1669        for col_idx in 0..buf.arity() {
1670            let col_type = schema.column_type(col_idx).ok_or_else(|| {
1671                PyRuntimeError::new_err(format!("Column {} type not found in schema", col_idx))
1672            })?;
1673            let col_i64: Vec<i64> = match col_type {
1674                ScalarType::I64 => self
1675                    .provider
1676                    .download_column::<i64>(buf, col_idx)
1677                    .map_err(types::xlog_err)?,
1678                ScalarType::I32 => self
1679                    .provider
1680                    .download_column::<i32>(buf, col_idx)
1681                    .map_err(types::xlog_err)?
1682                    .into_iter()
1683                    .map(|v| v as i64)
1684                    .collect(),
1685                ScalarType::U32 | ScalarType::Symbol => self
1686                    .provider
1687                    .download_column::<u32>(buf, col_idx)
1688                    .map_err(types::xlog_err)?
1689                    .into_iter()
1690                    .map(|v| v as i64)
1691                    .collect(),
1692                ScalarType::U64 => self
1693                    .provider
1694                    .download_column::<u64>(buf, col_idx)
1695                    .map_err(types::xlog_err)?
1696                    .into_iter()
1697                    .map(|v| v as i64)
1698                    .collect(),
1699                ScalarType::Bool => self
1700                    .provider
1701                    .download_column::<bool>(buf, col_idx)
1702                    .map_err(types::xlog_err)?
1703                    .into_iter()
1704                    .map(|v| if v { 1i64 } else { 0i64 })
1705                    .collect(),
1706                ScalarType::F32 | ScalarType::F64 => {
1707                    return Err(PyRuntimeError::new_err(format!(
1708                        "sample_false_positives does not support float column type {:?}",
1709                        col_type
1710                    )));
1711                }
1712            };
1713            columns.push(col_i64);
1714        }
1715
1716        // Filter out excluded tuples and cap at max_n
1717        Ok((0..num_rows)
1718            .map(|row_index| {
1719                columns
1720                    .iter()
1721                    .map(|column| column[row_index])
1722                    .collect::<Vec<_>>()
1723            })
1724            .filter(|row| !exclude_set.contains(row))
1725            .take(max_n)
1726            .collect())
1727    }
1728
1729    pub fn tagged_entries_containing_fact(
1730        &self,
1731        relation: &str,
1732        values: Vec<i64>,
1733    ) -> PyResult<Vec<(u32, u32, u32)>> {
1734        self.ensure_host_semantic_compat(
1735            "tagged_entries_containing_fact()",
1736            "batch_tagged_credit_device(...)",
1737        )?;
1738        let k_idx = self
1739            .rel_index
1740            .iter()
1741            .position(|(_, name)| name == relation)
1742            .ok_or_else(|| {
1743                PyValueError::new_err(format!("Relation '{}' not in ILP schema", relation))
1744            })? as u32;
1745
1746        let tagged = match self.executor.ilp_last_result() {
1747            Some(t) => t,
1748            None => return Ok(Vec::new()),
1749        };
1750
1751        let mut result = Vec::new();
1752        for entry in &tagged.entries {
1753            if entry.k != k_idx || entry.num_rows == 0 {
1754                continue;
1755            }
1756
1757            let (_, left_name) = &self.rel_index[entry.i as usize];
1758            let (_, right_name) = &self.rel_index[entry.j as usize];
1759
1760            let left_buf = match self.executor.store().get(left_name) {
1761                Some(buf) if buf.arity() > 0 => buf,
1762                _ => continue,
1763            };
1764            let right_buf = match self.executor.store().get(right_name) {
1765                Some(buf) if buf.arity() > 0 => buf,
1766                _ => continue,
1767            };
1768
1769            // Arity guard: same as executor (skip if join keys exceed columns)
1770            let left_max = self.left_keys.iter().copied().max().unwrap_or(0);
1771            let right_max = self.right_keys.iter().copied().max().unwrap_or(0);
1772            if left_buf.arity() <= left_max || right_buf.arity() <= right_max {
1773                continue;
1774            }
1775
1776            let joined = self
1777                .provider
1778                .hash_join_v2(
1779                    left_buf,
1780                    right_buf,
1781                    &self.left_keys,
1782                    &self.right_keys,
1783                    JoinType::Inner,
1784                )
1785                .map_err(types::xlog_err)?;
1786
1787            // Apply head_projection: check projected columns against values,
1788            // not the raw join output (which has more columns than the head).
1789            let found = if !self.head_projection.is_empty()
1790                && self.head_projection.len() == values.len()
1791            {
1792                Self::fact_exists_projected(&self.provider, &joined, &values, &self.head_projection)
1793                    .map_err(types::xlog_err)?
1794            } else {
1795                Self::fact_exists_in_buffer(&self.provider, &joined, &values)
1796                    .map_err(types::xlog_err)?
1797            };
1798
1799            if found {
1800                result.push((entry.i, entry.j, entry.k));
1801            }
1802        }
1803        Ok(result)
1804    }
1805
1806    pub fn ilp_schema_size(&self) -> usize {
1807        self.rel_index.len()
1808    }
1809
1810    pub fn ilp_relation_names(&self) -> Vec<String> {
1811        self.rel_index
1812            .iter()
1813            .map(|(_, name)| name.clone())
1814            .collect()
1815    }
1816
1817    /// Return declared predicate types from source `pred` declarations.
1818    ///
1819    /// Output is a list of `(name, types)` tuples so callers can
1820    /// deterministically inspect whether metadata is available for
1821    /// relations used during promotion.
1822    pub fn relation_type_annotations(&self) -> Vec<(String, Vec<String>)> {
1823        self.ast
1824            .predicates
1825            .iter()
1826            .map(|pred| {
1827                let types = pred
1828                    .schema_columns()
1829                    .iter()
1830                    .map(|column| type_ref_name(&column.typ))
1831                    .collect();
1832                (pred.name.clone(), types)
1833            })
1834            .collect()
1835    }
1836
1837    /// Return the set of valid (i,j,k) candidates for the given learnable mask.
1838    ///
1839    /// Pruning rules:
1840    /// - k must be the head relation for this mask
1841    /// - At least one of (i,j) must have nonzero tuples in the store
1842    /// - Template+template body pairs (both have zero tuples) are pruned
1843    /// - If allow_recursive is false: i==k_head or j==k_head are pruned
1844    ///   (unless head already has base facts)
1845    ///
1846    /// Returns list of dicts: [{id, i, j, k, left_name, right_name, head_name}]
1847    /// IDs assigned 0..C-1 after sorting by (k, i, j) ascending.
1848    #[pyo3(signature = (mask_name, allow_recursive=false))]
1849    fn valid_candidates(
1850        &self,
1851        py: Python<'_>,
1852        mask_name: String,
1853        allow_recursive: bool,
1854    ) -> PyResult<Vec<StdHashMap<String, Py<PyAny>>>> {
1855        let candidates = self.candidate_triples_for_mask(&mask_name, allow_recursive)?;
1856
1857        let result: Vec<StdHashMap<String, Py<PyAny>>> = candidates
1858            .iter()
1859            .enumerate()
1860            .map(
1861                |(id, &(i, j, k))| -> PyResult<StdHashMap<String, Py<PyAny>>> {
1862                    let mut d = StdHashMap::new();
1863                    d.insert("id".into(), id.into_pyobject(py)?.into_any().unbind());
1864                    d.insert("i".into(), i.into_pyobject(py)?.into_any().unbind());
1865                    d.insert("j".into(), j.into_pyobject(py)?.into_any().unbind());
1866                    d.insert("k".into(), k.into_pyobject(py)?.into_any().unbind());
1867                    d.insert(
1868                        "left_name".into(),
1869                        self.rel_index[i as usize]
1870                            .1
1871                            .clone()
1872                            .into_pyobject(py)?
1873                            .into_any()
1874                            .unbind(),
1875                    );
1876                    d.insert(
1877                        "right_name".into(),
1878                        self.rel_index[j as usize]
1879                            .1
1880                            .clone()
1881                            .into_pyobject(py)?
1882                            .into_any()
1883                            .unbind(),
1884                    );
1885                    d.insert(
1886                        "head_name".into(),
1887                        self.rel_index[k as usize]
1888                            .1
1889                            .clone()
1890                            .into_pyobject(py)?
1891                            .into_any()
1892                            .unbind(),
1893                    );
1894                    Ok(d)
1895                },
1896            )
1897            .collect::<PyResult<_>>()?;
1898
1899        Ok(result)
1900    }
1901
1902    pub fn commit_induced_rule(&mut self, rule_source: &str) -> PyResult<()> {
1903        let new_base = format!("{}\n{}", self.base_source, rule_source);
1904
1905        let ast = xlog_logic::parse_program(&new_base).map_err(types::val_err)?;
1906        let mut compiler = xlog_logic::Compiler::new();
1907        compiler.set_max_active_rules(self.max_active_rules);
1908        let plan = compiler.compile_program(&ast).map_err(types::xlog_err)?;
1909        let schemas = compiler.schemas().clone();
1910
1911        self.executor.reset_for_mc();
1912        for (name, rel_id) in compiler.rel_ids() {
1913            self.executor.register_relation(*rel_id, name);
1914        }
1915        for (name, schema) in &schemas {
1916            let empty = self
1917                .provider
1918                .create_empty_buffer(schema.clone())
1919                .map_err(types::xlog_err)?;
1920            self.executor.store_mut().put(name, empty);
1921        }
1922        load_facts_into_store(&ast, &self.provider, &mut self.executor, &schemas)
1923            .map_err(types::xlog_err)?;
1924        self.apply_relation_overrides().map_err(types::xlog_err)?;
1925        self.executor.execute_plan(&plan).map_err(types::xlog_err)?;
1926
1927        self.base_source = new_base;
1928        self.ast = ast;
1929        let tmj = extract_tmj_meta(&plan);
1930        self.left_keys = tmj.left_keys;
1931        self.right_keys = tmj.right_keys;
1932        self.head_projection = tmj.head_projection;
1933        self.compiled_schema_size = tmj.schema_size;
1934        self.head_rel_name = tmj.head_rel_name;
1935        self.plan = plan;
1936        self.schemas = schemas;
1937        Ok(())
1938    }
1939
1940    /// GPU-side batch fact membership check.
1941    /// Uploads `facts` (list of value-lists) to a temporary CudaBuffer,
1942    /// semi-joins against the named relation, returns per-fact boolean mask.
1943    /// Zero download_column_* calls — only downloads the u8 mask.
1944    pub fn batch_fact_membership_device(
1945        &self,
1946        py: Python<'_>,
1947        relation: &str,
1948        facts: Vec<Vec<i64>>,
1949    ) -> PyResult<Py<PyAny>> {
1950        let buf =
1951            self.executor.store().get(relation).ok_or_else(|| {
1952                PyValueError::new_err(format!("Relation '{}' not found", relation))
1953            })?;
1954
1955        if facts.is_empty() {
1956            let empty = self
1957                .provider
1958                .memory()
1959                .alloc::<u8>(0)
1960                .map_err(types::xlog_err)?;
1961            return export_device_bool_tensor(&self.provider, py, empty, 0);
1962        }
1963        if buf.arity() == 0 {
1964            let mut zeros = self
1965                .provider
1966                .memory()
1967                .alloc::<u8>(facts.len())
1968                .map_err(types::xlog_err)?;
1969            self.provider
1970                .device()
1971                .inner()
1972                .memset_zeros(&mut zeros)
1973                .map_err(types::xlog_err)?;
1974            return export_device_bool_tensor(&self.provider, py, zeros, facts.len());
1975        }
1976
1977        let col_bytes = pack_i64_columns_typed(relation, &facts, buf.schema())?;
1978        let col_slices: Vec<&[u8]> = col_bytes.iter().map(|c| c.as_slice()).collect();
1979        let query_buf = self
1980            .provider
1981            .create_buffer_from_slices(&col_slices, buf.schema().clone())
1982            .map_err(types::xlog_err)?;
1983
1984        let keys: Vec<usize> = (0..buf.arity()).collect();
1985        let mask = self
1986            .provider
1987            .membership_mask_device(&query_buf, buf, &keys, &keys)
1988            .map_err(types::xlog_err)?;
1989        export_device_bool_tensor(&self.provider, py, mask, facts.len())
1990    }
1991
1992    pub fn batch_fact_membership(
1993        &self,
1994        relation: &str,
1995        facts: Vec<Vec<i64>>,
1996    ) -> PyResult<Vec<bool>> {
1997        self.ensure_host_semantic_compat(
1998            "batch_fact_membership()",
1999            "batch_fact_membership_device(...)",
2000        )?;
2001        if facts.is_empty() {
2002            return Ok(Vec::new());
2003        }
2004
2005        let buf =
2006            self.executor.store().get(relation).ok_or_else(|| {
2007                PyValueError::new_err(format!("Relation '{}' not found", relation))
2008            })?;
2009
2010        let arity = buf.arity();
2011        if arity == 0 {
2012            return Ok(vec![false; facts.len()]);
2013        }
2014
2015        // Schema-aware typed upload
2016        let col_bytes = pack_i64_columns_typed(relation, &facts, buf.schema())?;
2017        let col_slices: Vec<&[u8]> = col_bytes.iter().map(|c| c.as_slice()).collect();
2018        let query_buf = self
2019            .provider
2020            .create_buffer_from_slices(&col_slices, buf.schema().clone())
2021            .map_err(types::xlog_err)?;
2022
2023        // All columns are keys (full-tuple match)
2024        let keys: Vec<usize> = (0..arity).collect();
2025
2026        self.provider
2027            .membership_mask(&query_buf, buf, &keys, &keys)
2028            .map_err(types::xlog_err)
2029    }
2030
2031    /// GPU-side batch credit assignment.
2032    ///
2033    /// For each fact in `facts`, returns the list of (i,j,k) entries whose
2034    /// join result contains that fact. Uses membership_mask against retained
2035    /// per-entry buffers — zero download_column_* calls.
2036    pub fn batch_tagged_credit(
2037        &self,
2038        relation: &str,
2039        facts: Vec<Vec<i64>>,
2040    ) -> PyResult<Vec<Vec<(u32, u32, u32)>>> {
2041        self.ensure_host_semantic_compat(
2042            "batch_tagged_credit()",
2043            "batch_tagged_credit_device(...)",
2044        )?;
2045        if facts.is_empty() {
2046            return Ok(Vec::new());
2047        }
2048
2049        // Find k index for this relation
2050        let k_idx = self
2051            .rel_index
2052            .iter()
2053            .position(|(_, name)| name == relation)
2054            .ok_or_else(|| {
2055                PyValueError::new_err(format!("Relation '{}' not in ILP schema", relation))
2056            })? as u32;
2057
2058        let tagged = match self.executor.ilp_last_result() {
2059            Some(t) => t,
2060            None => return Ok(vec![Vec::new(); facts.len()]),
2061        };
2062
2063        // Filter entries to those matching target relation k, with retained buffers
2064        let relevant_entries: Vec<&xlog_runtime::ilp_registry::IlpTagEntry> = tagged
2065            .entries
2066            .iter()
2067            .filter(|e| e.k == k_idx && e.num_rows > 0 && e.buffer.is_some())
2068            .collect();
2069
2070        if relevant_entries.is_empty() {
2071            return Ok(vec![Vec::new(); facts.len()]);
2072        }
2073
2074        // Determine arity from the first entry's buffer
2075        let first_buf = relevant_entries[0]
2076            .buffer
2077            .as_ref()
2078            .ok_or_else(|| PyRuntimeError::new_err("internal: filtered entry has no buffer"))?;
2079        let arity = first_buf.arity();
2080        if arity == 0 {
2081            return Ok(vec![Vec::new(); facts.len()]);
2082        }
2083
2084        // Schema-aware typed upload
2085        let schema = first_buf.schema().clone();
2086        let col_bytes = pack_i64_columns_typed(relation, &facts, &schema)?;
2087        let col_slices: Vec<&[u8]> = col_bytes.iter().map(|c| c.as_slice()).collect();
2088        let query_buf = self
2089            .provider
2090            .create_buffer_from_slices(&col_slices, schema)
2091            .map_err(types::xlog_err)?;
2092
2093        let keys: Vec<usize> = (0..arity).collect();
2094
2095        // For each relevant entry, compute membership mask against query facts
2096        let mut per_fact_credits: Vec<Vec<(u32, u32, u32)>> = vec![Vec::new(); facts.len()];
2097
2098        for entry in &relevant_entries {
2099            let entry_buf = entry
2100                .buffer
2101                .as_ref()
2102                .ok_or_else(|| PyRuntimeError::new_err("internal: filtered entry has no buffer"))?;
2103            let mask = self
2104                .provider
2105                .membership_mask(&query_buf, entry_buf, &keys, &keys)
2106                .map_err(types::xlog_err)?;
2107
2108            for (fact_idx, &found) in mask.iter().enumerate() {
2109                if found {
2110                    per_fact_credits[fact_idx].push((entry.i, entry.j, entry.k));
2111                }
2112            }
2113        }
2114
2115        Ok(per_fact_credits)
2116    }
2117
2118    /// GPU-side batch credit assignment.
2119    ///
2120    /// Returns a CSR-style device representation:
2121    /// - `fact_row_offsets`: len = num_facts + 1
2122    /// - `entry_indices`: COO candidate indices, sorted by fact row
2123    /// - `entry_i/j/k`: metadata arrays indexed by `entry_indices`
2124    ///
2125    /// Zero DTOH calls on the query path. Uses the non-chunked COO builder
2126    /// to avoid reading device-side nnz metadata back to the host.
2127    pub fn batch_tagged_credit_device(
2128        &self,
2129        py: Python<'_>,
2130        relation: &str,
2131        facts: Vec<Vec<i64>>,
2132    ) -> PyResult<IlpTaggedCreditDeviceResult> {
2133        if facts.is_empty() {
2134            return empty_tagged_credit_device_result(&self.provider, py, 0);
2135        }
2136
2137        let k_idx = self
2138            .rel_index
2139            .iter()
2140            .position(|(_, name)| name == relation)
2141            .ok_or_else(|| {
2142                PyValueError::new_err(format!("Relation '{}' not in ILP schema", relation))
2143            })? as u32;
2144
2145        let tagged = match self.executor.ilp_last_result() {
2146            Some(t) => t,
2147            None => return empty_tagged_credit_device_result(&self.provider, py, facts.len()),
2148        };
2149
2150        let relevant_entries: Vec<&xlog_runtime::ilp_registry::IlpTagEntry> = tagged
2151            .entries
2152            .iter()
2153            .filter(|e| e.k == k_idx && e.num_rows > 0 && e.buffer.is_some())
2154            .collect();
2155
2156        if relevant_entries.is_empty() {
2157            return empty_tagged_credit_device_result(&self.provider, py, facts.len());
2158        }
2159
2160        let first_buf = relevant_entries[0]
2161            .buffer
2162            .as_ref()
2163            .ok_or_else(|| PyRuntimeError::new_err("internal: filtered entry has no buffer"))?;
2164        let arity = first_buf.arity();
2165        if arity == 0 {
2166            return empty_tagged_credit_device_result(&self.provider, py, facts.len());
2167        }
2168
2169        let schema = first_buf.schema().clone();
2170        let col_bytes = pack_i64_columns_typed(relation, &facts, &schema)?;
2171        let col_slices: Vec<&[u8]> = col_bytes.iter().map(|c| c.as_slice()).collect();
2172        let query_buf = self
2173            .provider
2174            .create_buffer_from_slices(&col_slices, schema)
2175            .map_err(types::xlog_err)?;
2176
2177        let keys: Vec<usize> = (0..arity).collect();
2178        let num_facts = u32::try_from(facts.len())
2179            .map_err(|_| PyValueError::new_err("facts length exceeds u32::MAX"))?;
2180        let num_entries = u32::try_from(relevant_entries.len())
2181            .map_err(|_| PyValueError::new_err("entry count exceeds u32::MAX"))?;
2182        let upper_bound = num_facts
2183            .checked_mul(num_entries)
2184            .ok_or_else(|| PyValueError::new_err("credit upper bound overflow"))?;
2185
2186        let fact_indices_host: Vec<u32> = (0..num_facts).collect();
2187        let mut d_fact_indices = self
2188            .provider
2189            .memory()
2190            .alloc::<u32>(num_facts as usize)
2191            .map_err(|e| types::gpu_err("alloc fact_indices", e))?;
2192        self.provider
2193            .device()
2194            .inner()
2195            .htod_sync_copy_into(&fact_indices_host, &mut d_fact_indices)
2196            .map_err(|e| types::gpu_err("htod fact_indices", e))?;
2197
2198        let mut entry_i_host = Vec::with_capacity(relevant_entries.len());
2199        let mut entry_j_host = Vec::with_capacity(relevant_entries.len());
2200        let mut entry_k_host = Vec::with_capacity(relevant_entries.len());
2201        let mut tasks = Vec::with_capacity(relevant_entries.len());
2202
2203        for (entry_idx, entry) in relevant_entries.iter().enumerate() {
2204            let entry_buf = entry
2205                .buffer
2206                .as_ref()
2207                .ok_or_else(|| PyRuntimeError::new_err("internal: filtered entry has no buffer"))?;
2208            let d_mask = self
2209                .provider
2210                .membership_mask_device(&query_buf, entry_buf, &keys, &keys)
2211                .map_err(|e| types::gpu_err("membership_mask", e))?;
2212            tasks.push(ilp_gpu::CooTask {
2213                d_mask,
2214                fact_indices_idx: 0,
2215                cidx: entry_idx as u32,
2216                num_query: num_facts,
2217            });
2218            entry_i_host.push(entry.i);
2219            entry_j_host.push(entry.j);
2220            entry_k_host.push(entry.k);
2221        }
2222
2223        let (mut d_coo_facts, mut d_coo_cands, actual_nnz) = ilp_gpu::build_coo_single(
2224            &self.provider,
2225            &tasks,
2226            &[d_fact_indices],
2227            num_facts,
2228            num_entries,
2229            upper_bound,
2230        )?;
2231        let d_row_offsets = ilp_gpu::sort_and_build_csr(
2232            &self.provider,
2233            &mut d_coo_facts,
2234            &mut d_coo_cands,
2235            actual_nnz,
2236            num_facts,
2237        )?;
2238
2239        let mut d_entry_i = self
2240            .provider
2241            .memory()
2242            .alloc::<u32>(entry_i_host.len())
2243            .map_err(|e| types::gpu_err("alloc entry_i", e))?;
2244        let mut d_entry_j = self
2245            .provider
2246            .memory()
2247            .alloc::<u32>(entry_j_host.len())
2248            .map_err(|e| types::gpu_err("alloc entry_j", e))?;
2249        let mut d_entry_k = self
2250            .provider
2251            .memory()
2252            .alloc::<u32>(entry_k_host.len())
2253            .map_err(|e| types::gpu_err("alloc entry_k", e))?;
2254        self.provider
2255            .device()
2256            .inner()
2257            .htod_sync_copy_into(&entry_i_host, &mut d_entry_i)
2258            .map_err(|e| types::gpu_err("htod entry_i", e))?;
2259        self.provider
2260            .device()
2261            .inner()
2262            .htod_sync_copy_into(&entry_j_host, &mut d_entry_j)
2263            .map_err(|e| types::gpu_err("htod entry_j", e))?;
2264        self.provider
2265            .device()
2266            .inner()
2267            .htod_sync_copy_into(&entry_k_host, &mut d_entry_k)
2268            .map_err(|e| types::gpu_err("htod entry_k", e))?;
2269
2270        Ok(IlpTaggedCreditDeviceResult {
2271            fact_row_offsets: export_device_u32_tensor_as_i32(
2272                &self.provider,
2273                py,
2274                d_row_offsets,
2275                facts.len() + 1,
2276            )?,
2277            entry_indices: export_device_u32_tensor_as_i32(
2278                &self.provider,
2279                py,
2280                d_coo_cands,
2281                upper_bound as usize,
2282            )?,
2283            entry_i: export_device_u32_tensor_as_i32(
2284                &self.provider,
2285                py,
2286                d_entry_i,
2287                entry_i_host.len(),
2288            )?,
2289            entry_j: export_device_u32_tensor_as_i32(
2290                &self.provider,
2291                py,
2292                d_entry_j,
2293                entry_j_host.len(),
2294            )?,
2295            entry_k: export_device_u32_tensor_as_i32(
2296                &self.provider,
2297                py,
2298                d_entry_k,
2299                entry_k_host.len(),
2300            )?,
2301        })
2302    }
2303
2304    pub fn d2h_transfer_count(&self) -> u64 {
2305        self.provider.d2h_transfer_count()
2306    }
2307
2308    pub fn reset_d2h_transfer_count(&self) {
2309        self.provider.reset_d2h_transfer_count()
2310    }
2311
2312    pub fn host_transfer_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
2313        let stats = self.provider.host_transfer_stats();
2314        let dict = PyDict::new(py);
2315        dict.set_item("dtoh_bytes", stats.dtoh_bytes)?;
2316        dict.set_item("htod_bytes", stats.htod_bytes)?;
2317        dict.set_item("dtoh_calls", stats.dtoh_calls)?;
2318        dict.set_item("htod_calls", stats.htod_calls)?;
2319        Ok(dict.into())
2320    }
2321
2322    pub fn reset_host_transfer_stats(&self) {
2323        self.provider.reset_host_transfer_stats()
2324    }
2325}
2326
2327// ---------------------------------------------------------------------------
2328// CompiledIlpProgram — plain impl block (GPU export helpers + internal)
2329// ---------------------------------------------------------------------------
2330
2331impl CompiledIlpProgram {
2332    fn collect_relation_example_groups(
2333        &self,
2334        relations_obj: &Bound<'_, PyAny>,
2335        err_msg: &str,
2336    ) -> PyResult<Vec<RelationExampleGroup>> {
2337        let dict = relations_obj
2338            .cast::<PyDict>()
2339            .map_err(|_| PyValueError::new_err(err_msg.to_string()))?;
2340        let mut groups = Vec::with_capacity(dict.len());
2341        for (name_obj, columns_obj) in dict.iter() {
2342            let relation: String = name_obj.extract()?;
2343            let schema = self.schemas.get(&relation).ok_or_else(|| {
2344                PyValueError::new_err(format!(
2345                    "Unknown relation {} (not present in compiled schemas)",
2346                    relation
2347                ))
2348            })?;
2349            let tensors = collect_dlpack_columns(
2350                &columns_obj,
2351                &format!("Relation {} must be a sequence of DLPack columns", relation),
2352            )?;
2353            let query_buf = self
2354                .provider
2355                .from_dlpack_tensors_with_schema(schema.clone(), tensors)
2356                .map_err(types::xlog_err)?;
2357            let num_rows = u32::try_from(query_buf.num_rows())
2358                .map_err(|_| PyValueError::new_err("relation row count exceeds u32::MAX"))?;
2359            groups.push(RelationExampleGroup {
2360                relation,
2361                query_buf,
2362                num_rows,
2363            });
2364        }
2365        Ok(groups)
2366    }
2367
2368    fn apply_relation_overrides(&mut self) -> xlog_core::Result<()> {
2369        let relation_names: Vec<String> = self.relation_overrides.keys().cloned().collect();
2370        for name in relation_names {
2371            let stored = self
2372                .relation_overrides
2373                .get(&name)
2374                .expect("relation override disappeared during runtime reset");
2375            let live = self.provider.clone_buffer(stored)?;
2376            self.executor.put_relation(&name, live);
2377        }
2378        Ok(())
2379    }
2380
2381    fn evaluate_ilp_plan(&mut self, py: Python<'_>) -> PyResult<()> {
2382        let result: xlog_core::Result<xlog_cuda::CudaBuffer> = py.detach(|| {
2383            self.executor.reset_for_mc();
2384            for (name, schema) in &self.schemas {
2385                let empty = self.provider.create_empty_buffer(schema.clone())?;
2386                self.executor.store_mut().put(name, empty);
2387            }
2388            load_facts_into_store(&self.ast, &self.provider, &mut self.executor, &self.schemas)?;
2389            self.apply_relation_overrides()?;
2390            self.executor.execute_plan(&self.plan)
2391        });
2392        result.map_err(types::xlog_err)?;
2393        Ok(())
2394    }
2395
2396    fn set_rule_mask_sparse_selected_device_impl(
2397        &mut self,
2398        name: String,
2399        selected_candidate_ids_dlpack: &Bound<'_, PyAny>,
2400        selected_soft_probs_dlpack: &Bound<'_, PyAny>,
2401        allow_recursive: bool,
2402        validate_ids: bool,
2403    ) -> PyResult<()> {
2404        let tmj = extract_tmj_meta_for_mask(&self.plan, Some(&name));
2405        let n = tmj.schema_size;
2406        if n == 0 {
2407            return Err(PyValueError::new_err(format!(
2408                "no learnable mask '{}' found",
2409                name
2410            )));
2411        }
2412        if self.compiled_schema_size > 0 && n != self.compiled_schema_size {
2413            return Err(PyValueError::new_err(format!(
2414                "schema_size mismatch for '{}': plan N={} compiled N={}",
2415                name, n, self.compiled_schema_size
2416            )));
2417        }
2418
2419        let _ = allow_recursive;
2420        let candidate_order = self.candidate_order.as_ref().ok_or_else(|| {
2421            PyRuntimeError::new_err(
2422                "candidate order not set — call set_candidate_map() before strict sparse mask updates",
2423            )
2424        })?;
2425        let expected_c = candidate_order.len();
2426
2427        let ids_dmt = dlpack_from_py(selected_candidate_ids_dlpack)?;
2428        let ids_buf = self
2429            .provider
2430            .from_dlpack_tensors(vec![ids_dmt])
2431            .map_err(types::xlog_err)?;
2432        let soft_dmt = dlpack_from_py(selected_soft_probs_dlpack)?;
2433        let soft_buf = self
2434            .provider
2435            .from_dlpack_tensors(vec![soft_dmt])
2436            .map_err(types::xlog_err)?;
2437
2438        let selected_len = usize::try_from(ids_buf.num_rows())
2439            .map_err(|_| PyValueError::new_err("selected candidate ids length overflow"))?;
2440        let soft_len = usize::try_from(soft_buf.num_rows())
2441            .map_err(|_| PyValueError::new_err("selected soft_probs length overflow"))?;
2442        if soft_len != selected_len {
2443            return Err(PyValueError::new_err(format!(
2444                "selected soft_probs length {} != selected candidate ids length {}",
2445                soft_len, selected_len
2446            )));
2447        }
2448
2449        if validate_ids {
2450            self.provider
2451                .validate_selected_ids(&ids_buf, expected_c)
2452                .map_err(types::val_err)?;
2453        }
2454
2455        let active_flags = self
2456            .provider
2457            .build_selected_id_mask(&ids_buf, expected_c)
2458            .map_err(types::xlog_err)?;
2459
2460        self.executor
2461            .ilp_registry_mut()
2462            .insert_selected_mask_device(
2463                name,
2464                n,
2465                candidate_order.clone(),
2466                active_flags,
2467                selected_len,
2468            );
2469        Ok(())
2470    }
2471
2472    fn ensure_host_semantic_compat(&self, api: &str, device_hint: &str) -> PyResult<()> {
2473        if self.strict_zero_dtoh {
2474            return Err(PyRuntimeError::new_err(format!(
2475                "strict_zero_dtoh forbids {}; use {} instead",
2476                api, device_hint
2477            )));
2478        }
2479        Ok(())
2480    }
2481
2482    // ─── GPU loss/grad export helpers ──────────────────────────────────
2483
2484    /// Build zero loss (scalar) + zero grad (num_cands) on GPU, export via DLPack.
2485    fn build_zero_loss_grad(
2486        &self,
2487        py: Python<'_>,
2488        num_cands: u32,
2489        is_f64: bool,
2490    ) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
2491        if is_f64 {
2492            build_zero_typed::<f64>(&self.provider, py, num_cands, ScalarType::F64)
2493        } else {
2494            build_zero_typed::<f32>(&self.provider, py, num_cands, ScalarType::F32)
2495        }
2496    }
2497
2498    /// Handle the case where COO is empty but we have facts.
2499    /// Facts with no covering entries get: positive -> -log(eps), negative -> -log(1.0) = 0.
2500    /// We still run the kernels with an empty CSR for correctness.
2501    fn build_loss_grad_empty_coo(
2502        &self,
2503        py: Python<'_>,
2504        is_positive_host: &[u8],
2505        num_facts: u32,
2506        num_cands: u32,
2507        is_f64: bool,
2508    ) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
2509        // Build CSR with all-zero row_offsets (every row has 0 non-zeros)
2510        let row_offsets = vec![0u32; (num_facts + 1) as usize];
2511        let mut d_row_offsets = self
2512            .provider
2513            .memory()
2514            .alloc::<u32>((num_facts + 1) as usize)
2515            .map_err(|e| types::gpu_err("alloc", e))?;
2516        self.provider
2517            .device()
2518            .inner()
2519            .htod_sync_copy_into(&row_offsets, &mut d_row_offsets)
2520            .map_err(|e| types::gpu_err("htod", e))?;
2521
2522        // Empty col_indices
2523        let d_col_indices = self
2524            .provider
2525            .memory()
2526            .alloc::<u32>(0)
2527            .map_err(|e| types::gpu_err("alloc", e))?;
2528
2529        let mut d_is_positive = self
2530            .provider
2531            .memory()
2532            .alloc::<u8>(num_facts as usize)
2533            .map_err(|e| types::gpu_err("alloc", e))?;
2534        self.provider
2535            .device()
2536            .inner()
2537            .htod_sync_copy_into(is_positive_host, &mut d_is_positive)
2538            .map_err(|e| types::gpu_err("htod", e))?;
2539
2540        // Build a single-column CudaBuffer with 0 elements to represent an empty cand_probs
2541        // for the kernel launch (won't be read since row ranges are all empty).
2542        // We need a dummy CudaColumn. Use the actual cand count = num_cands.
2543        // Since COO is empty the kernel won't access cand_probs, but we need to pass something.
2544        let dummy_col: xlog_cuda::CudaColumn = if is_f64 {
2545            self.provider
2546                .memory()
2547                .alloc::<f64>(num_cands.max(1) as usize)
2548                .map_err(|e| types::gpu_err("alloc dummy", e))?
2549                .into_bytes()
2550                .into()
2551        } else {
2552            self.provider
2553                .memory()
2554                .alloc::<f32>(num_cands.max(1) as usize)
2555                .map_err(|e| types::gpu_err("alloc dummy", e))?
2556                .into_bytes()
2557                .into()
2558        };
2559
2560        ilp_gpu::forward_backward_reduce(
2561            &self.provider,
2562            py,
2563            ilp_gpu::ForwardBackwardInputs {
2564                row_offsets: &d_row_offsets,
2565                coo_candidates: &d_col_indices,
2566                candidate_probabilities: &dummy_col,
2567                is_positive: &d_is_positive,
2568                fact_count: num_facts,
2569                candidate_count: num_cands,
2570                uses_f64: is_f64,
2571            },
2572        )
2573    }
2574
2575    /// Returns sorted (i,j,k) candidate triples for the given learnable mask.
2576    /// Pruning logic must stay aligned with `valid_candidates`.
2577    fn candidate_triples_for_mask(
2578        &self,
2579        mask_name: &str,
2580        allow_recursive: bool,
2581    ) -> PyResult<Vec<(u32, u32, u32)>> {
2582        let tmj = extract_tmj_meta_for_mask(&self.plan, Some(mask_name));
2583        let n = tmj.schema_size;
2584        if n == 0 {
2585            return Err(PyValueError::new_err(format!(
2586                "no learnable mask '{}' found in compiled program",
2587                mask_name
2588            )));
2589        }
2590        let head_name = &tmj.head_rel_name;
2591        let k_head = self
2592            .rel_index
2593            .iter()
2594            .position(|(_, name)| name == head_name)
2595            .ok_or_else(|| {
2596                PyValueError::new_err(format!(
2597                    "head relation '{}' not in rel_index for mask '{}'",
2598                    head_name, mask_name
2599                ))
2600            })? as u32;
2601
2602        // Identify which relations currently have nonzero tuples in store.
2603        let has_tuples: Vec<bool> = self
2604            .rel_index
2605            .iter()
2606            .map(|(_, name)| {
2607                self.executor
2608                    .store()
2609                    .get(name)
2610                    .map(|buf| buf.num_rows() > 0)
2611                    .unwrap_or(false)
2612            })
2613            .collect();
2614
2615        let mut triples: Vec<(u32, u32, u32)> = Vec::new();
2616        for i in 0..n as u32 {
2617            for j in 0..n as u32 {
2618                let k = k_head;
2619
2620                // Prune template+template (both no tuples).
2621                if !has_tuples[i as usize] && !has_tuples[j as usize] {
2622                    continue;
2623                }
2624
2625                // Keep behavior aligned with existing alpha candidate pruning:
2626                // recursive body refs are allowed only if head already has tuples.
2627                if !allow_recursive && (i == k || j == k) && !has_tuples[k as usize] {
2628                    continue;
2629                }
2630
2631                triples.push((i, j, k));
2632            }
2633        }
2634        triples.sort_by_key(|&(i, j, k)| (k, i, j));
2635        Ok(triples)
2636    }
2637
2638    fn expected_candidate_count(&self, mask_name: &str, allow_recursive: bool) -> PyResult<usize> {
2639        Ok(self
2640            .candidate_triples_for_mask(mask_name, allow_recursive)?
2641            .len())
2642    }
2643
2644    fn fact_exists_in_buffer(
2645        provider: &CudaKernelProvider,
2646        buf: &xlog_cuda::CudaBuffer,
2647        values: &[i64],
2648    ) -> xlog_core::Result<bool> {
2649        use xlog_core::XlogError;
2650        let num_rows = read_device_row_count(provider, buf)? as usize;
2651        if num_rows == 0 {
2652            return Ok(false);
2653        }
2654        if values.len() != buf.arity() {
2655            return Ok(false);
2656        }
2657
2658        let schema = buf.schema();
2659        let mut columns: Vec<Vec<i64>> = Vec::new();
2660        for col_idx in 0..buf.arity() {
2661            let col_type = schema.column_type(col_idx).ok_or_else(|| {
2662                XlogError::Kernel(format!("Column {} type not found in schema", col_idx))
2663            })?;
2664            let col_i64: Vec<i64> = match col_type {
2665                ScalarType::I64 => provider.download_column::<i64>(buf, col_idx)?,
2666                ScalarType::I32 => provider
2667                    .download_column::<i32>(buf, col_idx)?
2668                    .into_iter()
2669                    .map(|v| v as i64)
2670                    .collect(),
2671                ScalarType::U32 | ScalarType::Symbol => provider
2672                    .download_column::<u32>(buf, col_idx)?
2673                    .into_iter()
2674                    .map(|v| v as i64)
2675                    .collect(),
2676                ScalarType::U64 => {
2677                    let col_u64 = provider.download_column::<u64>(buf, col_idx)?;
2678                    col_u64.into_iter().map(|v| v as i64).collect()
2679                }
2680                ScalarType::Bool => provider
2681                    .download_column::<bool>(buf, col_idx)?
2682                    .into_iter()
2683                    .map(|v| if v { 1i64 } else { 0i64 })
2684                    .collect(),
2685                ScalarType::F32 | ScalarType::F64 => {
2686                    return Err(XlogError::Kernel(format!(
2687                        "fact_exists does not support float column type {:?}",
2688                        col_type
2689                    )));
2690                }
2691            };
2692            columns.push(col_i64);
2693        }
2694
2695        Ok((0..num_rows).any(|row_index| {
2696            values
2697                .iter()
2698                .enumerate()
2699                .all(|(column_index, value)| columns[column_index][row_index] == *value)
2700        }))
2701    }
2702
2703    /// Like fact_exists_in_buffer but checks only the projected columns.
2704    /// `projection[i]` is the column index in `buf` that corresponds to
2705    /// `values[i]` in the head relation.
2706    fn fact_exists_projected(
2707        provider: &CudaKernelProvider,
2708        buf: &xlog_cuda::CudaBuffer,
2709        values: &[i64],
2710        projection: &[usize],
2711    ) -> xlog_core::Result<bool> {
2712        use xlog_core::XlogError;
2713        let num_rows = read_device_row_count(provider, buf)? as usize;
2714        if num_rows == 0 {
2715            return Ok(false);
2716        }
2717
2718        let schema = buf.schema();
2719        let mut columns: Vec<Vec<i64>> = Vec::new();
2720        for &col_idx in projection {
2721            if col_idx >= buf.arity() {
2722                return Ok(false);
2723            }
2724            let col_type = schema.column_type(col_idx).ok_or_else(|| {
2725                XlogError::Kernel(format!("Column {} type not found in schema", col_idx))
2726            })?;
2727            let col_i64: Vec<i64> = match col_type {
2728                ScalarType::I64 => provider.download_column::<i64>(buf, col_idx)?,
2729                ScalarType::I32 => provider
2730                    .download_column::<i32>(buf, col_idx)?
2731                    .into_iter()
2732                    .map(|v| v as i64)
2733                    .collect(),
2734                ScalarType::U32 | ScalarType::Symbol => provider
2735                    .download_column::<u32>(buf, col_idx)?
2736                    .into_iter()
2737                    .map(|v| v as i64)
2738                    .collect(),
2739                ScalarType::U64 => provider
2740                    .download_column::<u64>(buf, col_idx)?
2741                    .into_iter()
2742                    .map(|v| v as i64)
2743                    .collect(),
2744                ScalarType::Bool => provider
2745                    .download_column::<bool>(buf, col_idx)?
2746                    .into_iter()
2747                    .map(|v| if v { 1i64 } else { 0i64 })
2748                    .collect(),
2749                ScalarType::F32 | ScalarType::F64 => {
2750                    return Err(XlogError::Kernel(format!(
2751                        "fact_exists does not support float column type {:?}",
2752                        col_type
2753                    )));
2754                }
2755            };
2756            columns.push(col_i64);
2757        }
2758
2759        Ok((0..num_rows).any(|row_index| {
2760            values
2761                .iter()
2762                .enumerate()
2763                .all(|(column_index, value)| columns[column_index][row_index] == *value)
2764        }))
2765    }
2766}