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