Skip to main content

pyxlog/
epistemic.rs

1// Epistemic evidence handoff: run a `know`/`possible` program on the GPU and
2// condition an exact probabilistic query on the accepted world view.
3//
4// The #[pyclass] struct definitions remain in lib.rs, matching program.rs.
5
6use std::collections::{BTreeMap, HashMap};
7#[cfg(feature = "host-io")]
8use std::sync::Arc;
9
10use pyo3::prelude::*;
11#[cfg(feature = "host-io")]
12use pyo3::types::PyDict;
13
14#[cfg(feature = "host-io")]
15use xlog_core::{ScalarType, Schema};
16#[cfg(feature = "host-io")]
17use xlog_cuda::{CudaKernelProvider, DlpackManagedTensor};
18#[cfg(feature = "host-io")]
19use xlog_prob::epistemic_production::{
20    EpistemicProbProductionAdapter, EpistemicProbProductionTrace,
21};
22#[cfg(feature = "host-io")]
23use xlog_prob::exact::{ExactResult, GpuConfig};
24
25#[cfg(feature = "host-io")]
26use super::program::atom_to_string;
27#[cfg(feature = "host-io")]
28use super::{dlpack_capsule_from_tensor, enforce_call_memory_limit};
29use super::{
30    types, CompiledConditionedProgram, CompiledLogicProgram, EpistemicEvalResult, EpistemicEvidence,
31};
32
33#[pymethods]
34impl CompiledLogicProgram {
35    /// Compile this program's accepted epistemic evidence and `prob_source` once.
36    ///
37    /// The returned handle can be evaluated repeatedly while independent
38    /// probabilistic fact priors are changed atomically. It does not accept
39    /// caller-supplied input relations, matching `evaluate_conditioned`.
40    #[cfg(feature = "host-io")]
41    #[pyo3(signature = (prob_source, memory_mb=None))]
42    pub fn prepare_conditioned(
43        &self,
44        py: Python<'_>,
45        prob_source: &str,
46        memory_mb: Option<u64>,
47    ) -> PyResult<CompiledConditionedProgram> {
48        enforce_call_memory_limit(&self.provider, memory_mb)?;
49        let logic_program = self.program.clone();
50        let evidence_provider = self.provider.clone();
51        let inputs = HashMap::new();
52        let prob_source = prob_source.to_owned();
53        let (program, result_provider) = py
54            .detach(move || {
55                let evidence =
56                    logic_program.execute_epistemic_evidence(evidence_provider.clone(), inputs)?;
57                let mut config = GpuConfig::default();
58                config.device_ordinal = evidence_provider.device().ordinal();
59                config.memory_bytes = evidence_provider.memory().budget().device_bytes;
60                let mut adapter = EpistemicProbProductionAdapter::new(config);
61                let program = adapter.prepare_conditioned_source_with_gpu_execution_result(
62                    &prob_source,
63                    &evidence_provider,
64                    &evidence,
65                    Vec::new(),
66                )?;
67                Ok::<_, xlog_core::XlogError>((program, evidence_provider))
68            })
69            .map_err(types::xlog_err)?;
70        Ok(CompiledConditionedProgram {
71            program,
72            result_provider,
73        })
74    }
75
76    #[cfg(not(feature = "host-io"))]
77    #[pyo3(signature = (prob_source, memory_mb=None))]
78    pub fn prepare_conditioned(
79        &self,
80        _py: Python<'_>,
81        prob_source: &str,
82        memory_mb: Option<u64>,
83    ) -> PyResult<CompiledConditionedProgram> {
84        let _ = (prob_source, memory_mb);
85        Err(types::host_io_disabled_pyerr())
86    }
87
88    /// Run this epistemic program on the GPU and condition `prob_source` on what it knows.
89    ///
90    /// The compiled program must contain epistemic operators (`know`, `possible`, ...)
91    /// AND lower to a single-component epistemic plan: ordinary Datalog programs are
92    /// rejected, because there is no accepted world view to condition on. Only facts
93    /// declared in the epistemic program's own source are used to build that world view.
94    ///
95    /// Both epistemic modes are reachable here. FAEEL programs and non-recursive
96    /// G91-compatibility programs (`#pragma epistemic_mode = g91`) both lower to a
97    /// single-component epistemic plan and condition normally;
98    /// `epistemic_evidence().epistemic_mode` names the mode, and the trace's
99    /// `accepted_faeel_world_view_evidence_consumed` /
100    /// `accepted_g91_world_view_evidence_consumed` pair says which one actually supplied
101    /// the evidence. Only the *recursive* G91 shapes (positive `possible` cycles that
102    /// need tuple-level compatibility) compile to a dedicated G91-compatibility plan and
103    /// are rejected at plan level, alongside split, stratified and WFS plans.
104    ///
105    /// LIMITATION: unlike `evaluate`, this method does not accept `dlpack_inputs`.
106    /// Caller-supplied input relations are NOT consulted — if the epistemic program
107    /// depends on a relation that is normally supplied at call time via
108    /// `evaluate(dlpack_inputs=...)`, that relation is empty here and no world view is
109    /// accepted. This method then RAISES `RuntimeError` ("Unsupported epistemic
110    /// construct: accepted GPU world-view evidence ... probabilistic evidence requires
111    /// non-empty accepted GPU final output"); it does NOT fall back to the unconditioned
112    /// prior. That is fail-closed by design: a conditioned query that silently became
113    /// unconditioned would be indistinguishable from a successful one, which is exactly
114    /// the failure the trace counters exist to make visible. To test for the case
115    /// without catching an exception, call `epistemic_evidence()` first — it reports
116    /// `accepted_world_views == 0` without raising.
117    ///
118    /// The returned trace must show a non-zero `gpu_conditioned_evidence_facts` —
119    /// otherwise the conditioning did not reach the GPU exact path.
120    /// `gpu_conditioned_evidence_facts` is the total the
121    /// engine itself validates; the per-class counters
122    /// (`gpu_conditioned_know_evidence_facts`,
123    /// `gpu_conditioned_possible_evidence_facts`,
124    /// `gpu_conditioned_not_known_evidence_facts`,
125    /// `gpu_conditioned_not_possible_evidence_facts`) break it down. A `possible`-only
126    /// or negated-evidence program conditions correctly with the `know` counter at `0`,
127    /// so check the total, not the `know` class alone.
128    #[cfg(feature = "host-io")]
129    #[pyo3(signature = (prob_source, memory_mb=None))]
130    pub fn evaluate_conditioned(
131        &self,
132        py: Python<'_>,
133        prob_source: &str,
134        memory_mb: Option<u64>,
135    ) -> PyResult<EpistemicEvalResult> {
136        enforce_call_memory_limit(&self.provider, memory_mb)?;
137        let program = self.program.clone();
138        let provider = self.provider.clone();
139        let inputs = HashMap::new();
140        let prob_source = prob_source.to_owned();
141
142        let prepared = py
143            .detach(move || {
144                let evidence = program.execute_epistemic_evidence(provider.clone(), inputs)?;
145
146                // IMPORTANT: not `GpuConfig::default()`. The adapter does not reuse our
147                // provider — `ExactDdnnfProgram::compile_provenance_with_gpu` builds its
148                // OWN device from `config.device_ordinal` and `config.memory_bytes`.
149                //
150                // `GpuConfig` is `#[non_exhaustive]`, so it cannot be built with a struct
151                // literal naming every field from outside `xlog-prob`; start from
152                // `Default::default()` and overwrite the two fields we need to match.
153                let mut config = GpuConfig::default();
154                config.device_ordinal = provider.device().ordinal();
155                config.memory_bytes = provider.memory().budget().device_bytes;
156                let mut adapter = EpistemicProbProductionAdapter::new(config);
157                let exact = adapter
158                    .compile_and_evaluate_conditioned_source_with_gpu_execution_result(
159                        &prob_source,
160                        &provider,
161                        &evidence,
162                        Vec::new(),
163                    )?;
164                let trace = adapter.trace();
165
166                prepare_epistemic_eval_result(&provider, exact, trace)
167            })
168            .map_err(types::xlog_err)?;
169
170        pack_epistemic_eval_result(py, prepared)
171    }
172
173    #[cfg(not(feature = "host-io"))]
174    #[pyo3(signature = (prob_source, memory_mb=None))]
175    pub fn evaluate_conditioned(
176        &self,
177        _py: Python<'_>,
178        prob_source: &str,
179        memory_mb: Option<u64>,
180    ) -> PyResult<EpistemicEvalResult> {
181        let _ = (prob_source, memory_mb);
182        Err(types::host_io_disabled_pyerr())
183    }
184
185    /// Run this epistemic program on the GPU and report what it accepted.
186    ///
187    /// Diagnostic counterpart of `evaluate_conditioned`: it answers whether the
188    /// `know`-broadcast happened at all, without involving the probabilistic tier.
189    ///
190    /// LIMITATION: like `evaluate_conditioned`, this only ever sees facts declared in
191    /// the epistemic program's own source; it does not accept caller-supplied input
192    /// relations. A program that depends on such a relation reports
193    /// `accepted_world_views == 0`, `accepted_candidates == 0` and
194    /// `final_output_rows == 0` here — without raising. The operator censuses
195    /// (`know_operator_count`, `possible_operator_count`) are read off the plan, not the
196    /// execution, so they stay non-zero: it is the accepted/consumed family that goes to
197    /// zero, not "every counter". Calling `evaluate_conditioned()` on that same program
198    /// RAISES rather than returning an unconditioned result, so this method is the
199    /// non-raising way to probe for the case first.
200    pub fn epistemic_evidence(&self, py: Python<'_>) -> PyResult<EpistemicEvidence> {
201        let program = self.program.clone();
202        let provider = self.provider.clone();
203        let inputs = HashMap::new();
204        let result = py
205            .detach(move || program.execute_epistemic_evidence(provider, inputs))
206            .map_err(types::xlog_err)?;
207        let epistemic_mode = match result.prepared.preflight.epistemic_mode {
208            xlog_ir::EirEpistemicMode::G91 => "g91",
209            xlog_ir::EirEpistemicMode::Faeel => "faeel",
210        }
211        .to_string();
212        Ok(EpistemicEvidence {
213            epistemic_mode,
214            know_operator_count: result.prepared.preflight.know_operator_count,
215            possible_operator_count: result.prepared.preflight.possible_operator_count,
216            accepted_candidates: result.semantic_trace.accepted_candidates,
217            rejected_candidates: result.semantic_trace.rejected_candidates,
218            accepted_world_views: result.semantic_trace.accepted_world_views,
219            final_output_rows: result.final_result_transfer.final_output_rows,
220        })
221    }
222}
223
224#[pymethods]
225impl CompiledConditionedProgram {
226    /// Evaluate the prepared conditioned circuit without compiling source or program.
227    #[cfg(feature = "host-io")]
228    pub fn evaluate(&self, py: Python<'_>) -> PyResult<EpistemicEvalResult> {
229        let program = self.program.clone();
230        let result_provider = self.result_provider.clone();
231        let prepared = py
232            .detach(move || {
233                let (result, trace) = program.evaluate()?;
234                prepare_epistemic_eval_result(&result_provider, result, trace)
235            })
236            .map_err(types::xlog_err)?;
237        pack_epistemic_eval_result(py, prepared)
238    }
239
240    #[cfg(not(feature = "host-io"))]
241    pub fn evaluate(&self, _py: Python<'_>) -> PyResult<EpistemicEvalResult> {
242        Err(types::host_io_disabled_pyerr())
243    }
244
245    /// Atomically replace independent probabilistic fact priors by CNF variable id.
246    #[cfg(feature = "host-io")]
247    pub fn set_fact_probabilities(
248        &self,
249        py: Python<'_>,
250        updates: BTreeMap<u32, f64>,
251    ) -> PyResult<()> {
252        let program = self.program.clone();
253        py.detach(move || program.set_fact_probabilities(&updates))
254            .map_err(types::xlog_err)
255    }
256
257    #[cfg(not(feature = "host-io"))]
258    pub fn set_fact_probabilities(
259        &self,
260        _py: Python<'_>,
261        updates: BTreeMap<u32, f64>,
262    ) -> PyResult<()> {
263        let _ = updates;
264        Err(types::host_io_disabled_pyerr())
265    }
266
267    /// Describe the current probability assigned to each CNF variable.
268    #[cfg(feature = "host-io")]
269    pub fn prob_var_map(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
270        use xlog_prob::exact::ProbVarInfo;
271
272        let program = self.program.clone();
273        let entries = py
274            .detach(move || program.prob_var_map())
275            .map_err(types::xlog_err)?;
276        let mut out = Vec::with_capacity(entries.len());
277        for entry in entries {
278            let dict = PyDict::new(py);
279            match entry {
280                ProbVarInfo::Fact { atom, prob } => {
281                    dict.set_item("kind", "fact")?;
282                    dict.set_item("atom", atom_to_string(&atom))?;
283                    dict.set_item("prob", prob)?;
284                }
285                ProbVarInfo::Choice {
286                    choices,
287                    choice_index,
288                    prob,
289                } => {
290                    dict.set_item("kind", "choice")?;
291                    dict.set_item(
292                        "atoms",
293                        choices
294                            .iter()
295                            .map(|(atom, _)| atom_to_string(atom))
296                            .collect::<Vec<_>>(),
297                    )?;
298                    dict.set_item(
299                        "probs",
300                        choices.iter().map(|(_, prob)| *prob).collect::<Vec<_>>(),
301                    )?;
302                    dict.set_item("choice_index", choice_index)?;
303                    dict.set_item("prob", prob)?;
304                }
305                ProbVarInfo::Other => dict.set_item("kind", "other")?,
306            }
307            out.push(dict.into());
308        }
309        Ok(out)
310    }
311
312    #[cfg(not(feature = "host-io"))]
313    pub fn prob_var_map(&self, _py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
314        Err(types::host_io_disabled_pyerr())
315    }
316}
317
318#[cfg(feature = "host-io")]
319struct PreparedEpistemicEvalResult {
320    atoms: Vec<String>,
321    prob_tensor: DlpackManagedTensor,
322    log_prob_tensor: DlpackManagedTensor,
323    log_z_e: f64,
324    trace: EpistemicProbProductionTrace,
325}
326
327#[cfg(feature = "host-io")]
328fn prepare_epistemic_eval_result(
329    provider: &Arc<CudaKernelProvider>,
330    result: ExactResult,
331    trace: EpistemicProbProductionTrace,
332) -> xlog_core::Result<PreparedEpistemicEvalResult> {
333    let mut atoms: Vec<String> = Vec::with_capacity(result.query_probs.len());
334    let mut probs: Vec<f64> = Vec::with_capacity(result.query_probs.len());
335    let mut log_probs: Vec<f64> = Vec::with_capacity(result.query_probs.len());
336
337    for q in result.query_probs {
338        atoms.push(atom_to_string(&q.atom));
339        probs.push(q.prob);
340        log_probs.push(q.log_prob);
341    }
342
343    let schema = Schema::new(vec![("col0".to_string(), ScalarType::F64)]);
344    let prob_buf = provider.create_buffer_from_slice::<f64>(&probs, schema.clone())?;
345    let log_prob_buf = provider.create_buffer_from_slice::<f64>(&log_probs, schema)?;
346    let prob_tensor = provider.to_dlpack_table(prob_buf).column(0)?;
347    let log_prob_tensor = provider.to_dlpack_table(log_prob_buf).column(0)?;
348
349    Ok(PreparedEpistemicEvalResult {
350        atoms,
351        prob_tensor,
352        log_prob_tensor,
353        log_z_e: result.log_z_e,
354        trace,
355    })
356}
357
358#[cfg(feature = "host-io")]
359fn pack_epistemic_eval_result(
360    py: Python<'_>,
361    prepared: PreparedEpistemicEvalResult,
362) -> PyResult<EpistemicEvalResult> {
363    let PreparedEpistemicEvalResult {
364        atoms,
365        prob_tensor,
366        log_prob_tensor,
367        log_z_e,
368        trace,
369    } = prepared;
370
371    let dict = PyDict::new(py);
372    dict.set_item(
373        "accepted_world_view_evidence_consumed",
374        trace.accepted_world_view_evidence_consumed,
375    )?;
376    dict.set_item(
377        "accepted_faeel_world_view_evidence_consumed",
378        trace.accepted_faeel_world_view_evidence_consumed,
379    )?;
380    // Both modes reach this surface: a non-recursive `#pragma epistemic_mode = g91`
381    // program conditions through the G91 counter with its FAEEL twin at 0. Exposing
382    // only one of the pair would leave the trace unable to prove which mode ran.
383    dict.set_item(
384        "accepted_g91_world_view_evidence_consumed",
385        trace.accepted_g91_world_view_evidence_consumed,
386    )?;
387    dict.set_item(
388        "accepted_evidence_assumptions_consumed",
389        trace.accepted_evidence_assumptions_consumed,
390    )?;
391    dict.set_item(
392        "gpu_conditioned_evidence_facts",
393        trace.gpu_conditioned_evidence_facts,
394    )?;
395    // The full evidence-class family. `gpu_conditioned_evidence_facts` above is the
396    // total the engine's own `require_conditioned_evidence_trace` validates; the four
397    // classes below decompose it. A `possible`-only or negated-evidence program
398    // conditions with `gpu_conditioned_know_evidence_facts == 0`, so a caller checking
399    // only the `know` class would misread a correct run as unconditioned.
400    dict.set_item(
401        "gpu_conditioned_know_evidence_facts",
402        trace.gpu_conditioned_know_evidence_facts,
403    )?;
404    dict.set_item(
405        "gpu_conditioned_possible_evidence_facts",
406        trace.gpu_conditioned_possible_evidence_facts,
407    )?;
408    dict.set_item(
409        "gpu_conditioned_not_known_evidence_facts",
410        trace.gpu_conditioned_not_known_evidence_facts,
411    )?;
412    dict.set_item(
413        "gpu_conditioned_not_possible_evidence_facts",
414        trace.gpu_conditioned_not_possible_evidence_facts,
415    )?;
416    dict.set_item(
417        "gpu_exact_query_evaluations",
418        trace.gpu_exact_query_evaluations,
419    )?;
420    dict.set_item("gpu_exact_source_compiles", trace.gpu_exact_source_compiles)?;
421    dict.set_item(
422        "gpu_exact_program_compiles",
423        trace.gpu_exact_program_compiles,
424    )?;
425    dict.set_item(
426        "gpu_conditioned_circuit_reuses",
427        trace.gpu_conditioned_circuit_reuses,
428    )?;
429    dict.set_item(
430        "gpu_conditioned_circuit_preparation_compiles",
431        trace.gpu_conditioned_circuit_preparation_compiles,
432    )?;
433    dict.set_item(
434        "gpu_conditioned_circuit_materializations",
435        trace.gpu_conditioned_circuit_materializations,
436    )?;
437    dict.set_item(
438        "gpu_conditioned_circuit_disk_cache_restores",
439        trace.gpu_conditioned_circuit_disk_cache_restores,
440    )?;
441    dict.set_item(
442        "gpu_conditioned_circuit_gpu_cache_hits",
443        trace.gpu_conditioned_circuit_gpu_cache_hits,
444    )?;
445    dict.set_item(
446        "gpu_conditioned_circuit_generation",
447        trace.gpu_conditioned_circuit_generation,
448    )?;
449    dict.set_item(
450        "gpu_conditioned_circuit_cache_slot",
451        trace.gpu_conditioned_circuit_cache_slot,
452    )?;
453    dict.set_item(
454        "gpu_knowledge_compilation_end_to_end_runs",
455        trace.gpu_knowledge_compilation_end_to_end_runs,
456    )?;
457    dict.set_item(
458        "accepted_gpu_production_path_events",
459        trace.accepted_gpu_production_path_events,
460    )?;
461
462    Ok(EpistemicEvalResult {
463        atoms,
464        prob: dlpack_capsule_from_tensor(py, prob_tensor)?,
465        log_prob: dlpack_capsule_from_tensor(py, log_prob_tensor)?,
466        log_z_e,
467        trace: dict.into(),
468    })
469}