Skip to main content

plexus_devtools/
validate.rs

1use serde::Serialize;
2
3use plexus_engine::{
4    assess_embedded_graph_rag_capabilities, assess_embedded_graph_rag_plan, required_capabilities,
5    validate_plan_against_capabilities, CapabilityError, EngineCapabilities,
6};
7use plexus_serde::{validate_plan_structure, Plan};
8
9#[derive(Debug, Clone, Serialize)]
10pub struct ValidateReport {
11    pub structural_errors: Vec<String>,
12    pub required_ops: Vec<String>,
13    pub required_exprs: Vec<String>,
14    pub embedded_graph_rag_plan: EmbeddedGraphRagValidate,
15    pub engine_capability_gap: Option<EngineCapabilityGapReport>,
16    pub embedded_graph_rag_capability_gap: Option<EmbeddedGraphRagCapabilityGapReport>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct EmbeddedGraphRagValidate {
21    pub profile: String,
22    pub supported: bool,
23    pub missing_required_ops: Vec<String>,
24    pub unsupported_ops: Vec<String>,
25    pub unsupported_exprs: Vec<String>,
26    pub graph_ref_unsupported: bool,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct EngineCapabilityGapReport {
31    pub supported: bool,
32    pub missing_ops: Vec<String>,
33    pub missing_exprs: Vec<String>,
34    pub graph_ref_unsupported: bool,
35    pub multi_graph_unsupported: bool,
36    pub graph_param_unsupported: bool,
37    pub unsupported_plan_version: Option<String>,
38    pub raw_error: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct EmbeddedGraphRagCapabilityGapReport {
43    pub profile: String,
44    pub supported: bool,
45    pub missing_ops: Vec<String>,
46    pub missing_exprs: Vec<String>,
47}
48
49pub fn validate_plan(plan: &Plan, capabilities: Option<&EngineCapabilities>) -> ValidateReport {
50    let required = required_capabilities(plan);
51    let embedded_graph_rag_plan = assess_embedded_graph_rag_plan(plan);
52    let structural_errors = match validate_plan_structure(plan) {
53        Ok(()) => Vec::new(),
54        Err(errs) => errs.into_iter().map(|e| e.to_string()).collect(),
55    };
56
57    let (engine_capability_gap, embedded_graph_rag_capability_gap) = capabilities
58        .map(|caps| {
59            (
60                Some(capability_gap_report(plan, caps)),
61                Some(embedded_graph_rag_capability_gap_report(caps)),
62            )
63        })
64        .unwrap_or((None, None));
65
66    ValidateReport {
67        structural_errors,
68        required_ops: required
69            .required_ops
70            .iter()
71            .map(|k| k.as_str().to_string())
72            .collect(),
73        required_exprs: required
74            .required_exprs
75            .iter()
76            .map(|k| k.as_str().to_string())
77            .collect(),
78        embedded_graph_rag_plan: EmbeddedGraphRagValidate {
79            profile: embedded_graph_rag_plan.profile,
80            supported: embedded_graph_rag_plan.supported,
81            missing_required_ops: embedded_graph_rag_plan.missing_required_ops,
82            unsupported_ops: embedded_graph_rag_plan.unsupported_ops,
83            unsupported_exprs: embedded_graph_rag_plan.unsupported_exprs,
84            graph_ref_unsupported: embedded_graph_rag_plan.graph_ref_unsupported,
85        },
86        engine_capability_gap,
87        embedded_graph_rag_capability_gap,
88    }
89}
90
91fn embedded_graph_rag_capability_gap_report(
92    capabilities: &EngineCapabilities,
93) -> EmbeddedGraphRagCapabilityGapReport {
94    let gap = assess_embedded_graph_rag_capabilities(capabilities);
95    EmbeddedGraphRagCapabilityGapReport {
96        profile: gap.profile,
97        supported: gap.supported,
98        missing_ops: gap.missing_ops,
99        missing_exprs: gap.missing_exprs,
100    }
101}
102
103fn capability_gap_report(
104    plan: &Plan,
105    capabilities: &EngineCapabilities,
106) -> EngineCapabilityGapReport {
107    match validate_plan_against_capabilities(plan, capabilities) {
108        Ok(()) => EngineCapabilityGapReport {
109            supported: true,
110            missing_ops: Vec::new(),
111            missing_exprs: Vec::new(),
112            graph_ref_unsupported: false,
113            multi_graph_unsupported: false,
114            graph_param_unsupported: false,
115            unsupported_plan_version: None,
116            raw_error: None,
117        },
118        Err(error) => capability_error_to_gap(error),
119    }
120}
121
122fn capability_error_to_gap(error: CapabilityError) -> EngineCapabilityGapReport {
123    match error {
124        CapabilityError::MissingFeatureSupport {
125            missing_ops,
126            missing_exprs,
127        } => EngineCapabilityGapReport {
128            supported: false,
129            missing_ops: missing_ops
130                .into_iter()
131                .map(|op| op.as_str().to_string())
132                .collect(),
133            missing_exprs: missing_exprs
134                .into_iter()
135                .map(|expr| expr.as_str().to_string())
136                .collect(),
137            graph_ref_unsupported: false,
138            multi_graph_unsupported: false,
139            graph_param_unsupported: false,
140            unsupported_plan_version: None,
141            raw_error: None,
142        },
143        CapabilityError::GraphRefUnsupported => EngineCapabilityGapReport {
144            supported: false,
145            missing_ops: Vec::new(),
146            missing_exprs: Vec::new(),
147            graph_ref_unsupported: true,
148            multi_graph_unsupported: false,
149            graph_param_unsupported: false,
150            unsupported_plan_version: None,
151            raw_error: None,
152        },
153        CapabilityError::MultiGraphUnsupported => EngineCapabilityGapReport {
154            supported: false,
155            missing_ops: Vec::new(),
156            missing_exprs: Vec::new(),
157            graph_ref_unsupported: false,
158            multi_graph_unsupported: true,
159            graph_param_unsupported: false,
160            unsupported_plan_version: None,
161            raw_error: None,
162        },
163        CapabilityError::GraphParamUnsupported => EngineCapabilityGapReport {
164            supported: false,
165            missing_ops: Vec::new(),
166            missing_exprs: Vec::new(),
167            graph_ref_unsupported: false,
168            multi_graph_unsupported: false,
169            graph_param_unsupported: true,
170            unsupported_plan_version: None,
171            raw_error: None,
172        },
173        CapabilityError::UnsupportedPlanVersion {
174            plan_major,
175            plan_minor,
176            plan_patch,
177            min_major,
178            min_minor,
179            min_patch,
180            max_major,
181            max_minor,
182            max_patch,
183        } => EngineCapabilityGapReport {
184            supported: false,
185            missing_ops: Vec::new(),
186            missing_exprs: Vec::new(),
187            graph_ref_unsupported: false,
188            multi_graph_unsupported: false,
189            graph_param_unsupported: false,
190            unsupported_plan_version: Some(format!(
191                "plan {plan_major}.{plan_minor}.{plan_patch}; supported {min_major}.{min_minor}.{min_patch}..={max_major}.{max_minor}.{max_patch}"
192            )),
193            raw_error: None,
194        },
195        other => EngineCapabilityGapReport {
196            supported: false,
197            missing_ops: Vec::new(),
198            missing_exprs: Vec::new(),
199            graph_ref_unsupported: false,
200            multi_graph_unsupported: false,
201            graph_param_unsupported: false,
202            unsupported_plan_version: None,
203            raw_error: Some(other.to_string()),
204        },
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use plexus_engine::{ExprKind, OpKind, PlanSemver, VersionRange};
212    use plexus_serde::{
213        current_plan_version, ColDef, ColKind, Expr, LogicalType, Op, Plan, VectorMetric,
214    };
215
216    fn embedded_plan() -> Plan {
217        Plan {
218            version: current_plan_version("test"),
219            ops: vec![
220                Op::ConstRow,
221                Op::VectorScan {
222                    input: 0,
223                    collection: "docs".to_string(),
224                    query_vector: Expr::ListLiteral {
225                        items: vec![Expr::FloatLiteral(1.0), Expr::FloatLiteral(0.0)],
226                    },
227                    metric: VectorMetric::DotProduct,
228                    top_k: 8,
229                    approx_hint: false,
230                    schema: vec![
231                        ColDef {
232                            name: "doc".to_string(),
233                            kind: ColKind::Node,
234                            logical_type: LogicalType::Unknown,
235                        },
236                        ColDef {
237                            name: "score".to_string(),
238                            kind: ColKind::Float64,
239                            logical_type: LogicalType::Float64,
240                        },
241                    ],
242                },
243                Op::Rerank {
244                    input: 1,
245                    score_expr: Expr::VectorSimilarity {
246                        metric: VectorMetric::DotProduct,
247                        lhs: Box::new(Expr::PropAccess {
248                            col: 0,
249                            prop: "embedding".to_string(),
250                        }),
251                        rhs: Box::new(Expr::ListLiteral {
252                            items: vec![Expr::FloatLiteral(1.0), Expr::FloatLiteral(0.0)],
253                        }),
254                    },
255                    top_k: 4,
256                    schema: vec![
257                        ColDef {
258                            name: "doc".to_string(),
259                            kind: ColKind::Node,
260                            logical_type: LogicalType::Unknown,
261                        },
262                        ColDef {
263                            name: "score".to_string(),
264                            kind: ColKind::Float64,
265                            logical_type: LogicalType::Float64,
266                        },
267                    ],
268                },
269                Op::Return { input: 2 },
270            ],
271            root_op: 3,
272        }
273    }
274
275    #[test]
276    fn validate_reports_capability_gaps() {
277        let plan = embedded_plan();
278        let mut caps = EngineCapabilities::full(VersionRange::new(
279            PlanSemver::new(0, 3, 0),
280            PlanSemver::new(0, 3, 0),
281        ));
282        caps.supported_ops.remove(&OpKind::Rerank);
283        caps.supported_exprs.remove(&ExprKind::VectorSimilarity);
284
285        let report = validate_plan(&plan, Some(&caps));
286        let engine_gap = report
287            .engine_capability_gap
288            .expect("engine capability gap report");
289        assert!(!engine_gap.supported);
290        assert_eq!(engine_gap.missing_ops, vec!["Rerank"]);
291        assert_eq!(engine_gap.missing_exprs, vec!["VectorSimilarity"]);
292
293        let embedded_gap = report
294            .embedded_graph_rag_capability_gap
295            .expect("embedded profile capability gap");
296        assert_eq!(embedded_gap.missing_ops, vec!["Rerank"]);
297        assert_eq!(embedded_gap.missing_exprs, vec!["VectorSimilarity"]);
298    }
299}