Skip to main content

wallfacer_core/property/
runner.rs

1//! Property invariant evaluator.
2//!
3//! Phase D rewrites the runner to:
4//!
5//! * use [`Operand::resolve`] instead of the legacy `starts_with('$')`
6//!   heuristic, while preserving full v1 backwards compatibility;
7//! * support boolean combinators (`all_of`, `any_of`, `not`),
8//!   element-wise iteration (`for_each`), and inline schema validation
9//!   (`matches_schema`);
10//! * forbid `unwrap` / `expect` at the module level so any future
11//!   regression is caught by the workspace clippy gate.
12
13#![deny(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
14
15use jsonschema::validator_for;
16use rand::RngCore;
17use regex::Regex;
18use serde_json::{json, Map, Number, Value};
19use thiserror::Error;
20use tracing::warn;
21
22use super::{
23    dsl::{
24        Assertion, FixtureExpect, Invariant, JsonType, Operand, TestFixture, ValueKind, ValueSpec,
25    },
26    jsonpath,
27};
28
29/// Errors raised while evaluating a single invariant case.
30#[derive(Debug, Error)]
31pub enum RunnerError {
32    /// An assertion produced a human-readable failure.
33    #[error("{0}")]
34    Assertion(String),
35    /// A JSONPath used by the assertion did not resolve cleanly.
36    #[error(transparent)]
37    JsonPath(#[from] jsonpath::JsonPathError),
38    /// A regex inside a `matches_regex` assertion failed to compile.
39    #[error("invalid regex `{pattern}`: {source}")]
40    Regex {
41        pattern: String,
42        #[source]
43        source: regex::Error,
44    },
45    /// A JSON Schema inside a `matches_schema` assertion failed to compile.
46    #[error("invalid inline schema in `matches_schema`: {0}")]
47    Schema(String),
48}
49
50pub type Result<T> = std::result::Result<T, RunnerError>;
51
52pub fn input_for_case(invariant: &Invariant, case_index: u32, rng: &mut impl RngCore) -> Value {
53    if let Some(fixed) = &invariant.fixed {
54        return Value::Object(
55            fixed
56                .iter()
57                .map(|(key, value)| (key.clone(), value.clone()))
58                .collect(),
59        );
60    }
61
62    let mut input = Map::new();
63    if let Some(generate) = &invariant.generate {
64        for (key, spec) in generate {
65            let value = if case_index == 0 {
66                boundary_value(spec)
67            } else {
68                generated_value(spec, rng)
69            };
70            input.insert(key.clone(), value);
71        }
72    }
73    Value::Object(input)
74}
75
76/// Top-level entry point: evaluates every assertion of an invariant against
77/// a freshly built `{input, response}` context.
78pub fn evaluate(invariant: &Invariant, input: Value, response: Value) -> Result<()> {
79    let context = json!({
80        "input": input,
81        "response": response,
82    });
83    for assertion in &invariant.assertions {
84        evaluate_assertion(assertion, &context)?;
85    }
86    Ok(())
87}
88
89/// Outcome of [`evaluate_fixture`]: the comparison between what the
90/// fixture asked for and what the runner actually produced.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum FixtureOutcome {
93    /// Observed matches `expected`; this fixture passed.
94    Match,
95    /// Observed differs from `expected`; this fixture failed.
96    Mismatch {
97        /// What the fixture said should happen.
98        expected: FixtureExpect,
99        /// What the runner actually observed.
100        observed: FixtureExpect,
101        /// Human-readable detail (assertion message when the runner
102        /// returned `Err`, empty string on `Ok`).
103        detail: String,
104    },
105    /// The runner returned a structural error (bad path, malformed
106    /// regex / schema). The invariant itself is broken — this fixture
107    /// can neither pass nor fail meaningfully.
108    Structural {
109        /// Pass-through of the structural error.
110        error: String,
111    },
112}
113
114/// Phase H — evaluates an invariant against a synthetic
115/// `(input, response)` pair scripted by a [`TestFixture`] and reports
116/// whether the observed outcome matches the fixture's `expect`.
117pub fn evaluate_fixture(invariant: &Invariant, fixture: &TestFixture) -> FixtureOutcome {
118    let input = fixture.input.clone().unwrap_or_else(|| {
119        let map = invariant
120            .fixed
121            .clone()
122            .unwrap_or_default()
123            .into_iter()
124            .collect::<serde_json::Map<_, _>>();
125        Value::Object(map)
126    });
127    match evaluate(invariant, input, fixture.response.clone()) {
128        Ok(()) => match fixture.expect {
129            FixtureExpect::Pass => FixtureOutcome::Match,
130            FixtureExpect::Fail => FixtureOutcome::Mismatch {
131                expected: FixtureExpect::Fail,
132                observed: FixtureExpect::Pass,
133                detail: String::new(),
134            },
135        },
136        Err(RunnerError::Assertion(message)) => match fixture.expect {
137            FixtureExpect::Fail => FixtureOutcome::Match,
138            FixtureExpect::Pass => FixtureOutcome::Mismatch {
139                expected: FixtureExpect::Pass,
140                observed: FixtureExpect::Fail,
141                detail: message,
142            },
143        },
144        Err(other) => FixtureOutcome::Structural {
145            error: other.to_string(),
146        },
147    }
148}
149
150fn evaluate_assertion(assertion: &Assertion, context: &Value) -> Result<()> {
151    match assertion {
152        Assertion::Equals { lhs, rhs } => {
153            let left = lhs.resolve(context)?;
154            let right = rhs.resolve(context)?;
155            if left == right {
156                Ok(())
157            } else {
158                Err(RunnerError::Assertion(format!(
159                    "expected {left} to equal {right}"
160                )))
161            }
162        }
163        Assertion::NotEquals { lhs, rhs } => {
164            let left = lhs.resolve(context)?;
165            let right = rhs.resolve(context)?;
166            if left != right {
167                Ok(())
168            } else {
169                Err(RunnerError::Assertion(format!(
170                    "expected {left} to differ from {right}"
171                )))
172            }
173        }
174        Assertion::AtMost { path, value } => compare_number(path, value, context, |o| {
175            matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
176        }),
177        Assertion::AtLeast { path, value } => compare_number(path, value, context, |o| {
178            matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
179        }),
180        Assertion::LengthEq { path, value } => compare_length(path, value, context, |a, b| a == b),
181        Assertion::LengthAtMost { path, value } => {
182            compare_length(path, value, context, |a, b| a <= b)
183        }
184        Assertion::LengthAtLeast { path, value } => {
185            compare_length(path, value, context, |a, b| a >= b)
186        }
187        Assertion::IsType { path, expected } => {
188            let value = jsonpath::resolve_one(context, path)?;
189            let actual = json_type(&value);
190            if actual == *expected {
191                Ok(())
192            } else {
193                Err(RunnerError::Assertion(format!(
194                    "expected {path} to be {expected:?}, got {actual:?}"
195                )))
196            }
197        }
198        Assertion::MatchesRegex { path, pattern } => {
199            let value = jsonpath::resolve_one(context, path)?;
200            let Some(text) = value.as_str() else {
201                return Err(RunnerError::Assertion(format!(
202                    "expected {path} to resolve to a string"
203                )));
204            };
205            let regex = Regex::new(pattern).map_err(|source| RunnerError::Regex {
206                pattern: pattern.clone(),
207                source,
208            })?;
209            if regex.is_match(text) {
210                Ok(())
211            } else {
212                Err(RunnerError::Assertion(format!(
213                    "expected {path} to match {pattern}"
214                )))
215            }
216        }
217        Assertion::AllOf { assertions } => {
218            for child in assertions {
219                evaluate_assertion(child, context)?;
220            }
221            Ok(())
222        }
223        Assertion::AnyOf { assertions } => evaluate_any_of(assertions, context),
224        Assertion::Not { assertion } => match evaluate_assertion(assertion, context) {
225            Ok(()) => Err(RunnerError::Assertion(
226                "expected child assertion to fail under `not`".to_string(),
227            )),
228            // A failing child *is* OK semantically when:
229            //  - it produced an `Assertion` failure;
230            //  - it referenced a path that did not resolve. Missing
231            //    paths are equivalent to "child assertion does not
232            //    hold" rather than a malformed invariant — promoting
233            //    them to hard errors would make `not(matches_regex
234            //    path=...)` surprising whenever the path is absent.
235            // Other errors (regex compile, schema compile, malformed
236            // YAML) are genuinely structural and propagate.
237            Err(RunnerError::Assertion(_)) => Ok(()),
238            Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(_))) => Ok(()),
239            Err(other) => Err(other),
240        },
241        Assertion::ForEach { path, assertions } => evaluate_for_each(path, assertions, context),
242        Assertion::MatchesSchema { path, schema } => {
243            let target = jsonpath::resolve_one(context, path)?;
244            let validator =
245                validator_for(schema).map_err(|err| RunnerError::Schema(err.to_string()))?;
246            if validator.is_valid(&target) {
247                Ok(())
248            } else {
249                let errors = validator
250                    .iter_errors(&target)
251                    .map(|err| format!("{err} at {}", err.instance_path()))
252                    .collect::<Vec<_>>()
253                    .join("; ");
254                Err(RunnerError::Assertion(format!(
255                    "value at {path} does not validate against inline schema: {errors}"
256                )))
257            }
258        }
259    }
260}
261
262fn evaluate_any_of(assertions: &[Assertion], context: &Value) -> Result<()> {
263    if assertions.is_empty() {
264        return Err(RunnerError::Assertion(
265            "`any_of` requires at least one child assertion".to_string(),
266        ));
267    }
268    let mut last_assertion_error: Option<String> = None;
269    for child in assertions {
270        match evaluate_assertion(child, context) {
271            Ok(()) => return Ok(()),
272            Err(RunnerError::Assertion(message)) => {
273                last_assertion_error = Some(message);
274            }
275            // A missing path inside one branch of `any_of` simply means
276            // "this branch does not apply" — try the next one. Other
277            // structural errors (regex compile, schema compile) signal
278            // a broken invariant and propagate.
279            Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(path))) => {
280                last_assertion_error = Some(format!("path `{path}` did not resolve"));
281            }
282            Err(other) => return Err(other),
283        }
284    }
285    Err(RunnerError::Assertion(format!(
286        "no `any_of` branch matched (last failure: {})",
287        last_assertion_error.unwrap_or_else(|| "unknown".to_string())
288    )))
289}
290
291fn evaluate_for_each(path: &str, assertions: &[Assertion], context: &Value) -> Result<()> {
292    let nodes = jsonpath::resolve(context, path)?;
293    if nodes.is_empty() {
294        // `for_each` over an empty set is vacuously true. Emit a warning
295        // so an author who typo'd a path (e.g. `$.respnse.items[*]`)
296        // doesn't ship an invariant that silently always passes. Run
297        // `wallfacer property -v` to see this.
298        warn!(
299            jsonpath = path,
300            "for_each path matched zero nodes; the assertion is vacuously true. \
301             Double-check the path or wrap intentional empty-set cases in `any_of` / `not`."
302        );
303        return Ok(());
304    }
305    for (index, node) in nodes.into_iter().enumerate() {
306        // Build a child context with the iterated node exposed under
307        // `$.item`. Original `$.input` / `$.response` remain accessible so
308        // assertions can correlate the current element with global state.
309        let Some(base) = context.as_object() else {
310            return Err(RunnerError::Assertion(
311                "internal: evaluation context must be an object".to_string(),
312            ));
313        };
314        let mut child = base.clone();
315        child.insert("item".to_string(), node);
316        child.insert("index".to_string(), json!(index));
317        let child_context = Value::Object(child);
318        for assertion in assertions {
319            evaluate_assertion(assertion, &child_context).map_err(|err| match err {
320                RunnerError::Assertion(message) => {
321                    RunnerError::Assertion(format!("for_each at {path}[{index}]: {message}"))
322                }
323                other => other,
324            })?;
325        }
326    }
327    Ok(())
328}
329
330fn compare_number(
331    path: &str,
332    value: &Operand,
333    context: &Value,
334    compare: impl Fn(std::cmp::Ordering) -> bool,
335) -> Result<()> {
336    let left = jsonpath::resolve_one(context, path)?;
337    let right = value.resolve(context)?;
338
339    // Fast path: when both operands fit in `i128` (covers any JSON
340    // integer, signed or unsigned), compare without going through f64.
341    // f64 only has 53 bits of mantissa, so values like
342    // `9_007_199_254_740_993` lose precision and equal their
343    // `…992` neighbour — silently corrupting `at_most`/`at_least`.
344    if let (Some(l), Some(r)) = (as_i128(&left), as_i128(&right)) {
345        if compare(l.cmp(&r)) {
346            return Ok(());
347        }
348        return Err(RunnerError::Assertion(format!(
349            "numeric comparison failed: {l} vs {r}"
350        )));
351    }
352
353    let Some(left_f) = left.as_f64() else {
354        return Err(RunnerError::Assertion(format!(
355            "expected {path} to resolve to a number"
356        )));
357    };
358    let Some(right_f) = right.as_f64() else {
359        return Err(RunnerError::Assertion(
360            "expected comparison value to be a number".to_string(),
361        ));
362    };
363    let ordering = left_f
364        .partial_cmp(&right_f)
365        .ok_or_else(|| RunnerError::Assertion("comparison against NaN".to_string()))?;
366    if compare(ordering) {
367        Ok(())
368    } else {
369        Err(RunnerError::Assertion(format!(
370            "numeric comparison failed: {left_f} vs {right_f}"
371        )))
372    }
373}
374
375fn as_i128(value: &Value) -> Option<i128> {
376    let Value::Number(n) = value else {
377        return None;
378    };
379    if let Some(i) = n.as_i64() {
380        Some(i as i128)
381    } else {
382        n.as_u64().map(|u| u as i128)
383    }
384}
385
386fn compare_length(
387    path: &str,
388    value: &Operand,
389    context: &Value,
390    compare: impl FnOnce(usize, usize) -> bool,
391) -> Result<()> {
392    let left = jsonpath::resolve_one(context, path)?;
393    let right = value.resolve(context)?;
394    let Some(right) = right.as_u64().map(|value| value as usize) else {
395        return Err(RunnerError::Assertion(
396            "expected comparison value to be an integer".to_string(),
397        ));
398    };
399    let Some(left) = length(&left) else {
400        return Err(RunnerError::Assertion(format!(
401            "expected {path} to resolve to an array or string"
402        )));
403    };
404    if compare(left, right) {
405        Ok(())
406    } else {
407        Err(RunnerError::Assertion(format!(
408            "length comparison failed: {left} vs {right}"
409        )))
410    }
411}
412
413fn length(value: &Value) -> Option<usize> {
414    match value {
415        Value::Array(items) => Some(items.len()),
416        Value::String(text) => Some(text.chars().count()),
417        _ => None,
418    }
419}
420
421fn json_type(value: &Value) -> JsonType {
422    match value {
423        Value::Null => JsonType::Null,
424        Value::Bool(_) => JsonType::Boolean,
425        Value::Number(number) if number.is_i64() || number.is_u64() => JsonType::Integer,
426        Value::Number(_) => JsonType::Number,
427        Value::String(_) => JsonType::String,
428        Value::Array(_) => JsonType::Array,
429        Value::Object(_) => JsonType::Object,
430    }
431}
432
433impl Operand {
434    /// Resolves an operand against the `{input, response}` context.
435    ///
436    /// * [`Operand::Path`] → JSONPath lookup (errors if missing).
437    /// * [`Operand::Literal`] → returned verbatim.
438    /// * [`Operand::Direct`] strings starting with `$` → JSONPath lookup
439    ///   (legacy v1 contract); anything else → returned verbatim.
440    pub fn resolve(&self, context: &Value) -> Result<Value> {
441        match self {
442            Operand::Path { path } => Ok(jsonpath::resolve_one(context, path)?),
443            Operand::Literal { value } => Ok(value.clone()),
444            Operand::Direct(Value::String(s)) if s.starts_with('$') => {
445                Ok(jsonpath::resolve_one(context, s)?)
446            }
447            Operand::Direct(value) => Ok(value.clone()),
448        }
449    }
450}
451
452fn boundary_value(spec: &ValueSpec) -> Value {
453    match spec.kind {
454        ValueKind::String => {
455            let len = spec.max_length.or(spec.min_length).unwrap_or(8).min(1024);
456            Value::String("x".repeat(len))
457        }
458        ValueKind::Integer => json!(spec.max.or(spec.min).unwrap_or(1)),
459        ValueKind::Number => Number::from_f64(spec.max.or(spec.min).unwrap_or(1) as f64)
460            .map(Value::Number)
461            .unwrap_or(Value::Null),
462        ValueKind::Boolean => Value::Bool(true),
463        ValueKind::Array => {
464            let len = spec.max_items.or(spec.min_items).unwrap_or(1).min(64);
465            let item_spec = spec.items.as_deref();
466            Value::Array(
467                (0..len)
468                    .map(|_| item_spec.map(boundary_value).unwrap_or(Value::Null))
469                    .collect(),
470            )
471        }
472    }
473}
474
475fn generated_value(spec: &ValueSpec, rng: &mut impl RngCore) -> Value {
476    match spec.kind {
477        ValueKind::String => {
478            let min = spec.min_length.unwrap_or(0);
479            let max = spec.max_length.unwrap_or(32).max(min).min(1024);
480            let len = min + (rng.next_u64() as usize % (max - min + 1));
481            Value::String("a".repeat(len))
482        }
483        ValueKind::Integer => {
484            let min = spec.min.unwrap_or(-100);
485            let max = spec.max.unwrap_or(100).max(min);
486            let span = (max as i128 - min as i128 + 1) as u64;
487            json!(min + (rng.next_u64() % span) as i64)
488        }
489        ValueKind::Number => {
490            let min = spec.min.unwrap_or(-100) as f64;
491            let max = (spec.max.unwrap_or(100) as f64).max(min);
492            let unit = rng.next_u64() as f64 / u64::MAX as f64;
493            Number::from_f64(min + (max - min) * unit)
494                .map(Value::Number)
495                .unwrap_or(Value::Null)
496        }
497        ValueKind::Boolean => Value::Bool((rng.next_u64() & 1) == 0),
498        ValueKind::Array => {
499            let min = spec.min_items.unwrap_or(0);
500            let max = spec.max_items.unwrap_or(8).max(min).min(64);
501            let len = min + (rng.next_u64() as usize % (max - min + 1));
502            let item_spec = spec.items.as_deref();
503            Value::Array(
504                (0..len)
505                    .map(|_| {
506                        item_spec
507                            .map(|item| generated_value(item, rng))
508                            .unwrap_or(Value::Null)
509                    })
510                    .collect(),
511            )
512        }
513    }
514}
515
516#[cfg(test)]
517#[allow(
518    clippy::expect_used,
519    clippy::unwrap_used,
520    clippy::panic,
521    clippy::unwrap_in_result
522)]
523mod tests {
524    use super::*;
525    use crate::property::dsl::parse;
526
527    fn evaluate_yaml(source: &str, input: Value, response: Value) -> Result<()> {
528        let file = parse(source).unwrap();
529        evaluate(&file.invariants[0], input, response)
530    }
531
532    #[test]
533    fn explicit_path_operand_works() {
534        let source = r#"
535version: 2
536invariants:
537  - name: t
538    tool: x
539    fixed: {}
540    assert:
541      - kind: equals
542        lhs: { path: "$.response.x" }
543        rhs: { value: 42 }
544"#;
545        evaluate_yaml(source, json!({}), json!({"x": 42})).unwrap();
546        assert!(evaluate_yaml(source, json!({}), json!({"x": 41})).is_err());
547    }
548
549    #[test]
550    fn at_least_uses_integer_comparison_beyond_f64_mantissa() {
551        // 9_007_199_254_740_993 == 2^53 + 1, the first integer that loses
552        // precision when round-tripped through f64. With the previous
553        // f64-only path, `>= 9007199254740993` accepted 9007199254740992,
554        // which is silently wrong.
555        let source = r#"
556version: 2
557invariants:
558  - name: precision
559    tool: x
560    fixed: {}
561    assert:
562      - kind: at_least
563        path: "$.response.n"
564        value: { value: 9007199254740993 }
565"#;
566        // Equal: must pass.
567        evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_993_i64})).unwrap();
568        // One below: must fail (used to silently pass via f64 rounding).
569        let err =
570            evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_992_i64})).unwrap_err();
571        assert!(matches!(err, RunnerError::Assertion(_)));
572    }
573
574    #[test]
575    fn legacy_string_operand_still_works() {
576        let source = r#"
577version: 1
578invariants:
579  - name: t
580    tool: x
581    fixed: {}
582    assert:
583      - kind: equals
584        lhs: "$.response.x"
585        rhs: "$.input.expected"
586"#;
587        evaluate_yaml(source, json!({"expected": 7}), json!({"x": 7})).unwrap();
588    }
589
590    #[test]
591    fn all_of_combines_assertions() {
592        let source = r#"
593version: 2
594invariants:
595  - name: t
596    tool: x
597    fixed: {}
598    assert:
599      - kind: all_of
600        assert:
601          - kind: equals
602            lhs: { path: "$.response.a" }
603            rhs: { value: 1 }
604          - kind: at_most
605            path: "$.response.b"
606            value: { value: 5 }
607"#;
608        evaluate_yaml(source, json!({}), json!({"a": 1, "b": 4})).unwrap();
609        let err = evaluate_yaml(source, json!({}), json!({"a": 1, "b": 99})).unwrap_err();
610        assert!(matches!(err, RunnerError::Assertion(_)));
611    }
612
613    #[test]
614    fn any_of_succeeds_when_one_branch_passes() {
615        let source = r#"
616version: 2
617invariants:
618  - name: t
619    tool: x
620    fixed: {}
621    assert:
622      - kind: any_of
623        assert:
624          - kind: equals
625            lhs: { path: "$.response.a" }
626            rhs: { value: 1 }
627          - kind: equals
628            lhs: { path: "$.response.a" }
629            rhs: { value: 2 }
630"#;
631        evaluate_yaml(source, json!({}), json!({"a": 2})).unwrap();
632        let err = evaluate_yaml(source, json!({}), json!({"a": 9})).unwrap_err();
633        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("any_of")));
634    }
635
636    #[test]
637    fn not_inverts_assertion_outcome() {
638        let source = r#"
639version: 2
640invariants:
641  - name: t
642    tool: x
643    fixed: {}
644    assert:
645      - kind: not
646        assertion:
647          kind: equals
648          lhs: { path: "$.response.a" }
649          rhs: { value: 0 }
650"#;
651        evaluate_yaml(source, json!({}), json!({"a": 5})).unwrap();
652        let err = evaluate_yaml(source, json!({}), json!({"a": 0})).unwrap_err();
653        assert!(matches!(err, RunnerError::Assertion(_)));
654    }
655
656    #[test]
657    fn for_each_visits_every_node() {
658        let source = r#"
659version: 2
660invariants:
661  - name: t
662    tool: x
663    fixed: {}
664    assert:
665      - kind: for_each
666        path: "$.response.items[*]"
667        assert:
668          - kind: at_least
669            path: "$.item.score"
670            value: { value: 0 }
671"#;
672        evaluate_yaml(
673            source,
674            json!({}),
675            json!({"items": [{"score": 1}, {"score": 5}]}),
676        )
677        .unwrap();
678        let err = evaluate_yaml(
679            source,
680            json!({}),
681            json!({"items": [{"score": 1}, {"score": -3}]}),
682        )
683        .unwrap_err();
684        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("for_each at")));
685    }
686
687    #[test]
688    fn matches_schema_validates_inline_schema() {
689        let source = r#"
690version: 2
691invariants:
692  - name: t
693    tool: x
694    fixed: {}
695    assert:
696      - kind: matches_schema
697        path: "$.response.user"
698        schema:
699          type: object
700          required: [name]
701          properties:
702            name: { type: string }
703            age: { type: integer, minimum: 0 }
704"#;
705        evaluate_yaml(
706            source,
707            json!({}),
708            json!({"user": {"name": "alice", "age": 30}}),
709        )
710        .unwrap();
711        let err = evaluate_yaml(source, json!({}), json!({"user": {"age": -1}})).unwrap_err();
712        assert!(matches!(err, RunnerError::Assertion(message) if message.contains("schema")));
713    }
714}