1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Instant;
5
6use pyo3::exceptions::{PyKeyError, PyRuntimeError, PyValueError};
7use pyo3::prelude::*;
8use pyo3::types::{PyDict, PySequence};
9
10use xlog_core::{symbol, ScalarType};
11use xlog_cuda::DlpackManagedTensor;
12use xlog_gpu::logic as gpu_logic;
13use xlog_logic::ast::ProbEngine;
14use xlog_neural::{NetworkRegistry, TensorSourceRegistry};
15use xlog_prob::exact::{ExactDdnnfProgram, GpuConfig};
16use xlog_prob::mc::McProgram;
17use xlog_runtime::RelationDelta;
18
19use std::collections::HashMap as StdHashMap;
20
21use super::neural_registry::NeuralPredicateRegistry;
22use super::relation_metadata::{
23 metadata_error, pack_session_evidence, relation_schema_fingerprint,
24 require_positive_metadata_arity, PreparedInsertEvidence, PreparedRelationMetadataUpdate,
25 RelationEvidence, RelationMetadataStore, RelationReplacementRequest, RelationSnapshot,
26};
27use super::{
28 dlpack_capsule_from_tensor, dlpack_from_py, enforce_call_memory_limit, pack_query_proof_traces,
29 pack_rule_provenance, parse_prob_engine_override, provider_from_config, provider_memory_stats,
30 types, CompiledLogicProgram, CompiledProbProgram, CompiledProgram, LogicDeltaStats,
31 LogicEvalResult, LogicProgram, LogicQueryResult, LogicRelationSession, Program,
32 RelationChangeCallback,
33};
34
35struct ParsedRelationDeltaUpdate {
36 name: String,
37 delta: RelationDelta,
38 insert_evidence: Option<PreparedInsertEvidence>,
39}
40
41type ParsedRelationUpdateParts = (
42 Vec<(String, RelationDelta)>,
43 Vec<PreparedRelationMetadataUpdate>,
44 Vec<String>,
45 BTreeMap<String, xlog_core::Schema>,
46 BTreeSet<String>,
47);
48
49enum RelationReplacementMetadata {
50 Clear,
51 Replace(RelationMetadataStore),
52}
53
54#[pymethods]
55impl Program {
56 #[staticmethod]
57 #[pyo3(signature = (source, device=0, memory_mb=32768, prob_engine=None))]
58 pub fn compile(
59 source: &str,
60 device: usize,
61 memory_mb: u64,
62 prob_engine: Option<String>,
63 ) -> PyResult<CompiledProgram> {
64 if memory_mb == 0 {
65 return Err(PyValueError::new_err("memory_mb must be > 0"));
66 }
67
68 let mut config = GpuConfig::default();
69 config.device_ordinal = device;
70 config.memory_bytes = memory_mb * 1024 * 1024;
71
72 let ast = xlog_logic::parse_program(source).map_err(types::xlog_err)?;
74
75 let declared_networks: HashSet<String> = ast
77 .neural_predicates
78 .iter()
79 .map(|np| np.network.clone())
80 .collect();
81 let mut declared_network_forms: HashMap<String, bool> = HashMap::new();
83 for np in &ast.neural_predicates {
84 let is_embedding = np.labels.is_none();
85 match declared_network_forms.get(&np.network) {
86 Some(&existing_form) if existing_form != is_embedding => {
87 return Err(PyValueError::new_err(format!(
88 "network '{}' is declared as both classification and embedding; \
89 each network name must have a single form",
90 np.network
91 )));
92 }
93 _ => {
94 declared_network_forms.insert(np.network.clone(), is_embedding);
95 }
96 }
97 }
98
99 let neural_registry = NeuralPredicateRegistry::from_ast(&ast).map_err(types::val_err)?;
100
101 let engine = match prob_engine {
102 Some(s) => parse_prob_engine_override(&s)?,
103 None => ast.prob_engine(),
104 };
105
106 let program = match engine {
107 ProbEngine::ExactDdnnf => CompiledProbProgram::Exact(Box::new(
108 ExactDdnnfProgram::compile_source_with_gpu(source, config)
109 .map_err(types::xlog_err)?,
110 )),
111 ProbEngine::Mc => CompiledProbProgram::Mc(Box::new(
112 McProgram::compile_source_with_gpu(source, config).map_err(types::xlog_err)?,
113 )),
114 };
115 let provider = provider_from_config(config).map_err(types::xlog_err)?;
116
117 Ok(CompiledProgram {
118 program,
119 output_provider: Arc::new(provider),
120 network_registry: NetworkRegistry::new(),
121 neural_registry,
122 declared_networks,
123 declared_network_forms,
124 tensor_sources: TensorSourceRegistry::new(),
125 domain_source: None,
126 domain_ids: Vec::new(),
127 _source: source.to_string(),
128 ast,
129 _gpu_config: config,
130 _prob_engine: engine,
131 query_signature_cache: StdHashMap::new(),
132 circuit_cache: StdHashMap::new(),
133 circuit_cache_hits: 0,
134 circuit_cache_misses: 0,
135 template_compile_count: 0,
136 batch_queries: true,
137 last_compile_profile: None,
138 })
139 }
140}
141
142#[pymethods]
143impl LogicProgram {
144 #[staticmethod]
145 #[pyo3(signature = (source, device=0, memory_mb=32768))]
146 pub fn compile(source: &str, device: usize, memory_mb: u64) -> PyResult<CompiledLogicProgram> {
147 require_logic_memory_budget(memory_mb)?;
148 let program = gpu_logic::LogicProgram::compile(source).map_err(types::xlog_err)?;
149 compiled_logic_program(program, device, memory_mb)
150 }
151
152 #[staticmethod]
153 #[pyo3(signature = (entrypoint, module_paths=Vec::new(), device=0, memory_mb=32768))]
154 pub fn compile_file(
155 entrypoint: PathBuf,
156 module_paths: Vec<PathBuf>,
157 device: usize,
158 memory_mb: u64,
159 ) -> PyResult<CompiledLogicProgram> {
160 require_logic_memory_budget(memory_mb)?;
161 let program = gpu_logic::LogicProgram::compile_file(&entrypoint, module_paths)
162 .map_err(types::xlog_err)?;
163 compiled_logic_program(program, device, memory_mb)
164 }
165}
166
167fn require_logic_memory_budget(memory_mb: u64) -> PyResult<()> {
168 if memory_mb == 0 {
169 return Err(PyValueError::new_err("memory_mb must be > 0"));
170 }
171 Ok(())
172}
173
174fn compiled_logic_program(
175 program: gpu_logic::LogicProgram,
176 device: usize,
177 memory_mb: u64,
178) -> PyResult<CompiledLogicProgram> {
179 let mut config = GpuConfig::default();
180 config.device_ordinal = device;
181 config.memory_bytes = memory_mb * 1024 * 1024;
182
183 let provider = provider_from_config(config).map_err(types::xlog_err)?;
184
185 Ok(CompiledLogicProgram {
186 program: Arc::new(program),
187 provider: Arc::new(provider),
188 })
189}
190
191#[pymethods]
192impl CompiledLogicProgram {
193 #[pyo3(signature = (dlpack_inputs=None, memory_mb=None))]
194 pub fn evaluate(
195 &self,
196 py: Python<'_>,
197 dlpack_inputs: Option<&Bound<'_, PyDict>>,
198 memory_mb: Option<u64>,
199 ) -> PyResult<LogicEvalResult> {
200 enforce_call_memory_limit(&self.provider, memory_mb)?;
201 let mut inputs: HashMap<String, xlog_cuda::CudaBuffer> = HashMap::new();
202
203 if let Some(dict) = dlpack_inputs {
204 for (k, v) in dict.iter() {
205 let name: String = k.extract()?;
206 let schema = self.program.schema(&name).ok_or_else(|| {
207 PyValueError::new_err(format!(
208 "Unknown input relation {} (not present in compiled schemas)",
209 name
210 ))
211 })?;
212
213 let tensors = collect_dlpack_columns(
214 &v,
215 schema.arity(),
216 &format!(
217 "Input relation {} must be a sequence of DLPack columns",
218 name
219 ),
220 )?;
221
222 let buffer = self
223 .provider
224 .from_dlpack_tensors_with_schema(schema.clone(), tensors)
225 .map_err(types::xlog_err)?;
226
227 inputs.insert(name, buffer);
228 }
229 }
230
231 let program = self.program.clone();
232 let provider = self.provider.clone();
233 let result = py
234 .detach(move || program.evaluate(provider, inputs))
235 .map_err(types::xlog_err)?;
236 pack_logic_result_with_provider(py, &self.provider, result)
237 }
238
239 pub fn session(&self) -> PyResult<LogicRelationSession> {
240 let relation_store = self
241 .program
242 .create_relation_store(self.provider.clone())
243 .map_err(types::xlog_err)?;
244 Ok(LogicRelationSession {
245 program: self.program.clone(),
246 provider: self.provider.clone(),
247 relation_store,
248 evaluation_store: None,
249 session_runtime: None,
250 last_delta_stats: None,
251 relation_callbacks: Vec::new(),
252 next_relation_callback_id: 1,
253 relation_generations: HashMap::new(),
254 relation_metadata: RelationMetadataStore::default(),
255 })
256 }
257
258 pub fn memory_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
260 provider_memory_stats(py, &self.provider)
261 }
262
263 pub fn rule_provenance(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
264 pack_rule_provenance(py, &self.program.rule_provenance())
265 }
266
267 pub fn proof_traces(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
268 pack_query_proof_traces(py, &self.program.proof_traces())
269 }
270}
271
272impl CompiledLogicProgram {}
273
274#[pymethods]
275impl LogicRelationSession {
276 pub fn put_relation(
277 &mut self,
278 name: String,
279 dlpack_columns: &Bound<'_, PyAny>,
280 ) -> PyResult<()> {
281 let schema = self.relation_replacement_schema(&name)?;
282 let buffer = self.detached_relation_replacement_buffer(&name, schema, dlpack_columns)?;
283 self.commit_relation_replacement(name, buffer, RelationReplacementMetadata::Clear)
284 }
285
286 pub fn put_relation_rows(&mut self, name: String, rows: Vec<Vec<String>>) -> PyResult<()> {
287 let schema = self.relation_replacement_schema(&name)?.clone();
288 let buffer = self.lexical_relation_buffer(&name, &schema, rows)?;
289 self.commit_relation_replacement(name, buffer, RelationReplacementMetadata::Clear)
290 }
291
292 #[pyo3(signature = (name, dlpack_columns, *, roles, facts))]
293 pub fn put_relation_with_provenance(
294 &mut self,
295 py: Python<'_>,
296 name: String,
297 dlpack_columns: &Bound<'_, PyAny>,
298 roles: &Bound<'_, PyAny>,
299 facts: &Bound<'_, PyAny>,
300 ) -> PyResult<Py<PyAny>> {
301 let schema = self.relation_replacement_schema(&name)?;
302 require_positive_metadata_arity(&name, schema)?;
303 let arguments = self.program.argument_schema(&name).ok_or_else(|| {
304 PyRuntimeError::new_err(format!(
305 "Relation '{name}' compiled argument contract is unavailable"
306 ))
307 })?;
308 let buffer = self.detached_relation_replacement_buffer(&name, schema, dlpack_columns)?;
309 let row_count = self
310 .provider
311 .validated_logical_row_count(&buffer)
312 .map_err(types::xlog_err)?;
313 let (prospective_metadata, snapshot) =
314 self.relation_metadata
315 .prepare_replacement(RelationReplacementRequest {
316 relation: &name,
317 arguments: &arguments,
318 schema,
319 provider: &self.provider,
320 relation_buffer: &buffer,
321 roles,
322 facts,
323 row_count,
324 })?;
325 let packed_snapshot = snapshot.pack(py)?;
326 self.commit_relation_replacement(
327 name,
328 buffer,
329 RelationReplacementMetadata::Replace(prospective_metadata),
330 )?;
331 Ok(packed_snapshot)
332 }
333
334 pub fn put_relation_from_manifest(
335 &mut self,
336 py: Python<'_>,
337 name: String,
338 dlpack_columns: &Bound<'_, PyAny>,
339 manifest: &Bound<'_, PyAny>,
340 ) -> PyResult<Py<PyAny>> {
341 let schema = self.relation_replacement_schema(&name)?.clone();
342 require_positive_metadata_arity(&name, &schema)?;
343 let arguments = self.program.argument_schema(&name).ok_or_else(|| {
344 PyRuntimeError::new_err(format!(
345 "Relation '{name}' compiled argument contract is unavailable"
346 ))
347 })?;
348 let prepared_manifest = self
349 .relation_metadata
350 .parse_manifest(&name, &arguments, &schema, manifest)?;
351 let buffer = self.detached_relation_replacement_buffer(&name, &schema, dlpack_columns)?;
352 let row_count = self
353 .provider
354 .validated_logical_row_count(&buffer)
355 .map_err(types::xlog_err)?;
356 let (prospective_metadata, snapshot) =
357 self.relation_metadata.prepare_manifest_replacement(
358 &name,
359 &schema,
360 &self.provider,
361 &buffer,
362 row_count,
363 prepared_manifest,
364 )?;
365 let packed_snapshot = snapshot.pack(py)?;
366 let metadata = prospective_metadata.map_or(
367 RelationReplacementMetadata::Clear,
368 RelationReplacementMetadata::Replace,
369 );
370 self.commit_relation_replacement(name, buffer, metadata)?;
371 Ok(packed_snapshot)
372 }
373
374 pub fn relation(&self, name: &str) -> PyResult<RelationEvidence> {
375 let buffer = self
376 .relation_store
377 .get(name)
378 .ok_or_else(|| PyKeyError::new_err(format!("Relation '{name}' is not stored")))?;
379 Ok(RelationEvidence::new(
380 self.snapshot_stored_relation(name, buffer)?,
381 ))
382 }
383
384 #[pyo3(signature = (name=None))]
385 pub fn evidence(&self, py: Python<'_>, name: Option<&str>) -> PyResult<Py<PyAny>> {
386 if let Some(name) = name {
387 if !self.relation_store.contains(name) {
388 return Err(PyKeyError::new_err(format!(
389 "Relation '{name}' is not stored"
390 )));
391 }
392 }
393 let mut relation_names = self
394 .relation_store
395 .names()
396 .map(str::to_string)
397 .collect::<Vec<_>>();
398 relation_names.sort();
399 let mut snapshots = Vec::with_capacity(relation_names.len());
400 for relation_name in relation_names {
401 let buffer = self.relation_store.get(&relation_name).ok_or_else(|| {
402 PyRuntimeError::new_err(format!(
403 "Relation '{relation_name}' disappeared while preparing evidence"
404 ))
405 })?;
406 snapshots.push(self.snapshot_stored_relation(&relation_name, buffer)?);
407 }
408 pack_session_evidence(py, snapshots, name)
409 }
410
411 #[pyo3(signature = (memory_mb=None))]
412 pub fn evaluate(
413 &mut self,
414 py: Python<'_>,
415 memory_mb: Option<u64>,
416 ) -> PyResult<LogicEvalResult> {
417 enforce_call_memory_limit(&self.provider, memory_mb)?;
418 let result = if let Some(store) = &self.evaluation_store {
419 let program = self.program.clone();
420 let provider = self.provider.clone();
421 py.detach(move || program.evaluate_cached_relation_store(provider, store))
422 .map_err(types::xlog_err)?
423 } else {
424 if self.session_runtime.is_none() {
425 let program = self.program.clone();
426 let provider = self.provider.clone();
427 let relation_store = &self.relation_store;
428 self.session_runtime = Some(
429 py.detach(move || {
430 program.create_session_runtime(provider, relation_store, false)
431 })
432 .map_err(types::xlog_err)?,
433 );
434 }
435 let runtime = self.session_runtime.as_mut().ok_or_else(|| {
436 PyRuntimeError::new_err("session runtime unavailable during evaluation")
437 })?;
438 let program = self.program.clone();
439 let provider = self.provider.clone();
440 let (result, store) = py
441 .detach(move || program.evaluate_with_session_runtime(provider, runtime))
442 .map_err(types::xlog_err)?;
443 self.evaluation_store = Some(store);
444 result
445 };
446 pack_logic_result_with_provider(py, &self.provider, result)
447 }
448
449 #[pyo3(signature = (name, dlpack_columns, *, facts=None))]
450 pub fn insert_relation(
451 &mut self,
452 py: Python<'_>,
453 name: String,
454 dlpack_columns: &Bound<'_, PyAny>,
455 facts: Option<&Bound<'_, PyAny>>,
456 ) -> PyResult<Py<PyAny>> {
457 self.require_insert_metadata_arity(&name, facts)?;
458 let insert = self.relation_delta_buffer(&name, dlpack_columns)?;
459 let insert_evidence = self.prepare_insert_evidence(&name, &insert, facts)?;
460 self.apply_single_relation_delta(py, name, Some(insert), None, insert_evidence)
461 }
462
463 pub fn delete_relation(
464 &mut self,
465 py: Python<'_>,
466 name: String,
467 dlpack_columns: &Bound<'_, PyAny>,
468 ) -> PyResult<Py<PyAny>> {
469 let delete = self.relation_delta_buffer(&name, dlpack_columns)?;
470 self.apply_single_relation_delta(py, name, None, Some(delete), None)
471 }
472
473 #[pyo3(signature = (name, insert_columns=None, delete_columns=None, *, insert_facts=None))]
474 pub fn apply_relation_delta(
475 &mut self,
476 py: Python<'_>,
477 name: String,
478 insert_columns: Option<&Bound<'_, PyAny>>,
479 delete_columns: Option<&Bound<'_, PyAny>>,
480 insert_facts: Option<&Bound<'_, PyAny>>,
481 ) -> PyResult<Py<PyAny>> {
482 if insert_facts.is_some() && insert_columns.is_none() {
483 return Err(metadata_error(
484 "apply_relation_delta insert_facts requires insert_columns".to_string(),
485 ));
486 }
487 if insert_columns.is_none() && delete_columns.is_none() {
488 return Err(PyValueError::new_err(
489 "apply_relation_delta requires insert_columns, delete_columns, or both",
490 ));
491 }
492 self.require_insert_metadata_arity(&name, insert_facts)?;
493 let insert = insert_columns
494 .map(|columns| self.relation_delta_buffer(&name, columns))
495 .transpose()?;
496 let delete = delete_columns
497 .map(|columns| self.relation_delta_buffer(&name, columns))
498 .transpose()?;
499 let insert_evidence = match insert.as_ref() {
500 Some(insert) => self.prepare_insert_evidence(&name, insert, insert_facts)?,
501 None => None,
502 };
503 self.apply_single_relation_delta(py, name, insert, delete, insert_evidence)
504 }
505
506 pub fn apply_relation_delta_batch(
507 &mut self,
508 py: Python<'_>,
509 updates: &Bound<'_, PyAny>,
510 ) -> PyResult<Py<PyAny>> {
511 let parsed = self.parse_relation_delta_batch("apply_relation_delta_batch", updates)?;
512 let (batch, metadata_updates, relation_names, schemas, cancellation_capture_relations) =
513 split_parsed_relation_updates(&self.program, parsed)?;
514 let prepared_batch = self
515 .program
516 .prepare_relation_delta_batch(
517 self.provider.as_ref(),
518 batch,
519 &cancellation_capture_relations,
520 )
521 .map_err(types::xlog_err)?;
522 let metadata_transition = self.relation_metadata.prepare_batch_transition(
523 &self.provider,
524 &schemas,
525 metadata_updates,
526 &prepared_batch,
527 )?;
528 let data_commit = self
529 .program
530 .prepare_relation_delta_commit_with_session_runtime(
531 self.provider.clone(),
532 &mut self.relation_store,
533 &mut self.evaluation_store,
534 &mut self.session_runtime,
535 prepared_batch,
536 )
537 .map_err(types::xlog_err)?;
538 let report = data_commit.commit();
539 metadata_transition.commit(&mut self.relation_metadata);
540 let stats = logic_delta_stats_from_report(report);
541 self.last_delta_stats = Some(stats.clone());
542 self.fire_relation_callbacks(py, &relation_names, &stats)?;
543 pack_delta_stats(py, &stats)
544 }
545
546 #[pyo3(signature = (updates, check_equivalence=false))]
547 pub fn apply_relation_delta_debug(
548 &mut self,
549 py: Python<'_>,
550 updates: &Bound<'_, PyAny>,
551 check_equivalence: bool,
552 ) -> PyResult<Py<PyAny>> {
553 let parsed = self.parse_relation_delta_batch("apply_relation_delta_debug", updates)?;
554 let (batch, metadata_updates, relation_names, schemas, cancellation_capture_relations) =
555 split_parsed_relation_updates(&self.program, parsed)?;
556 let had_derived_state = self.evaluation_store.is_some() || self.session_runtime.is_some();
557 let delta_start = Instant::now();
558 let prepared_batch = self
559 .program
560 .prepare_relation_delta_batch(
561 self.provider.as_ref(),
562 batch,
563 &cancellation_capture_relations,
564 )
565 .map_err(types::xlog_err)?;
566 let coalesced_no_op = prepared_batch.net_deltas().is_empty();
567 let metadata_transition = self.relation_metadata.prepare_batch_transition(
568 &self.provider,
569 &schemas,
570 metadata_updates,
571 &prepared_batch,
572 )?;
573 let delta_prepare_micros = delta_start.elapsed().as_micros() as u64;
574 let full_recompute = if check_equivalence {
575 let full_start = Instant::now();
576 let full_store = match (|| {
577 let prospective_base = self
578 .program
579 .clone_prospective_base_for_prepared_delta_batch(
580 &self.provider,
581 &self.relation_store,
582 &prepared_batch,
583 )?;
584 let (_, full_store) = self.program.evaluate_with_relation_store_and_cache(
585 self.provider.clone(),
586 &prospective_base,
587 false,
588 )?;
589 Ok::<_, xlog_core::XlogError>(full_store)
590 })() {
591 Ok(full_store) => full_store,
592 Err(error) => {
593 if let Err(reap_error) = self.provider.memory().reap_pending_deallocations() {
594 return Err(types::xlog_err(xlog_core::XlogError::Execution(
595 format!(
596 "full-recompute diagnostic failed: {error}; releasing its temporary GPU buffers also failed: {reap_error}"
597 ),
598 )));
599 }
600 return Err(types::xlog_err(error));
601 }
602 };
603 Some((full_store, full_start.elapsed().as_micros() as u64))
604 } else {
605 None
606 };
607 let delta_commit_start = Instant::now();
608 let data_commit = self
609 .program
610 .prepare_relation_delta_commit_with_session_runtime(
611 self.provider.clone(),
612 &mut self.relation_store,
613 &mut self.evaluation_store,
614 &mut self.session_runtime,
615 prepared_batch,
616 )
617 .map_err(types::xlog_err)?;
618 let delta_micros = delta_prepare_micros
619 .saturating_add(delta_commit_start.elapsed().as_micros() as u64)
620 .max(1);
621 let mut equivalent_to_full_recompute = None;
622 let mut measured_full_micros = None;
623 if let Some((full_store, full_micros)) = full_recompute {
624 let equivalent = if coalesced_no_op && !had_derived_state {
625 true
626 } else {
627 self.program
628 .relation_stores_query_equivalent(
629 self.provider.as_ref(),
630 full_store.as_relation_store(),
631 data_commit.prospective_derived_store(),
632 )
633 .map_err(types::xlog_err)?
634 };
635 equivalent_to_full_recompute = Some(equivalent);
636 measured_full_micros = Some(full_micros);
637 }
638 let report = data_commit.commit();
639 metadata_transition.commit(&mut self.relation_metadata);
640 let mut stats = logic_delta_stats_from_report(report);
641 stats.equivalent_to_full_recompute = equivalent_to_full_recompute;
642 if let Some(full_micros) = measured_full_micros {
643 let speedup = full_micros as f64 / delta_micros as f64;
644 stats.planner_telemetry.measured_delta_speedup = Some(speedup);
645 if speedup >= 1.0 {
646 stats
647 .planner_telemetry
648 .planner_advice
649 .push(format!("delta path is faster by {speedup:.2}x"));
650 } else {
651 stats.planner_telemetry.planner_advice.push(format!(
652 "full recompute may be faster; delta measured {speedup:.2}x"
653 ));
654 }
655 }
656 self.last_delta_stats = Some(stats.clone());
657 self.fire_relation_callbacks(py, &relation_names, &stats)?;
658 pack_delta_stats(py, &stats)
659 }
660
661 pub fn delta_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
662 match &self.last_delta_stats {
663 Some(stats) => pack_delta_stats(py, stats),
664 None => {
665 let dict = PyDict::new(py);
666 dict.set_item("status", "unavailable")?;
667 dict.set_item("reason", "no relation delta has been applied")?;
668 Ok(dict.into())
669 }
670 }
671 }
672
673 pub fn rule_provenance(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
674 pack_rule_provenance(py, &self.program.rule_provenance())
675 }
676
677 pub fn proof_traces(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
678 pack_query_proof_traces(py, &self.program.proof_traces())
679 }
680
681 pub fn register_relation_callback(
682 &mut self,
683 py: Python<'_>,
684 callback: Py<PyAny>,
685 ) -> PyResult<u64> {
686 if !callback.bind(py).is_callable() {
687 return Err(PyValueError::new_err(
688 "register_relation_callback expects a callable",
689 ));
690 }
691 let id = self.next_relation_callback_id;
692 self.next_relation_callback_id = self.next_relation_callback_id.saturating_add(1);
693 self.relation_callbacks
694 .push(RelationChangeCallback { id, callback });
695 Ok(id)
696 }
697
698 pub fn unregister_relation_callback(&mut self, callback_id: u64) -> bool {
699 let before = self.relation_callbacks.len();
700 self.relation_callbacks
701 .retain(|registered| registered.id != callback_id);
702 before != self.relation_callbacks.len()
703 }
704
705 pub fn cuda_graph_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
706 let dict = PyDict::new(py);
707 dict.set_item(
708 "csm_cuda_graph_captures",
709 self.provider.csm_cuda_graph_captures(),
710 )?;
711 dict.set_item(
712 "csm_cuda_graph_launches",
713 self.provider.csm_cuda_graph_launches(),
714 )?;
715 dict.set_item(
716 "csm_cuda_graph_fallbacks",
717 self.provider.csm_cuda_graph_fallbacks(),
718 )?;
719 dict.set_item(
720 "csm_cuda_graph_cache_hits",
721 self.provider.csm_cuda_graph_cache_hits(),
722 )?;
723 Ok(dict.into())
724 }
725
726 pub fn host_transfer_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
727 let stats = self.provider.host_transfer_stats();
728 let dict = PyDict::new(py);
729 dict.set_item("dtoh_bytes", stats.dtoh_bytes)?;
730 dict.set_item("htod_bytes", stats.htod_bytes)?;
731 dict.set_item("dtoh_calls", stats.dtoh_calls)?;
732 dict.set_item("htod_calls", stats.htod_calls)?;
733 Ok(dict.into())
734 }
735
736 pub fn join_index_cache_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
737 let dict = PyDict::new(py);
738 let stats = self
739 .session_runtime
740 .as_ref()
741 .map(|runtime| runtime.join_index_cache_stats())
742 .unwrap_or_default();
743 dict.set_item("lookups", stats.lookups)?;
744 dict.set_item("hits", stats.hits)?;
745 dict.set_item("misses", stats.misses)?;
746 dict.set_item("builds", stats.builds)?;
747 dict.set_item("evictions", stats.evictions)?;
748 dict.set_item("invalidations", stats.invalidations)?;
749 dict.set_item("stale_rejections", stats.stale_rejections)?;
750 dict.set_item("background_build_requests", stats.background_build_requests)?;
751 dict.set_item(
752 "background_builds_completed",
753 stats.background_builds_completed,
754 )?;
755 dict.set_item(
756 "background_builds_deferred",
757 stats.background_builds_deferred,
758 )?;
759 dict.set_item("entries", stats.entries)?;
760 dict.set_item("total_bytes", stats.total_bytes)?;
761 Ok(dict.into())
762 }
763
764 pub fn wcoj_dispatch_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
768 let dict = PyDict::new(py);
769 let stats = self
770 .session_runtime
771 .as_ref()
772 .map(|runtime| runtime.wcoj_dispatch_stats())
773 .unwrap_or_default();
774 dict.set_item("free_join_dispatch_count", stats.free_join_dispatch_count)?;
775 dict.set_item(
776 "factorized_delta_dispatch_count",
777 stats.factorized_delta_dispatch_count,
778 )?;
779 dict.set_item(
780 "wcoj_groupby_fusion_dispatch_count",
781 stats.wcoj_groupby_fusion_dispatch_count,
782 )?;
783 dict.set_item("wcoj_error_decline_count", stats.wcoj_error_decline_count)?;
784 let fallback = PyDict::new(py);
785 fallback.set_item("total", stats.wcoj_fallback.total())?;
786 fallback.set_item("chain", stats.wcoj_fallback.chain)?;
787 fallback.set_item("dedicated_multiway", stats.wcoj_fallback.dedicated_multiway)?;
788 fallback.set_item("free_join", stats.wcoj_fallback.free_join)?;
789 fallback.set_item("planned_hash", stats.wcoj_fallback.planned_hash)?;
790 fallback.set_item("factorized_delta", stats.wcoj_fallback.factorized_delta)?;
791 fallback.set_item("groupby_fusion", stats.wcoj_fallback.groupby_fusion)?;
792 dict.set_item("wcoj_fallback", fallback)?;
793 Ok(dict.into())
794 }
795
796 pub fn reset_host_transfer_stats(&self) {
797 self.provider.reset_host_transfer_stats()
798 }
799
800 pub fn set_strict_deterministic_d2h(&self, enabled: bool) {
801 if enabled {
802 self.provider.enable_strict_deterministic_d2h();
803 } else {
804 self.provider.disable_strict_deterministic_d2h();
805 }
806 }
807
808 pub fn strict_deterministic_d2h_enabled(&self) -> bool {
809 self.provider.strict_deterministic_d2h_enabled()
810 }
811
812 pub fn deterministic_d2h_violation_count(&self) -> u64 {
813 self.provider.deterministic_d2h_violation_count()
814 }
815
816 pub fn reset_deterministic_d2h_violations(&self) {
817 self.provider.reset_deterministic_d2h_violations();
818 }
819
820 pub fn memory_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
822 provider_memory_stats(py, &self.provider)
823 }
824
825 pub fn export_relation(&mut self, py: Python<'_>, name: &str) -> PyResult<Vec<Py<PyAny>>> {
826 let existing = self.relation_store.get(name).ok_or_else(|| {
827 PyValueError::new_err(format!(
828 "Relation '{}' not found in persistent session",
829 name
830 ))
831 })?;
832 let replacement = self
833 .provider
834 .clone_buffer(existing)
835 .map_err(types::xlog_err)?;
836 let stored = self.relation_store.get_mut(name).ok_or_else(|| {
837 PyRuntimeError::new_err(format!("Relation '{}' disappeared during export", name))
838 })?;
839 let buffer = std::mem::replace(stored, replacement);
840 export_buffer_columns(py, &self.provider, buffer)
841 }
842
843 pub fn export_relation_rows(&self, name: &str) -> PyResult<Vec<Vec<String>>> {
844 let buffer = self
845 .evaluation_store
846 .as_ref()
847 .and_then(|store| store.as_relation_store().get(name))
848 .or_else(|| self.relation_store.get(name))
849 .ok_or_else(|| {
850 PyValueError::new_err(format!(
851 "Relation '{name}' is unavailable for lexical export"
852 ))
853 })?;
854 lexical_relation_rows(&self.provider, buffer)
855 }
856
857 pub fn export_relation_with_provenance(
858 &mut self,
859 py: Python<'_>,
860 name: &str,
861 ) -> PyResult<Py<PyAny>> {
862 let schema = self.relation_replacement_schema(name)?;
863 require_positive_metadata_arity(name, schema)?;
864 let existing = self.relation_store.get(name).ok_or_else(|| {
865 PyValueError::new_err(format!(
866 "Relation '{}' not found in persistent session",
867 name
868 ))
869 })?;
870 let manifest = self
871 .snapshot_stored_relation(name, existing)?
872 .pack_manifest(py)?;
873 let columns = self.export_relation(py, name)?;
874 let exported = PyDict::new(py);
875 exported.set_item("columns", columns)?;
876 exported.set_item("manifest", manifest)?;
877 Ok(exported.into())
878 }
879
880 pub fn remove_relation(&mut self, name: &str) -> bool {
881 let removed = self.relation_store.remove(name).is_some();
882 if removed {
883 self.relation_metadata.clear_relation(name);
884 self.evaluation_store = None;
885 self.session_runtime = None;
886 self.last_delta_stats = None;
887 }
888 removed
889 }
890
891 pub fn clear_relations(&mut self) {
892 self.relation_store.clear();
893 self.relation_metadata.clear();
894 self.evaluation_store = None;
895 self.session_runtime = None;
896 self.last_delta_stats = None;
897 }
898}
899
900impl LogicRelationSession {
901 fn commit_relation_replacement(
902 &mut self,
903 name: String,
904 buffer: xlog_cuda::CudaBuffer,
905 metadata: RelationReplacementMetadata,
906 ) -> PyResult<()> {
907 let additional = usize::from(!self.relation_store.contains(&name));
908 self.relation_store
909 .try_reserve_relations(additional)
910 .map_err(types::xlog_err)?;
911 match metadata {
912 RelationReplacementMetadata::Clear => {
913 self.relation_metadata.clear_relation(&name);
914 self.relation_store.put_owned(name, buffer);
915 }
916 RelationReplacementMetadata::Replace(prospective) => {
917 self.relation_store.put_owned(name, buffer);
918 self.relation_metadata = prospective
919 }
920 }
921 self.evaluation_store = None;
922 self.session_runtime = None;
923 self.last_delta_stats = None;
924 Ok(())
925 }
926
927 fn snapshot_stored_relation(
928 &self,
929 name: &str,
930 buffer: &xlog_cuda::CudaBuffer,
931 ) -> PyResult<RelationSnapshot> {
932 let arguments = self.program.argument_schema(name).ok_or_else(|| {
933 PyRuntimeError::new_err(format!(
934 "Relation '{name}' compiled argument contract is unavailable"
935 ))
936 })?;
937 let schema_sha256 = relation_schema_fingerprint(name, &arguments)?;
938 let row_count = self
939 .provider
940 .validated_logical_row_count(buffer)
941 .map_err(types::xlog_err)?;
942 Ok(self
943 .relation_metadata
944 .snapshot(name, row_count, schema_sha256, arguments.len()))
945 }
946
947 fn relation_replacement_schema(&self, name: &str) -> PyResult<&xlog_core::Schema> {
948 if name.starts_with("__") {
949 return Err(PyValueError::new_err(format!(
950 "Relation {name} is internal and cannot be stored in a persistent session"
951 )));
952 }
953 self.program.schema(name).ok_or_else(|| {
954 PyValueError::new_err(format!(
955 "Unknown relation {name} (not present in compiled schemas)"
956 ))
957 })
958 }
959
960 fn relation_replacement_buffer(
961 &self,
962 name: &str,
963 schema: &xlog_core::Schema,
964 dlpack_columns: &Bound<'_, PyAny>,
965 ) -> PyResult<xlog_cuda::CudaBuffer> {
966 let tensors = collect_dlpack_columns(
967 dlpack_columns,
968 schema.arity(),
969 &format!("Relation {name} must be a sequence of DLPack columns"),
970 )?;
971 self.provider
972 .from_dlpack_tensors_with_schema(schema.clone(), tensors)
973 .map_err(types::xlog_err)
974 }
975
976 fn lexical_relation_buffer(
977 &self,
978 name: &str,
979 schema: &xlog_core::Schema,
980 rows: Vec<Vec<String>>,
981 ) -> PyResult<xlog_cuda::CudaBuffer> {
982 if rows.iter().any(|row| row.len() != schema.arity()) {
983 return Err(PyValueError::new_err(format!(
984 "Relation {name} lexical row arity differs from compiled arity {}",
985 schema.arity()
986 )));
987 }
988 if schema.arity() == 0 {
989 let row_count = u32::try_from(rows.len()).map_err(|_| {
990 PyValueError::new_err(format!("Relation {name} row count exceeds u32::MAX"))
991 })?;
992 return self
993 .provider
994 .create_zero_arity_buffer(schema.clone(), row_count)
995 .map_err(types::xlog_err);
996 }
997 let mut columns = schema
998 .columns
999 .iter()
1000 .map(|_| Vec::<u8>::new())
1001 .collect::<Vec<_>>();
1002 for (row_index, row) in rows.iter().enumerate() {
1003 for (column_index, value) in row.iter().enumerate() {
1004 let scalar_type = schema.column_type(column_index).ok_or_else(|| {
1005 PyRuntimeError::new_err(format!(
1006 "Relation {name} column {column_index} schema disappeared"
1007 ))
1008 })?;
1009 append_lexical_scalar(
1010 &mut columns[column_index],
1011 value,
1012 scalar_type,
1013 name,
1014 row_index,
1015 column_index,
1016 )?;
1017 }
1018 }
1019 let slices = columns.iter().map(Vec::as_slice).collect::<Vec<_>>();
1020 self.provider
1021 .create_buffer_from_slices(&slices, schema.clone())
1022 .map_err(types::xlog_err)
1023 }
1024
1025 fn detached_relation_replacement_buffer(
1026 &self,
1027 name: &str,
1028 schema: &xlog_core::Schema,
1029 dlpack_columns: &Bound<'_, PyAny>,
1030 ) -> PyResult<xlog_cuda::CudaBuffer> {
1031 let imported = self.relation_replacement_buffer(name, schema, dlpack_columns)?;
1035 self.provider
1036 .clone_buffer(&imported)
1037 .map_err(types::xlog_err)
1038 }
1039
1040 fn parse_relation_delta_batch(
1041 &self,
1042 method_name: &str,
1043 updates: &Bound<'_, PyAny>,
1044 ) -> PyResult<Vec<ParsedRelationDeltaUpdate>> {
1045 let seq = updates.cast::<PySequence>().map_err(|_| {
1046 PyValueError::new_err(format!(
1047 "{method_name} expects a sequence of update dictionaries"
1048 ))
1049 })?;
1050 let mut parsed = Vec::new();
1051 for (update_index, item) in seq.try_iter()?.enumerate() {
1052 let item = item?;
1053 let dict = item.cast::<PyDict>().map_err(|_| {
1054 PyValueError::new_err(format!("{method_name} updates must be dictionaries"))
1055 })?;
1056 reject_unknown_delta_update_keys(dict, method_name, update_index)?;
1057 let name_obj = dict.get_item("name")?.ok_or_else(|| {
1058 PyValueError::new_err(format!("{method_name} update missing 'name'"))
1059 })?;
1060 let name: String = name_obj.extract()?;
1061 let insert_columns = optional_delta_columns(dict, "insert_columns");
1062 let delete_columns = optional_delta_columns(dict, "delete_columns");
1063 let insert_facts = dict
1064 .get_item("insert_facts")?
1065 .filter(|value| !value.is_none());
1066 if insert_facts.is_some() && insert_columns.is_none() {
1067 return Err(metadata_error(format!(
1068 "{method_name} update {update_index} insert_facts requires insert_columns"
1069 )));
1070 }
1071 if insert_columns.is_none() && delete_columns.is_none() {
1072 return Err(PyValueError::new_err(format!(
1073 "{method_name} updates require insert_columns, delete_columns, or both"
1074 )));
1075 }
1076 self.require_insert_metadata_arity(&name, insert_facts.as_ref())?;
1077 let insert = insert_columns
1078 .map(|columns| self.relation_delta_buffer(&name, &columns))
1079 .transpose()?;
1080 let delete = delete_columns
1081 .map(|columns| self.relation_delta_buffer(&name, &columns))
1082 .transpose()?;
1083 let insert_evidence = match insert.as_ref() {
1084 Some(insert) => {
1085 self.prepare_insert_evidence(&name, insert, insert_facts.as_ref())?
1086 }
1087 None => None,
1088 };
1089 parsed.push(ParsedRelationDeltaUpdate {
1090 name,
1091 delta: RelationDelta::new(insert, delete),
1092 insert_evidence,
1093 });
1094 }
1095 Ok(parsed)
1096 }
1097
1098 fn require_insert_metadata_arity(
1099 &self,
1100 name: &str,
1101 facts: Option<&Bound<'_, PyAny>>,
1102 ) -> PyResult<()> {
1103 if facts.is_none() {
1104 return Ok(());
1105 }
1106 require_positive_metadata_arity(name, self.relation_delta_schema(name)?)
1107 }
1108
1109 fn prepare_insert_evidence(
1110 &self,
1111 name: &str,
1112 insert: &xlog_cuda::CudaBuffer,
1113 facts: Option<&Bound<'_, PyAny>>,
1114 ) -> PyResult<Option<PreparedInsertEvidence>> {
1115 let Some(facts) = facts else {
1116 return Ok(None);
1117 };
1118 let schema = self.program.schema(name).ok_or_else(|| {
1119 PyValueError::new_err(format!(
1120 "Unknown relation {name} (not present in compiled schemas)"
1121 ))
1122 })?;
1123 let arguments = self.program.argument_schema(name).ok_or_else(|| {
1124 PyRuntimeError::new_err(format!(
1125 "Relation '{name}' compiled argument contract is unavailable"
1126 ))
1127 })?;
1128 self.relation_metadata
1129 .prepare_insert_evidence(name, &arguments, schema, &self.provider, insert, facts)
1130 .map(Some)
1131 }
1132
1133 fn relation_delta_buffer(
1134 &self,
1135 name: &str,
1136 dlpack_columns: &Bound<'_, PyAny>,
1137 ) -> PyResult<xlog_cuda::CudaBuffer> {
1138 let schema = self.relation_delta_schema(name)?;
1139 let tensors = collect_dlpack_columns(
1140 dlpack_columns,
1141 schema.arity(),
1142 &format!(
1143 "Relation {} delta must be a sequence of DLPack columns",
1144 name
1145 ),
1146 )?;
1147 self.provider
1148 .from_dlpack_tensors_with_schema(schema.clone(), tensors)
1149 .map_err(types::xlog_err)
1150 }
1151
1152 fn relation_delta_schema(&self, name: &str) -> PyResult<&xlog_core::Schema> {
1153 if name.starts_with("__") {
1154 return Err(PyValueError::new_err(format!(
1155 "Relation {} is internal and cannot be updated in a persistent session",
1156 name
1157 )));
1158 }
1159 let schema = self.program.schema(name).ok_or_else(|| {
1160 PyValueError::new_err(format!(
1161 "Unknown relation {} (not present in compiled schemas)",
1162 name
1163 ))
1164 })?;
1165 Ok(schema)
1166 }
1167
1168 fn apply_single_relation_delta(
1169 &mut self,
1170 py: Python<'_>,
1171 name: String,
1172 insert: Option<xlog_cuda::CudaBuffer>,
1173 delete: Option<xlog_cuda::CudaBuffer>,
1174 insert_evidence: Option<PreparedInsertEvidence>,
1175 ) -> PyResult<Py<PyAny>> {
1176 let relation_names = vec![name.clone()];
1177 let schema = self.program.schema(&name).ok_or_else(|| {
1178 PyValueError::new_err(format!(
1179 "Unknown relation {name} (not present in compiled schemas)"
1180 ))
1181 })?;
1182 let metadata_transition = self.relation_metadata.prepare_delta_transition(
1183 &name,
1184 schema,
1185 &self.provider,
1186 delete.as_ref(),
1187 insert_evidence,
1188 )?;
1189 let mut deltas = HashMap::new();
1190 deltas.insert(name, RelationDelta::new(insert, delete));
1191 let data_commit = self
1192 .program
1193 .prepare_relation_deltas_commit_with_session_runtime(
1194 self.provider.clone(),
1195 &mut self.relation_store,
1196 &mut self.evaluation_store,
1197 &mut self.session_runtime,
1198 deltas,
1199 )
1200 .map_err(types::xlog_err)?;
1201 let report = data_commit.commit();
1202 metadata_transition.commit(&mut self.relation_metadata);
1203 let stats = logic_delta_stats_from_report(report);
1204 self.last_delta_stats = Some(stats.clone());
1205 self.fire_relation_callbacks(py, &relation_names, &stats)?;
1206 pack_delta_stats(py, &stats)
1207 }
1208
1209 fn fire_relation_callbacks(
1210 &mut self,
1211 py: Python<'_>,
1212 relation_names: &[String],
1213 stats: &LogicDeltaStats,
1214 ) -> PyResult<()> {
1215 if self.relation_callbacks.is_empty() || stats.changed_relations == 0 {
1216 return Ok(());
1217 }
1218
1219 let effective_relations = stats
1220 .changed_relation_names
1221 .iter()
1222 .map(String::as_str)
1223 .collect::<HashSet<_>>();
1224 let mut seen = HashSet::new();
1225 let mut events: Vec<(String, u64)> = Vec::new();
1226 for relation in relation_names {
1227 if effective_relations.contains(relation.as_str()) && seen.insert(relation.clone()) {
1228 let generation = self
1229 .relation_generations
1230 .entry(relation.clone())
1231 .and_modify(|current| *current = current.saturating_add(1))
1232 .or_insert(1);
1233 events.push((relation.clone(), *generation));
1234 }
1235 }
1236
1237 for (relation, generation) in events {
1238 let payload = relation_callback_payload(py, &relation, generation, stats)?;
1239 for registered in &self.relation_callbacks {
1240 registered.callback.call1(py, (payload.clone_ref(py),))?;
1241 }
1242 }
1243
1244 Ok(())
1245 }
1246}
1247
1248fn relation_callback_payload(
1249 py: Python<'_>,
1250 relation: &str,
1251 generation: u64,
1252 stats: &LogicDeltaStats,
1253) -> PyResult<Py<PyAny>> {
1254 let dict = PyDict::new(py);
1255 dict.set_item("relation", relation)?;
1256 dict.set_item("generation", generation)?;
1257 dict.set_item("input_delta_count", stats.input_delta_count)?;
1258 dict.set_item(
1259 "changed_relation_names",
1260 stats.changed_relation_names.clone(),
1261 )?;
1262 dict.set_item("insert_rows", stats.insert_rows)?;
1263 dict.set_item("delete_rows", stats.delete_rows)?;
1264 dict.set_item("has_deletes", stats.has_deletes)?;
1265 dict.set_item("coalesced_insert_rows", stats.coalesced_insert_rows)?;
1266 dict.set_item("coalesced_delete_rows", stats.coalesced_delete_rows)?;
1267 dict.set_item("canceled_rows", stats.canceled_rows)?;
1268 dict.set_item("affected_sccs", stats.affected_sccs)?;
1269 dict.set_item("recomputed_sccs", stats.recomputed_sccs)?;
1270 dict.set_item("incremental_sccs", stats.incremental_sccs)?;
1271 dict.set_item("debug_trace", stats.debug_trace.clone())?;
1272 dict.set_item("telemetry", pack_delta_stats(py, stats)?)?;
1273 Ok(dict.into())
1274}
1275
1276fn optional_delta_columns<'py>(dict: &Bound<'py, PyDict>, key: &str) -> Option<Bound<'py, PyAny>> {
1277 match dict.get_item(key) {
1278 Ok(Some(value)) if !value.is_none() => Some(value),
1279 _ => None,
1280 }
1281}
1282
1283fn reject_unknown_delta_update_keys(
1284 dict: &Bound<'_, PyDict>,
1285 method_name: &str,
1286 update_index: usize,
1287) -> PyResult<()> {
1288 for key in dict.keys().iter() {
1289 let key = key.extract::<String>().map_err(|_| {
1290 PyValueError::new_err(format!(
1291 "{method_name} update {update_index} keys must be strings"
1292 ))
1293 })?;
1294 if !matches!(
1295 key.as_str(),
1296 "name" | "insert_columns" | "delete_columns" | "insert_facts"
1297 ) {
1298 return Err(PyValueError::new_err(format!(
1299 "{method_name} update {update_index} has unknown key '{key}'"
1300 )));
1301 }
1302 }
1303 Ok(())
1304}
1305
1306fn split_parsed_relation_updates(
1307 program: &gpu_logic::LogicProgram,
1308 parsed: Vec<ParsedRelationDeltaUpdate>,
1309) -> PyResult<ParsedRelationUpdateParts> {
1310 let mut batch = Vec::with_capacity(parsed.len());
1311 let mut metadata_updates = Vec::with_capacity(parsed.len());
1312 let mut relation_names = Vec::with_capacity(parsed.len());
1313 let mut schemas = BTreeMap::new();
1314 let mut cancellation_capture_relations = BTreeSet::new();
1315
1316 for update in parsed {
1317 if update
1318 .insert_evidence
1319 .as_ref()
1320 .is_some_and(PreparedInsertEvidence::has_fact_keys)
1321 {
1322 cancellation_capture_relations.insert(update.name.clone());
1323 }
1324 let schema = program.schema(&update.name).ok_or_else(|| {
1325 PyRuntimeError::new_err(format!(
1326 "Relation '{}' schema disappeared while preparing its delta batch",
1327 update.name
1328 ))
1329 })?;
1330 schemas
1331 .entry(update.name.clone())
1332 .or_insert_with(|| schema.clone());
1333 relation_names.push(update.name.clone());
1334 metadata_updates.push(PreparedRelationMetadataUpdate::new(
1335 update.name.clone(),
1336 update.insert_evidence,
1337 ));
1338 batch.push((update.name, update.delta));
1339 }
1340
1341 Ok((
1342 batch,
1343 metadata_updates,
1344 relation_names,
1345 schemas,
1346 cancellation_capture_relations,
1347 ))
1348}
1349
1350fn logic_delta_stats_from_report(report: gpu_logic::LogicDeltaReport) -> LogicDeltaStats {
1351 LogicDeltaStats {
1352 input_delta_count: report.input_delta_count,
1353 changed_relations: report.changed_relations,
1354 changed_relation_names: report.changed_relation_names,
1355 insert_rows: report.insert_rows,
1356 delete_rows: report.delete_rows,
1357 has_deletes: report.has_deletes,
1358 affected_sccs: report.affected_sccs,
1359 recomputed_sccs: report.recomputed_sccs,
1360 incremental_sccs: report.incremental_sccs,
1361 coalesced_insert_rows: report.coalesced_insert_rows,
1362 coalesced_delete_rows: report.coalesced_delete_rows,
1363 canceled_rows: report.canceled_rows,
1364 equivalent_to_full_recompute: None,
1365 planner_telemetry: report.planner_telemetry,
1366 debug_trace: report.debug_trace,
1367 }
1368}
1369
1370fn pack_delta_stats(py: Python<'_>, stats: &LogicDeltaStats) -> PyResult<Py<PyAny>> {
1371 let dict = PyDict::new(py);
1372 dict.set_item("status", "ok")?;
1373 dict.set_item("input_delta_count", stats.input_delta_count)?;
1374 dict.set_item("changed_relations", stats.changed_relations)?;
1375 dict.set_item(
1376 "changed_relation_names",
1377 stats.changed_relation_names.clone(),
1378 )?;
1379 dict.set_item("insert_rows", stats.insert_rows)?;
1380 dict.set_item("delete_rows", stats.delete_rows)?;
1381 dict.set_item("has_deletes", stats.has_deletes)?;
1382 dict.set_item("affected_sccs", stats.affected_sccs)?;
1383 dict.set_item("recomputed_sccs", stats.recomputed_sccs)?;
1384 dict.set_item("incremental_sccs", stats.incremental_sccs)?;
1385 dict.set_item("coalesced_insert_rows", stats.coalesced_insert_rows)?;
1386 dict.set_item("coalesced_delete_rows", stats.coalesced_delete_rows)?;
1387 dict.set_item("canceled_rows", stats.canceled_rows)?;
1388 dict.set_item(
1389 "equivalent_to_full_recompute",
1390 stats.equivalent_to_full_recompute,
1391 )?;
1392 dict.set_item(
1393 "planner_telemetry",
1394 pack_delta_planner_telemetry(py, &stats.planner_telemetry)?,
1395 )?;
1396 dict.set_item("debug_trace", stats.debug_trace.clone())?;
1397 Ok(dict.into())
1398}
1399
1400fn pack_delta_planner_telemetry(
1401 py: Python<'_>,
1402 telemetry: &gpu_logic::DeltaPlannerTelemetry,
1403) -> PyResult<Py<PyAny>> {
1404 let dict = PyDict::new(py);
1405 dict.set_item("cache_reused", telemetry.cache_reused)?;
1406 dict.set_item("fallback_decision", telemetry.fallback_decision.clone())?;
1407 dict.set_item("affected_sccs", telemetry.affected_sccs)?;
1408 dict.set_item("recomputed_sccs", telemetry.recomputed_sccs)?;
1409 dict.set_item("incremental_sccs", telemetry.incremental_sccs)?;
1410 dict.set_item("estimated_delta_speedup", telemetry.estimated_delta_speedup)?;
1411 dict.set_item("measured_delta_speedup", telemetry.measured_delta_speedup)?;
1412 dict.set_item("planner_advice", telemetry.planner_advice.clone())?;
1413 Ok(dict.into())
1414}
1415
1416fn collect_dlpack_columns(
1417 obj: &Bound<'_, PyAny>,
1418 expected_arity: usize,
1419 type_error_message: &str,
1420) -> PyResult<Vec<DlpackManagedTensor>> {
1421 let seq = obj
1422 .cast::<PySequence>()
1423 .map_err(|_| PyValueError::new_err(type_error_message.to_string()))?;
1424
1425 let mut iterator = seq.try_iter()?;
1426 let mut items = Vec::with_capacity(expected_arity);
1427 for column in 0..expected_arity {
1428 let item = iterator.next().transpose()?.ok_or_else(|| {
1429 PyRuntimeError::new_err(format!(
1430 "Schema arity {expected_arity} does not match tensor count {column}"
1431 ))
1432 })?;
1433 items.push(item);
1434 }
1435 if iterator.next().transpose()?.is_some() {
1436 return Err(PyRuntimeError::new_err(format!(
1437 "Schema arity {expected_arity} does not match tensor count greater than {expected_arity}"
1438 )));
1439 }
1440 items.iter().map(dlpack_from_py).collect()
1441}
1442
1443fn export_buffer_columns(
1444 py: Python<'_>,
1445 provider: &Arc<xlog_cuda::CudaKernelProvider>,
1446 buffer: xlog_cuda::CudaBuffer,
1447) -> PyResult<Vec<Py<PyAny>>> {
1448 let arity = buffer.arity();
1449 let table = provider.to_dlpack_table(buffer);
1450 let mut tensors: Vec<Py<PyAny>> = Vec::with_capacity(arity);
1451 for col_idx in 0..arity {
1452 let tensor = table.column(col_idx).map_err(types::xlog_err)?;
1453 tensors.push(dlpack_capsule_from_tensor(py, tensor)?);
1454 }
1455 Ok(tensors)
1456}
1457
1458fn lexical_parse_error(
1459 relation: &str,
1460 row_index: usize,
1461 column_index: usize,
1462 scalar_type: ScalarType,
1463 value: &str,
1464) -> PyErr {
1465 PyValueError::new_err(format!(
1466 "Relation {relation} row {row_index} column {column_index} value {value:?} is not valid {scalar_type:?}"
1467 ))
1468}
1469
1470fn append_lexical_scalar(
1471 output: &mut Vec<u8>,
1472 value: &str,
1473 scalar_type: ScalarType,
1474 relation: &str,
1475 row_index: usize,
1476 column_index: usize,
1477) -> PyResult<()> {
1478 macro_rules! parse_scalar {
1479 ($target:ty) => {{
1480 let parsed = value.parse::<$target>().map_err(|_| {
1481 lexical_parse_error(relation, row_index, column_index, scalar_type, value)
1482 })?;
1483 output.extend_from_slice(&parsed.to_le_bytes());
1484 }};
1485 }
1486 match scalar_type {
1487 ScalarType::U32 => parse_scalar!(u32),
1488 ScalarType::U64 => parse_scalar!(u64),
1489 ScalarType::I32 => parse_scalar!(i32),
1490 ScalarType::I64 => parse_scalar!(i64),
1491 ScalarType::F32 => {
1492 let parsed = value.parse::<f32>().map_err(|_| {
1493 lexical_parse_error(relation, row_index, column_index, scalar_type, value)
1494 })?;
1495 if !parsed.is_finite() {
1496 return Err(lexical_parse_error(
1497 relation,
1498 row_index,
1499 column_index,
1500 scalar_type,
1501 value,
1502 ));
1503 }
1504 output.extend_from_slice(&parsed.to_le_bytes());
1505 }
1506 ScalarType::F64 => {
1507 let parsed = value.parse::<f64>().map_err(|_| {
1508 lexical_parse_error(relation, row_index, column_index, scalar_type, value)
1509 })?;
1510 if !parsed.is_finite() {
1511 return Err(lexical_parse_error(
1512 relation,
1513 row_index,
1514 column_index,
1515 scalar_type,
1516 value,
1517 ));
1518 }
1519 output.extend_from_slice(&parsed.to_le_bytes());
1520 }
1521 ScalarType::Bool => match value {
1522 "true" => output.push(1),
1523 "false" => output.push(0),
1524 _ => {
1525 return Err(lexical_parse_error(
1526 relation,
1527 row_index,
1528 column_index,
1529 scalar_type,
1530 value,
1531 ))
1532 }
1533 },
1534 ScalarType::Symbol => output.extend_from_slice(&symbol::intern(value).to_le_bytes()),
1535 }
1536 Ok(())
1537}
1538
1539fn lexical_relation_rows(
1540 provider: &Arc<xlog_cuda::CudaKernelProvider>,
1541 buffer: &xlog_cuda::CudaBuffer,
1542) -> PyResult<Vec<Vec<String>>> {
1543 let row_count = provider
1544 .validated_logical_row_count(buffer)
1545 .map_err(types::xlog_err)?;
1546 let mut rows = vec![Vec::with_capacity(buffer.arity()); row_count];
1547 for column_index in 0..buffer.arity() {
1548 let scalar_type = buffer.schema().column_type(column_index).ok_or_else(|| {
1549 PyRuntimeError::new_err(format!(
1550 "Relation export column {column_index} schema disappeared"
1551 ))
1552 })?;
1553 let values: Vec<String> = match scalar_type {
1554 ScalarType::U32 => provider
1555 .download_column::<u32>(buffer, column_index)
1556 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1557 ScalarType::U64 => provider
1558 .download_column::<u64>(buffer, column_index)
1559 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1560 ScalarType::I32 => provider
1561 .download_column::<i32>(buffer, column_index)
1562 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1563 ScalarType::I64 => provider
1564 .download_column::<i64>(buffer, column_index)
1565 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1566 ScalarType::F32 => provider
1567 .download_column::<f32>(buffer, column_index)
1568 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1569 ScalarType::F64 => provider
1570 .download_column::<f64>(buffer, column_index)
1571 .map(|values| values.into_iter().map(|value| value.to_string()).collect()),
1572 ScalarType::Bool => {
1573 provider
1574 .download_column::<bool>(buffer, column_index)
1575 .map(|values| {
1576 values
1577 .into_iter()
1578 .map(|value| if value { "true" } else { "false" }.to_string())
1579 .collect()
1580 })
1581 }
1582 ScalarType::Symbol => provider
1583 .download_column::<u32>(buffer, column_index)
1584 .and_then(|values| {
1585 values
1586 .into_iter()
1587 .map(|value| {
1588 symbol::resolve_checked(value).ok_or_else(|| {
1589 xlog_core::XlogError::Execution(format!(
1590 "Relation export contains unknown symbol ID {value}"
1591 ))
1592 })
1593 })
1594 .collect()
1595 }),
1596 }
1597 .map_err(types::xlog_err)?;
1598 if values.len() != row_count {
1599 return Err(PyRuntimeError::new_err(format!(
1600 "Relation export column {column_index} row count changed"
1601 )));
1602 }
1603 for (row, value) in rows.iter_mut().zip(values) {
1604 row.push(value);
1605 }
1606 }
1607 Ok(rows)
1608}
1609
1610fn pack_logic_result_with_provider(
1611 py: Python<'_>,
1612 provider: &Arc<xlog_cuda::CudaKernelProvider>,
1613 result: gpu_logic::LogicEvalResult,
1614) -> PyResult<LogicEvalResult> {
1615 let mut queries: Vec<Py<LogicQueryResult>> = Vec::with_capacity(result.queries.len());
1616
1617 for q in result.queries {
1618 let num_rows = provider
1619 .validated_logical_row_count(&q.buffer)
1620 .map_err(types::xlog_err)?;
1621 let is_true = q.columns.is_empty() && num_rows > 0;
1622 let tensors = if q.columns.is_empty() {
1623 Vec::new()
1624 } else {
1625 export_buffer_columns(py, provider, q.buffer)?
1626 };
1627
1628 queries.push(Py::new(
1629 py,
1630 LogicQueryResult {
1631 relation_name: q.relation_name,
1632 columns: q.columns,
1633 sort_labels: q.sort_labels,
1634 tensors,
1635 num_rows,
1636 is_true,
1637 },
1638 )?);
1639 }
1640
1641 Ok(LogicEvalResult { queries })
1642}