Skip to main content

faucet_cli/
discovery_matrix.rs

1//! Pure planning logic for the discovery-driven request matrix (#501).
2//!
3//! A `discover:` row enumerates a value-set at runtime (build source → drain →
4//! project `select` → dedup); a `for_each: [dims]` row fans out over the
5//! **cartesian product** of those value-sets, one invocation per tuple.
6//!
7//! Everything here is pure (no I/O): projection, dedup, the cartesian product,
8//! the per-tuple interpolation context, and the tuple state-key suffix — so the
9//! fan-out semantics are unit-testable without a network. The only I/O (running
10//! the discovery source) lives in the executor and feeds [`Dim::values`].
11
12use serde_json::{Map, Value};
13use std::collections::HashMap;
14
15/// Hard ceiling on the number of invocations a single `for_each` row may expand
16/// to. A cartesian product multiplies quickly; above this the run fails rather
17/// than silently spawning an unbounded fleet.
18pub const MAX_MATRIX_PRODUCT: usize = 10_000;
19
20/// One resolved discovery dimension: the discovery row id, the alias its
21/// projected value is exposed under, and the deduped value-set.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Dim {
24    /// Discovery row id (the `${<id>.<alias>}` reference target).
25    pub id: String,
26    /// Alias the projected value is exposed under.
27    pub alias: String,
28    /// Projected, deduped values (first-seen order).
29    pub values: Vec<Value>,
30}
31
32/// A **collected (list-valued) dimension** (#531): a chained `discover:` row
33/// (`for_each: [...]` + `collect: true`) that publishes the *whole* deduped
34/// value-set as one list per upstream tuple, keyed by that tuple. A consuming
35/// row injects the list into one request (`${<id>.<alias>}` → comma-joined),
36/// rather than fanning out one invocation per element.
37#[derive(Debug, Clone, PartialEq)]
38pub struct CollectedDim {
39    /// Discovery row id (the `${<id>.<alias>}` reference target).
40    pub id: String,
41    /// Alias the collected list is exposed under.
42    pub alias: String,
43    /// The upstream discovery dimension ids this row fanned out over, in order;
44    /// used to compute the tuple key both when storing and when injecting.
45    pub dims: Vec<String>,
46    /// The collected list per upstream tuple, keyed by [`collected_tuple_key`].
47    pub by_tuple: HashMap<String, Vec<Value>>,
48}
49
50/// A canonical, symmetric key for one upstream tuple over `dims`, derived from a
51/// per-tuple interpolation context (`{dim_id: {alias: value}}`). Both the
52/// storing side (the chained discovery, over its own `for_each` dims) and the
53/// reading side (a consuming `for_each` row, whose dims are a superset) compute
54/// the same key for the same tuple, so a collected list looks up correctly. Pure.
55pub fn collected_tuple_key(dims: &[String], ctx: &HashMap<String, Value>) -> String {
56    dims.iter()
57        .map(|d| {
58            let v = ctx.get(d).cloned().unwrap_or(Value::Null);
59            format!("{d}={}", serde_json::to_string(&v).unwrap_or_default())
60        })
61        .collect::<Vec<_>>()
62        .join("&")
63}
64
65/// Enrich a product tuple `ctx` in place with every collected dimension whose
66/// list is available for this tuple, so `${<id>.<alias>}` resolves to the list.
67/// A collected dim with no entry for the tuple injects an empty list (the
68/// consuming request then renders an empty param — the "type has no properties"
69/// fallback). Pure.
70pub fn inject_collected(ctx: &mut HashMap<String, Value>, collected: &[CollectedDim]) {
71    for cd in collected {
72        let key = collected_tuple_key(&cd.dims, ctx);
73        let list = cd.by_tuple.get(&key).cloned().unwrap_or_default();
74        let mut obj = Map::new();
75        obj.insert(cd.alias.clone(), Value::Array(list));
76        ctx.insert(cd.id.clone(), Value::Object(obj));
77    }
78}
79
80/// Project `select` (a dot-path, optionally `$`-prefixed) from each record and
81/// dedup the results in first-seen order. `null` / missing projections are
82/// skipped. `$` (or `""`) selects the whole record. Pure.
83pub fn project_dedup(records: &[Value], select: &str) -> Vec<Value> {
84    let mut seen: Vec<Value> = Vec::new();
85    for rec in records {
86        let Some(v) = project_value(rec, select) else {
87            continue;
88        };
89        if v.is_null() {
90            continue;
91        }
92        if !seen.contains(&v) {
93            seen.push(v);
94        }
95    }
96    seen
97}
98
99/// Resolve a dot-path projection against one record. Supports a leading `$.` or
100/// bare `$` (whole record), then `a.b.c` segment walking into objects. Returns
101/// `None` if any segment is missing. Pure.
102fn project_value(record: &Value, select: &str) -> Option<Value> {
103    let path = select
104        .strip_prefix("$.")
105        .or_else(|| select.strip_prefix('$'))
106        .unwrap_or(select);
107    if path.is_empty() {
108        return Some(record.clone());
109    }
110    let mut cur = record;
111    for seg in path.split('.') {
112        cur = cur.get(seg)?;
113    }
114    Some(cur.clone())
115}
116
117/// The number of invocations `dims` expands to (the product of the value-set
118/// sizes). Returns `0` if any dimension is empty. Saturating, so it never
119/// overflows; compare against [`MAX_MATRIX_PRODUCT`]. Pure.
120pub fn product_size(dims: &[Dim]) -> usize {
121    if dims.is_empty() {
122        return 0;
123    }
124    dims.iter()
125        .map(|d| d.values.len())
126        .try_fold(1usize, |acc, n| acc.checked_mul(n))
127        .unwrap_or(usize::MAX)
128}
129
130/// Build the cartesian product of the dimensions as per-tuple interpolation
131/// contexts. Each context maps `dim_id -> { alias: value }`, so a token
132/// `${dim_id.alias}` resolves to that tuple's value via the same
133/// `interpolate_record` path the parent/child matrix uses. An empty dimension
134/// yields no tuples. Pure.
135pub fn cartesian(dims: &[Dim]) -> Vec<HashMap<String, Value>> {
136    if dims.is_empty() || dims.iter().any(|d| d.values.is_empty()) {
137        return Vec::new();
138    }
139    let mut out: Vec<HashMap<String, Value>> = vec![HashMap::new()];
140    for dim in dims {
141        let mut next = Vec::with_capacity(out.len() * dim.values.len());
142        for base in &out {
143            for v in &dim.values {
144                let mut ctx = base.clone();
145                let mut obj = Map::new();
146                obj.insert(dim.alias.clone(), v.clone());
147                ctx.insert(dim.id.clone(), Value::Object(obj));
148                next.push(ctx);
149            }
150        }
151        out = next;
152    }
153    out
154}
155
156/// Stable, collision-resistant state-key suffix for one product tuple, in
157/// declared dimension order: `alias=value&alias=value…`. `dims` supplies the
158/// order + aliases; `ctx` is one entry from [`cartesian`]. Pure.
159pub fn tuple_state_key_suffix(dims: &[Dim], ctx: &HashMap<String, Value>) -> String {
160    dims.iter()
161        .map(|d| {
162            let v = ctx
163                .get(&d.id)
164                .and_then(|o| o.get(&d.alias))
165                .map(value_brief)
166                .unwrap_or_else(|| "(missing)".to_string());
167            format!("{}={}", d.alias, v)
168        })
169        .collect::<Vec<_>>()
170        .join("&")
171}
172
173/// Brief scalar rendering of a JSON value for a state-key segment.
174fn value_brief(v: &Value) -> String {
175    match v {
176        Value::String(s) => s.clone(),
177        Value::Null => "null".to_string(),
178        other => other.to_string(),
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    #[test]
188    fn project_dedup_dotpath_and_dollar() {
189        let recs = vec![
190            json!({"id": 1, "name": "a"}),
191            json!({"id": 2, "name": "b"}),
192            json!({"id": 1, "name": "c"}), // dup id
193        ];
194        assert_eq!(project_dedup(&recs, "$.id"), vec![json!(1), json!(2)]);
195        assert_eq!(project_dedup(&recs, "id"), vec![json!(1), json!(2)]);
196        // `$` selects whole records (all distinct here).
197        assert_eq!(project_dedup(&recs, "$").len(), 3);
198    }
199
200    #[test]
201    fn project_dedup_skips_null_and_missing() {
202        let recs = vec![
203            json!({"id": 1}),
204            json!({"id": null}),
205            json!({"other": 9}), // missing `id`
206            json!({"id": 2}),
207        ];
208        assert_eq!(project_dedup(&recs, "$.id"), vec![json!(1), json!(2)]);
209    }
210
211    #[test]
212    fn project_nested_path() {
213        let recs = vec![
214            json!({"meta": {"code": "X"}}),
215            json!({"meta": {"code": "Y"}}),
216        ];
217        assert_eq!(
218            project_dedup(&recs, "$.meta.code"),
219            vec![json!("X"), json!("Y")]
220        );
221    }
222
223    fn dim(id: &str, alias: &str, vals: Vec<Value>) -> Dim {
224        Dim {
225            id: id.into(),
226            alias: alias.into(),
227            values: vals,
228        }
229    }
230
231    #[test]
232    fn cartesian_two_dims_is_product() {
233        let dims = vec![
234            dim("subs", "subsidiary_id", vec![json!(1), json!(2)]),
235            dim(
236                "fields",
237                "field_id",
238                vec![json!("a"), json!("b"), json!("c")],
239            ),
240        ];
241        let ctxs = cartesian(&dims);
242        assert_eq!(ctxs.len(), 6);
243        // First tuple pairs the first value of each dim.
244        assert_eq!(ctxs[0]["subs"]["subsidiary_id"], json!(1));
245        assert_eq!(ctxs[0]["fields"]["field_id"], json!("a"));
246        // Product covers every combination.
247        let pairs: Vec<(Value, Value)> = ctxs
248            .iter()
249            .map(|c| {
250                (
251                    c["subs"]["subsidiary_id"].clone(),
252                    c["fields"]["field_id"].clone(),
253                )
254            })
255            .collect();
256        assert!(pairs.contains(&(json!(2), json!("c"))));
257    }
258
259    #[test]
260    fn cartesian_single_dim() {
261        let dims = vec![dim("d", "v", vec![json!(1), json!(2)])];
262        let ctxs = cartesian(&dims);
263        assert_eq!(ctxs.len(), 2);
264        assert_eq!(ctxs[1]["d"]["v"], json!(2));
265    }
266
267    #[test]
268    fn cartesian_empty_dim_yields_nothing() {
269        let dims = vec![dim("a", "x", vec![json!(1)]), dim("b", "y", vec![])];
270        assert!(cartesian(&dims).is_empty());
271        assert!(cartesian(&[]).is_empty());
272    }
273
274    #[test]
275    fn product_size_math() {
276        assert_eq!(product_size(&[]), 0);
277        assert_eq!(
278            product_size(&[
279                dim("a", "x", vec![json!(1), json!(2)]),
280                dim("b", "y", vec![json!(1)])
281            ]),
282            2
283        );
284        assert_eq!(
285            product_size(&[dim("a", "x", vec![]), dim("b", "y", vec![json!(1)])]),
286            0
287        );
288    }
289
290    #[test]
291    fn collected_tuple_key_symmetric_between_store_and_read() {
292        // The chained discovery stores over its own dims; the consuming row reads
293        // over its (superset) dims. For the shared upstream tuple the key matches.
294        let dims = vec!["types".to_string()];
295        let store_ctx: HashMap<String, Value> =
296            [("types".to_string(), json!({"name": "deal"}))].into();
297        // Consuming row's ctx also has an extra axis, but the key uses only `dims`.
298        let read_ctx: HashMap<String, Value> = [
299            ("types".to_string(), json!({"name": "deal"})),
300            ("other".to_string(), json!({"x": 1})),
301        ]
302        .into();
303        assert_eq!(
304            collected_tuple_key(&dims, &store_ctx),
305            collected_tuple_key(&dims, &read_ctx)
306        );
307    }
308
309    #[test]
310    fn inject_collected_puts_the_list_into_ctx() {
311        let cd = CollectedDim {
312            id: "props".into(),
313            alias: "name".into(),
314            dims: vec!["types".into()],
315            by_tuple: [(
316                collected_tuple_key(
317                    &["types".into()],
318                    &[("types".to_string(), json!({"name": "deal"}))].into(),
319                ),
320                vec![json!("amount"), json!("stage")],
321            )]
322            .into(),
323        };
324        let mut ctx: HashMap<String, Value> =
325            [("types".to_string(), json!({"name": "deal"}))].into();
326        inject_collected(&mut ctx, std::slice::from_ref(&cd));
327        assert_eq!(ctx["props"]["name"], json!(["amount", "stage"]));
328
329        // A tuple with no collected entry injects an empty list.
330        let mut miss: HashMap<String, Value> =
331            [("types".to_string(), json!({"name": "ticket"}))].into();
332        inject_collected(&mut miss, &[cd]);
333        assert_eq!(miss["props"]["name"], json!([]));
334    }
335
336    #[test]
337    fn tuple_state_key_is_stable_and_ordered() {
338        let dims = vec![
339            dim("subs", "subsidiary_id", vec![json!(1)]),
340            dim("fields", "field_id", vec![json!("a")]),
341        ];
342        let ctxs = cartesian(&dims);
343        assert_eq!(
344            tuple_state_key_suffix(&dims, &ctxs[0]),
345            "subsidiary_id=1&field_id=a"
346        );
347    }
348}