Skip to main content

ops_rs/
batch_metadata.rs

1use crate::prelude::*;
2use serde_json::{json, Map, Value as JsonValue};
3use std::collections::{HashMap, HashSet};
4
5/// Analyzes and constructs metadata for batch operations by understanding data flow
6pub struct BatchMetadataBuilder {
7    ops_metadata: Vec<OpMetadata>,
8}
9
10impl BatchMetadataBuilder {
11    pub fn new<T>(ops: &[Arc<dyn Op<T>>]) -> Self {
12        Self {
13            ops_metadata: ops.iter().map(|op| op.metadata()).collect(),
14        }
15    }
16
17    /// Build the complete metadata for a batch operation
18    pub fn build(&self) -> OpMetadata {
19        let (input_schema, outputs_by_index) = self.analyze_input_requirements();
20        let reference_schema = self.merge_reference_schemas();
21        let output_schema = self.construct_output_schema(&outputs_by_index);
22
23        OpMetadata::builder("BatchOp")
24            .description(format!(
25                "Batch of {} operations with data flow analysis",
26                self.ops_metadata.len()
27            ))
28            .input_schema(input_schema)
29            .reference_schema(reference_schema)
30            .output_schema(output_schema)
31            .build()
32    }
33
34    /// Analyze input requirements considering data flow between ops
35    /// Returns the final input schema and a map of outputs by op index
36    fn analyze_input_requirements(&self) -> (JsonValue, HashMap<usize, HashSet<String>>) {
37        let mut required_inputs = HashSet::new();
38        let mut available_outputs = HashSet::new();
39        let mut outputs_by_index = HashMap::new();
40
41        // Analyze each op in sequence
42        for (index, metadata) in self.ops_metadata.iter().enumerate() {
43            // Collect this op's required inputs
44            if let Some(input_schema) = &metadata.input_schema {
45                if let Some(required) = self.extract_required_fields(input_schema) {
46                    for field in required {
47                        // Only add to required inputs if not produced by a previous op
48                        if !available_outputs.contains(&field) {
49                            required_inputs.insert(field);
50                        }
51                    }
52                }
53            }
54
55            // Collect this op's outputs
56            let op_outputs = self.extract_output_fields(&metadata.output_schema);
57            outputs_by_index.insert(index, op_outputs.clone());
58            available_outputs.extend(op_outputs);
59        }
60
61        // Build the final input schema with only unsatisfied requirements
62        let schema = self.build_input_schema_from_requirements(required_inputs);
63        (schema, outputs_by_index)
64    }
65
66    /// Extract required fields from an input schema
67    fn extract_required_fields(&self, schema: &JsonValue) -> Option<Vec<String>> {
68        schema
69            .as_object()
70            .and_then(|obj| obj.get("required"))
71            .and_then(|req| req.as_array())
72            .map(|arr| {
73                arr.iter()
74                    .filter_map(|v| v.as_str())
75                    .map(|s| s.to_string())
76                    .collect()
77            })
78    }
79
80    /// Extract output field names from an output schema
81    fn extract_output_fields(&self, schema: &Option<JsonValue>) -> HashSet<String> {
82        let mut fields = HashSet::new();
83
84        if let Some(schema) = schema {
85            // Handle different schema types
86            if let Some(obj) = schema.as_object() {
87                // Object schema with properties
88                if let Some(properties) = obj.get("properties").and_then(|p| p.as_object()) {
89                    fields.extend(properties.keys().cloned());
90                } else if obj.get("type").and_then(|t| t.as_str()) == Some("string") {
91                    // Single string output - we'll need to infer the field name from op name
92                    // This is a simplification; in practice, you might want a naming convention
93                    fields.insert("result".to_string());
94                }
95            }
96        }
97
98        fields
99    }
100
101    /// Build input schema from required fields
102    fn build_input_schema_from_requirements(&self, required_fields: HashSet<String>) -> JsonValue {
103        let mut properties = Map::new();
104        let mut required = Vec::new();
105
106        // Collect properties from all ops that match required fields
107        for metadata in &self.ops_metadata {
108            if let Some(input_schema) = &metadata.input_schema {
109                if let Some(schema_props) = input_schema
110                    .as_object()
111                    .and_then(|obj| obj.get("properties"))
112                    .and_then(|p| p.as_object())
113                {
114                    for (field_name, field_schema) in schema_props {
115                        if required_fields.contains(field_name)
116                            && !properties.contains_key(field_name)
117                        {
118                            properties.insert(field_name.clone(), field_schema.clone());
119                            required.push(JsonValue::String(field_name.clone()));
120                        }
121                    }
122                }
123            }
124        }
125
126        json!({
127            "type": "object",
128            "properties": properties,
129            "required": required,
130            "additionalProperties": false
131        })
132    }
133
134    /// Merge reference schemas from all ops (union of all requirements)
135    fn merge_reference_schemas(&self) -> JsonValue {
136        let mut all_properties = Map::new();
137        let mut all_required = HashSet::new();
138
139        for metadata in &self.ops_metadata {
140            if let Some(ref_schema) = &metadata.reference_schema {
141                if let Some(obj) = ref_schema.as_object() {
142                    // Merge properties
143                    if let Some(properties) = obj.get("properties").and_then(|p| p.as_object()) {
144                        for (key, value) in properties {
145                            if !all_properties.contains_key(key) {
146                                all_properties.insert(key.clone(), value.clone());
147                            }
148                        }
149                    }
150
151                    // Merge required fields
152                    if let Some(required) = obj.get("required").and_then(|r| r.as_array()) {
153                        for req in required {
154                            if let Some(field) = req.as_str() {
155                                all_required.insert(field.to_string());
156                            }
157                        }
158                    }
159                }
160            }
161        }
162
163        let required: Vec<JsonValue> = all_required.into_iter().map(JsonValue::String).collect();
164
165        json!({
166            "type": "object",
167            "properties": all_properties,
168            "required": required,
169            "additionalProperties": false
170        })
171    }
172
173    /// Construct output schema as array of all distinct outputs
174    fn construct_output_schema(
175        &self,
176        _outputs_by_index: &HashMap<usize, HashSet<String>>,
177    ) -> JsonValue {
178        // For batch ops, the output is a Vec<T>, so we return an array schema
179        // This is a simplified version - in practice, you might want to preserve
180        // the structure of individual outputs
181        json!({
182            "type": "array",
183            "items": {
184                "type": "object",
185                "description": "Output from individual ops in the batch"
186            },
187            "minItems": self.ops_metadata.len(),
188            "maxItems": self.ops_metadata.len()
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    struct ProducerOp;
198    struct ConsumerOp;
199
200    #[async_trait]
201    impl Op<String> for ProducerOp {
202        async fn perform(&self, dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<String> {
203            let input = dry.get_required::<String>("initial_input")?;
204            Ok(format!("produced_{}", input))
205        }
206
207        fn metadata(&self) -> OpMetadata {
208            OpMetadata::builder("ProducerOp")
209                .input_schema(json!({
210                    "type": "object",
211                    "properties": {
212                        "initial_input": { "type": "string" }
213                    },
214                    "required": ["initial_input"]
215                }))
216                .output_schema(json!({
217                    "type": "object",
218                    "properties": {
219                        "produced_value": { "type": "string" }
220                    }
221                }))
222                .build()
223        }
224    }
225
226    #[async_trait]
227    impl Op<String> for ConsumerOp {
228        async fn perform(&self, dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<String> {
229            let value = dry.get_required::<String>("produced_value")?;
230            Ok(format!("consumed_{}", value))
231        }
232
233        fn metadata(&self) -> OpMetadata {
234            OpMetadata::builder("ConsumerOp")
235                .input_schema(json!({
236                    "type": "object",
237                    "properties": {
238                        "produced_value": { "type": "string" }
239                    },
240                    "required": ["produced_value"]
241                }))
242                .output_schema(json!({
243                    "type": "object",
244                    "properties": {
245                        "final_result": { "type": "string" }
246                    }
247                }))
248                .build()
249        }
250    }
251
252    // TEST0047: Build BatchMetadata from producer/consumer ops and verify only external inputs are required
253    #[test]
254    fn test0047_batch_metadata_with_data_flow() {
255        let ops: Vec<Arc<dyn Op<String>>> = vec![Arc::new(ProducerOp), Arc::new(ConsumerOp)];
256
257        let builder = BatchMetadataBuilder::new(&ops);
258        let metadata = builder.build();
259
260        // The batch should only require initial_input since produced_value
261        // is satisfied by the ProducerOp
262        if let Some(input_schema) = metadata.input_schema {
263            let required = input_schema
264                .get("required")
265                .and_then(|r| r.as_array())
266                .unwrap();
267            assert_eq!(required.len(), 1);
268            assert_eq!(required[0].as_str().unwrap(), "initial_input");
269        }
270    }
271
272    // TEST0048: Build BatchMetadata from two ops with different reference schemas and verify union of required refs
273    #[test]
274    fn test0048_reference_schema_merging() {
275        struct ServiceAOp;
276        struct ServiceBOp;
277
278        #[async_trait]
279        impl Op<()> for ServiceAOp {
280            async fn perform(&self, _dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<()> {
281                Ok(())
282            }
283
284            fn metadata(&self) -> OpMetadata {
285                OpMetadata::builder("ServiceAOp")
286                    .reference_schema(json!({
287                        "type": "object",
288                        "properties": {
289                            "service_a": { "type": "ServiceA" }
290                        },
291                        "required": ["service_a"]
292                    }))
293                    .build()
294            }
295        }
296
297        #[async_trait]
298        impl Op<()> for ServiceBOp {
299            async fn perform(&self, _dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<()> {
300                Ok(())
301            }
302
303            fn metadata(&self) -> OpMetadata {
304                OpMetadata::builder("ServiceBOp")
305                    .reference_schema(json!({
306                        "type": "object",
307                        "properties": {
308                            "service_b": { "type": "ServiceB" }
309                        },
310                        "required": ["service_b"]
311                    }))
312                    .build()
313            }
314        }
315
316        let ops: Vec<Arc<dyn Op<()>>> = vec![Arc::new(ServiceAOp), Arc::new(ServiceBOp)];
317
318        let builder = BatchMetadataBuilder::new(&ops);
319        let metadata = builder.build();
320
321        // The batch should require both services
322        if let Some(ref_schema) = metadata.reference_schema {
323            let required = ref_schema
324                .get("required")
325                .and_then(|r| r.as_array())
326                .unwrap();
327            assert_eq!(required.len(), 2);
328
329            let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
330            assert!(required_strs.contains(&"service_a"));
331            assert!(required_strs.contains(&"service_b"));
332        }
333    }
334}