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
17/// How deep nested objects/arrays are followed before giving up — a cyclic
18/// or pathological schema degrades to a placeholder, not a stack overflow.
19const DEPTH_CAP: usize = 6;
20
21/// A deterministic instance generator.
22#[derive(Debug, Clone, Copy)]
23pub struct Synth {
24    pub seed: u64,
25}
26
27/// A cheap deterministic hash for per-field phase offsets (FNV-1a) — not
28/// cryptographic, just stable across runs and platforms.
29fn fnv(s: &str) -> u64 {
30    let mut h: u64 = 0xcbf29ce484222325;
31    for b in s.bytes() {
32        h ^= u64::from(b);
33        h = h.wrapping_mul(0x100000001b3);
34    }
35    h
36}
37
38impl Synth {
39    pub fn new(seed: u64) -> Synth {
40        Synth { seed }
41    }
42
43    /// A wandering numeric value: a sine over `tick`, phase-offset by the
44    /// field's name so sibling fields do not move in lockstep.
45    fn wander(&self, field: &str, tick: u64, min: f64, max: f64) -> f64 {
46        let phase = (fnv(field) ^ self.seed) % 628 /* 2π·100 */;
47        let x = (tick as f64) / 10.0 + (phase as f64) / 100.0;
48        let mid = f64::midpoint(min, max);
49        let amp = (max - min) / 2.0;
50        mid + amp * x.sin()
51    }
52
53    /// Synthesize an instance for a schema entry. `None` means this kind
54    /// cannot be synthesized here (unknown kind) — the caller degrades with
55    /// a stated note, never silently.
56    pub fn instance(&self, schema: &TypeSchema, tick: u64) -> Option<Value> {
57        match schema.kind_str() {
58            zenkey::schema::SchemaKind::JSON_SCHEMA => schema
59                .json_document()
60                .map(|doc| self.json_schema_value(doc, "", tick, 0)),
61            zenkey::schema::SchemaKind::CDR => {
62                let fields = schema.cdr_fields()?;
63                let types = schema.cdr_types();
64                Some(self.cdr_fields_value(fields, types, tick, 0))
65            }
66            #[cfg(feature = "decode-protobuf")]
67            zenkey::schema::SchemaKind::PROTOBUF => self.protobuf_value(schema, tick),
68            _ => None,
69        }
70    }
71
72    /// Walk a draft 2020-12 document conservatively: satisfy `type`,
73    /// `required` (by emitting every declared property), `enum`/`const`,
74    /// and numeric bounds. Unknown or empty schemas get a wandering number —
75    /// `{}` accepts anything.
76    fn json_schema_value(&self, doc: &Value, field: &str, tick: u64, depth: usize) -> Value {
77        if depth > DEPTH_CAP {
78            return Value::Null;
79        }
80        if let Some(c) = doc.get("const") {
81            return c.clone();
82        }
83        if let Some(e) = doc.get("enum").and_then(Value::as_array)
84            && let Some(first) = e.first()
85        {
86            return first.clone();
87        }
88        for branch in ["oneOf", "anyOf"] {
89            if let Some(b) = doc.get(branch).and_then(Value::as_array)
90                && let Some(first) = b.first()
91            {
92                return self.json_schema_value(first, field, tick, depth + 1);
93            }
94        }
95        let ty = doc.get("type").and_then(Value::as_str).unwrap_or("number");
96        match ty {
97            "object" => {
98                let mut out = Map::new();
99                if let Some(props) = doc.get("properties").and_then(Value::as_object) {
100                    for (name, sub) in props {
101                        out.insert(
102                            name.clone(),
103                            self.json_schema_value(sub, name, tick, depth + 1),
104                        );
105                    }
106                }
107                Value::Object(out)
108            }
109            "array" => {
110                let n = doc
111                    .get("minItems")
112                    .and_then(Value::as_u64)
113                    .unwrap_or(1)
114                    .max(1);
115                let item = doc.get("items").cloned().unwrap_or(json!({}));
116                Value::Array(
117                    (0..n)
118                        .map(|i| self.json_schema_value(&item, field, tick + i, depth + 1))
119                        .collect(),
120                )
121            }
122            "string" => Value::String(format!(
123                "{}-{}",
124                if field.is_empty() { "s" } else { field },
125                tick % 10
126            )),
127            "boolean" => Value::Bool(tick.is_multiple_of(2)),
128            "integer" => {
129                let (min, max) = bounds(doc, 0.0, 100.0);
130                json!(self.wander(field, tick, min, max).round() as i64)
131            }
132            "null" => Value::Null,
133            // "number" and anything else numeric-shaped.
134            _ => {
135                let (min, max) = bounds(doc, 0.0, 100.0);
136                json!(self.wander(field, tick, min, max))
137            }
138        }
139    }
140
141    /// The `cdr` kind's compact field list (RFC 08 §7.1): positional
142    /// `[{name, type}]` with a local `types` table for composites.
143    fn cdr_fields_value(
144        &self,
145        fields: &Value,
146        types: Option<&Map<String, Value>>,
147        tick: u64,
148        depth: usize,
149    ) -> Value {
150        if depth > DEPTH_CAP {
151            return Value::Null;
152        }
153        let Some(list) = fields.as_array() else {
154            return Value::Null;
155        };
156        let mut out = Map::new();
157        for f in list {
158            let Some(name) = f.get("name").and_then(Value::as_str) else {
159                continue;
160            };
161            let ty = f.get("type").cloned().unwrap_or(Value::Null);
162            out.insert(
163                name.to_string(),
164                self.cdr_value(&ty, types, name, tick, depth),
165            );
166        }
167        Value::Object(out)
168    }
169
170    fn cdr_value(
171        &self,
172        ty: &Value,
173        types: Option<&Map<String, Value>>,
174        field: &str,
175        tick: u64,
176        depth: usize,
177    ) -> Value {
178        if depth > DEPTH_CAP {
179            return Value::Null;
180        }
181        match ty {
182            Value::String(name) => match name.as_str() {
183                "bool" => Value::Bool(tick.is_multiple_of(2)),
184                "string" => Value::String(format!("{field}-{}", tick % 10)),
185                "float32" | "float" | "float64" | "double" => {
186                    json!(self.wander(field, tick, 0.0, 100.0))
187                }
188                // The full primitive vocabulary of the cdr kind (RFC 08
189                // §7.1's IDL-flavoured aliases included).
190                "int8" | "char" | "int16" | "short" | "int32" | "long" | "int64" | "long long" => {
191                    json!(self.wander(field, tick, 0.0, 100.0).round() as i64)
192                }
193                "uint8" | "byte" | "octet" | "uint16" | "unsigned short" | "uint32"
194                | "unsigned long" | "uint64" | "unsigned long long" => {
195                    json!(self.wander(field, tick, 0.0, 100.0).round().abs() as u64)
196                }
197                // A named composite from the local table.
198                other => match types.and_then(|t| t.get(other)) {
199                    Some(composite) => {
200                        let fields = composite.get("fields").unwrap_or(composite);
201                        self.cdr_fields_value(fields, types, tick, depth + 1)
202                    }
203                    None => Value::Null,
204                },
205            },
206            // {"array": {"of": T, "len": n}} / {"sequence": {"of": T}}
207            Value::Object(o) => {
208                if let Some(arr) = o.get("array") {
209                    let n = arr.get("len").and_then(Value::as_u64).unwrap_or(1).max(1);
210                    let of = arr.get("of").cloned().unwrap_or(Value::Null);
211                    Value::Array(
212                        (0..n)
213                            .map(|i| self.cdr_value(&of, types, field, tick + i, depth + 1))
214                            .collect(),
215                    )
216                } else if let Some(seq) = o.get("sequence") {
217                    let of = seq.get("of").cloned().unwrap_or(Value::Null);
218                    Value::Array(vec![self.cdr_value(&of, types, field, tick, depth + 1)])
219                } else {
220                    Value::Null
221                }
222            }
223            _ => Value::Null,
224        }
225    }
226
227    /// Protobuf: field names and kinds off the served descriptor; the value
228    /// is JSON in prost-reflect's serde dialect, which `store.encode`
229    /// deserializes into a `DynamicMessage`.
230    #[cfg(feature = "decode-protobuf")]
231    fn protobuf_value(&self, schema: &TypeSchema, tick: u64) -> Option<Value> {
232        use prost_reflect::{DescriptorPool, Kind};
233        let fds = schema.protobuf_descriptor_set()?;
234        let message = schema.protobuf_message()?;
235        let pool = DescriptorPool::decode(fds.as_slice()).ok()?;
236        let desc = pool.get_message_by_name(message)?;
237        fn message_value(
238            synth: &Synth,
239            desc: &prost_reflect::MessageDescriptor,
240            tick: u64,
241            depth: usize,
242        ) -> Value {
243            if depth > DEPTH_CAP {
244                return Value::Object(Map::new());
245            }
246            let mut out = Map::new();
247            for field in desc.fields() {
248                let name = field.json_name().to_string();
249                let v = match field.kind() {
250                    Kind::Double | Kind::Float => json!(synth.wander(&name, tick, 0.0, 100.0)),
251                    Kind::Int32
252                    | Kind::Int64
253                    | Kind::Sint32
254                    | Kind::Sint64
255                    | Kind::Sfixed32
256                    | Kind::Sfixed64 => {
257                        json!(synth.wander(&name, tick, 0.0, 100.0).round() as i64)
258                    }
259                    Kind::Uint32 | Kind::Uint64 | Kind::Fixed32 | Kind::Fixed64 => {
260                        json!(synth.wander(&name, tick, 0.0, 100.0).round().abs() as u64)
261                    }
262                    Kind::Bool => Value::Bool(tick.is_multiple_of(2)),
263                    Kind::String => Value::String(format!("{name}-{}", tick % 10)),
264                    Kind::Bytes => Value::String(String::new()),
265                    Kind::Enum(e) => e
266                        .values()
267                        .next()
268                        .map(|v| Value::String(v.name().to_string()))
269                        .unwrap_or(Value::Null),
270                    Kind::Message(m) => message_value(synth, &m, tick, depth + 1),
271                };
272                let v = if field.is_list() {
273                    Value::Array(vec![v])
274                } else {
275                    v
276                };
277                out.insert(name, v);
278            }
279            Value::Object(out)
280        }
281        Some(message_value(self, &desc, tick, 0))
282    }
283}
284
285fn bounds(doc: &Value, dmin: f64, dmax: f64) -> (f64, f64) {
286    let min = doc.get("minimum").and_then(Value::as_f64).unwrap_or(dmin);
287    let max = doc
288        .get("maximum")
289        .and_then(Value::as_f64)
290        .unwrap_or_else(|| dmax.max(min + 1.0));
291    (min, max.max(min))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use zenkey::schema::WireEncoding;
298    use zenkey::schema::decode::DecoderRegistry;
299
300    /// The whole point: a synthesized instance survives the kind's own
301    /// encoder — and for json-schema (with validate-json on in tests) that
302    /// encoder *validates*, so this is a real conformance round trip.
303    #[test]
304    fn a_synthesized_json_instance_encodes_and_validates() {
305        let schema = TypeSchema::json_schema(json!({
306            "type": "object",
307            "required": ["status", "load", "cores"],
308            "properties": {
309                "status": { "type": "string", "enum": ["ok", "degraded"] },
310                "load": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
311                "cores": { "type": "integer", "minimum": 1, "maximum": 128 },
312                "tags": { "type": "array", "items": { "type": "string" } },
313                "nested": {
314                    "type": "object",
315                    "properties": { "up": { "type": "boolean" } },
316                },
317            },
318        }));
319        let registry = DecoderRegistry::new();
320        let synth = Synth::new(42);
321        for tick in 0..20 {
322            let v = synth
323                .instance(&schema, tick)
324                .expect("json-schema synthesizes");
325            let bytes = registry
326                .encode(&schema, &v, &WireEncoding::Json)
327                .unwrap_or_else(|e| panic!("tick {tick}: {v} refused: {e}"));
328            let back = registry
329                .decode(&schema, &WireEncoding::Json, &bytes)
330                .unwrap();
331            assert_eq!(
332                back.verdict,
333                zenkey::schema::validate::Verdict::Valid,
334                "tick {tick}"
335            );
336        }
337    }
338
339    /// Same (seed, tick) → same instance; different tick → the numerics move.
340    #[test]
341    fn synthesis_is_deterministic_and_wanders() {
342        let schema = TypeSchema::json_schema(json!({
343            "type": "object",
344            "properties": { "v": { "type": "number" } },
345        }));
346        let synth = Synth::new(7);
347        assert_eq!(
348            synth.instance(&schema, 3),
349            synth.instance(&schema, 3),
350            "reproducible runs are the point"
351        );
352        assert_ne!(synth.instance(&schema, 3), synth.instance(&schema, 4));
353    }
354
355    /// The cdr field list synthesizes an object its encoder accepts.
356    #[cfg(feature = "decode-cdr")]
357    #[test]
358    fn a_synthesized_cdr_instance_encodes() {
359        let schema = TypeSchema::cdr(json!({
360            "fields": [
361                { "name": "x", "type": "float64" },
362                { "name": "n", "type": "uint32" },
363                { "name": "label", "type": "string" },
364            ],
365        }));
366        let registry = DecoderRegistry::new();
367        let v = Synth::new(1).instance(&schema, 0).expect("cdr synthesizes");
368        let bytes = registry
369            .encode(&schema, &v, &WireEncoding::Cdr)
370            .expect("the instance encodes");
371        assert!(!bytes.is_empty());
372    }
373
374    /// An unknown kind is `None` — the caller states the degradation.
375    #[test]
376    fn an_unknown_kind_declines_instead_of_guessing() {
377        let set = zenkey::schema::SchemaSet::parse(
378            r#"{"schema_version":1,"app":"t",
379                "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
380        )
381        .unwrap();
382        assert_eq!(Synth::new(0).instance(set.get("W").unwrap(), 0), None);
383    }
384}