Skip to main content

synth_ai_core/orchestration/
graph_validation.rs

1//! GraphGen (graph optimization) validation helpers.
2
3use crate::errors::CoreError;
4use serde_json::{Map, Value};
5use std::collections::HashSet;
6
7#[derive(Debug, Clone, Default)]
8pub struct GraphGenValidationResult {
9    pub errors: Vec<Value>,
10    pub warnings: Vec<String>,
11}
12
13#[derive(Debug, Clone)]
14struct GraphOptModels {
15    policy_models: HashSet<String>,
16}
17
18fn load_graph_opt_models() -> Option<GraphOptModels> {
19    let raw = include_str!("../../assets/supported_models.json");
20    let value: Value = serde_json::from_str(raw).ok()?;
21    let graph_opt = value.get("graph_opt")?.as_object()?;
22    let policy_models = graph_opt.get("policy_models")?.as_array()?;
23    let mut set = HashSet::new();
24    for item in policy_models {
25        if let Some(name) = item.as_str() {
26            set.insert(name.to_string());
27        }
28    }
29    Some(GraphOptModels { policy_models: set })
30}
31
32static GRAPH_OPT_MODELS: once_cell::sync::Lazy<Option<GraphOptModels>> =
33    once_cell::sync::Lazy::new(load_graph_opt_models);
34
35fn value_to_string(value: &Value) -> Option<String> {
36    match value {
37        Value::String(s) => Some(s.to_string()),
38        Value::Number(n) => Some(n.to_string()),
39        Value::Bool(b) => Some(b.to_string()),
40        _ => None,
41    }
42}
43
44fn parse_int(value: &Value) -> Option<i64> {
45    match value {
46        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
47        Value::String(s) => s.trim().parse::<i64>().ok(),
48        _ => None,
49    }
50}
51
52fn similarity(a: &str, b: &str) -> f64 {
53    let a_chars: Vec<char> = a.chars().collect();
54    let b_chars: Vec<char> = b.chars().collect();
55    let max_len = a_chars.len().max(b_chars.len()).max(1);
56    let dist = levenshtein(&a_chars, &b_chars) as f64;
57    1.0 - (dist / max_len as f64)
58}
59
60fn levenshtein(a: &[char], b: &[char]) -> usize {
61    let mut costs: Vec<usize> = (0..=b.len()).collect();
62    for (i, ca) in a.iter().enumerate() {
63        let mut prev = costs[0];
64        costs[0] = i + 1;
65        for (j, cb) in b.iter().enumerate() {
66            let temp = costs[j + 1];
67            let mut new_cost = prev + if ca == cb { 0 } else { 1 };
68            new_cost = new_cost.min(costs[j] + 1).min(temp + 1);
69            costs[j + 1] = new_cost;
70            prev = temp;
71        }
72    }
73    costs[b.len()]
74}
75
76fn find_similar_models(model: &str, supported: &HashSet<String>) -> Vec<String> {
77    let mut scored: Vec<(f64, String)> = supported
78        .iter()
79        .map(|candidate| (similarity(model, candidate), candidate.clone()))
80        .filter(|(score, _)| *score >= 0.4)
81        .collect();
82    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
83    scored.into_iter().take(3).map(|(_, m)| m).collect()
84}
85
86fn push_error(
87    errors: &mut Vec<Value>,
88    field: &str,
89    error: String,
90    suggestion: Option<String>,
91    similar: Option<Vec<String>>,
92) {
93    let mut map = Map::new();
94    map.insert("field".to_string(), Value::String(field.to_string()));
95    map.insert("error".to_string(), Value::String(error));
96    if let Some(suggestion) = suggestion {
97        map.insert("suggestion".to_string(), Value::String(suggestion));
98    }
99    if let Some(similar) = similar {
100        map.insert(
101            "similar".to_string(),
102            Value::Array(similar.into_iter().map(Value::String).collect()),
103        );
104    }
105    errors.push(Value::Object(map));
106}
107
108pub fn validate_graphgen_job_config(config: &Value, dataset: &Value) -> GraphGenValidationResult {
109    let mut result = GraphGenValidationResult::default();
110
111    let config_map = match config.as_object() {
112        Some(map) => map,
113        None => {
114            push_error(
115                &mut result.errors,
116                "policy_models",
117                "policy_models is required".to_string(),
118                None,
119                None,
120            );
121            return result;
122        }
123    };
124
125    let policy_models_raw = config_map
126        .get("policy_models")
127        .or_else(|| config_map.get("policy_model"))
128        .or_else(|| config_map.get("model"));
129    let mut policy_models_list: Vec<String> = Vec::new();
130    match policy_models_raw {
131        None => {
132            push_error(
133                &mut result.errors,
134                "policy_models",
135                "policy_models is required".to_string(),
136                Some("Supported models: see graph_opt.policy_models".to_string()),
137                None,
138            );
139        }
140        Some(Value::Array(arr)) => {
141            for item in arr {
142                if let Some(name) = value_to_string(item) {
143                    policy_models_list.push(name);
144                } else {
145                    push_error(
146                        &mut result.errors,
147                        "policy_models",
148                        "policy_models contains empty value".to_string(),
149                        Some("Supported models: see graph_opt.policy_models".to_string()),
150                        None,
151                    );
152                }
153            }
154        }
155        Some(value) => {
156            if let Some(name) = value_to_string(value) {
157                policy_models_list.push(name);
158            } else {
159                push_error(
160                    &mut result.errors,
161                    "policy_models",
162                    "policy_models contains empty value".to_string(),
163                    Some("Supported models: see graph_opt.policy_models".to_string()),
164                    None,
165                );
166            }
167        }
168    }
169
170    if let Some(models) = GRAPH_OPT_MODELS.as_ref() {
171        for policy_model in &policy_models_list {
172            let clean = policy_model.trim();
173            if clean.is_empty() {
174                push_error(
175                    &mut result.errors,
176                    "policy_models",
177                    "policy_models contains empty value".to_string(),
178                    Some("Supported models: see graph_opt.policy_models".to_string()),
179                    None,
180                );
181                continue;
182            }
183            if !models.policy_models.contains(clean) {
184                let similar = find_similar_models(clean, &models.policy_models);
185                push_error(
186                    &mut result.errors,
187                    "policy_models",
188                    format!("Unsupported policy model: {}", clean),
189                    Some(format!("Supported models: {:?}", models.policy_models)),
190                    if similar.is_empty() {
191                        None
192                    } else {
193                        Some(similar)
194                    },
195                );
196            }
197        }
198    }
199
200    if let Some(effort) = config_map.get("proposer_effort").and_then(|v| v.as_str()) {
201        if effort != "low" && effort != "medium" && effort != "high" {
202            push_error(
203                &mut result.errors,
204                "proposer_effort",
205                format!("Invalid proposer_effort: {}", effort),
206                Some("Must be one of: 'low', 'medium', 'high'".to_string()),
207                None,
208            );
209        }
210    }
211
212    let rollout_budget = config_map
213        .get("rollout_budget")
214        .or_else(|| config_map.get("budget"))
215        .and_then(parse_int)
216        .unwrap_or(100);
217    if rollout_budget < 10 {
218        push_error(
219            &mut result.errors,
220            "rollout_budget",
221            format!("rollout_budget must be >= 10, got {}", rollout_budget),
222            None,
223            None,
224        );
225    }
226    if rollout_budget > 10000 {
227        push_error(
228            &mut result.errors,
229            "rollout_budget",
230            format!("rollout_budget must be <= 10000, got {}", rollout_budget),
231            None,
232            None,
233        );
234    }
235
236    let dataset_map = match dataset.as_object() {
237        Some(map) => map,
238        None => {
239            push_error(
240                &mut result.errors,
241                "dataset",
242                "dataset must be a dict".to_string(),
243                None,
244                None,
245            );
246            return result;
247        }
248    };
249
250    match dataset_map.get("tasks") {
251        Some(Value::Array(tasks)) => {
252            if tasks.is_empty() {
253                push_error(
254                    &mut result.errors,
255                    "dataset.tasks",
256                    "Dataset must contain at least one task".to_string(),
257                    None,
258                    None,
259                );
260            } else if tasks.len() < 2 {
261                result.warnings.push(
262                    "GraphGen datasets with <2 tasks are unlikely to optimize meaningfully."
263                        .to_string(),
264                );
265            }
266        }
267        _ => {
268            push_error(
269                &mut result.errors,
270                "dataset.tasks",
271                "Dataset must contain at least one task".to_string(),
272                None,
273                None,
274            );
275        }
276    }
277
278    result
279}
280
281/// Return the graph optimization supported models config from assets.
282pub fn graph_opt_supported_models() -> Value {
283    let raw = include_str!("../../assets/supported_models.json");
284    let value: Value = match serde_json::from_str(raw) {
285        Ok(v) => v,
286        Err(_) => return Value::Object(Map::new()),
287    };
288    value
289        .get("graph_opt")
290        .cloned()
291        .unwrap_or_else(|| Value::Object(Map::new()))
292}
293
294/// Validate a GraphGen taskset payload (dataset-only validation).
295pub fn validate_graphgen_taskset(dataset: &Value) -> Vec<Value> {
296    let mut errors: Vec<Value> = Vec::new();
297
298    let dataset_map = match dataset.as_object() {
299        Some(map) => map,
300        None => {
301            push_error(
302                &mut errors,
303                "dataset",
304                "dataset must be an object".to_string(),
305                None,
306                None,
307            );
308            return errors;
309        }
310    };
311
312    // Metadata name
313    match dataset_map.get("metadata") {
314        Some(Value::Object(meta)) => match meta.get("name") {
315            Some(Value::String(name)) if !name.trim().is_empty() => {}
316            _ => {
317                push_error(
318                    &mut errors,
319                    "metadata.name",
320                    "metadata.name is required".to_string(),
321                    None,
322                    None,
323                );
324            }
325        },
326        _ => {
327            push_error(
328                &mut errors,
329                "metadata",
330                "metadata is required".to_string(),
331                None,
332                None,
333            );
334        }
335    }
336
337    // Tasks
338    let mut task_ids: HashSet<String> = HashSet::new();
339    match dataset_map.get("tasks") {
340        Some(Value::Array(tasks)) => {
341            if tasks.is_empty() {
342                push_error(
343                    &mut errors,
344                    "tasks",
345                    "dataset must contain at least one task".to_string(),
346                    None,
347                    None,
348                );
349            }
350            for (idx, task) in tasks.iter().enumerate() {
351                match task.as_object() {
352                    Some(task_map) => match task_map.get("id") {
353                        Some(Value::String(id)) if !id.trim().is_empty() => {
354                            if !task_ids.insert(id.to_string()) {
355                                push_error(
356                                    &mut errors,
357                                    "tasks.id",
358                                    format!("duplicate task id '{}'", id),
359                                    None,
360                                    None,
361                                );
362                            }
363                        }
364                        _ => {
365                            push_error(
366                                &mut errors,
367                                &format!("tasks[{}].id", idx),
368                                "task id is required".to_string(),
369                                None,
370                                None,
371                            );
372                        }
373                    },
374                    None => {
375                        push_error(
376                            &mut errors,
377                            &format!("tasks[{}]", idx),
378                            "task must be an object".to_string(),
379                            None,
380                            None,
381                        );
382                    }
383                }
384            }
385        }
386        _ => {
387            push_error(
388                &mut errors,
389                "tasks",
390                "dataset.tasks must be a non-empty list".to_string(),
391                None,
392                None,
393            );
394        }
395    }
396
397    // Gold outputs must reference valid tasks if task_id provided.
398    if let Some(Value::Array(gold_outputs)) = dataset_map.get("gold_outputs") {
399        for (idx, gold) in gold_outputs.iter().enumerate() {
400            if let Some(gold_map) = gold.as_object() {
401                if let Some(Value::String(task_id)) = gold_map.get("task_id") {
402                    if !task_id.is_empty() && !task_ids.contains(task_id) {
403                        push_error(
404                            &mut errors,
405                            &format!("gold_outputs[{}].task_id", idx),
406                            format!("invalid task_id '{}'", task_id),
407                            None,
408                            None,
409                        );
410                    }
411                }
412            }
413        }
414    }
415
416    // select_output validation
417    let select_output = dataset_map.get("select_output").or_else(|| {
418        dataset_map
419            .get("metadata")
420            .and_then(|m| m.as_object())
421            .and_then(|m| m.get("select_output"))
422    });
423    if let Some(value) = select_output {
424        match value {
425            Value::Null | Value::String(_) => {}
426            Value::Array(items) => {
427                if !items.iter().all(|item| item.as_str().is_some()) {
428                    push_error(
429                        &mut errors,
430                        "select_output",
431                        "select_output must be a string or list of strings".to_string(),
432                        None,
433                        None,
434                    );
435                }
436            }
437            _ => {
438                push_error(
439                    &mut errors,
440                    "select_output",
441                    "select_output must be a string or list of strings".to_string(),
442                    None,
443                    None,
444                );
445            }
446        }
447    }
448
449    // output_config validation
450    let output_config = dataset_map.get("output_config").or_else(|| {
451        dataset_map
452            .get("metadata")
453            .and_then(|m| m.as_object())
454            .and_then(|m| m.get("output_config"))
455    });
456    if let Some(value) = output_config {
457        if !value.is_null() && !value.is_object() {
458            push_error(
459                &mut errors,
460                "output_config",
461                "output_config must be an object".to_string(),
462                None,
463                None,
464            );
465        }
466    }
467
468    // input_schema/output_schema type checks
469    for field in ["input_schema", "output_schema"] {
470        let value = dataset_map.get(field).or_else(|| {
471            dataset_map
472                .get("metadata")
473                .and_then(|m| m.as_object())
474                .and_then(|m| m.get(field))
475        });
476        if let Some(v) = value {
477            if !v.is_null() && !v.is_object() {
478                push_error(
479                    &mut errors,
480                    field,
481                    format!("{} must be an object", field),
482                    None,
483                    None,
484                );
485            }
486        }
487    }
488
489    errors
490}
491
492/// Parse and validate a GraphGen taskset payload.
493pub fn parse_graphgen_taskset(dataset: &Value) -> Result<Value, CoreError> {
494    let errors = validate_graphgen_taskset(dataset);
495    if !errors.is_empty() {
496        return Err(CoreError::Validation(format!(
497            "invalid GraphGenTaskSet: {} errors",
498            errors.len()
499        )));
500    }
501    Ok(dataset.clone())
502}
503
504/// Load and validate a GraphGen taskset from a JSON file.
505pub fn load_graphgen_taskset(path: &std::path::Path) -> Result<Value, CoreError> {
506    let contents = std::fs::read_to_string(path).map_err(|e| {
507        CoreError::InvalidInput(format!(
508            "failed to read dataset file '{}': {}",
509            path.display(),
510            e
511        ))
512    })?;
513    let value: Value = serde_json::from_str(&contents).map_err(|e| {
514        CoreError::Validation(format!(
515            "failed to parse dataset JSON '{}': {}",
516            path.display(),
517            e
518        ))
519    })?;
520    parse_graphgen_taskset(&value)
521}
522
523// =============================================================================
524// Graph TOML validation helpers
525// =============================================================================
526
527fn push_graph_error(
528    errors: &mut Vec<Value>,
529    field: &str,
530    error: String,
531    suggestion: Option<String>,
532) {
533    let mut map = Map::new();
534    map.insert("field".to_string(), Value::String(field.to_string()));
535    map.insert("error".to_string(), Value::String(error));
536    if let Some(suggestion) = suggestion {
537        map.insert("suggestion".to_string(), Value::String(suggestion));
538    }
539    errors.push(Value::Object(map));
540}
541
542fn value_to_bool(value: &Value, default_value: bool) -> bool {
543    match value {
544        Value::Bool(v) => *v,
545        Value::Number(n) => n.as_i64().map(|v| v != 0).unwrap_or(default_value),
546        Value::String(s) => {
547            let trimmed = s.trim().to_lowercase();
548            match trimmed.as_str() {
549                "true" | "1" | "yes" => true,
550                "false" | "0" | "no" => false,
551                _ => default_value,
552            }
553        }
554        _ => default_value,
555    }
556}
557
558fn normalize_policy_models(raw: Option<&Value>, errors: &mut Vec<Value>) -> Vec<String> {
559    match raw {
560        None => {
561            push_graph_error(
562                errors,
563                "policy_models",
564                "policy_models is required".to_string(),
565                None,
566            );
567            Vec::new()
568        }
569        Some(Value::Array(arr)) => arr
570            .iter()
571            .filter_map(value_to_string)
572            .collect::<Vec<String>>(),
573        Some(value) => value_to_string(value).map(|v| vec![v]).unwrap_or_default(),
574    }
575}
576
577fn build_graph_config(section: &Map<String, Value>, errors: &mut Vec<Value>) -> Value {
578    let policy_models_raw = section
579        .get("policy_models")
580        .or_else(|| section.get("policy_model"))
581        .or_else(|| section.get("model"));
582    let policy_models = normalize_policy_models(policy_models_raw, errors);
583
584    let rollout_budget = section
585        .get("rollout_budget")
586        .or_else(|| section.get("budget"))
587        .and_then(parse_int)
588        .unwrap_or(100);
589
590    let proposer_effort = section
591        .get("proposer_effort")
592        .or_else(|| section.get("effort"))
593        .and_then(|v| v.as_str())
594        .unwrap_or("medium")
595        .to_string();
596
597    let mut map = Map::new();
598    map.insert(
599        "policy_models".to_string(),
600        Value::Array(policy_models.into_iter().map(Value::String).collect()),
601    );
602    if let Some(provider) = section.get("policy_provider").and_then(|v| v.as_str()) {
603        map.insert(
604            "policy_provider".to_string(),
605            Value::String(provider.to_string()),
606        );
607    }
608    map.insert(
609        "rollout_budget".to_string(),
610        Value::Number(rollout_budget.into()),
611    );
612    map.insert(
613        "proposer_effort".to_string(),
614        Value::String(proposer_effort),
615    );
616    if let Some(verifier_model) = section.get("verifier_model").and_then(|v| v.as_str()) {
617        map.insert(
618            "verifier_model".to_string(),
619            Value::String(verifier_model.to_string()),
620        );
621    }
622    if let Some(verifier_provider) = section.get("verifier_provider").and_then(|v| v.as_str()) {
623        map.insert(
624            "verifier_provider".to_string(),
625            Value::String(verifier_provider.to_string()),
626        );
627    }
628    if let Some(population_size) = section.get("population_size").and_then(parse_int) {
629        map.insert(
630            "population_size".to_string(),
631            Value::Number(population_size.into()),
632        );
633    } else {
634        map.insert("population_size".to_string(), Value::Number(4.into()));
635    }
636    if let Some(num_generations) = section.get("num_generations").and_then(parse_int) {
637        map.insert(
638            "num_generations".to_string(),
639            Value::Number(num_generations.into()),
640        );
641    }
642
643    Value::Object(map)
644}
645
646pub fn validate_graph_job_section(
647    section: &Value,
648    base_dir: Option<&std::path::Path>,
649) -> (Option<Value>, Vec<Value>) {
650    let mut errors: Vec<Value> = Vec::new();
651    let section_map = match section.as_object() {
652        Some(map) => map,
653        None => {
654            push_graph_error(
655                &mut errors,
656                "graph",
657                "graph section must be a table".to_string(),
658                None,
659            );
660            return (None, errors);
661        }
662    };
663
664    let dataset_ref = section_map
665        .get("dataset_path")
666        .or_else(|| section_map.get("dataset"))
667        .and_then(|v| v.as_str())
668        .map(|s| s.to_string());
669
670    let (dataset_path, dataset_value) = if let Some(path_str) = dataset_ref {
671        let mut path = std::path::PathBuf::from(path_str.clone());
672        if let Some(base) = base_dir {
673            if path.is_relative() {
674                path = base.join(path);
675            }
676        }
677        let resolved = path.clone();
678        let data = std::fs::read_to_string(&resolved);
679        match data {
680            Ok(contents) => match serde_json::from_str::<Value>(&contents) {
681                Ok(value) => (Some(resolved), Some(value)),
682                Err(err) => {
683                    push_graph_error(
684                        &mut errors,
685                        "graph.dataset",
686                        format!("Invalid GraphGenTaskSet JSON: {}", err),
687                        None,
688                    );
689                    (Some(resolved), None)
690                }
691            },
692            Err(_) => {
693                push_graph_error(
694                    &mut errors,
695                    "graph.dataset",
696                    format!("Dataset file not found: {}", resolved.display()),
697                    None,
698                );
699                (Some(resolved), None)
700            }
701        }
702    } else {
703        push_graph_error(
704            &mut errors,
705            "graph.dataset",
706            "dataset (path) is required".to_string(),
707            Some("Set graph.dataset = \"my_tasks.json\"".to_string()),
708        );
709        (None, None)
710    };
711
712    let config_value = build_graph_config(section_map, &mut errors);
713
714    let auto_start = section_map
715        .get("auto_start")
716        .map(|v| value_to_bool(v, true))
717        .unwrap_or(true);
718    let metadata = match section_map.get("metadata") {
719        Some(Value::Object(map)) => Value::Object(map.clone()),
720        _ => Value::Object(Map::new()),
721    };
722    let initial_prompt = section_map
723        .get("initial_prompt")
724        .and_then(|v| v.as_str())
725        .map(|s| Value::String(s.to_string()));
726
727    if let Some(dataset) = dataset_value.as_ref() {
728        let validation = validate_graphgen_job_config(&config_value, dataset);
729        errors.extend(validation.errors);
730    }
731
732    if !errors.is_empty() {
733        return (None, errors);
734    }
735
736    let mut result = Map::new();
737    if let Some(path) = dataset_path {
738        result.insert(
739            "dataset_path".to_string(),
740            Value::String(path.to_string_lossy().to_string()),
741        );
742    }
743    if let Some(dataset) = dataset_value {
744        result.insert("dataset".to_string(), dataset);
745    }
746    result.insert("config".to_string(), config_value);
747    result.insert("auto_start".to_string(), Value::Bool(auto_start));
748    result.insert("metadata".to_string(), metadata);
749    if let Some(prompt) = initial_prompt {
750        result.insert("initial_prompt".to_string(), prompt);
751    }
752
753    (Some(Value::Object(result)), errors)
754}
755
756pub fn load_graph_job_toml(path: &std::path::Path) -> (Option<Value>, Vec<Value>) {
757    let content = match std::fs::read_to_string(path) {
758        Ok(content) => content,
759        Err(err) => {
760            let mut errors = Vec::new();
761            push_graph_error(
762                &mut errors,
763                "graph",
764                format!("Failed to read TOML: {}", err),
765                None,
766            );
767            return (None, errors);
768        }
769    };
770
771    let toml_value: Value = match crate::config::parse_toml(&content) {
772        Ok(value) => value,
773        Err(err) => {
774            let mut errors = Vec::new();
775            push_graph_error(
776                &mut errors,
777                "graph",
778                format!("Failed to parse TOML: {}", err),
779                None,
780            );
781            return (None, errors);
782        }
783    };
784
785    let graph_section = toml_value
786        .get("graph")
787        .cloned()
788        .unwrap_or_else(|| Value::Object(Map::new()));
789
790    validate_graph_job_section(&graph_section, path.parent())
791}
792
793pub fn validate_graph_job_payload(payload: &Value) -> Vec<Value> {
794    let mut errors = Vec::new();
795    let payload_map = match payload.as_object() {
796        Some(map) => map,
797        None => {
798            push_graph_error(
799                &mut errors,
800                "payload",
801                "Payload must be an object".to_string(),
802                None,
803            );
804            return errors;
805        }
806    };
807
808    let dataset = match payload_map.get("dataset") {
809        Some(Value::Object(map)) => Value::Object(map.clone()),
810        Some(_) => {
811            push_graph_error(
812                &mut errors,
813                "dataset",
814                "dataset must be a dict".to_string(),
815                None,
816            );
817            return errors;
818        }
819        None => {
820            push_graph_error(
821                &mut errors,
822                "dataset",
823                "dataset must be a dict".to_string(),
824                None,
825            );
826            return errors;
827        }
828    };
829
830    let metadata: Map<String, Value> = match payload_map.get("metadata") {
831        Some(Value::Object(map)) => map.clone(),
832        _ => Map::new(),
833    };
834
835    let policy_models_raw = payload_map
836        .get("policy_models")
837        .or_else(|| payload_map.get("policy_model"));
838    let policy_models = normalize_policy_models(policy_models_raw, &mut errors);
839
840    let rollout_budget = payload_map
841        .get("rollout_budget")
842        .and_then(parse_int)
843        .unwrap_or(100);
844    let proposer_effort = payload_map
845        .get("proposer_effort")
846        .and_then(|v| v.as_str())
847        .unwrap_or("medium")
848        .to_string();
849
850    let mut config_map = Map::new();
851    config_map.insert(
852        "policy_models".to_string(),
853        Value::Array(policy_models.into_iter().map(Value::String).collect()),
854    );
855    if let Some(policy_provider) = payload_map.get("policy_provider").and_then(|v| v.as_str()) {
856        config_map.insert(
857            "policy_provider".to_string(),
858            Value::String(policy_provider.to_string()),
859        );
860    }
861    config_map.insert(
862        "rollout_budget".to_string(),
863        Value::Number(rollout_budget.into()),
864    );
865    config_map.insert(
866        "proposer_effort".to_string(),
867        Value::String(proposer_effort),
868    );
869    if let Some(verifier_model) = payload_map.get("verifier_model").and_then(|v| v.as_str()) {
870        config_map.insert(
871            "verifier_model".to_string(),
872            Value::String(verifier_model.to_string()),
873        );
874    }
875    if let Some(verifier_provider) = payload_map
876        .get("verifier_provider")
877        .and_then(|v| v.as_str())
878    {
879        config_map.insert(
880            "verifier_provider".to_string(),
881            Value::String(verifier_provider.to_string()),
882        );
883    }
884    if let Some(population_size) = metadata.get("population_size").and_then(parse_int) {
885        config_map.insert(
886            "population_size".to_string(),
887            Value::Number(population_size.into()),
888        );
889    }
890    if let Some(num_generations) = metadata.get("num_generations").and_then(parse_int) {
891        config_map.insert(
892            "num_generations".to_string(),
893            Value::Number(num_generations.into()),
894        );
895    }
896
897    let config_value = Value::Object(config_map);
898    let validation = validate_graphgen_job_config(&config_value, &dataset);
899    errors.extend(validation.errors);
900    errors
901}