1use 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
30struct 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
65fn 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 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
191pub(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
366struct 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 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#[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 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 build_ilp_program(
483 source,
484 max_active_rules,
485 ProviderFor::New { device, memory_mb },
486 )
487 }
488}
489
490enum ProviderFor {
492 New { device: usize, memory_mb: u64 },
494 Shared(Arc<CudaKernelProvider>),
496}
497
498fn ms_since(t: Instant) -> f64 {
499 t.elapsed().as_secs_f64() * 1000.0
500}
501
502fn 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#[pymethods]
598impl CompiledIlpProgram {
599 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 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 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 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 pub fn set_coo_chunk_budget(&mut self, bytes: u64) {
668 self.coo_chunk_budget = bytes;
669 }
670
671 pub fn set_coo_memory_cap(&mut self, bytes: u64) {
673 self.coo_chunk_budget = bytes;
674 }
675
676 pub fn set_strict_zero_dtoh(&mut self, strict: bool) {
680 self.strict_zero_dtoh = strict;
681 }
682
683 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 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 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 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 let num_pos = positives.len();
781 let num_neg = negatives.len();
782 let num_facts = (num_pos + num_neg) as u32;
783
784 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 if num_facts == 0 {
808 return self.build_zero_loss_grad(py, num_cands, is_f64);
809 }
810
811 let is_positive_host: Vec<u8> = all_facts
813 .iter()
814 .map(|f| if f.is_positive { 1u8 } else { 0u8 })
815 .collect();
816
817 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 let tagged = self
832 .executor
833 .ilp_last_result()
834 .ok_or_else(|| PyRuntimeError::new_err("No ILP result — call evaluate() first"))?;
835
836 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 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 let coo_bytes = (upper_bound as u64) * 8;
947 let needs_chunking = coo_bytes > self.coo_chunk_budget;
948
949 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 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 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 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 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 #[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 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 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 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 #[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 #[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 pub fn reset_runtime(&mut self, py: Python<'_>) -> PyResult<()> {
1506 let _result: xlog_core::Result<()> = py.detach(|| {
1507 self.executor.reset_for_ilp();
1509
1510 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 load_facts_into_store(&self.ast, &self.provider, &mut self.executor, &self.schemas)?;
1518
1519 self.apply_relation_overrides()?;
1521
1522 self.executor.execute_plan(&self.plan)?;
1524
1525 Ok(())
1526 });
1527 _result.map_err(types::xlog_err)?;
1528
1529 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 #[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 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 #[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 let exclude_set: HashSet<Vec<i64>> = exclude
1650 .into_iter()
1651 .filter(|(rel, _)| rel == &head_rel)
1652 .map(|(_, vals)| vals)
1653 .collect();
1654
1655 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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
2327impl 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 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 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 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 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 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 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 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 if !has_tuples[i as usize] && !has_tuples[j as usize] {
2622 continue;
2623 }
2624
2625 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 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}