Skip to main content

plexus_devtools/
explain.rs

1use serde::Serialize;
2
3use plexus_engine::{
4    assess_embedded_graph_rag_plan, op_ordering_contract, required_capabilities, ExprKind, OpKind,
5};
6use plexus_serde::{validate_plan_structure, ColDef, Op, Plan};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct ExplainReport {
10    pub version: String,
11    pub root_op: u32,
12    pub op_count: usize,
13    pub structural_errors: Vec<String>,
14    pub required_ops: Vec<String>,
15    pub required_exprs: Vec<String>,
16    pub embedded_graph_rag: EmbeddedGraphRagExplain,
17    pub operations: Vec<ExplainedOp>,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct EmbeddedGraphRagExplain {
22    pub profile: String,
23    pub supported: bool,
24    pub missing_required_ops: Vec<String>,
25    pub unsupported_ops: Vec<String>,
26    pub unsupported_exprs: Vec<String>,
27    pub graph_ref_unsupported: bool,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct ExplainedOp {
32    pub index: usize,
33    pub kind: String,
34    pub inputs: Vec<u32>,
35    pub schema: Vec<String>,
36    pub ordering_contract: String,
37    pub has_graph_ref: bool,
38}
39
40pub fn explain_plan(plan: &Plan) -> ExplainReport {
41    let required = required_capabilities(plan);
42    let embedded_graph_rag = assess_embedded_graph_rag_plan(plan);
43    let structural_errors = match validate_plan_structure(plan) {
44        Ok(()) => Vec::new(),
45        Err(errs) => errs.into_iter().map(|e| e.to_string()).collect(),
46    };
47
48    let operations = plan
49        .ops
50        .iter()
51        .enumerate()
52        .map(|(index, op)| {
53            let kind = op_kind(op);
54            let ordering = op_ordering_contract(kind);
55            ExplainedOp {
56                index,
57                kind: kind.as_str().to_string(),
58                inputs: op_inputs(op),
59                schema: op_schema(op),
60                ordering_contract: format!("{ordering:?}"),
61                has_graph_ref: op_has_graph_ref(op),
62            }
63        })
64        .collect();
65
66    ExplainReport {
67        version: format!(
68            "{}.{}.{} ({})",
69            plan.version.major, plan.version.minor, plan.version.patch, plan.version.producer
70        ),
71        root_op: plan.root_op,
72        op_count: plan.ops.len(),
73        structural_errors,
74        required_ops: required
75            .required_ops
76            .iter()
77            .map(|k| k.as_str().to_string())
78            .collect(),
79        required_exprs: required
80            .required_exprs
81            .iter()
82            .map(|k| k.as_str().to_string())
83            .collect(),
84        embedded_graph_rag: EmbeddedGraphRagExplain {
85            profile: embedded_graph_rag.profile,
86            supported: embedded_graph_rag.supported,
87            missing_required_ops: embedded_graph_rag.missing_required_ops,
88            unsupported_ops: embedded_graph_rag.unsupported_ops,
89            unsupported_exprs: embedded_graph_rag.unsupported_exprs,
90            graph_ref_unsupported: embedded_graph_rag.graph_ref_unsupported,
91        },
92        operations,
93    }
94}
95
96fn op_schema(op: &Op) -> Vec<String> {
97    fn cols(schema: &[ColDef]) -> Vec<String> {
98        schema
99            .iter()
100            .map(|c| format!("{}:{:?}", c.name, c.kind))
101            .collect()
102    }
103
104    match op {
105        Op::ScanNodes { schema, .. }
106        | Op::ScanRels { schema, .. }
107        | Op::Expand { schema, .. }
108        | Op::OptionalExpand { schema, .. }
109        | Op::SemiExpand { schema, .. }
110        | Op::ExpandVarLen { schema, .. }
111        | Op::Project { schema, .. }
112        | Op::Aggregate { schema, .. }
113        | Op::Unwind { schema, .. }
114        | Op::PathConstruct { schema, .. }
115        | Op::Union { schema, .. }
116        | Op::CreateNode { schema, .. }
117        | Op::CreateRel { schema, .. }
118        | Op::Merge { schema, .. }
119        | Op::Delete { schema, .. }
120        | Op::SetProperty { schema, .. }
121        | Op::RemoveProperty { schema, .. }
122        | Op::VectorScan { schema, .. }
123        | Op::Rerank { schema, .. } => cols(schema),
124        Op::Filter { .. }
125        | Op::BlockMarker { .. }
126        | Op::Sort { .. }
127        | Op::Limit { .. }
128        | Op::Return { .. }
129        | Op::ConstRow => Vec::new(),
130    }
131}
132
133pub(crate) fn op_inputs(op: &Op) -> Vec<u32> {
134    match op {
135        Op::ScanNodes { .. } | Op::ScanRels { .. } | Op::ConstRow => Vec::new(),
136        Op::Union { lhs, rhs, .. } => vec![*lhs, *rhs],
137        Op::Expand { input, .. }
138        | Op::OptionalExpand { input, .. }
139        | Op::SemiExpand { input, .. }
140        | Op::ExpandVarLen { input, .. }
141        | Op::Filter { input, .. }
142        | Op::BlockMarker { input, .. }
143        | Op::Project { input, .. }
144        | Op::Aggregate { input, .. }
145        | Op::Sort { input, .. }
146        | Op::Limit { input, .. }
147        | Op::Unwind { input, .. }
148        | Op::PathConstruct { input, .. }
149        | Op::CreateNode { input, .. }
150        | Op::CreateRel { input, .. }
151        | Op::Merge { input, .. }
152        | Op::Delete { input, .. }
153        | Op::SetProperty { input, .. }
154        | Op::RemoveProperty { input, .. }
155        | Op::VectorScan { input, .. }
156        | Op::Rerank { input, .. }
157        | Op::Return { input } => vec![*input],
158    }
159}
160
161pub(crate) fn op_has_graph_ref(op: &Op) -> bool {
162    match op {
163        Op::ScanNodes { graph_ref, .. }
164        | Op::Expand { graph_ref, .. }
165        | Op::OptionalExpand { graph_ref, .. }
166        | Op::ExpandVarLen { graph_ref, .. } => graph_ref.is_some(),
167        _ => false,
168    }
169}
170
171pub(crate) fn op_kind(op: &Op) -> OpKind {
172    match op {
173        Op::ScanNodes { .. } => OpKind::ScanNodes,
174        Op::ScanRels { .. } => OpKind::ScanRels,
175        Op::Expand { .. } => OpKind::Expand,
176        Op::OptionalExpand { .. } => OpKind::OptionalExpand,
177        Op::SemiExpand { .. } => OpKind::SemiExpand,
178        Op::ExpandVarLen { .. } => OpKind::ExpandVarLen,
179        Op::Filter { .. } => OpKind::Filter,
180        Op::BlockMarker { .. } => OpKind::BlockMarker,
181        Op::Project { .. } => OpKind::Project,
182        Op::Aggregate { .. } => OpKind::Aggregate,
183        Op::Sort { .. } => OpKind::Sort,
184        Op::Limit { .. } => OpKind::Limit,
185        Op::Unwind { .. } => OpKind::Unwind,
186        Op::PathConstruct { .. } => OpKind::PathConstruct,
187        Op::Union { .. } => OpKind::Union,
188        Op::CreateNode { .. } => OpKind::CreateNode,
189        Op::CreateRel { .. } => OpKind::CreateRel,
190        Op::Merge { .. } => OpKind::Merge,
191        Op::Delete { .. } => OpKind::Delete,
192        Op::SetProperty { .. } => OpKind::SetProperty,
193        Op::RemoveProperty { .. } => OpKind::RemoveProperty,
194        Op::VectorScan { .. } => OpKind::VectorScan,
195        Op::Rerank { .. } => OpKind::Rerank,
196        Op::Return { .. } => OpKind::Return,
197        Op::ConstRow => OpKind::ConstRow,
198    }
199}
200
201pub fn expr_kind_name(kind: ExprKind) -> &'static str {
202    kind.as_str()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use plexus_ir::mlir_text_to_plan;
209    use plexus_serde::{current_plan_version, ColDef, ColKind, Op, Plan};
210    use std::fs;
211    use std::path::Path;
212
213    #[test]
214    fn explain_includes_ops_and_capabilities() {
215        let plan = Plan {
216            version: current_plan_version("test"),
217            ops: vec![
218                Op::ScanNodes {
219                    labels: vec!["Person".to_string()],
220                    schema: vec![ColDef {
221                        name: "n".to_string(),
222                        kind: ColKind::Node,
223                        logical_type: plexus_serde::LogicalType::Unknown,
224                    }],
225                    must_labels: Vec::new(),
226                    forbidden_labels: Vec::new(),
227                    est_rows: 10,
228                    selectivity: 1.0,
229                    graph_ref: Some("social".to_string()),
230                },
231                Op::Return { input: 0 },
232            ],
233            root_op: 1,
234        };
235
236        let report = explain_plan(&plan);
237        assert_eq!(report.op_count, 2);
238        assert!(report.required_ops.iter().any(|x| x == "ScanNodes"));
239        assert!(report.operations[0].has_graph_ref);
240        assert!(!report.embedded_graph_rag.supported);
241        assert!(report
242            .embedded_graph_rag
243            .unsupported_ops
244            .iter()
245            .any(|op| op == "ScanNodes"));
246    }
247
248    #[test]
249    fn explain_reports_embedded_graph_rag_profile_support() {
250        let plan = Plan {
251            version: current_plan_version("test"),
252            ops: vec![
253                Op::ConstRow,
254                Op::VectorScan {
255                    input: 0,
256                    collection: "docs".to_string(),
257                    query_vector: plexus_serde::Expr::ListLiteral {
258                        items: vec![
259                            plexus_serde::Expr::FloatLiteral(1.0),
260                            plexus_serde::Expr::FloatLiteral(0.0),
261                        ],
262                    },
263                    metric: plexus_serde::VectorMetric::DotProduct,
264                    top_k: 8,
265                    approx_hint: false,
266                    schema: vec![
267                        ColDef {
268                            name: "doc".to_string(),
269                            kind: ColKind::Node,
270                            logical_type: plexus_serde::LogicalType::Unknown,
271                        },
272                        ColDef {
273                            name: "score".to_string(),
274                            kind: ColKind::Float64,
275                            logical_type: plexus_serde::LogicalType::Float64,
276                        },
277                    ],
278                },
279                Op::Rerank {
280                    input: 1,
281                    score_expr: plexus_serde::Expr::VectorSimilarity {
282                        metric: plexus_serde::VectorMetric::DotProduct,
283                        lhs: Box::new(plexus_serde::Expr::PropAccess {
284                            col: 0,
285                            prop: "embedding".to_string(),
286                        }),
287                        rhs: Box::new(plexus_serde::Expr::ListLiteral {
288                            items: vec![
289                                plexus_serde::Expr::FloatLiteral(1.0),
290                                plexus_serde::Expr::FloatLiteral(0.0),
291                            ],
292                        }),
293                    },
294                    top_k: 4,
295                    schema: vec![
296                        ColDef {
297                            name: "doc".to_string(),
298                            kind: ColKind::Node,
299                            logical_type: plexus_serde::LogicalType::Unknown,
300                        },
301                        ColDef {
302                            name: "score".to_string(),
303                            kind: ColKind::Float64,
304                            logical_type: plexus_serde::LogicalType::Float64,
305                        },
306                    ],
307                },
308                Op::Return { input: 2 },
309            ],
310            root_op: 3,
311        };
312
313        let report = explain_plan(&plan);
314        assert!(report.embedded_graph_rag.supported);
315        assert!(report.embedded_graph_rag.missing_required_ops.is_empty());
316        assert!(report.embedded_graph_rag.unsupported_ops.is_empty());
317    }
318
319    #[test]
320    fn explain_embedded_graphrag_fixture_is_supported() {
321        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
322            .join("../plexus-engine/src/tests/fixtures/vector/003_embedded_graphrag.feature");
323        let text = fs::read_to_string(&fixture).expect("read embedded GraphRAG fixture");
324        let start = text
325            .find("func.func @embedded_graphrag_vector_first()")
326            .expect("fixture plan start");
327        let end = text[start..]
328            .find("\"\"\"")
329            .map(|offset| start + offset)
330            .expect("fixture plan end");
331        let plan_mlir = &text[start..end];
332        let plan = mlir_text_to_plan(plan_mlir).expect("parse fixture plan");
333
334        let report = explain_plan(&plan);
335        assert!(report.embedded_graph_rag.supported);
336        assert_eq!(report.operations.len(), 5);
337        assert!(report.required_ops.iter().any(|op| op == "VectorScan"));
338        assert!(report.required_ops.iter().any(|op| op == "Rerank"));
339    }
340}