Skip to main content

meta_language/benchmark/
mod.rs

1//! Competitor benchmark orchestration for the E3 grammar-inference suite.
2//!
3//! The metric math stays in the D1 evaluation module and the inferred grammar
4//! comes from the D5 `infer_cfg` entry point. This module owns manifest loading,
5//! vendored-corpus integrity checks, report formatting, and bar assertions.
6//! Manifest byte counts assume the repository-pinned LF checkout policy for
7//! `benches/corpora/**` so CI reports the same fixture totals on every OS.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::error::Error;
11use std::fmt;
12use std::fmt::Write as _;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17use serde_json::Value;
18
19use crate::{
20    emit_gbnf, evaluate, infer_cfg, Grammar, GrammarExpr, GrammarFormat, GrammarOracle,
21    GrammarRule, InferenceOptions, MetricScores, PositiveOnlyOracle, SampleConfig, ScoringMode,
22};
23
24/// Default manifest path used by the bench and integration gate.
25pub const DEFAULT_CORPUS_MANIFEST: &str = "benches/corpus-manifest.json";
26/// Default vendored corpus root used by the bench and integration gate.
27pub const DEFAULT_CORPORA_ROOT: &str = "benches/corpora";
28/// Published `TreeVada` headline average F1 from the E3 competitive-analysis notes.
29pub const PUBLISHED_TREEVADA_AVG_F1: f64 = 0.32;
30/// Published `NatGI` headline average F1 from the E3 competitive-analysis notes.
31pub const PUBLISHED_NATGI_AVG_F1: f64 = 0.57;
32/// Deterministic D5 runs must meet the current top published average F1 bar.
33pub const D5_REQUIRED_AVG_F1: f64 = PUBLISHED_NATGI_AVG_F1;
34
35/// A parsed competitor corpus manifest.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct CompetitorManifest {
38    /// Manifest schema revision.
39    pub schema: u64,
40    /// Corpus subject entries.
41    pub entries: Vec<CorpusManifestEntry>,
42}
43
44/// One vendored competitor subject.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct CorpusManifestEntry {
47    /// Competitor/tool name.
48    pub tool: String,
49    /// Subject directory under `benches/corpora/<tool>/`.
50    pub subject: String,
51    /// Source repository identifier.
52    pub source: String,
53    /// Pinned source commit.
54    pub commit: String,
55    /// SPDX-style license label.
56    pub license: String,
57    /// Expected number of vendored files below the subject directory.
58    pub files: usize,
59    /// Expected total byte count below the subject directory.
60    pub bytes: u64,
61    /// Whether the always-on gate runs this subject.
62    pub included: bool,
63    /// Required reason when [`Self::included`] is false.
64    pub exclude_reason: String,
65    /// Relative paths holding positive examples for included runs.
66    pub example_paths: Vec<String>,
67    /// Golden oracle mode used for included runs.
68    pub golden: String,
69}
70
71impl CorpusManifestEntry {
72    /// Stable `tool/subject` identifier.
73    #[must_use]
74    pub fn id(&self) -> String {
75        format!("{}/{}", self.tool, self.subject)
76    }
77}
78
79/// One skipped manifest entry rendered in the benchmark log.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct SkippedCorpus {
82    /// Stable `tool/subject` identifier.
83    pub id: String,
84    /// Explicit skip reason from the manifest.
85    pub reason: String,
86}
87
88/// One included corpus result.
89#[derive(Clone, Debug, PartialEq)]
90pub struct CompetitorRun {
91    /// Stable `tool/subject` identifier.
92    pub id: String,
93    /// Competitor/tool name.
94    pub tool: String,
95    /// Subject name.
96    pub subject: String,
97    /// Number of positive example files loaded.
98    pub examples: usize,
99    /// D1 primary metrics.
100    pub scores: MetricScores,
101    /// Number of unique precision/recall samples considered by D1.
102    pub samples_drawn: usize,
103    /// D1 deterministic sample seed.
104    pub seed: u64,
105    /// Recall source used by D1.
106    pub scoring_mode: ScoringMode,
107    /// D5 inference plus D1 evaluation wall-clock time.
108    pub wall_clock_ms: u128,
109    /// Number of rules emitted by D5.
110    pub inferred_rules: usize,
111    /// Per-subject F1 threshold used by the gate.
112    pub required_f1: f64,
113    /// Whether the inferred grammar emitted non-empty GBNF.
114    pub gbnf_emitted: bool,
115}
116
117/// Secondary metric report row.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct SecondaryMetricRow {
120    /// Metric label.
121    pub metric: &'static str,
122    /// Reported value or pending status.
123    pub value: String,
124}
125
126/// Non-fatal suite failure collected so one run reports every problem.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub struct BenchmarkFailure {
129    /// Optional corpus identifier.
130    pub corpus: Option<String>,
131    /// Failure details.
132    pub message: String,
133}
134
135impl BenchmarkFailure {
136    fn new(corpus: Option<String>, message: impl Into<String>) -> Self {
137        Self {
138            corpus,
139            message: message.into(),
140        }
141    }
142}
143
144/// Full suite result.
145#[derive(Clone, Debug, PartialEq)]
146pub struct CompetitorSuiteReport {
147    /// Deterministic D1 sample seed used for all included runs.
148    pub seed: u64,
149    /// Included corpus runs.
150    pub runs: Vec<CompetitorRun>,
151    /// Explicitly skipped subjects.
152    pub skipped: Vec<SkippedCorpus>,
153    /// Secondary metric rows.
154    pub secondary: Vec<SecondaryMetricRow>,
155    /// Manifest, loading, metric, or bar failures.
156    pub failures: Vec<BenchmarkFailure>,
157}
158
159/// Fatal setup error that prevents a suite report from being built.
160#[derive(Debug)]
161pub enum BenchmarkError {
162    /// File-system access failed.
163    Io {
164        /// Path being accessed.
165        path: PathBuf,
166        /// Source I/O error.
167        source: std::io::Error,
168    },
169    /// JSON parsing failed.
170    Json {
171        /// Manifest path.
172        path: PathBuf,
173        /// Source parser error.
174        source: serde_json::Error,
175    },
176    /// Manifest structure was invalid.
177    Manifest(String),
178}
179
180impl fmt::Display for BenchmarkError {
181    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182        match self {
183            Self::Io { path, source } => {
184                write!(formatter, "{}: {source}", path.display())
185            }
186            Self::Json { path, source } => {
187                write!(formatter, "{}: {source}", path.display())
188            }
189            Self::Manifest(message) => formatter.write_str(message),
190        }
191    }
192}
193
194impl Error for BenchmarkError {
195    fn source(&self) -> Option<&(dyn Error + 'static)> {
196        match self {
197            Self::Io { source, .. } => Some(source),
198            Self::Json { source, .. } => Some(source),
199            Self::Manifest(_) => None,
200        }
201    }
202}
203
204/// Loads the default manifest and runs the default vendored competitor suite.
205///
206/// # Errors
207///
208/// Returns an error when the manifest cannot be read or parsed.
209pub fn run_competitor_suite(
210    config: &SampleConfig,
211) -> Result<CompetitorSuiteReport, BenchmarkError> {
212    run_competitor_suite_from_paths(
213        Path::new(DEFAULT_CORPUS_MANIFEST),
214        Path::new(DEFAULT_CORPORA_ROOT),
215        config,
216    )
217}
218
219/// Loads a manifest and runs a vendored competitor suite from explicit paths.
220///
221/// # Errors
222///
223/// Returns an error when the manifest cannot be read or parsed.
224pub fn run_competitor_suite_from_paths(
225    manifest_path: &Path,
226    corpora_root: &Path,
227    config: &SampleConfig,
228) -> Result<CompetitorSuiteReport, BenchmarkError> {
229    let manifest = load_manifest(manifest_path)?;
230    let mut report = validate_manifest(&manifest, corpora_root)?;
231    report.seed = config.seed;
232
233    for entry in manifest.entries.iter().filter(|entry| entry.included) {
234        match run_manifest_entry(entry, corpora_root, config) {
235            Ok(run) => {
236                if run.scores.f1 < run.required_f1 {
237                    report.failures.push(BenchmarkFailure::new(
238                        Some(run.id.clone()),
239                        format!(
240                            "F1 {:.3} is below required NatGI bar {:.3}",
241                            run.scores.f1, run.required_f1
242                        ),
243                    ));
244                }
245                report.runs.push(run);
246            }
247            Err(message) => {
248                report
249                    .failures
250                    .push(BenchmarkFailure::new(Some(entry.id()), message));
251            }
252        }
253    }
254
255    if report.runs.is_empty() {
256        report.failures.push(BenchmarkFailure::new(
257            None,
258            "manifest includes no always-on competitor corpus subjects",
259        ));
260    }
261
262    report.secondary = secondary_rows(&report.runs);
263    Ok(report)
264}
265
266/// Parses a competitor corpus manifest.
267///
268/// # Errors
269///
270/// Returns an error when the manifest cannot be read or parsed.
271pub fn load_manifest(path: &Path) -> Result<CompetitorManifest, BenchmarkError> {
272    let text = fs::read_to_string(path).map_err(|source| BenchmarkError::Io {
273        path: path.to_path_buf(),
274        source,
275    })?;
276    let value = serde_json::from_str::<Value>(&text).map_err(|source| BenchmarkError::Json {
277        path: path.to_path_buf(),
278        source,
279    })?;
280    parse_manifest(&value)
281}
282
283/// Formats a suite report as the CI failure log and bench output.
284#[must_use]
285pub fn render_competitor_report(report: &CompetitorSuiteReport) -> String {
286    let mut output = String::new();
287    let _ = writeln!(
288        output,
289        "D1/D5 competitor benchmark suite (seed {})",
290        report.seed
291    );
292    let _ = writeln!(
293        output,
294        "Published bars: TreeVada avg F1 ~= {PUBLISHED_TREEVADA_AVG_F1:.2}; NatGI avg F1 ~= {PUBLISHED_NATGI_AVG_F1:.2}"
295    );
296
297    if !report.skipped.is_empty() {
298        let _ = writeln!(output);
299        for skipped in &report.skipped {
300            let _ = writeln!(output, "SKIPPED {}: {}", skipped.id, skipped.reason);
301        }
302    }
303
304    let _ = writeln!(output);
305    let _ = writeln!(
306        output,
307        "| corpus | examples | precision | recall | F1 | required F1 | wall-clock ms | rules | samples |"
308    );
309    let _ = writeln!(output, "|---|---:|---:|---:|---:|---:|---:|---:|---:|");
310    for run in &report.runs {
311        let _ = writeln!(
312            output,
313            "| {} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {} | {} | {} |",
314            run.id,
315            run.examples,
316            run.scores.precision,
317            run.scores.recall,
318            run.scores.f1,
319            run.required_f1,
320            run.wall_clock_ms,
321            run.inferred_rules,
322            run.samples_drawn
323        );
324    }
325
326    let _ = writeln!(output);
327    let _ = writeln!(output, "| secondary metric | value |");
328    let _ = writeln!(output, "|---|---|");
329    for row in &report.secondary {
330        let _ = writeln!(output, "| {} | {} |", row.metric, row.value);
331    }
332
333    if !report.failures.is_empty() {
334        let _ = writeln!(output);
335        let _ = writeln!(output, "Failures:");
336        for failure in &report.failures {
337            match &failure.corpus {
338                Some(corpus) => {
339                    let _ = writeln!(output, "- {corpus}: {}", failure.message);
340                }
341                None => {
342                    let _ = writeln!(output, "- {}", failure.message);
343                }
344            }
345        }
346    }
347
348    output
349}
350
351fn parse_manifest(value: &Value) -> Result<CompetitorManifest, BenchmarkError> {
352    let object = value
353        .as_object()
354        .ok_or_else(|| BenchmarkError::Manifest("manifest root must be a JSON object".into()))?;
355    let schema = required_u64(object, "schema")?;
356    let corpus = object
357        .get("corpus")
358        .and_then(Value::as_array)
359        .ok_or_else(|| BenchmarkError::Manifest("manifest corpus must be an array".into()))?;
360    let mut entries = Vec::with_capacity(corpus.len());
361    for (index, entry) in corpus.iter().enumerate() {
362        let object = entry.as_object().ok_or_else(|| {
363            BenchmarkError::Manifest(format!("manifest corpus[{index}] must be an object"))
364        })?;
365        entries.push(CorpusManifestEntry {
366            tool: required_string(object, "tool")?,
367            subject: required_string(object, "subject")?,
368            source: required_string(object, "source")?,
369            commit: required_string(object, "commit")?,
370            license: required_string(object, "license")?,
371            files: usize::try_from(required_u64(object, "files")?).map_err(|_| {
372                BenchmarkError::Manifest(format!("manifest corpus[{index}].files overflows usize"))
373            })?,
374            bytes: required_u64(object, "bytes")?,
375            included: required_bool(object, "included")?,
376            exclude_reason: required_string(object, "exclude_reason")?,
377            example_paths: required_string_array(object, "example_paths")?,
378            golden: required_string(object, "golden")?,
379        });
380    }
381
382    Ok(CompetitorManifest { schema, entries })
383}
384
385fn required_string(
386    object: &serde_json::Map<String, Value>,
387    key: &str,
388) -> Result<String, BenchmarkError> {
389    object
390        .get(key)
391        .and_then(Value::as_str)
392        .map(ToOwned::to_owned)
393        .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a string")))
394}
395
396fn required_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<u64, BenchmarkError> {
397    object
398        .get(key)
399        .and_then(Value::as_u64)
400        .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a u64")))
401}
402
403fn required_bool(
404    object: &serde_json::Map<String, Value>,
405    key: &str,
406) -> Result<bool, BenchmarkError> {
407    object
408        .get(key)
409        .and_then(Value::as_bool)
410        .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a bool")))
411}
412
413fn required_string_array(
414    object: &serde_json::Map<String, Value>,
415    key: &str,
416) -> Result<Vec<String>, BenchmarkError> {
417    object
418        .get(key)
419        .and_then(Value::as_array)
420        .ok_or_else(|| {
421            BenchmarkError::Manifest(format!("manifest field `{key}` must be an array"))
422        })?
423        .iter()
424        .map(|value| {
425            value.as_str().map(ToOwned::to_owned).ok_or_else(|| {
426                BenchmarkError::Manifest(format!("manifest field `{key}` must contain strings"))
427            })
428        })
429        .collect()
430}
431
432fn validate_manifest(
433    manifest: &CompetitorManifest,
434    corpora_root: &Path,
435) -> Result<CompetitorSuiteReport, BenchmarkError> {
436    let mut report = CompetitorSuiteReport {
437        seed: 0,
438        runs: Vec::new(),
439        skipped: Vec::new(),
440        secondary: Vec::new(),
441        failures: Vec::new(),
442    };
443    let mut entries = BTreeMap::<String, &CorpusManifestEntry>::new();
444
445    for entry in &manifest.entries {
446        let id = entry.id();
447        if entries.insert(id.clone(), entry).is_some() {
448            report.failures.push(BenchmarkFailure::new(
449                Some(id.clone()),
450                "duplicate manifest entry",
451            ));
452        }
453
454        if !entry.included {
455            if entry.exclude_reason.trim().is_empty() {
456                report.failures.push(BenchmarkFailure::new(
457                    Some(id.clone()),
458                    "excluded corpus must provide a non-empty exclude_reason",
459                ));
460            } else {
461                report.skipped.push(SkippedCorpus {
462                    id: id.clone(),
463                    reason: entry.exclude_reason.clone(),
464                });
465            }
466        }
467
468        let subject_dir = corpora_root.join(&entry.tool).join(&entry.subject);
469        if !subject_dir.is_dir() {
470            report.failures.push(BenchmarkFailure::new(
471                Some(id.clone()),
472                format!(
473                    "vendored subject directory {} is missing",
474                    subject_dir.display()
475                ),
476            ));
477            continue;
478        }
479
480        let (files, bytes) =
481            count_subject_files(&subject_dir).map_err(|source| BenchmarkError::Io {
482                path: subject_dir.clone(),
483                source,
484            })?;
485        if files != entry.files {
486            report.failures.push(BenchmarkFailure::new(
487                Some(id.clone()),
488                format!("manifest files={} but vendored files={files}", entry.files),
489            ));
490        }
491        if bytes != entry.bytes {
492            report.failures.push(BenchmarkFailure::new(
493                Some(id.clone()),
494                format!("manifest bytes={} but vendored bytes={bytes}", entry.bytes),
495            ));
496        }
497
498        if entry.included && entry.example_paths.is_empty() {
499            report.failures.push(BenchmarkFailure::new(
500                Some(id),
501                "included corpus must list at least one example path",
502            ));
503        }
504    }
505
506    for subject in vendored_subjects(corpora_root)? {
507        if !entries.contains_key(&subject) {
508            report.failures.push(BenchmarkFailure::new(
509                Some(subject),
510                "vendored subject is missing from manifest",
511            ));
512        }
513    }
514
515    Ok(report)
516}
517
518fn run_manifest_entry(
519    entry: &CorpusManifestEntry,
520    corpora_root: &Path,
521    config: &SampleConfig,
522) -> Result<CompetitorRun, String> {
523    if entry.golden != "exact_examples" {
524        return Err(format!(
525            "unsupported included golden oracle mode `{}`",
526            entry.golden
527        ));
528    }
529
530    let id = entry.id();
531    let subject_dir = corpora_root.join(&entry.tool).join(&entry.subject);
532    let examples = load_examples(&subject_dir, &entry.example_paths)
533        .map_err(|error| format!("failed to load examples: {error}"))?;
534    if examples.is_empty() {
535        return Err("included corpus loaded no positive examples".into());
536    }
537
538    let start = Instant::now();
539    let inferred = infer_cfg(&examples, &PositiveOnlyOracle, InferenceOptions::default());
540    let golden = exact_examples_grammar(&examples);
541    let oracle = GrammarOracle::new(&golden);
542    let positive_refs = examples.iter().map(String::as_str).collect::<Vec<_>>();
543    let scores = evaluate(
544        &inferred.grammar,
545        &oracle,
546        Some(&golden),
547        &positive_refs,
548        config,
549    )
550    .map_err(|error| format!("D1 evaluation failed: {error}"))?;
551    let samples_drawn = sample_count(&inferred.grammar, &golden, config)
552        .map_err(|error| format!("D1 sample accounting failed: {error}"))?;
553    let gbnf_emitted = emit_gbnf(&inferred.grammar).is_ok_and(|(text, _)| !text.trim().is_empty());
554
555    Ok(CompetitorRun {
556        id,
557        tool: entry.tool.clone(),
558        subject: entry.subject.clone(),
559        examples: examples.len(),
560        scores,
561        samples_drawn,
562        seed: config.seed,
563        scoring_mode: ScoringMode::GoldenGrammar,
564        wall_clock_ms: start.elapsed().as_millis(),
565        inferred_rules: inferred.report.rules,
566        required_f1: D5_REQUIRED_AVG_F1,
567        gbnf_emitted,
568    })
569}
570
571fn sample_count(
572    inferred: &Grammar,
573    golden: &Grammar,
574    config: &SampleConfig,
575) -> Result<usize, crate::EvalError> {
576    let inferred_count = crate::sample(inferred, config)?.len();
577    let golden_count = crate::sample(golden, config)?.len();
578    Ok(inferred_count.saturating_add(golden_count))
579}
580
581fn exact_examples_grammar(examples: &[String]) -> Grammar {
582    let alternatives = examples.iter().map(|example| {
583        if example.is_empty() {
584            GrammarExpr::Empty
585        } else {
586            GrammarExpr::Terminal(example.clone())
587        }
588    });
589    Grammar::new()
590        .with_source_format(GrammarFormat::Inferred)
591        .with_rule(GrammarRule::new(
592            "Root",
593            finish_choice(alternatives.collect()),
594        ))
595        .with_start("Root")
596}
597
598fn finish_choice(alternatives: Vec<GrammarExpr>) -> GrammarExpr {
599    let mut unique = BTreeMap::<String, GrammarExpr>::new();
600    for alternative in alternatives {
601        unique
602            .entry(format!("{alternative:?}"))
603            .or_insert(alternative);
604    }
605
606    match unique.len() {
607        0 => GrammarExpr::Empty,
608        1 => unique
609            .into_values()
610            .next()
611            .expect("one choice alternative must exist"),
612        _ => GrammarExpr::Choice {
613            ordered: false,
614            alternatives: unique.into_values().collect(),
615        },
616    }
617}
618
619fn load_examples(subject_dir: &Path, example_paths: &[String]) -> Result<Vec<String>, String> {
620    let mut files = Vec::new();
621    for relative in example_paths {
622        let path = subject_dir.join(relative);
623        if path.is_file() {
624            files.push(path);
625        } else if path.is_dir() {
626            collect_files(&path, &mut files)
627                .map_err(|error| format!("{}: {error}", path.display()))?;
628        } else {
629            return Err(format!("example path {} is missing", path.display()));
630        }
631    }
632    files.sort();
633    files.dedup();
634
635    files
636        .iter()
637        .map(|path| {
638            fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))
639        })
640        .collect()
641}
642
643fn count_subject_files(path: &Path) -> Result<(usize, u64), std::io::Error> {
644    let mut files = Vec::new();
645    collect_files(path, &mut files)?;
646    let mut bytes = 0u64;
647    for file in &files {
648        bytes = bytes.saturating_add(fs::metadata(file)?.len());
649    }
650    Ok((files.len(), bytes))
651}
652
653fn collect_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
654    for entry in fs::read_dir(path)? {
655        let entry = entry?;
656        let path = entry.path();
657        if path.is_dir() {
658            collect_files(&path, files)?;
659        } else if path.is_file() {
660            files.push(path);
661        }
662    }
663    Ok(())
664}
665
666fn vendored_subjects(corpora_root: &Path) -> Result<BTreeSet<String>, BenchmarkError> {
667    let mut subjects = BTreeSet::new();
668    let tools = fs::read_dir(corpora_root).map_err(|source| BenchmarkError::Io {
669        path: corpora_root.to_path_buf(),
670        source,
671    })?;
672    for tool in tools {
673        let tool = tool.map_err(|source| BenchmarkError::Io {
674            path: corpora_root.to_path_buf(),
675            source,
676        })?;
677        let tool_path = tool.path();
678        if !tool_path.is_dir() {
679            continue;
680        }
681        let tool_name = tool.file_name().to_string_lossy().into_owned();
682        for subject in fs::read_dir(&tool_path).map_err(|source| BenchmarkError::Io {
683            path: tool_path.clone(),
684            source,
685        })? {
686            let subject = subject.map_err(|source| BenchmarkError::Io {
687                path: tool_path.clone(),
688                source,
689            })?;
690            let subject_path = subject.path();
691            if subject_path.is_dir() {
692                let subject_name = subject.file_name().to_string_lossy().into_owned();
693                subjects.insert(format!("{tool_name}/{subject_name}"));
694            }
695        }
696    }
697    Ok(subjects)
698}
699
700fn secondary_rows(runs: &[CompetitorRun]) -> Vec<SecondaryMetricRow> {
701    let gbnf_successes = runs.iter().filter(|run| run.gbnf_emitted).count();
702
703    vec![
704        SecondaryMetricRow {
705            metric: "format coverage",
706            value: "n/a (pending B*/C* cross-format coverage aggregation)".to_string(),
707        },
708        SecondaryMetricRow {
709            metric: "round-trip fidelity",
710            value: "n/a (pending F2 fidelity matrix)".to_string(),
711        },
712        SecondaryMetricRow {
713            metric: "GBNF emit",
714            value: format!(
715                "{gbnf_successes}/{} included grammars emitted non-empty GBNF",
716                runs.len()
717            ),
718        },
719        SecondaryMetricRow {
720            metric: "cross-language translation",
721            value: "n/a (pending C6 full metric wiring)".to_string(),
722        },
723    ]
724}