Skip to main content

zenkey_fleet/tape/
synth.rs

1//! Schema-driven payload synthesis (#162) — the datagen half of `zenctl gen`.
2//!
3//! Synthesis produces a **JSON value** for every schema kind; the kind's own
4//! encoder (`DecoderRegistry::encode`, the same seam `zenctl pub` writes
5//! through) turns it into wire bytes. That keeps this module codec-free: it
6//! never frames bytes, it only answers "what instance would this schema
7//! accept?".
8//!
9//! Deterministic on purpose: a `(seed, tick)` pair always yields the same
10//! instance (spray's seeded-sine precedent) — a generator whose runs cannot
11//! be reproduced cannot be used to bisect a consumer bug. Numeric leaves
12//! wander on a sine per field so plots move; everything else is stable.
13
14use serde_json::{Map, Value, json};
15use zenkey::schema::TypeSchema;
16
17use crate::model::jsonschema::resolve_ref;
18
19/// How deep nested objects/arrays are followed before giving up — a cyclic
20/// or pathological schema degrades to a placeholder, not a stack overflow.
21///
22/// Raised from 6 with `$ref` following (#384): a resolved reference costs a
23/// level, and `schemars` hoists every nested named type into `$defs`, so the
24/// old cap was spent on indirection rather than on nesting. A cycle still
25/// terminates here — the cap is what stops it, since a `$ref` chain has no
26/// other bottom.
27const DEPTH_CAP: usize = 16;
28
29/// A deterministic instance generator.
30#[derive(Debug, Clone, Copy)]
31pub struct Synth {
32    pub seed: u64,
33}
34
35/// A cheap deterministic hash for per-field phase offsets (FNV-1a) — not
36/// cryptographic, just stable across runs and platforms.
37fn fnv(s: &str) -> u64 {
38    let mut h: u64 = 0xcbf29ce484222325;
39    for b in s.bytes() {
40        h ^= u64::from(b);
41        h = h.wrapping_mul(0x100000001b3);
42    }
43    h
44}
45
46impl Synth {
47    pub fn new(seed: u64) -> Synth {
48        Synth { seed }
49    }
50
51    /// A wandering numeric value: a sine over `tick`, phase-offset by the
52    /// field's name so sibling fields do not move in lockstep.
53    fn wander(&self, field: &str, tick: u64, min: f64, max: f64) -> f64 {
54        let phase = (fnv(field) ^ self.seed) % 628 /* 2π·100 */;
55        let x = (tick as f64) / 10.0 + (phase as f64) / 100.0;
56        let mid = f64::midpoint(min, max);
57        let amp = (max - min) / 2.0;
58        mid + amp * x.sin()
59    }
60
61    /// Synthesize an instance for a schema entry. `None` means this kind
62    /// cannot be synthesized here (unknown kind) — the caller degrades with
63    /// a stated note, never silently.
64    pub fn instance(&self, schema: &TypeSchema, tick: u64) -> Option<Value> {
65        match schema.kind_str() {
66            zenkey::schema::SchemaKind::JSON_SCHEMA => schema
67                .json_document()
68                .map(|doc| self.json_schema_value(doc, doc, "", tick, 0)),
69            zenkey::schema::SchemaKind::CDR => {
70                let fields = schema.cdr_fields()?;
71                let types = schema.cdr_types();
72                Some(self.cdr_fields_value(fields, types, tick, 0))
73            }
74            #[cfg(feature = "decode-protobuf")]
75            zenkey::schema::SchemaKind::PROTOBUF => self.protobuf_value(schema, tick),
76            _ => None,
77        }
78    }
79
80    /// Walk a draft 2020-12 document conservatively: satisfy `type`,
81    /// `required` (by emitting every declared property), `enum`/`const`,
82    /// combinators, `$ref`, and numeric bounds. Unknown or empty schemas get
83    /// a wandering number — `{}` accepts anything.
84    ///
85    /// `root` is the whole document, carried so `$ref` can be resolved
86    /// against it; `doc` is the subschema being satisfied.
87    fn json_schema_value(
88        &self,
89        root: &Value,
90        doc: &Value,
91        field: &str,
92        tick: u64,
93        depth: usize,
94    ) -> Value {
95        if depth > DEPTH_CAP {
96            return Value::Null;
97        }
98        if let Some(c) = doc.get("const") {
99            return c.clone();
100        }
101        if let Some(e) = doc.get("enum").and_then(Value::as_array)
102            && let Some(first) = e.first()
103        {
104            return first.clone();
105        }
106        // `$ref` into `$defs` is where `schemars` puts every nested named
107        // type, so a walk that does not follow it synthesizes a wandering
108        // number where a struct belongs (#384). Unresolvable falls through
109        // to the conservative default below, as an unknown schema does.
110        if let Some(pointer) = doc.get("$ref").and_then(Value::as_str)
111            && let Some(target) = resolve_ref(root, pointer)
112        {
113            return self.json_schema_value(root, target, field, tick, depth + 1);
114        }
115        // `allOf` composes one shape out of several, so an instance must
116        // satisfy every member: merge them.
117        if let Some(members) = doc.get("allOf").and_then(Value::as_array) {
118            let mut merged = Map::new();
119            for member in members {
120                if let Value::Object(o) =
121                    self.json_schema_value(root, member, field, tick, depth + 1)
122                {
123                    merged.extend(o);
124                }
125            }
126            return Value::Object(merged);
127        }
128        // `oneOf`/`anyOf` alternate, and one instance satisfies one branch —
129        // so the first is as good a pick as any. Deliberately unlike the
130        // declared-path walk in `judge::field`, which must union every
131        // branch: a *surface* is all the shapes allowed, an *instance* is one.
132        for branch in ["oneOf", "anyOf"] {
133            if let Some(b) = doc.get(branch).and_then(Value::as_array)
134                && let Some(first) = b.first()
135            {
136                return self.json_schema_value(root, first, field, tick, depth + 1);
137            }
138        }
139        let ty = doc.get("type").and_then(Value::as_str).unwrap_or("number");
140        match ty {
141            "object" => {
142                let mut out = Map::new();
143                if let Some(props) = doc.get("properties").and_then(Value::as_object) {
144                    for (name, sub) in props {
145                        out.insert(
146                            name.clone(),
147                            self.json_schema_value(root, sub, name, tick, depth + 1),
148                        );
149                    }
150                }
151                Value::Object(out)
152            }
153            "array" => {
154                let n = doc
155                    .get("minItems")
156                    .and_then(Value::as_u64)
157                    .unwrap_or(1)
158                    .max(1);
159                let item = doc.get("items").cloned().unwrap_or(json!({}));
160                Value::Array(
161                    (0..n)
162                        .map(|i| self.json_schema_value(root, &item, field, tick + i, depth + 1))
163                        .collect(),
164                )
165            }
166            "string" => Value::String(format!(
167                "{}-{}",
168                if field.is_empty() { "s" } else { field },
169                tick % 10
170            )),
171            "boolean" => Value::Bool(tick.is_multiple_of(2)),
172            "integer" => {
173                let (min, max) = bounds(doc, 0.0, 100.0);
174                json!(self.wander(field, tick, min, max).round() as i64)
175            }
176            "null" => Value::Null,
177            // "number" and anything else numeric-shaped.
178            _ => {
179                let (min, max) = bounds(doc, 0.0, 100.0);
180                json!(self.wander(field, tick, min, max))
181            }
182        }
183    }
184
185    /// The `cdr` kind's compact field list (RFC 08 §7.1): positional
186    /// `[{name, type}]` with a local `types` table for composites.
187    fn cdr_fields_value(
188        &self,
189        fields: &Value,
190        types: Option<&Map<String, Value>>,
191        tick: u64,
192        depth: usize,
193    ) -> Value {
194        if depth > DEPTH_CAP {
195            return Value::Null;
196        }
197        let Some(list) = fields.as_array() else {
198            return Value::Null;
199        };
200        let mut out = Map::new();
201        for f in list {
202            let Some(name) = f.get("name").and_then(Value::as_str) else {
203                continue;
204            };
205            let ty = f.get("type").cloned().unwrap_or(Value::Null);
206            out.insert(
207                name.to_string(),
208                self.cdr_value(&ty, types, name, tick, depth),
209            );
210        }
211        Value::Object(out)
212    }
213
214    fn cdr_value(
215        &self,
216        ty: &Value,
217        types: Option<&Map<String, Value>>,
218        field: &str,
219        tick: u64,
220        depth: usize,
221    ) -> Value {
222        if depth > DEPTH_CAP {
223            return Value::Null;
224        }
225        match ty {
226            Value::String(name) => match name.as_str() {
227                "bool" => Value::Bool(tick.is_multiple_of(2)),
228                "string" => Value::String(format!("{field}-{}", tick % 10)),
229                "float32" | "float" | "float64" | "double" => {
230                    json!(self.wander(field, tick, 0.0, 100.0))
231                }
232                // The full primitive vocabulary of the cdr kind (RFC 08
233                // §7.1's IDL-flavoured aliases included).
234                "int8" | "char" | "int16" | "short" | "int32" | "long" | "int64" | "long long" => {
235                    json!(self.wander(field, tick, 0.0, 100.0).round() as i64)
236                }
237                "uint8" | "byte" | "octet" | "uint16" | "unsigned short" | "uint32"
238                | "unsigned long" | "uint64" | "unsigned long long" => {
239                    json!(self.wander(field, tick, 0.0, 100.0).round().abs() as u64)
240                }
241                // A named composite from the local table.
242                other => match types.and_then(|t| t.get(other)) {
243                    Some(composite) => {
244                        let fields = composite.get("fields").unwrap_or(composite);
245                        self.cdr_fields_value(fields, types, tick, depth + 1)
246                    }
247                    None => Value::Null,
248                },
249            },
250            // {"array": {"of": T, "len": n}} / {"sequence": {"of": T}}
251            Value::Object(o) => {
252                if let Some(arr) = o.get("array") {
253                    let n = arr.get("len").and_then(Value::as_u64).unwrap_or(1).max(1);
254                    let of = arr.get("of").cloned().unwrap_or(Value::Null);
255                    Value::Array(
256                        (0..n)
257                            .map(|i| self.cdr_value(&of, types, field, tick + i, depth + 1))
258                            .collect(),
259                    )
260                } else if let Some(seq) = o.get("sequence") {
261                    let of = seq.get("of").cloned().unwrap_or(Value::Null);
262                    Value::Array(vec![self.cdr_value(&of, types, field, tick, depth + 1)])
263                } else {
264                    Value::Null
265                }
266            }
267            _ => Value::Null,
268        }
269    }
270
271    /// Protobuf: field names and kinds off the served descriptor; the value
272    /// is JSON in prost-reflect's serde dialect, which `store.encode`
273    /// deserializes into a `DynamicMessage`.
274    #[cfg(feature = "decode-protobuf")]
275    fn protobuf_value(&self, schema: &TypeSchema, tick: u64) -> Option<Value> {
276        use prost_reflect::{DescriptorPool, Kind};
277        let fds = schema.protobuf_descriptor_set()?;
278        let message = schema.protobuf_message()?;
279        let pool = DescriptorPool::decode(fds.as_slice()).ok()?;
280        let desc = pool.get_message_by_name(message)?;
281        fn message_value(
282            synth: &Synth,
283            desc: &prost_reflect::MessageDescriptor,
284            tick: u64,
285            depth: usize,
286        ) -> Value {
287            if depth > DEPTH_CAP {
288                return Value::Object(Map::new());
289            }
290            let mut out = Map::new();
291            for field in desc.fields() {
292                let name = field.json_name().to_string();
293                let v = match field.kind() {
294                    Kind::Double | Kind::Float => json!(synth.wander(&name, tick, 0.0, 100.0)),
295                    Kind::Int32
296                    | Kind::Int64
297                    | Kind::Sint32
298                    | Kind::Sint64
299                    | Kind::Sfixed32
300                    | Kind::Sfixed64 => {
301                        json!(synth.wander(&name, tick, 0.0, 100.0).round() as i64)
302                    }
303                    Kind::Uint32 | Kind::Uint64 | Kind::Fixed32 | Kind::Fixed64 => {
304                        json!(synth.wander(&name, tick, 0.0, 100.0).round().abs() as u64)
305                    }
306                    Kind::Bool => Value::Bool(tick.is_multiple_of(2)),
307                    Kind::String => Value::String(format!("{name}-{}", tick % 10)),
308                    Kind::Bytes => Value::String(String::new()),
309                    Kind::Enum(e) => e
310                        .values()
311                        .next()
312                        .map(|v| Value::String(v.name().to_string()))
313                        .unwrap_or(Value::Null),
314                    Kind::Message(m) => message_value(synth, &m, tick, depth + 1),
315                };
316                let v = if field.is_list() {
317                    Value::Array(vec![v])
318                } else {
319                    v
320                };
321                out.insert(name, v);
322            }
323            Value::Object(out)
324        }
325        Some(message_value(self, &desc, tick, 0))
326    }
327}
328
329fn bounds(doc: &Value, dmin: f64, dmax: f64) -> (f64, f64) {
330    let min = doc.get("minimum").and_then(Value::as_f64).unwrap_or(dmin);
331    let max = doc
332        .get("maximum")
333        .and_then(Value::as_f64)
334        .unwrap_or_else(|| dmax.max(min + 1.0));
335    (min, max.max(min))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use zenkey::schema::WireEncoding;
342    use zenkey::schema::decode::DecoderRegistry;
343
344    /// The whole point: a synthesized instance survives the kind's own
345    /// encoder — and for json-schema (with validate-json on in tests) that
346    /// encoder *validates*, so this is a real conformance round trip.
347    #[test]
348    fn a_synthesized_json_instance_encodes_and_validates() {
349        let schema = TypeSchema::json_schema(json!({
350            "type": "object",
351            "required": ["status", "load", "cores"],
352            "properties": {
353                "status": { "type": "string", "enum": ["ok", "degraded"] },
354                "load": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
355                "cores": { "type": "integer", "minimum": 1, "maximum": 128 },
356                "tags": { "type": "array", "items": { "type": "string" } },
357                "nested": {
358                    "type": "object",
359                    "properties": { "up": { "type": "boolean" } },
360                },
361            },
362        }));
363        let registry = DecoderRegistry::new();
364        let synth = Synth::new(42);
365        for tick in 0..20 {
366            let v = synth
367                .instance(&schema, tick)
368                .expect("json-schema synthesizes");
369            let bytes = registry
370                .encode(&schema, &v, &WireEncoding::Json)
371                .unwrap_or_else(|e| panic!("tick {tick}: {v} refused: {e}"));
372            let back = registry
373                .decode(&schema, &WireEncoding::Json, &bytes)
374                .unwrap();
375            assert_eq!(
376                back.verdict,
377                zenkey::schema::validate::Verdict::Valid,
378                "tick {tick}"
379            );
380        }
381    }
382
383    /// A tagged enum behind a `$ref` — the shape `schemars` emits for every
384    /// nested named type, and the one #384 was about. Without following the
385    /// reference the synthesizer produced a wandering *number* for `value`,
386    /// which the validator on this same path then rejected: the round trip
387    /// below is what fails if the resolver goes away.
388    #[test]
389    fn a_ref_into_defs_synthesizes_the_referenced_shape() {
390        let schema = TypeSchema::json_schema(json!({
391            "type": "object",
392            "required": ["name", "value"],
393            "properties": {
394                "name": { "type": "string" },
395                "value": { "$ref": "#/$defs/TelemetryValue" },
396            },
397            "$defs": {
398                "TelemetryValue": {
399                    "oneOf": [
400                        { "type": "object",
401                          "required": ["type", "value"],
402                          "properties": {
403                              "type": { "const": "counter", "type": "string" },
404                              "value": { "type": "integer", "minimum": 0 } } },
405                        { "type": "object",
406                          "required": ["type", "value"],
407                          "properties": {
408                              "type": { "const": "gauge", "type": "string" },
409                              "value": { "type": "number" } } },
410                    ],
411                },
412            },
413        }));
414        let registry = DecoderRegistry::new();
415        let synth = Synth::new(7);
416        let v = synth.instance(&schema, 3).expect("json-schema synthesizes");
417        assert!(
418            v["value"].is_object(),
419            "the reference resolved to the enum, not to a placeholder number: {v}"
420        );
421        assert_eq!(v["value"]["type"], "counter", "the first branch, satisfied");
422
423        let bytes = registry
424            .encode(&schema, &v, &WireEncoding::Json)
425            .unwrap_or_else(|e| panic!("{v} refused: {e}"));
426        let back = registry
427            .decode(&schema, &WireEncoding::Json, &bytes)
428            .unwrap();
429        assert_eq!(back.verdict, zenkey::schema::validate::Verdict::Valid);
430    }
431
432    /// Same (seed, tick) → same instance; different tick → the numerics move.
433    #[test]
434    fn synthesis_is_deterministic_and_wanders() {
435        let schema = TypeSchema::json_schema(json!({
436            "type": "object",
437            "properties": { "v": { "type": "number" } },
438        }));
439        let synth = Synth::new(7);
440        assert_eq!(
441            synth.instance(&schema, 3),
442            synth.instance(&schema, 3),
443            "reproducible runs are the point"
444        );
445        assert_ne!(synth.instance(&schema, 3), synth.instance(&schema, 4));
446    }
447
448    /// The cdr field list synthesizes an object its encoder accepts.
449    #[cfg(feature = "decode-cdr")]
450    #[test]
451    fn a_synthesized_cdr_instance_encodes() {
452        let schema = TypeSchema::cdr(json!({
453            "fields": [
454                { "name": "x", "type": "float64" },
455                { "name": "n", "type": "uint32" },
456                { "name": "label", "type": "string" },
457            ],
458        }));
459        let registry = DecoderRegistry::new();
460        let v = Synth::new(1).instance(&schema, 0).expect("cdr synthesizes");
461        let bytes = registry
462            .encode(&schema, &v, &WireEncoding::Cdr)
463            .expect("the instance encodes");
464        assert!(!bytes.is_empty());
465    }
466
467    /// An unknown kind is `None` — the caller states the degradation.
468    #[test]
469    fn an_unknown_kind_declines_instead_of_guessing() {
470        let set = zenkey::schema::SchemaSet::parse(
471            r#"{"schema_version":1,"app":"t",
472                "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
473        )
474        .unwrap();
475        assert_eq!(Synth::new(0).instance(set.get("W").unwrap(), 0), None);
476    }
477}