Skip to main content

pyxlog/
logic.rs

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        // Parse the AST to get prob_engine and neural predicates
73        let ast = xlog_logic::parse_program(source).map_err(types::xlog_err)?;
74
75        // Extract declared neural network names
76        let declared_networks: HashSet<String> = ast
77            .neural_predicates
78            .iter()
79            .map(|np| np.network.clone())
80            .collect();
81        // Build by-network form index: network name -> is_embedding
82        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    /// Return memory diagnostics including allocated_bytes and memory_limit_bytes.
259    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    /// Multiway/Free-Join dispatch telemetry for the retained session
765    /// executor. Counters accumulate across evaluates within this session;
766    /// all zeros before the first evaluate.
767    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    /// Return memory diagnostics including allocated_bytes and memory_limit_bytes.
821    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        // Persistent sessions own a stable snapshot. Keeping a producer-backed
1032        // DLPack pointer here would let later producer writes bypass relation
1033        // versions and invalidate whole-fact evidence.
1034        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}