Skip to main content

uni_locy/
result.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use uni_common::{Properties, Value};
5
6use crate::types::{RuntimeWarning, RuntimeWarningCode};
7
8/// A single row of bindings from a Locy evaluation result.
9pub type FactRow = HashMap<String, Value>;
10
11/// The result of evaluating a compiled Locy program.
12#[derive(Debug, Clone)]
13pub struct LocyResult {
14    /// Derived facts per rule name.
15    pub derived: HashMap<String, Vec<FactRow>>,
16    /// Execution statistics.
17    pub stats: LocyStats,
18    /// Results from Phase 4 commands.
19    pub command_results: Vec<CommandResult>,
20    /// Runtime warnings collected during evaluation.
21    pub warnings: Vec<RuntimeWarning>,
22    /// Groups where BDD computation fell back to independence mode.
23    /// Maps rule name → list of human-readable key group descriptions.
24    pub approximate_groups: HashMap<String, Vec<String>>,
25    /// When present, contains the derived facts from a session-level DERIVE
26    /// that have not yet been applied. Use `tx.apply(derived)` to materialize.
27    pub derived_fact_set: Option<DerivedFactSet>,
28    /// True when the evaluation was cut short by a timeout. The `derived` map
29    /// contains whatever facts were accumulated before the timeout fired.
30    /// Partial results may not satisfy the fixpoint invariant.
31    pub timed_out: bool,
32}
33
34/// Result of executing a single Phase 4 command.
35#[derive(Debug, Clone)]
36pub enum CommandResult {
37    Query(Vec<FactRow>),
38    Assume(Vec<FactRow>),
39    Explain(DerivationNode),
40    Abduce(AbductionResult),
41    Derive { affected: usize },
42    Cypher(Vec<FactRow>),
43}
44
45/// A node in a derivation tree, produced by EXPLAIN RULE.
46#[derive(Debug, Clone)]
47pub struct DerivationNode {
48    pub rule: String,
49    pub clause_index: usize,
50    pub priority: Option<i64>,
51    pub bindings: HashMap<String, Value>,
52    pub along_values: HashMap<String, Value>,
53    pub children: Vec<DerivationNode>,
54    pub graph_fact: Option<String>,
55    /// True when this node's probability was computed via BDD fallback
56    /// (independence mode) because the group exceeded `max_bdd_variables`.
57    pub approximate: bool,
58    /// Probability of this specific proof path, populated when top-k proof
59    /// filtering is active (Scallop, Huang et al. 2021).
60    pub proof_probability: Option<f64>,
61}
62
63/// Result of an ABDUCE query.
64#[derive(Debug, Clone, serde::Serialize)]
65pub struct AbductionResult {
66    pub modifications: Vec<ValidatedModification>,
67}
68
69/// A modification with validation status and cost.
70#[derive(Debug, Clone, serde::Serialize)]
71pub struct ValidatedModification {
72    pub modification: Modification,
73    /// Whether this modification satisfies the ABDUCE goal when applied via savepoint.
74    pub validated: bool,
75    /// Cost metric for ranking modifications: RemoveEdge=1.0, ChangeProperty=0.5, AddEdge=1.5.
76    pub cost: f64,
77}
78
79/// A proposed graph modification from ABDUCE.
80#[derive(Debug, Clone, serde::Serialize)]
81pub enum Modification {
82    RemoveEdge {
83        source_var: String,
84        target_var: String,
85        edge_var: String,
86        edge_type: String,
87        /// Property constraints used to identify the specific edge to remove.
88        match_properties: HashMap<String, Value>,
89    },
90    ChangeProperty {
91        element_var: String,
92        property: String,
93        old_value: Box<Value>,
94        new_value: Box<Value>,
95    },
96    AddEdge {
97        source_var: String,
98        target_var: String,
99        edge_type: String,
100        properties: HashMap<String, Value>,
101    },
102}
103
104/// A derived edge to be materialized.
105#[derive(Debug, Clone)]
106pub struct DerivedEdge {
107    pub edge_type: String,
108    pub source_label: String,
109    pub source_properties: Properties,
110    pub target_label: String,
111    pub target_properties: Properties,
112    pub edge_properties: Properties,
113}
114
115/// Pure-data representation of facts derived by a session-level DERIVE.
116///
117/// Apply to a transaction via `tx.apply(derived)` or `tx.apply_with(derived)`.
118#[derive(Debug, Clone)]
119pub struct DerivedFactSet {
120    /// New vertices grouped by label.
121    pub vertices: HashMap<String, Vec<Properties>>,
122    /// Derived edges connecting source/target vertices.
123    pub edges: Vec<DerivedEdge>,
124    /// Evaluation statistics from the DERIVE run.
125    pub stats: LocyStats,
126    /// Database version at evaluation time (for staleness detection).
127    pub evaluated_at_version: u64,
128    /// Internal: Cypher ASTs for faithful replay during `tx.apply()`.
129    #[doc(hidden)]
130    pub mutation_queries: Vec<uni_cypher::ast::Query>,
131}
132
133impl DerivedFactSet {
134    /// Total number of derived facts (vertices + edges).
135    pub fn fact_count(&self) -> usize {
136        self.vertices.values().map(|v| v.len()).sum::<usize>() + self.edges.len()
137    }
138
139    /// True when no facts were derived.
140    pub fn is_empty(&self) -> bool {
141        self.vertices.is_empty() && self.edges.is_empty()
142    }
143}
144
145/// Statistics collected during Locy program evaluation.
146#[derive(Debug, Clone, Default)]
147pub struct LocyStats {
148    pub strata_evaluated: usize,
149    pub total_iterations: usize,
150    pub derived_nodes: usize,
151    pub derived_edges: usize,
152    pub evaluation_time: Duration,
153    pub queries_executed: usize,
154    pub mutations_executed: usize,
155    /// Peak memory used by derived relations (in bytes).
156    pub peak_memory_bytes: usize,
157}
158
159impl LocyResult {
160    /// Get derived facts for a specific rule.
161    pub fn derived_facts(&self, rule: &str) -> Option<&Vec<FactRow>> {
162        self.derived.get(rule)
163    }
164
165    /// Get rows from the first Query command result.
166    pub fn rows(&self) -> Option<&Vec<FactRow>> {
167        self.command_results.iter().find_map(|cr| cr.as_query())
168    }
169
170    /// Get column names from the first Query command result's first row.
171    pub fn columns(&self) -> Option<Vec<String>> {
172        self.rows()
173            .and_then(|rows| rows.first().map(|row| row.keys().cloned().collect()))
174    }
175
176    /// Get execution statistics.
177    pub fn stats(&self) -> &LocyStats {
178        &self.stats
179    }
180
181    /// Get the total number of fixpoint iterations.
182    pub fn iterations(&self) -> usize {
183        self.stats.total_iterations
184    }
185
186    /// Get runtime warnings collected during evaluation.
187    pub fn warnings(&self) -> &[RuntimeWarning] {
188        &self.warnings
189    }
190
191    /// Check whether a specific warning code was emitted.
192    pub fn has_warning(&self, code: &RuntimeWarningCode) -> bool {
193        self.warnings.iter().any(|w| w.code == *code)
194    }
195}
196
197impl CommandResult {
198    /// If this is an Explain result, return the derivation node.
199    pub fn as_explain(&self) -> Option<&DerivationNode> {
200        match self {
201            CommandResult::Explain(node) => Some(node),
202            _ => None,
203        }
204    }
205
206    /// If this is a Query result, return the rows.
207    pub fn as_query(&self) -> Option<&Vec<FactRow>> {
208        match self {
209            CommandResult::Query(rows) => Some(rows),
210            _ => None,
211        }
212    }
213
214    /// If this is an Abduce result, return it.
215    pub fn as_abduce(&self) -> Option<&AbductionResult> {
216        match self {
217            CommandResult::Abduce(result) => Some(result),
218            _ => None,
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn abduce_result_serializes_to_json() {
229        let result = AbductionResult {
230            modifications: vec![
231                ValidatedModification {
232                    modification: Modification::ChangeProperty {
233                        element_var: "a".into(),
234                        property: "flagged".into(),
235                        old_value: Box::new(Value::String("false".into())),
236                        new_value: Box::new(Value::String("true".into())),
237                    },
238                    validated: true,
239                    cost: 0.5,
240                },
241                ValidatedModification {
242                    modification: Modification::RemoveEdge {
243                        source_var: "a".into(),
244                        target_var: "b".into(),
245                        edge_var: "e".into(),
246                        edge_type: "TRANSFERS_TO".into(),
247                        match_properties: HashMap::from([("amount".into(), Value::Float(1000.0))]),
248                    },
249                    validated: false,
250                    cost: 1.0,
251                },
252                ValidatedModification {
253                    modification: Modification::AddEdge {
254                        source_var: "a".into(),
255                        target_var: "b".into(),
256                        edge_type: "FLAGGED_BY".into(),
257                        properties: HashMap::new(),
258                    },
259                    validated: true,
260                    cost: 1.5,
261                },
262            ],
263        };
264
265        let json = serde_json::to_value(&result).expect("serialization failed");
266        let mods = json["modifications"].as_array().unwrap();
267        assert_eq!(mods.len(), 3);
268        assert_eq!(mods[0]["validated"], true);
269        assert_eq!(mods[0]["cost"], 0.5);
270        assert!(mods[0]["modification"]["ChangeProperty"].is_object());
271        assert!(mods[1]["modification"]["RemoveEdge"].is_object());
272        assert!(mods[2]["modification"]["AddEdge"].is_object());
273    }
274}