Skip to main content

pyxlog/
program.rs

1// Remaining CompiledProgram methods and internal types.
2//
3// Contains: evaluate/evaluate_device, NLL loss helpers, training control
4// methods (zero_grad, optimizer_step, etc.), pack_result helpers, and the
5// internal types used by CompiledProgram's implementation (CachedCircuit,
6// QuerySignature, InputSource, NeuralGroup, CompiledProbProgram).
7//
8// The #[pyclass] struct definitions remain in lib.rs.
9
10use cudarc::driver::DeviceSlice;
11use pyo3::exceptions::{PyRuntimeError, PyValueError};
12use pyo3::prelude::*;
13use pyo3::types::PyDict;
14
15#[cfg(feature = "host-io")]
16use xlog_core::symbol;
17use xlog_core::{ScalarType, Schema};
18use xlog_logic::ast::Term;
19use xlog_prob::exact::ExactDdnnfProgram;
20#[cfg(feature = "host-io")]
21use xlog_prob::exact::{ExactResultWithGrads, QueryProbability};
22use xlog_prob::mc::{McEvalConfig, McProgram, McSamplingMethod};
23use xlog_prob::neural_fast_path::GpuWeightSlots;
24
25use super::neural_registry::NeuralPredicateInfo;
26use super::{
27    dlpack_capsule_from_tensor, enforce_call_memory_limit, pack_query_proof_traces,
28    pack_rule_provenance, provider_memory_stats, types, CompiledProgram, EpochStats, EvalResult,
29    McDeviceEvalResult, TrainingHistory,
30};
31
32// =========================================================================
33// Internal types
34// =========================================================================
35
36/// A cached circuit for a specific query template.
37///
38/// The circuit structure is immutable - only weights change between queries.
39/// Weight slots map network outputs to circuit variables.
40pub(crate) struct CachedCircuit {
41    /// The compiled program containing the GPU circuit
42    pub(crate) program: ExactDdnnfProgram,
43
44    /// Device-resident mapping from neural output slots to CNF variable ids.
45    pub(crate) slots: GpuWeightSlots,
46
47    /// Ordered target domain for Targeted signatures. Empty for Boolean.
48    pub(crate) target_domain: Vec<String>,
49}
50
51#[derive(Debug, Clone)]
52pub(crate) enum InputSource {
53    QueryArg(usize),
54    ImplicitSlot(usize),
55    /// Stage-B real-domain grounding: read row `usize` of the join-domain tensor
56    /// source (`nsr_domain`, the per-event feature batch). Used by the per-event
57    /// expansions of a neural predicate joined on an existential variable.
58    DomainRow(usize),
59    /// Stage-B real-domain grounding: an input-independent group (the rule-weight
60    /// guard expanded per head-key constant). The forward feeds a dummy row of the
61    /// declared input width; only the network's parameters (not the input) matter.
62    ConstDummy,
63}
64
65#[derive(Debug, Clone)]
66pub(crate) struct NeuralGroup {
67    pub(crate) info: NeuralPredicateInfo,
68    pub(crate) input_source: InputSource,
69    /// Stage-B real-domain grounding: when `Some(c)`, the template grounds this
70    /// group's neural atom at the real constant `c` (a domain event id or a
71    /// head-key edge id) instead of a synthetic placeholder, so the one neural
72    /// occurrence expands into one circuit leaf per domain constant.
73    pub(crate) ground_const: Option<Term>,
74    #[cfg(feature = "host-io")]
75    pub(crate) output_var: Option<String>,
76}
77
78/// An ordinary-relation body atom in a trainable-rule query treated as a HARD
79/// join condition: it gates which query groundings can fire but contributes no
80/// probability mass and no gradient. The query probability is
81/// `(hard conditions satisfiable?) x (neural prob)`; gradients flow only
82/// through the neural predicates x sigma(w), never through these fact atoms.
83#[derive(Debug, Clone)]
84pub(crate) struct HardFilter {
85    /// Ordinary relation name (must hold over the program's facts).
86    pub(crate) relation: String,
87    /// For each relation argument position, the query HEAD position whose value
88    /// it must equal. Current scope: every relation argument is a head
89    /// variable; hard conditions that join on existential (non-head) variables
90    /// are a documented follow-up.
91    pub(crate) arg_head_positions: Vec<usize>,
92}
93
94/// Stage-B existential join: the plan for grounding a neural predicate over the
95/// REAL join domain inside the circuit (instead of stripping the join relation as
96/// a pre-filter). The neural groups are already expanded per domain constant; this
97/// records the ordinary relations whose ground facts must stay IN the circuit (so
98/// provenance OR-aggregates `OR_event(neural(event) ∧ join(event, head))` at each
99/// head binding) and the head-key domain that the query ranges over.
100#[derive(Debug, Clone)]
101pub(crate) struct JoinPlan {
102    /// Ordinary relations kept inside the circuit rule; their ground facts are
103    /// added to the template program (read from `self.ast`).
104    pub(crate) relations: Vec<String>,
105    /// The real head-key domain the query ranges over (e.g. edge ids), in the
106    /// same sorted order as the per-edge guard group expansion and the emitted
107    /// `prob_queries`. Serves as the `target_domain` for a join signature.
108    pub(crate) head_domain: Vec<String>,
109}
110
111#[derive(Debug, Clone)]
112pub(crate) enum QuerySignature {
113    Boolean {
114        groups: Vec<NeuralGroup>,
115        hard_filters: Vec<HardFilter>,
116    },
117    Targeted {
118        target_position: usize,
119        groups: Vec<NeuralGroup>,
120        hard_filters: Vec<HardFilter>,
121        /// `Some` for a Stage-B existential-join signature: the head target
122        /// position ranges over the real head-key domain (e.g. edges) and the
123        /// groups are real-domain-grounded. `None` for the ordinary targeted path.
124        join: Option<JoinPlan>,
125    },
126}
127
128impl QuerySignature {
129    pub(crate) fn groups(&self) -> &[NeuralGroup] {
130        match self {
131            QuerySignature::Boolean { groups, .. } | QuerySignature::Targeted { groups, .. } => {
132                groups
133            }
134        }
135    }
136
137    pub(crate) fn hard_filters(&self) -> &[HardFilter] {
138        match self {
139            QuerySignature::Boolean { hard_filters, .. }
140            | QuerySignature::Targeted { hard_filters, .. } => hard_filters,
141        }
142    }
143
144    /// The Stage-B join plan, if this is an existential-join signature.
145    pub(crate) fn join(&self) -> Option<&JoinPlan> {
146        match self {
147            QuerySignature::Targeted { join, .. } => join.as_ref(),
148            QuerySignature::Boolean { .. } => None,
149        }
150    }
151}
152
153pub(crate) enum CompiledProbProgram {
154    Exact(ExactDdnnfProgram),
155    Mc(McProgram),
156}
157
158impl CompiledProbProgram {
159    #[cfg(feature = "host-io")]
160    pub(crate) fn num_vars(&self) -> usize {
161        match self {
162            Self::Exact(p) => p.num_vars(),
163            Self::Mc(p) => p.num_vars(),
164        }
165    }
166}
167
168// =========================================================================
169// Helper functions
170// =========================================================================
171
172#[cfg(feature = "host-io")]
173pub(crate) fn atom_to_string(atom: &xlog_prob::provenance::GroundAtom) -> String {
174    use xlog_prob::provenance::Value;
175
176    if atom.args.is_empty() {
177        return format!("{}()", atom.predicate);
178    }
179
180    let mut s = String::new();
181    s.push_str(&atom.predicate);
182    s.push('(');
183    for (i, arg) in atom.args.iter().enumerate() {
184        if i != 0 {
185            s.push_str(", ");
186        }
187        match arg {
188            Value::I64(v) => s.push_str(&v.to_string()),
189            Value::F64(bits) => s.push_str(&f64::from_bits(*bits).to_string()),
190            Value::Symbol(sym) => {
191                s.push_str(&symbol::resolve_checked(*sym).unwrap_or_else(|| format!("sym#{}", sym)))
192            }
193            Value::String(v) => s.push_str(v),
194        }
195    }
196    s.push(')');
197    s
198}
199
200// =========================================================================
201// impl CompiledProgram — private helpers
202// =========================================================================
203
204impl CompiledProgram {
205    pub(crate) fn parse_sampling_method(s: Option<String>) -> PyResult<Option<McSamplingMethod>> {
206        match s.as_deref() {
207            None => Ok(None),
208            Some("rejection") => Ok(Some(McSamplingMethod::Rejection)),
209            Some("evidence_clamping") => Ok(Some(McSamplingMethod::EvidenceClamping)),
210            Some(other) => Err(PyValueError::new_err(format!(
211                "Unknown sampling_method '{}'. Use 'rejection' or 'evidence_clamping'.",
212                other
213            ))),
214        }
215    }
216
217    /// Evaluate probability of a single query by compiling a temporary program.
218    pub(crate) fn evaluate_query_probability(&self, query: &str) -> PyResult<f64> {
219        let probs = self.evaluate_query_probabilities(&[query.to_string()])?;
220        probs
221            .into_iter()
222            .next()
223            .ok_or_else(|| PyRuntimeError::new_err("Query evaluation returned no results"))
224    }
225
226    /// Evaluate probabilities for multiple queries by compiling a temporary program.
227    pub(crate) fn evaluate_query_probabilities(&self, queries: &[String]) -> PyResult<Vec<f64>> {
228        #[cfg(not(feature = "host-io"))]
229        {
230            let _ = queries;
231            return Err(types::host_io_disabled_pyerr());
232        }
233
234        #[cfg(feature = "host-io")]
235        {
236            // Build source with queries appended
237            let mut source_with_queries = self._source.clone();
238            for query in queries {
239                source_with_queries.push_str(&format!("\nquery({}).", query));
240            }
241
242            // Compile and evaluate the temporary program
243            let result: Vec<QueryProbability> = match self._prob_engine {
244                xlog_logic::ast::ProbEngine::ExactDdnnf => {
245                    let program = ExactDdnnfProgram::compile_source_with_gpu(
246                        &source_with_queries,
247                        self._gpu_config,
248                    )
249                    .map_err(|e| types::gpu_err("Query compilation error", e))?;
250
251                    program
252                        .evaluate()
253                        .map_err(|e| types::gpu_err("Query evaluation error", e))?
254                        .query_probs
255                }
256                xlog_logic::ast::ProbEngine::Mc => {
257                    let program =
258                        McProgram::compile_source_with_gpu(&source_with_queries, self._gpu_config)
259                            .map_err(|e| types::gpu_err("Query compilation error", e))?;
260
261                    let cfg = McEvalConfig::default();
262                    program
263                        .evaluate(cfg)
264                        .map_err(|e| types::gpu_err("Query evaluation error", e))?
265                        .query_estimates
266                        .into_iter()
267                        .map(|e| QueryProbability {
268                            atom: e.atom,
269                            prob: e.prob,
270                            log_prob: e.log_prob,
271                        })
272                        .collect()
273                }
274            };
275
276            // Extract probabilities in query order
277            // The results should be in the same order as queries were added
278            let probs: Vec<f64> = result.iter().map(|qp| qp.prob).collect();
279
280            if probs.len() != queries.len() {
281                return Err(PyRuntimeError::new_err(format!(
282                    "Expected {} query results, got {}",
283                    queries.len(),
284                    probs.len()
285                )));
286            }
287
288            Ok(probs)
289        }
290    }
291
292    #[cfg(feature = "host-io")]
293    fn pack_result_probs(
294        &self,
295        py: Python<'_>,
296        query_probs: Vec<QueryProbability>,
297        log_z_e: f64,
298    ) -> PyResult<EvalResult> {
299        let mut atoms: Vec<String> = Vec::with_capacity(query_probs.len());
300        let mut probs: Vec<f64> = Vec::with_capacity(query_probs.len());
301        let mut log_probs: Vec<f64> = Vec::with_capacity(query_probs.len());
302
303        for q in query_probs {
304            atoms.push(atom_to_string(&q.atom));
305            probs.push(q.prob);
306            log_probs.push(q.log_prob);
307        }
308
309        let schema = Schema::new(vec![("col0".to_string(), ScalarType::F64)]);
310        let prob_buf = self
311            .output_provider
312            .create_buffer_from_slice::<f64>(&probs, schema.clone())
313            .map_err(types::xlog_err)?;
314        let log_prob_buf = self
315            .output_provider
316            .create_buffer_from_slice::<f64>(&log_probs, schema)
317            .map_err(types::xlog_err)?;
318
319        let prob_tensor = self
320            .output_provider
321            .to_dlpack_table(prob_buf)
322            .column(0)
323            .map_err(types::xlog_err)?;
324        let log_prob_tensor = self
325            .output_provider
326            .to_dlpack_table(log_prob_buf)
327            .column(0)
328            .map_err(types::xlog_err)?;
329
330        Ok(EvalResult {
331            atoms,
332            prob: dlpack_capsule_from_tensor(py, prob_tensor)?,
333            log_prob: dlpack_capsule_from_tensor(py, log_prob_tensor)?,
334            num_vars: self.program.num_vars(),
335            log_z_e: Some(log_z_e),
336            grad_true: None,
337            grad_false: None,
338            approx: false,
339            stderr: None,
340            ci_low: None,
341            ci_high: None,
342            samples: None,
343            evidence_samples: None,
344            seed: None,
345            confidence: None,
346            nonmonotone_semantics: None,
347            nonmonotone_sccs: None,
348            nonmonotone_cycles: None,
349            nonmonotone_iteration_limit_hits: None,
350            sampling_method: None,
351            mc_engine: None,
352        })
353    }
354
355    #[cfg(feature = "host-io")]
356    fn pack_result_with_grads(
357        &self,
358        py: Python<'_>,
359        result: ExactResultWithGrads,
360    ) -> PyResult<EvalResult> {
361        let mut atoms: Vec<String> = Vec::with_capacity(result.query_grads.len());
362        let mut probs: Vec<f64> = Vec::with_capacity(result.query_grads.len());
363        let mut log_probs: Vec<f64> = Vec::with_capacity(result.query_grads.len());
364
365        let mut grad_true_caps: Vec<Py<PyAny>> = Vec::with_capacity(result.query_grads.len());
366        let mut grad_false_caps: Vec<Py<PyAny>> = Vec::with_capacity(result.query_grads.len());
367
368        let schema = Schema::new(vec![("col0".to_string(), ScalarType::F64)]);
369
370        let num_vars = self.program.num_vars();
371        let log_z_e = result.log_z_e;
372        for q in result.query_grads {
373            atoms.push(atom_to_string(&q.atom));
374            probs.push(q.prob);
375            log_probs.push(q.log_prob);
376
377            let grad_true_buf = self
378                .output_provider
379                .create_buffer_from_slice::<f64>(&q.grad_true, schema.clone())
380                .map_err(types::xlog_err)?;
381            let grad_false_buf = self
382                .output_provider
383                .create_buffer_from_slice::<f64>(&q.grad_false, schema.clone())
384                .map_err(types::xlog_err)?;
385
386            let grad_true_tensor = self
387                .output_provider
388                .to_dlpack_table(grad_true_buf)
389                .column(0)
390                .map_err(types::xlog_err)?;
391            let grad_false_tensor = self
392                .output_provider
393                .to_dlpack_table(grad_false_buf)
394                .column(0)
395                .map_err(types::xlog_err)?;
396
397            grad_true_caps.push(dlpack_capsule_from_tensor(py, grad_true_tensor)?);
398            grad_false_caps.push(dlpack_capsule_from_tensor(py, grad_false_tensor)?);
399        }
400
401        let prob_buf = self
402            .output_provider
403            .create_buffer_from_slice::<f64>(&probs, schema.clone())
404            .map_err(types::xlog_err)?;
405        let log_prob_buf = self
406            .output_provider
407            .create_buffer_from_slice::<f64>(&log_probs, schema)
408            .map_err(types::xlog_err)?;
409
410        let prob_tensor = self
411            .output_provider
412            .to_dlpack_table(prob_buf)
413            .column(0)
414            .map_err(types::xlog_err)?;
415        let log_prob_tensor = self
416            .output_provider
417            .to_dlpack_table(log_prob_buf)
418            .column(0)
419            .map_err(types::xlog_err)?;
420
421        Ok(EvalResult {
422            atoms,
423            prob: dlpack_capsule_from_tensor(py, prob_tensor)?,
424            log_prob: dlpack_capsule_from_tensor(py, log_prob_tensor)?,
425            num_vars,
426            log_z_e: Some(log_z_e),
427            grad_true: Some(grad_true_caps),
428            grad_false: Some(grad_false_caps),
429            approx: false,
430            stderr: None,
431            ci_low: None,
432            ci_high: None,
433            samples: None,
434            evidence_samples: None,
435            seed: None,
436            confidence: None,
437            nonmonotone_semantics: None,
438            nonmonotone_sccs: None,
439            nonmonotone_cycles: None,
440            nonmonotone_iteration_limit_hits: None,
441            sampling_method: None,
442            mc_engine: None,
443        })
444    }
445
446    #[cfg(feature = "host-io")]
447    fn pack_result_mc(
448        &self,
449        py: Python<'_>,
450        result: xlog_prob::mc::McResult,
451    ) -> PyResult<EvalResult> {
452        let mut atoms: Vec<String> = Vec::with_capacity(result.query_estimates.len());
453        let mut probs: Vec<f64> = Vec::with_capacity(result.query_estimates.len());
454        let mut log_probs: Vec<f64> = Vec::with_capacity(result.query_estimates.len());
455        let mut stderrs: Vec<f64> = Vec::with_capacity(result.query_estimates.len());
456        let mut ci_lows: Vec<f64> = Vec::with_capacity(result.query_estimates.len());
457        let mut ci_highs: Vec<f64> = Vec::with_capacity(result.query_estimates.len());
458
459        for q in &result.query_estimates {
460            atoms.push(atom_to_string(&q.atom));
461            probs.push(q.prob);
462            log_probs.push(q.log_prob);
463            stderrs.push(q.stderr);
464            ci_lows.push(q.ci_low);
465            ci_highs.push(q.ci_high);
466        }
467
468        let schema = Schema::new(vec![("col0".to_string(), ScalarType::F64)]);
469        let prob_buf = self
470            .output_provider
471            .create_buffer_from_slice::<f64>(&probs, schema.clone())
472            .map_err(types::xlog_err)?;
473        let log_prob_buf = self
474            .output_provider
475            .create_buffer_from_slice::<f64>(&log_probs, schema.clone())
476            .map_err(types::xlog_err)?;
477        let stderr_buf = self
478            .output_provider
479            .create_buffer_from_slice::<f64>(&stderrs, schema.clone())
480            .map_err(types::xlog_err)?;
481        let ci_low_buf = self
482            .output_provider
483            .create_buffer_from_slice::<f64>(&ci_lows, schema.clone())
484            .map_err(types::xlog_err)?;
485        let ci_high_buf = self
486            .output_provider
487            .create_buffer_from_slice::<f64>(&ci_highs, schema)
488            .map_err(types::xlog_err)?;
489
490        let prob_tensor = self
491            .output_provider
492            .to_dlpack_table(prob_buf)
493            .column(0)
494            .map_err(types::xlog_err)?;
495        let log_prob_tensor = self
496            .output_provider
497            .to_dlpack_table(log_prob_buf)
498            .column(0)
499            .map_err(types::xlog_err)?;
500        let stderr_tensor = self
501            .output_provider
502            .to_dlpack_table(stderr_buf)
503            .column(0)
504            .map_err(types::xlog_err)?;
505        let ci_low_tensor = self
506            .output_provider
507            .to_dlpack_table(ci_low_buf)
508            .column(0)
509            .map_err(types::xlog_err)?;
510        let ci_high_tensor = self
511            .output_provider
512            .to_dlpack_table(ci_high_buf)
513            .column(0)
514            .map_err(types::xlog_err)?;
515
516        Ok(EvalResult {
517            atoms,
518            prob: dlpack_capsule_from_tensor(py, prob_tensor)?,
519            log_prob: dlpack_capsule_from_tensor(py, log_prob_tensor)?,
520            num_vars: self.program.num_vars(),
521            log_z_e: None,
522            grad_true: None,
523            grad_false: None,
524            approx: true,
525            stderr: Some(dlpack_capsule_from_tensor(py, stderr_tensor)?),
526            ci_low: Some(dlpack_capsule_from_tensor(py, ci_low_tensor)?),
527            ci_high: Some(dlpack_capsule_from_tensor(py, ci_high_tensor)?),
528            samples: Some(result.total_samples),
529            evidence_samples: Some(result.evidence_samples),
530            seed: Some(result.seed),
531            confidence: Some(result.confidence),
532            nonmonotone_semantics: Some(xlog_prob::mc::NONMONOTONE_SEMANTICS.to_string()),
533            nonmonotone_sccs: Some(result.nonmonotone_sccs),
534            nonmonotone_cycles: Some(result.nonmonotone_cycles),
535            nonmonotone_iteration_limit_hits: Some(result.nonmonotone_iteration_limit_hits),
536            sampling_method: Some(match result.sampling_method {
537                McSamplingMethod::Rejection => "rejection".to_string(),
538                McSamplingMethod::EvidenceClamping => "evidence_clamping".to_string(),
539            }),
540            mc_engine: Some(result.engine.as_str().to_string()),
541        })
542    }
543}
544
545// =========================================================================
546// #[pymethods] impl CompiledProgram — evaluate, NLL, training controls
547// =========================================================================
548
549#[pymethods]
550impl CompiledProgram {
551    #[pyo3(signature = (return_grads=false, samples=None, seed=None, confidence=0.95, max_nonmonotone_iterations=1024, sampling_method=None, memory_mb=None, allow_cpu_oracle=false))]
552    pub fn evaluate(
553        &self,
554        _py: Python<'_>,
555        return_grads: bool,
556        samples: Option<usize>,
557        seed: Option<u64>,
558        confidence: f64,
559        max_nonmonotone_iterations: usize,
560        sampling_method: Option<String>,
561        memory_mb: Option<u64>,
562        allow_cpu_oracle: bool,
563    ) -> PyResult<EvalResult> {
564        enforce_call_memory_limit(&self.output_provider, memory_mb)?;
565        match &self.program {
566            CompiledProbProgram::Exact(_program) => {
567                if samples.is_some() || seed.is_some() {
568                    return Err(PyValueError::new_err(
569                        "samples/seed are only supported for prob_engine='mc'",
570                    ));
571                }
572                #[cfg(feature = "host-io")]
573                {
574                    if return_grads {
575                        let result = _program
576                            .evaluate_gpu_with_grads()
577                            .map_err(types::xlog_err)?;
578                        self.pack_result_with_grads(_py, result)
579                    } else {
580                        let result = _program.evaluate().map_err(types::xlog_err)?;
581                        self.pack_result_probs(_py, result.query_probs, result.log_z_e)
582                    }
583                }
584                #[cfg(not(feature = "host-io"))]
585                {
586                    let _ = return_grads;
587                    Err(types::host_io_disabled_pyerr())
588                }
589            }
590            CompiledProbProgram::Mc(_program) => {
591                if return_grads {
592                    return Err(PyValueError::new_err(
593                        "MC inference does not support gradients (return_grads must be false)",
594                    ));
595                }
596
597                let mut cfg = McEvalConfig::default();
598                cfg.samples = samples.unwrap_or(10000);
599                cfg.seed = seed.unwrap_or(0);
600                cfg.confidence = confidence;
601                cfg.max_nonmonotone_iterations = max_nonmonotone_iterations;
602                cfg.sampling_method = Self::parse_sampling_method(sampling_method)?;
603                // Fail-closed contract: resident-rejected programs (negation,
604                // aggregates, ...) error unless the caller explicitly opts
605                // into the labeled CPU oracle.
606                cfg.allow_cpu_oracle_fallback = allow_cpu_oracle;
607                #[cfg(feature = "host-io")]
608                {
609                    let result = _program.evaluate(cfg).map_err(types::xlog_err)?;
610                    self.pack_result_mc(_py, result)
611                }
612                #[cfg(not(feature = "host-io"))]
613                {
614                    let _ = cfg;
615                    Err(types::host_io_disabled_pyerr())
616                }
617            }
618        }
619    }
620
621    /// Which probabilistic fact each CNF variable stands for.
622    ///
623    /// The returned list's length is the CNF encoder's variable *capacity*,
624    /// not the number of CNF variables in use and not the number of random
625    /// variables in the program — a real fraction of entries are
626    /// `{"kind": "other"}` padding (do not use `len()` of the result as a
627    /// variable count). Entry `i` describes CNF variable `i` — the same
628    /// position `i` that the `grad_true` / `grad_false` vectors of
629    /// `evaluate(return_grads=True)` use (index `0` is unused padding, since
630    /// CNF variables are 1-indexed).
631    ///
632    /// For a `"choice"` entry (one Bernoulli decision of an annotated
633    /// disjunction's chain), `probs[choice_index]` is the disjunction's
634    /// *declared, marginal* probability and is display context only; `prob`
635    /// is the *conditional* Bernoulli parameter actually assigned to this
636    /// variable's weight, and `prob * (1 - prob)` — not
637    /// `probs[choice_index] * (1 - probs[choice_index])` — is the correct
638    /// Jacobian for `grad_true` / `grad_false` at this position.
639    ///
640    /// Raises `ValueError` for Monte Carlo programs, and for exact programs
641    /// compiled through the GPU count-lift fast path (count aggregates
642    /// without evidence or annotated disjunctions), since that path never
643    /// builds a CNF encoding and therefore has no variable map to report —
644    /// this is *not* the same as the program having no probabilistic facts.
645    pub fn prob_var_map(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
646        #[cfg(feature = "host-io")]
647        {
648            use xlog_prob::exact::ProbVarInfo;
649
650            let entries = match &self.program {
651                CompiledProbProgram::Exact(p) => {
652                    if p.uses_gpu_native_count_lift() {
653                        return Err(PyValueError::new_err(
654                            "prob_var_map is unavailable: this program was compiled through \
655                             the GPU count-lift fast path (a count aggregate without evidence \
656                             or annotated disjunctions), which evaluates the aggregate with a \
657                             dedicated kernel and never builds a CNF encoding. There is no \
658                             CNF-variable-to-fact map to report. This does not mean the \
659                             program has no probabilistic facts.",
660                        ));
661                    }
662                    p.prob_var_map()
663                }
664                CompiledProbProgram::Mc(_) => {
665                    return Err(PyValueError::new_err(
666                        "prob_var_map is only available for the exact engine",
667                    ))
668                }
669            };
670
671            let mut out: Vec<Py<PyAny>> = Vec::with_capacity(entries.len());
672            for entry in entries {
673                let d = PyDict::new(py);
674                match entry {
675                    ProbVarInfo::Fact { atom, prob } => {
676                        d.set_item("kind", "fact")?;
677                        d.set_item("atom", atom_to_string(&atom))?;
678                        d.set_item("prob", prob)?;
679                    }
680                    ProbVarInfo::Choice {
681                        choices,
682                        choice_index,
683                        prob,
684                    } => {
685                        d.set_item("kind", "choice")?;
686                        d.set_item(
687                            "atoms",
688                            choices
689                                .iter()
690                                .map(|(a, _)| atom_to_string(a))
691                                .collect::<Vec<_>>(),
692                        )?;
693                        // Declared, marginal probabilities of the whole disjunction
694                        // (display context only — probs[choice_index] is NOT this
695                        // variable's own Bernoulli parameter; see "prob" below).
696                        d.set_item(
697                            "probs",
698                            choices.iter().map(|(_, p)| *p).collect::<Vec<f64>>(),
699                        )?;
700                        d.set_item("choice_index", choice_index)?;
701                        // This chain variable's own (conditional) Bernoulli parameter,
702                        // i.e. the weight actually used by the GPU circuit for this CNF
703                        // variable. Use prob * (1 - prob), not
704                        // probs[choice_index] * (1 - probs[choice_index]), as the
705                        // Jacobian for grad_true/grad_false at this position.
706                        d.set_item("prob", prob)?;
707                    }
708                    ProbVarInfo::Other => {
709                        d.set_item("kind", "other")?;
710                    }
711                }
712                out.push(d.into());
713            }
714            Ok(out)
715        }
716        #[cfg(not(feature = "host-io"))]
717        {
718            let _ = py;
719            Err(types::host_io_disabled_pyerr())
720        }
721    }
722
723    /// Evaluate Monte Carlo programs and return device-only result counts via DLPack.
724    ///
725    /// This is the primary GPU-native API surface for MC inference. It never performs
726    /// device->host reads for result data (only returns device buffers).
727    #[pyo3(signature = (samples=None, seed=None, confidence=0.95, max_nonmonotone_iterations=1024, sampling_method=None, memory_mb=None))]
728    pub fn evaluate_device(
729        &self,
730        py: Python<'_>,
731        samples: Option<usize>,
732        seed: Option<u64>,
733        confidence: f64,
734        max_nonmonotone_iterations: usize,
735        sampling_method: Option<String>,
736        memory_mb: Option<u64>,
737    ) -> PyResult<McDeviceEvalResult> {
738        enforce_call_memory_limit(&self.output_provider, memory_mb)?;
739        let (
740            query_counts,
741            evidence_count,
742            total_samples,
743            seed,
744            confidence,
745            nonmonotone_sccs,
746            nonmonotone_cycles,
747            nonmonotone_iteration_limit_hits,
748            sampling_method_val,
749            no_host,
750        ) = match &self.program {
751            CompiledProbProgram::Mc(program) => {
752                let mut cfg = McEvalConfig::default();
753                cfg.samples = samples.unwrap_or(10000);
754                cfg.seed = seed.unwrap_or(0);
755                cfg.confidence = confidence;
756                cfg.max_nonmonotone_iterations = max_nonmonotone_iterations;
757                cfg.sampling_method = Self::parse_sampling_method(sampling_method)?;
758
759                let result = program
760                    .evaluate_gpu_device_with_provider(cfg, self.output_provider.clone())
761                    .map_err(types::xlog_err)?;
762
763                (
764                    result.query_counts,
765                    result.evidence_count,
766                    result.total_samples,
767                    result.seed,
768                    result.confidence,
769                    result.nonmonotone_sccs,
770                    result.nonmonotone_cycles,
771                    result.nonmonotone_iteration_limit_hits,
772                    result.sampling_method,
773                    result.no_host,
774                )
775            }
776            _ => {
777                return Err(PyValueError::new_err(
778                    "evaluate_device is only supported for prob_engine='mc'",
779                ))
780            }
781        };
782
783        // PyTorch does not support unsigned 32-bit types. Export as i32 (bitwise identical for
784        // counts < 2^31) for maximum DLPack consumer compatibility.
785        let schema_i32 = Schema::new(vec![("col0".to_string(), ScalarType::I32)]);
786
787        let make_count_tensor =
788            |counts: xlog_cuda::memory::TrackedCudaSlice<u32>, rows: u64| -> PyResult<Py<PyAny>> {
789                let rows_u32 = u32::try_from(rows).map_err(|_| {
790                    PyValueError::new_err(format!("Row count {} exceeds u32::MAX", rows))
791                })?;
792
793                let mut d_num_rows = self
794                    .output_provider
795                    .memory()
796                    .alloc::<u32>(1)
797                    .map_err(types::xlog_err)?;
798                self.output_provider
799                    .device()
800                    .inner()
801                    .htod_sync_copy_into(&[rows_u32], &mut d_num_rows)
802                    .map_err(types::xlog_err)?;
803
804                let buffer = xlog_cuda::CudaBuffer::from_columns(
805                    vec![counts.into_bytes().into()],
806                    rows,
807                    d_num_rows,
808                    schema_i32.clone(),
809                );
810                let tensor = self
811                    .output_provider
812                    .to_dlpack_table(buffer)
813                    .column(0)
814                    .map_err(types::xlog_err)?;
815                dlpack_capsule_from_tensor(py, tensor)
816            };
817
818        let query_rows = u64::try_from(query_counts.len())
819            .map_err(|_| PyValueError::new_err("query_counts length overflow"))?;
820        let query_counts_capsule = make_count_tensor(query_counts, query_rows)?;
821        let evidence_count_capsule = make_count_tensor(evidence_count, 1)?;
822        let resident_no_host_certified = no_host.is_no_host();
823
824        Ok(McDeviceEvalResult {
825            query_counts: query_counts_capsule,
826            evidence_count: evidence_count_capsule,
827            total_samples,
828            seed,
829            confidence,
830            nonmonotone_semantics: xlog_prob::mc::NONMONOTONE_SEMANTICS.to_string(),
831            nonmonotone_sccs,
832            nonmonotone_cycles,
833            nonmonotone_iteration_limit_hits,
834            sampling_method: match sampling_method_val {
835                McSamplingMethod::Rejection => "rejection".to_string(),
836                McSamplingMethod::EvidenceClamping => "evidence_clamping".to_string(),
837            },
838            resident_no_host_certified,
839            resident_no_host_policy_result: if resident_no_host_certified {
840                "certified".to_string()
841            } else {
842                "failed".to_string()
843            },
844            resident_no_host_tracked_dtoh_calls: no_host.tracked_dtoh_calls,
845            resident_no_host_tracked_htod_calls: no_host.tracked_htod_calls,
846            resident_no_host_host_loop_iterations: no_host.host_loop_iterations,
847            resident_no_host_per_sample_host_launches: no_host.per_sample_host_launches,
848            resident_no_host_untracked_metadata_reads: no_host.untracked_metadata_reads,
849            resident_no_host_engine_launches: no_host.engine_launches,
850            resident_no_host_host_fixpoint_iterations: no_host.host_fixpoint_iterations,
851            resident_no_host_per_operator_host_allocations: no_host.per_operator_host_allocations,
852        })
853    }
854
855    // =========================================================================
856    // NLL Loss Functions
857    // =========================================================================
858
859    /// Compute negative log-likelihood loss for a single query.
860    ///
861    /// NLL loss = -log(P(query))
862    ///
863    /// This is the fundamental training objective for neural-symbolic programs.
864    /// Lower loss means higher probability of the query being true.
865    ///
866    /// # Arguments
867    /// * `query` - Query atom as string, e.g., "digit(0, 5)" or "path(1, 3)"
868    ///
869    /// # Returns
870    /// The NLL loss value (always non-negative, 0 for certain facts)
871    fn nll_loss(&self, query: &str) -> PyResult<f64> {
872        let prob = self.evaluate_query_probability(query)?;
873        Ok(types::nll_loss_value(prob))
874    }
875
876    /// Compute sum of NLL losses for a batch of queries.
877    ///
878    /// Batch loss = Σ -log(P(query_i))
879    ///
880    /// More efficient than calling nll_loss repeatedly as all queries
881    /// are compiled and evaluated together.
882    ///
883    /// # Arguments
884    /// * `queries` - List of query atoms as strings
885    ///
886    /// # Returns
887    /// Sum of individual NLL losses (0.0 for empty batch)
888    fn nll_loss_batch(&self, queries: Vec<String>) -> PyResult<f64> {
889        if queries.is_empty() {
890            return Ok(0.0);
891        }
892
893        let probs = self.evaluate_query_probabilities(&queries)?;
894        Ok(probs.iter().map(|&p| types::nll_loss_value(p)).sum())
895    }
896
897    /// Compute mean NLL loss for a batch of queries.
898    ///
899    /// Mean loss = (1/n) Σ -log(P(query_i))
900    ///
901    /// Useful for comparing loss across batches of different sizes.
902    ///
903    /// # Arguments
904    /// * `queries` - List of query atoms as strings (must be non-empty)
905    ///
906    /// # Returns
907    /// Mean of individual NLL losses
908    ///
909    /// # Errors
910    /// Returns error if queries is empty
911    fn nll_loss_mean(&self, queries: Vec<String>) -> PyResult<f64> {
912        if queries.is_empty() {
913            return Err(PyValueError::new_err(
914                "Cannot compute mean NLL loss for empty query batch",
915            ));
916        }
917
918        let probs = self.evaluate_query_probabilities(&queries)?;
919        let sum: f64 = probs.iter().map(|&p| types::nll_loss_value(p)).sum();
920        Ok(sum / probs.len() as f64)
921    }
922
923    /// Compute NLL loss and return as PyTorch tensor.
924    ///
925    /// Returns a scalar tensor that can participate in autograd.
926    /// Use this when you need gradients to flow back through the loss.
927    ///
928    /// # Arguments
929    /// * `query` - Query atom as string
930    ///
931    /// # Returns
932    /// PyTorch scalar tensor containing the loss value
933    fn nll_loss_tensor(&self, py: Python<'_>, query: &str) -> PyResult<Py<PyAny>> {
934        let loss = self.nll_loss(query)?;
935        types::create_torch_tensor(py, loss)
936    }
937
938    /// Compute batch NLL loss and return as PyTorch tensor.
939    ///
940    /// # Arguments
941    /// * `queries` - List of query atoms as strings
942    ///
943    /// # Returns
944    /// PyTorch scalar tensor containing the sum of losses
945    fn nll_loss_batch_tensor(&self, py: Python<'_>, queries: Vec<String>) -> PyResult<Py<PyAny>> {
946        let loss = self.nll_loss_batch(queries)?;
947        types::create_torch_tensor(py, loss)
948    }
949
950    // =========================================================================
951    // Backward Pass / Training Methods
952    // =========================================================================
953
954    /// Zero gradients for all registered networks.
955    ///
956    /// This should be called at the start of each training iteration
957    /// to clear accumulated gradients from previous iterations.
958    pub fn zero_grad(&self, py: Python<'_>) -> PyResult<()> {
959        for name in self.network_registry.names() {
960            if let Some(handle) = self.network_registry.get(name) {
961                if let Some(optimizer) = handle.optimizer() {
962                    optimizer.call_method0(py, "zero_grad")?;
963                }
964            }
965        }
966        Ok(())
967    }
968
969    /// Perform optimizer step for all registered networks.
970    ///
971    /// This applies the accumulated gradients to update network parameters.
972    /// Should be called after forward_backward().
973    pub fn optimizer_step(&self, py: Python<'_>) -> PyResult<()> {
974        for name in self.network_registry.names() {
975            if let Some(handle) = self.network_registry.get(name) {
976                if let Some(optimizer) = handle.optimizer() {
977                    optimizer.call_method0(py, "step")?;
978                }
979            }
980        }
981        Ok(())
982    }
983
984    /// Clip gradient norms for all registered networks.
985    ///
986    /// Uses `torch.nn.utils.clip_grad_norm_`.
987    pub fn clip_grad_norms(&self, py: Python<'_>, max_norm: f64) -> PyResult<()> {
988        let clip_fn = py.import("torch.nn.utils")?.getattr("clip_grad_norm_")?;
989        for name in self.network_registry.names() {
990            if let Some(handle) = self.network_registry.get(name) {
991                if let Some(module) = handle.module() {
992                    let params = module.call_method0(py, "parameters")?;
993                    clip_fn.call1((params, max_norm))?;
994                }
995            }
996        }
997        Ok(())
998    }
999
1000    /// Step the learning rate scheduler.
1001    ///
1002    /// PyTorch schedulers expect at least one optimizer step before the first
1003    /// scheduler step. Call this after `optimizer_step()` (or after a training
1004    /// path that performs an optimizer step internally).
1005    ///
1006    /// If `network_name` is provided, steps only that network's scheduler.
1007    /// If `None` (default), steps all registered schedulers.
1008    #[pyo3(signature = (network_name=None))]
1009    fn scheduler_step(&self, py: Python<'_>, network_name: Option<&str>) -> PyResult<()> {
1010        match network_name {
1011            Some(name) => {
1012                let handle = self.network_registry.get(name).ok_or_else(|| {
1013                    pyo3::exceptions::PyValueError::new_err(format!(
1014                        "No network registered with name '{name}'"
1015                    ))
1016                })?;
1017                if let Some(scheduler) = handle.scheduler() {
1018                    scheduler.call_method0(py, "step")?;
1019                }
1020            }
1021            None => {
1022                for name in self.network_registry.names() {
1023                    if let Some(handle) = self.network_registry.get(name) {
1024                        if let Some(scheduler) = handle.scheduler() {
1025                            scheduler.call_method0(py, "step")?;
1026                        }
1027                    }
1028                }
1029            }
1030        }
1031        Ok(())
1032    }
1033
1034    /// Get the current learning rate for a registered network.
1035    ///
1036    /// Reads `optimizer.param_groups[0]['lr']`.
1037    ///
1038    /// # Arguments
1039    /// * `network_name` - Name used in register_network()
1040    fn get_lr(&self, py: Python<'_>, network_name: &str) -> PyResult<f64> {
1041        let handle = self.network_registry.get(network_name).ok_or_else(|| {
1042            pyo3::exceptions::PyValueError::new_err(format!(
1043                "No network registered with name '{network_name}'"
1044            ))
1045        })?;
1046        let optimizer = handle.optimizer().ok_or_else(|| {
1047            pyo3::exceptions::PyValueError::new_err(format!(
1048                "Network '{network_name}' has no optimizer"
1049            ))
1050        })?;
1051        let param_groups = optimizer.getattr(py, "param_groups")?;
1052        let group0 = param_groups.call_method1(py, "__getitem__", (0i32,))?;
1053        let lr = group0.call_method1(py, "__getitem__", ("lr",))?;
1054        lr.extract(py)
1055    }
1056
1057    /// Set the learning rate for a registered network.
1058    ///
1059    /// Writes to all `optimizer.param_groups[i]['lr']`.
1060    ///
1061    /// # Arguments
1062    /// * `network_name` - Name used in register_network()
1063    /// * `lr` - New learning rate value
1064    fn set_lr(&self, py: Python<'_>, network_name: &str, lr: f64) -> PyResult<()> {
1065        let handle = self.network_registry.get(network_name).ok_or_else(|| {
1066            pyo3::exceptions::PyValueError::new_err(format!(
1067                "No network registered with name '{network_name}'"
1068            ))
1069        })?;
1070        let optimizer = handle.optimizer().ok_or_else(|| {
1071            pyo3::exceptions::PyValueError::new_err(format!(
1072                "Network '{network_name}' has no optimizer"
1073            ))
1074        })?;
1075        let param_groups = optimizer.getattr(py, "param_groups")?;
1076        let num_groups: usize = param_groups.call_method0(py, "__len__")?.extract(py)?;
1077        for i in 0..num_groups {
1078            let group = param_groups.call_method1(py, "__getitem__", (i as i32,))?;
1079            group.call_method(py, "__setitem__", ("lr", lr), None)?;
1080        }
1081        Ok(())
1082    }
1083
1084    // =========================================================================
1085    // Training Methods
1086    // =========================================================================
1087
1088    /// Train for one epoch over the given queries.
1089    ///
1090    /// This method:
1091    /// 1. Processes queries in batches
1092    /// 2. For each batch: zero_grad, forward_backward for each query, optimizer_step
1093    /// 3. Returns statistics for the epoch
1094    ///
1095    /// # Arguments
1096    /// * `queries` - List of query strings to train on
1097    /// * `batch_size` - Number of queries per batch (default: 32)
1098    ///
1099    /// # Returns
1100    /// EpochStats with avg_loss, num_batches, total_queries
1101    #[pyo3(signature = (queries, batch_size=32, max_grad_norm=None))]
1102    fn train_epoch(
1103        &mut self,
1104        py: Python<'_>,
1105        queries: Vec<String>,
1106        batch_size: usize,
1107        max_grad_norm: Option<f64>,
1108    ) -> PyResult<EpochStats> {
1109        let mut history = TrainingHistory::new();
1110        self.train_epoch_internal(
1111            py,
1112            &queries,
1113            batch_size,
1114            usize::MAX,
1115            max_grad_norm,
1116            &mut history,
1117        )
1118    }
1119
1120    /// Evaluate mean NLL loss over queries without updating parameters.
1121    ///
1122    /// Useful for validation/test set evaluation.
1123    ///
1124    /// # Arguments
1125    /// * `queries` - List of query strings to evaluate
1126    ///
1127    /// # Returns
1128    /// Mean NLL loss over all queries
1129    pub fn evaluate_loss(&self, queries: Vec<String>) -> PyResult<f64> {
1130        if queries.is_empty() {
1131            return Ok(0.0);
1132        }
1133
1134        let probs = self.evaluate_query_probabilities(&queries)?;
1135        let total_loss: f64 = probs.iter().map(|&p| types::nll_loss_value(p)).sum();
1136        Ok(total_loss / queries.len() as f64)
1137    }
1138
1139    /// Train for one epoch with GPU-native loss accumulation (no per-query .item()).
1140    #[pyo3(signature = (queries, batch_size=32, max_grad_norm=None))]
1141    fn train_epoch_tensor(
1142        &mut self,
1143        py: Python<'_>,
1144        queries: Vec<String>,
1145        batch_size: usize,
1146        max_grad_norm: Option<f64>,
1147    ) -> PyResult<EpochStats> {
1148        let mut history = TrainingHistory::new();
1149        self.train_epoch_tensor_internal(
1150            py,
1151            &queries,
1152            batch_size,
1153            usize::MAX,
1154            max_grad_norm,
1155            &mut history,
1156        )
1157    }
1158
1159    /// Return warmup profiling data as a Python dict (or None if profiling disabled).
1160    ///
1161    /// When XLOG_WARMUP_PROFILE=1, returns a dict with:
1162    ///   - "ptx": PTX load timing breakdown
1163    ///   - "circuit": circuit compilation timing breakdown
1164    /// Returns None if profiling is not enabled or no data is available.
1165    fn warmup_breakdown(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
1166        let ptx_profile = self.output_provider.ptx_load_profile();
1167        // The neural forward path records `self.last_compile_profile`. For a
1168        // purely probabilistic/deterministic program no neural forward runs,
1169        // so fall back to the compile profile captured when the program's own
1170        // circuit was built (the cold D4-compile + CDCL-verify). Without this
1171        // fallback `warmup_breakdown()` returned None for non-neural programs,
1172        // hiding the verification-overhead split from benchmark isolation runs.
1173        let circuit_profile = self
1174            .last_compile_profile
1175            .as_ref()
1176            .or_else(|| match &self.program {
1177                CompiledProbProgram::Exact(p) => p.last_compile_profile(),
1178                CompiledProbProgram::Mc(_) => None,
1179            });
1180
1181        // Return None if neither profile is available.
1182        if ptx_profile.is_none() && circuit_profile.is_none() {
1183            return Ok(None);
1184        }
1185
1186        let result = PyDict::new(py);
1187
1188        if let Some(ptx) = ptx_profile {
1189            let ptx_dict = PyDict::new(py);
1190            ptx_dict.set_item("total_sec", ptx.total_sec)?;
1191            ptx_dict.set_item("cubin_loaded", ptx.cubin_loaded)?;
1192            ptx_dict.set_item("ptx_fallback", ptx.ptx_fallback)?;
1193            let per_module = PyDict::new(py);
1194            for (name, sec) in &ptx.per_module_sec {
1195                per_module.set_item(name, *sec)?;
1196            }
1197            ptx_dict.set_item("per_module_sec", per_module)?;
1198            result.set_item("ptx", ptx_dict)?;
1199        }
1200
1201        if let Some(circuit) = circuit_profile {
1202            let circuit_dict = PyDict::new(py);
1203            circuit_dict.set_item("gpu_cache_hit", circuit.gpu_cache_hit)?;
1204            circuit_dict.set_item("disk_cache_hit", circuit.disk_cache_hit)?;
1205            circuit_dict.set_item("d4_compile_sec", circuit.d4_compile_sec)?;
1206            circuit_dict.set_item("verify_sec", circuit.verify_sec)?;
1207            circuit_dict.set_item("smooth_sec", circuit.smooth_sec)?;
1208            circuit_dict.set_item("cache_store_sec", circuit.cache_store_sec)?;
1209            circuit_dict.set_item("free_var_mask_sec", circuit.free_var_mask_sec)?;
1210            circuit_dict.set_item("cnf_hash_sec", circuit.cnf_hash_sec)?;
1211            result.set_item("circuit", circuit_dict)?;
1212        }
1213
1214        Ok(Some(result.into()))
1215    }
1216
1217    /// Clear the circuit template cache, forcing recompilation on next query.
1218    /// Used for cache ablation benchmarks.
1219    fn clear_circuit_cache(&mut self) {
1220        self.circuit_cache.clear();
1221    }
1222
1223    /// Return memory diagnostics including allocated_bytes and memory_limit_bytes.
1224    pub fn memory_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1225        provider_memory_stats(py, &self.output_provider)
1226    }
1227
1228    pub fn rule_provenance(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1229        let provenance = xlog_logic::rule_provenance(&self.ast, None);
1230        pack_rule_provenance(py, &provenance)
1231    }
1232
1233    pub fn proof_traces(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1234        let provenance = xlog_logic::rule_provenance(&self.ast, None);
1235        let traces = xlog_logic::query_proof_traces(&self.ast, &provenance);
1236        pack_query_proof_traces(py, &traces)
1237    }
1238
1239    pub fn host_transfer_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1240        let stats = self.output_provider.host_transfer_stats();
1241        let dict = PyDict::new(py);
1242        dict.set_item("dtoh_bytes", stats.dtoh_bytes)?;
1243        dict.set_item("htod_bytes", stats.htod_bytes)?;
1244        dict.set_item("dtoh_calls", stats.dtoh_calls)?;
1245        dict.set_item("htod_calls", stats.htod_calls)?;
1246        Ok(dict.into())
1247    }
1248
1249    pub fn reset_host_transfer_stats(&self) {
1250        self.output_provider.reset_host_transfer_stats()
1251    }
1252
1253    pub fn neural_hot_loop_diagnostics(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1254        let transfers = self.output_provider.host_transfer_stats();
1255        let dict = PyDict::new(py);
1256        dict.set_item("post_load_dtoh_bytes", transfers.dtoh_bytes)?;
1257        dict.set_item("post_load_htod_bytes", transfers.htod_bytes)?;
1258        dict.set_item("post_load_dtoh_calls", transfers.dtoh_calls)?;
1259        dict.set_item("post_load_htod_calls", transfers.htod_calls)?;
1260        dict.set_item("control_plane_bytes_per_iteration", py.None())?;
1261        dict.set_item(
1262            "control_plane_status",
1263            "unavailable: per-iteration control-plane byte counter is not registered",
1264        )?;
1265        dict.set_item("scalar_sync_checks", py.None())?;
1266        dict.set_item(
1267            "scalar_sync_status",
1268            "unavailable: scalar synchronization counter is not registered",
1269        )?;
1270
1271        let cuda_graph = PyDict::new(py);
1272        cuda_graph.set_item(
1273            "csm_cuda_graph_captures",
1274            self.output_provider.csm_cuda_graph_captures(),
1275        )?;
1276        cuda_graph.set_item(
1277            "csm_cuda_graph_launches",
1278            self.output_provider.csm_cuda_graph_launches(),
1279        )?;
1280        cuda_graph.set_item(
1281            "csm_cuda_graph_fallbacks",
1282            self.output_provider.csm_cuda_graph_fallbacks(),
1283        )?;
1284        cuda_graph.set_item(
1285            "csm_cuda_graph_cache_hits",
1286            self.output_provider.csm_cuda_graph_cache_hits(),
1287        )?;
1288        dict.set_item("cuda_graph", cuda_graph)?;
1289
1290        let circuit_cache = PyDict::new(py);
1291        circuit_cache.set_item("circuit_cache_size", self.circuit_cache.len())?;
1292        circuit_cache.set_item("circuit_cache_hits", self.circuit_cache_hits)?;
1293        circuit_cache.set_item("circuit_cache_misses", self.circuit_cache_misses)?;
1294        circuit_cache.set_item("template_compile_count", self.template_compile_count)?;
1295        circuit_cache.set_item(
1296            "query_signature_cache_size",
1297            self.query_signature_cache.len(),
1298        )?;
1299        dict.set_item("circuit_cache", circuit_cache)?;
1300
1301        Ok(dict.into())
1302    }
1303
1304    pub fn cuda_graph_stats(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1305        let dict = PyDict::new(py);
1306        dict.set_item(
1307            "csm_cuda_graph_captures",
1308            self.output_provider.csm_cuda_graph_captures(),
1309        )?;
1310        dict.set_item(
1311            "csm_cuda_graph_launches",
1312            self.output_provider.csm_cuda_graph_launches(),
1313        )?;
1314        dict.set_item(
1315            "csm_cuda_graph_fallbacks",
1316            self.output_provider.csm_cuda_graph_fallbacks(),
1317        )?;
1318        dict.set_item(
1319            "csm_cuda_graph_cache_hits",
1320            self.output_provider.csm_cuda_graph_cache_hits(),
1321        )?;
1322        Ok(dict.into())
1323    }
1324}