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}