Skip to main content

nmbrs_workload/
inline.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inline workload synthesis from the `op=` command-line parameter.
5//!
6//! Parses an inline op template string into a [`Workload`] — the same
7//! type that [`parse_workload()`](crate::parse::parse_workload) returns
8//! from YAML. Inline `{{expr}}` bindings are extracted, assigned
9//! synthetic Polydat output names, and compiled to a Polydat source block.
10//!
11//! See SRD 35 for design details.
12
13use std::collections::HashMap;
14
15use crate::model::{BindingsDef, ParsedOp, Workload};
16
17/// Synthesize a [`Workload`] from an inline `op=` string.
18///
19/// # Inline binding syntax
20///
21/// - `{{expr}}` — inline Polydat expression. Compiled into the GK
22///   kernel at init time, then invoked per cycle like any other
23///   Polydat output. Extracted and replaced with `{__inline_N}`.
24/// - `{name}` — reference bind point, resolved by the standard
25///   bind point pipeline (GK output, coordinate, capture).
26///
27/// # Multiple ops
28///
29/// Semicolons separate multiple ops. An optional `N:` prefix sets
30/// the ratio:
31///
32/// ```text
33/// "3:read {{cycle}};1:write {{mod(cycle, 100)}}"
34/// ```
35///
36/// # Examples
37///
38/// ```
39/// use nmbrs_workload::inline::synthesize_inline_workload;
40///
41/// let w = synthesize_inline_workload("hello {{cycle}}").unwrap();
42/// assert_eq!(w.ops.len(), 1);
43/// assert_eq!(w.ops[0].name, "inline_0");
44/// ```
45pub fn synthesize_inline_workload(op_template: &str) -> Result<Workload, String> {
46    if op_template.trim().is_empty() {
47        return Err("op= value is empty".into());
48    }
49
50    // Polydat bindings-block form: `op='a := ...; b := ...'`. When the
51    // whole value is a set of `name := expr` assignments that compiles
52    // as valid Polydat, treat it as a bindings block whose binding
53    // names become the op's fields — so any adapter consumes the named
54    // outputs (stdout prints them, plotter plots them). Falls through
55    // to the text-template form when it isn't valid Polydat.
56    if let Some(w) = try_polydat_block_workload(op_template) {
57        return Ok(w);
58    }
59
60    // Split on unquoted semicolons into individual op segments.
61    let segments = split_ops(op_template);
62
63    // Collect all inline expressions across all segments to build
64    // a single shared Polydat source block.
65    let mut inline_exprs: Vec<String> = Vec::new();
66    let mut expr_index: HashMap<String, usize> = HashMap::new();
67
68    // First pass: discover all inline expressions across all segments.
69    // Sources: {{expr}} (double-brace), {expr} (detected expression),
70    // {:=expr}, {:=expr:=}
71    for seg in &segments {
72        // Double-brace {{expr}}
73        for expr in extract_inline_exprs(&seg.template) {
74            if !expr_index.contains_key(&expr) {
75                let idx = inline_exprs.len();
76                expr_index.insert(expr.clone(), idx);
77                inline_exprs.push(expr);
78            }
79        }
80        // Single-brace expressions detected by bind point parser
81        for bp in crate::bindpoints::extract_bind_points(&seg.template) {
82            if let crate::bindpoints::BindPoint::InlineDefinition(expr) = bp
83                && !expr_index.contains_key(&expr)
84            {
85                let idx = inline_exprs.len();
86                expr_index.insert(expr.clone(), idx);
87                inline_exprs.push(expr);
88            }
89        }
90    }
91
92    // Build Polydat source. The `input cycle: u64` line is always emitted
93    // for inline mode: `cycle` is the wire name CLI users reference
94    // (via `{cycle}` placeholders) without writing any explicit
95    // bindings block, so the inline parser makes that convention
96    // explicit in the generated model. Without this declaration the
97    // workload-level placeholder validator can't see `cycle` as a
98    // known wire name and rejects `{cycle}` as undeclared.
99    let mut polydat_source = String::from("input cycle: u64\n");
100    for (i, expr) in inline_exprs.iter().enumerate() {
101        polydat_source.push_str(&format!("__inline_{i} := {expr}\n"));
102    }
103
104    // Second pass: rewrite templates and build ParsedOps.
105    let mut ops = Vec::with_capacity(segments.len());
106    for (i, seg) in segments.iter().enumerate() {
107        let rewritten = rewrite_template(&seg.template, &expr_index);
108
109        let mut op = ParsedOp::simple(&format!("inline_{i}"), &rewritten);
110
111        if seg.ratio != 1 {
112            op.params.insert(
113                "ratio".to_string(),
114                serde_json::Value::Number(serde_json::Number::from(seg.ratio)),
115            );
116        }
117
118        op.tags.insert("name".to_string(), op.name.clone());
119        op.tags.insert("op".to_string(), op.name.clone());
120        op.tags.insert("block".to_string(), "inline".to_string());
121
122        op.bindings = BindingsDef::PolydatSource(polydat_source.clone());
123
124        ops.push(op);
125    }
126
127    Ok(Workload {
128        description: Some("inline workload".into()),
129        scenarios: HashMap::new(),
130        stop_when: Vec::new(),
131        ops,
132        bindings: crate::model::BindingsDef::default(),
133        params: HashMap::new(),
134        phases: HashMap::new(),
135        phase_order: Vec::new(),
136        declared_params: Vec::new(),
137        report: crate::report::Report::default(),
138        report_warnings: Vec::new(),
139        resolution_warnings: Vec::new(),
140        scenario_parse_errors: Vec::new(),
141        status_metrics: Vec::new(),
142        readouts: crate::model::ReadoutsBindings::default(),
143        wrappers: None,
144        implements: None,
145        stick_session: None,
146    })
147}
148
149// ─── Polydat-block form ─────────────────────────────────────
150
151/// Interpret `op=` as a Polydat program when it is one. The rule is
152/// uniform — there are no special syntactic cases for `name := …`
153/// vs `{…}` vs a bare expression: a candidate Polydat source is built
154/// and handed to the compiler, and if it compiles, that's a Polydat
155/// block. Its compiled OUTPUTS become the op's fields, so every
156/// adapter consumes them (stdout prints them, plotter plots them).
157/// Returns `None` when it doesn't compile, so the caller falls back
158/// to the text-template form (which still resolves `{ref}` /
159/// `{{expr}}` interpolation, i.e. the string-composite form).
160fn try_polydat_block_workload(op_template: &str) -> Option<Workload> {
161    let source = build_polydat_candidate(op_template);
162    // The compiler is the sole arbiter of "is this Polydat?".
163    polydat::dsl::compile::compile_polydat(&source).ok()?;
164    // Fields are the declared wire names (`name := …`) — taken from the
165    // source, since the compiler mangles *output* names with `__anon`
166    // suffixes whereas the wires keep their declared names (what
167    // `{name}` placeholders resolve against).
168    let names = binding_wire_names(&source);
169    if names.is_empty() {
170        return None;
171    }
172    let mut op_fields: HashMap<String, serde_json::Value> = HashMap::new();
173    for n in &names {
174        // `{name}` resolves to wire `name` via the adapter's
175        // `resolve_op_fields_via_wires`.
176        op_fields.insert(n.clone(), serde_json::Value::String(format!("{{{n}}}")));
177    }
178    let mut op = ParsedOp::simple("inline_0", "");
179    op.op = op_fields;
180    op.bindings = BindingsDef::PolydatSource(source);
181    op.tags.insert("name".to_string(), "inline_0".to_string());
182    op.tags.insert("op".to_string(), "inline_0".to_string());
183    op.tags.insert("block".to_string(), "inline".to_string());
184
185    Some(Workload {
186        description: Some("inline polydat workload".into()),
187        scenarios: HashMap::new(),
188        stop_when: Vec::new(),
189        ops: vec![op],
190        bindings: crate::model::BindingsDef::default(),
191        params: HashMap::new(),
192        phases: HashMap::new(),
193        phase_order: Vec::new(),
194        declared_params: Vec::new(),
195        report: crate::report::Report::default(),
196        report_warnings: Vec::new(),
197        resolution_warnings: Vec::new(),
198        scenario_parse_errors: Vec::new(),
199        status_metrics: Vec::new(),
200        readouts: crate::model::ReadoutsBindings::default(),
201        wrappers: None,
202        implements: None,
203        stick_session: None,
204    })
205}
206
207/// Build a candidate Polydat source from an `op=` spec: split on
208/// top-level `;` into statements, declare `cycle`, and give any bare
209/// trailing expression an `out :=` so the program has an output. An
210/// already-complete `name := …` statement is kept verbatim. This is
211/// pure source construction — whether the result is Polydat is decided
212/// by the compiler, not by inspecting the shape here.
213fn build_polydat_candidate(op_template: &str) -> String {
214    let segs: Vec<String> = split_top_level_semicolons(op_template)
215        .into_iter()
216        .map(|s| s.trim().to_string())
217        .filter(|s| !s.is_empty())
218        .collect();
219    let mut lines = vec!["input cycle: u64".to_string()];
220    let last = segs.len().saturating_sub(1);
221    for (i, seg) in segs.iter().enumerate() {
222        if has_top_level_assignment(seg) {
223            lines.push(seg.clone());
224        } else if i == last {
225            lines.push(format!("out := {seg}"));
226        } else {
227            lines.push(format!("__expr_{i} := {seg}"));
228        }
229    }
230    lines.join("\n") + "\n"
231}
232
233/// Declared wire names from a built candidate source: the LHS
234/// identifier of each `name := …` line (last whitespace token, so
235/// `const x` → `x`), skipping the `input` line and internal
236/// `__`-prefixed wraps.
237pub(crate) fn binding_wire_names(source: &str) -> Vec<String> {
238    source
239        .lines()
240        .filter_map(|line| {
241            let line = line.trim();
242            if line.starts_with("input ") || !line.contains(":=") {
243                return None;
244            }
245            let lhs = line.split(":=").next()?.trim();
246            let name = lhs.split_whitespace().last()?;
247            let is_ident = !name.is_empty()
248                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
249                && name
250                    .chars()
251                    .next()
252                    .is_some_and(|c| c.is_alphabetic() || c == '_');
253            if is_ident && !name.starts_with("__") {
254                Some(name.to_string())
255            } else {
256                None
257            }
258        })
259        .collect()
260}
261
262/// True when `s` contains a `:=` at brace-depth 0 (a real statement
263/// boundary, not one buried inside `{{expr}}` / `{ref}`).
264fn has_top_level_assignment(s: &str) -> bool {
265    let bytes = s.as_bytes();
266    let mut depth = 0i32;
267    let mut i = 0;
268    while i < bytes.len() {
269        match bytes[i] {
270            b'{' => depth += 1,
271            b'}' => depth = (depth - 1).max(0),
272            b':' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'=' => {
273                return true;
274            }
275            _ => {}
276        }
277        i += 1;
278    }
279    false
280}
281
282/// Split `s` on `;` at brace-depth 0.
283fn split_top_level_semicolons(s: &str) -> Vec<String> {
284    let mut out = Vec::new();
285    let mut cur = String::new();
286    let mut depth = 0i32;
287    for c in s.chars() {
288        match c {
289            '{' => {
290                depth += 1;
291                cur.push(c);
292            }
293            '}' => {
294                depth = (depth - 1).max(0);
295                cur.push(c);
296            }
297            ';' if depth == 0 => {
298                out.push(std::mem::take(&mut cur));
299            }
300            _ => cur.push(c),
301        }
302    }
303    if !cur.trim().is_empty() {
304        out.push(cur);
305    }
306    out
307}
308
309// ─── Internal Types ─────────────────────────────────────────
310
311struct OpSegment {
312    template: String,
313    ratio: u64,
314}
315
316// ─── Helpers ────────────────────────────────────────────────
317
318/// Split an op string on unquoted semicolons, extracting optional
319/// ratio prefixes (`3:template`).
320fn split_ops(input: &str) -> Vec<OpSegment> {
321    let mut segments = Vec::new();
322    let mut current = String::new();
323    let mut in_braces = 0u32;
324
325    for c in input.chars() {
326        match c {
327            '{' => {
328                in_braces += 1;
329                current.push(c);
330            }
331            '}' => {
332                in_braces = in_braces.saturating_sub(1);
333                current.push(c);
334            }
335            ';' if in_braces == 0 => {
336                let seg = current.trim().to_string();
337                if !seg.is_empty() {
338                    segments.push(parse_segment(&seg));
339                }
340                current.clear();
341            }
342            _ => current.push(c),
343        }
344    }
345    let seg = current.trim().to_string();
346    if !seg.is_empty() {
347        segments.push(parse_segment(&seg));
348    }
349    segments
350}
351
352/// Parse a single segment, extracting an optional `N:` ratio prefix.
353fn parse_segment(s: &str) -> OpSegment {
354    // Look for `N:` at the start, but don't confuse with `{{...}}`.
355    if let Some(colon_pos) = s.find(':') {
356        let prefix = &s[..colon_pos];
357        // Only treat as ratio if prefix is all digits.
358        if !prefix.is_empty()
359            && prefix.chars().all(|c| c.is_ascii_digit())
360            && let Ok(ratio) = prefix.parse::<u64>()
361        {
362            return OpSegment {
363                template: s[colon_pos + 1..].trim().to_string(),
364                ratio,
365            };
366        }
367    }
368    OpSegment {
369        template: s.to_string(),
370        ratio: 1,
371    }
372}
373
374/// Extract all `{{expr}}` occurrences from a template string.
375fn extract_inline_exprs(template: &str) -> Vec<String> {
376    let mut exprs = Vec::new();
377    let bytes = template.as_bytes();
378    let len = bytes.len();
379    let mut i = 0;
380
381    while i + 1 < len {
382        if bytes[i] == b'{' && bytes[i + 1] == b'{' {
383            // Find matching }}.
384            let start = i + 2;
385            let mut depth = 1u32;
386            let mut j = start;
387            while j + 1 < len {
388                if bytes[j] == b'{' && bytes[j + 1] == b'{' {
389                    depth += 1;
390                    j += 2;
391                } else if bytes[j] == b'}' && bytes[j + 1] == b'}' {
392                    depth -= 1;
393                    if depth == 0 {
394                        let expr = template[start..j].trim().to_string();
395                        if !expr.is_empty() {
396                            exprs.push(expr);
397                        }
398                        i = j + 2;
399                        break;
400                    }
401                    j += 2;
402                } else {
403                    j += 1;
404                }
405            }
406            if depth > 0 {
407                // Unmatched {{ — skip past it.
408                i += 2;
409            }
410        } else {
411            i += 1;
412        }
413    }
414    exprs
415}
416
417/// Rewrite a template by replacing inline expressions with `{__inline_N}`.
418/// Handles both `{{expr}}` (double-brace) and single-brace expressions
419/// ({:=expr}, {:=expr:=}, and auto-detected {expr}).
420fn rewrite_template(template: &str, expr_index: &HashMap<String, usize>) -> String {
421    // First pass: rewrite {{expr}} double-brace forms
422    let after_double = rewrite_double_brace(template, expr_index);
423    // Second pass: rewrite single-brace expressions {expr}, {:=expr}, {:=expr:=}
424    rewrite_single_brace_exprs(&after_double, expr_index)
425}
426
427fn rewrite_single_brace_exprs(template: &str, expr_index: &HashMap<String, usize>) -> String {
428    let mut result = String::with_capacity(template.len());
429    let chars: Vec<char> = template.chars().collect();
430    let mut i = 0;
431
432    while i < chars.len() {
433        if chars[i] == '{' && (i + 1 >= chars.len() || chars[i + 1] != '{') {
434            let start = i + 1;
435            let mut depth = 1u32;
436            let mut j = start;
437            while j < chars.len() {
438                if chars[j] == '{' {
439                    depth += 1;
440                }
441                if chars[j] == '}' {
442                    depth -= 1;
443                    if depth == 0 {
444                        break;
445                    }
446                }
447                j += 1;
448            }
449            if j < chars.len() {
450                let raw: String = chars[start..j].iter().collect();
451                let raw = raw.trim();
452
453                // Check for {:=expr} or {:=expr:=}
454                let expr = if let Some(e) = raw.strip_prefix(":=") {
455                    Some(e.strip_suffix(":=").unwrap_or(e).trim())
456                } else if crate::bindpoints::is_expression_public(raw) {
457                    Some(raw)
458                } else {
459                    None
460                };
461
462                if let Some(expr) = expr {
463                    if let Some(&idx) = expr_index.get(expr) {
464                        result.push_str(&format!("{{__inline_{idx}}}"));
465                    } else {
466                        // Not in index — preserve as-is
467                        result.push('{');
468                        result.push_str(raw);
469                        result.push('}');
470                    }
471                } else {
472                    // Simple reference — preserve
473                    result.push('{');
474                    result.push_str(raw);
475                    result.push('}');
476                }
477                i = j + 1;
478            } else {
479                result.push(chars[i]);
480                i += 1;
481            }
482        } else {
483            result.push(chars[i]);
484            i += 1;
485        }
486    }
487    result
488}
489
490fn rewrite_double_brace(template: &str, expr_index: &HashMap<String, usize>) -> String {
491    let mut result = String::with_capacity(template.len());
492    let bytes = template.as_bytes();
493    let len = bytes.len();
494    let mut i = 0;
495
496    while i < len {
497        if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
498            let start = i + 2;
499            let mut depth = 1u32;
500            let mut j = start;
501            while j + 1 < len {
502                if bytes[j] == b'{' && bytes[j + 1] == b'{' {
503                    depth += 1;
504                    j += 2;
505                } else if bytes[j] == b'}' && bytes[j + 1] == b'}' {
506                    depth -= 1;
507                    if depth == 0 {
508                        let expr = template[start..j].trim().to_string();
509                        if let Some(&idx) = expr_index.get(&expr) {
510                            result.push_str(&format!("{{__inline_{idx}}}"));
511                        } else {
512                            // Should not happen, but preserve original.
513                            result.push_str(&template[i..j + 2]);
514                        }
515                        i = j + 2;
516                        break;
517                    }
518                    j += 2;
519                } else {
520                    j += 1;
521                }
522            }
523            if depth > 0 {
524                result.push_str(&template[i..]);
525                break;
526            }
527        } else {
528            result.push(bytes[i] as char);
529            i += 1;
530        }
531    }
532    result
533}
534
535// ─── Tests ──────────────────────────────────────────────────
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn simple_inline_binding() {
543        let w = synthesize_inline_workload("hello {{cycle}}").unwrap();
544        assert_eq!(w.ops.len(), 1);
545        assert_eq!(w.ops[0].name, "inline_0");
546        let stmt = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
547        assert_eq!(stmt, "hello {__inline_0}");
548        match &w.ops[0].bindings {
549            BindingsDef::PolydatSource(src) => {
550                assert!(src.contains("input cycle: u64"));
551                assert!(src.contains("__inline_0 := cycle"));
552            }
553            _ => panic!("expected PolydatSource bindings"),
554        }
555    }
556
557    #[test]
558    fn multiple_inline_bindings() {
559        let w = synthesize_inline_workload(
560            "id={{mod(hash(cycle), 100000)}} name={{number_to_words(cycle)}}",
561        )
562        .unwrap();
563        assert_eq!(w.ops.len(), 1);
564        let stmt = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
565        assert_eq!(stmt, "id={__inline_0} name={__inline_1}");
566        match &w.ops[0].bindings {
567            BindingsDef::PolydatSource(src) => {
568                assert!(src.contains("__inline_0 := mod(hash(cycle), 100000)"));
569                assert!(src.contains("__inline_1 := number_to_words(cycle)"));
570            }
571            _ => panic!("expected PolydatSource bindings"),
572        }
573    }
574
575    #[test]
576    fn bindings_block_op_becomes_polydat_fields() {
577        // A valid Polydat bindings block → one op whose fields are the
578        // bound wire names, each resolved via `{name}`.
579        let w =
580            synthesize_inline_workload("x := cos(to_f64(cycle)); y := sin(to_f64(cycle))").unwrap();
581        assert_eq!(w.ops.len(), 1);
582        let keys: std::collections::BTreeSet<&str> =
583            w.ops[0].op.keys().map(|s| s.as_str()).collect();
584        assert!(keys.contains("x") && keys.contains("y"), "fields: {keys:?}");
585        assert!(!w.ops[0].op.contains_key("stmt"), "should not be a text op");
586        assert_eq!(w.ops[0].op.get("x").unwrap().as_str().unwrap(), "{x}");
587        assert!(matches!(w.ops[0].bindings, BindingsDef::PolydatSource(_)));
588    }
589
590    #[test]
591    fn bare_polydat_expr_becomes_out_field() {
592        let w = synthesize_inline_workload("cos(to_f64(cycle))").unwrap();
593        assert_eq!(w.ops.len(), 1);
594        assert!(
595            w.ops[0].op.contains_key("out"),
596            "fields: {:?}",
597            w.ops[0].op.keys().collect::<Vec<_>>()
598        );
599    }
600
601    #[test]
602    fn invalid_polydat_falls_back_to_text_template() {
603        // Has `:=` but doesn't compile → NOT adopted as Polydat; the
604        // text-template form handles it instead (no panic, one op).
605        let w = synthesize_inline_workload("x := not_a_real_fn(@@@)").unwrap();
606        assert_eq!(w.ops.len(), 1);
607        assert!(w.ops[0].op.contains_key("stmt"));
608    }
609
610    #[test]
611    fn detection_is_compile_driven_not_syntactic() {
612        // `{ref}` composite text isn't valid standalone Polydat → text
613        // template (which still interpolates the ref).
614        let w = synthesize_inline_workload("id-{cycle}").unwrap();
615        assert!(w.ops[0].op.contains_key("stmt"));
616    }
617
618    #[test]
619    fn helpers_split_and_name_bindings() {
620        assert!(has_top_level_assignment("x := 1"));
621        assert!(!has_top_level_assignment("hello {{x := 1}}")); // inside braces
622        assert_eq!(split_top_level_semicolons("a := 1; b := 2").len(), 2);
623        let src = build_polydat_candidate("a := 1; sin(cycle)");
624        assert!(src.contains("a := 1"));
625        assert!(src.contains("out := sin(cycle)")); // bare last → out
626        assert_eq!(
627            binding_wire_names("input cycle: u64\nt := 1\n__expr_0 := 2\nx := 3\n"),
628            vec!["t".to_string(), "x".to_string()]
629        ); // skips input + __
630    }
631
632    #[test]
633    fn no_inline_bindings_plain_text() {
634        let w = synthesize_inline_workload("hello world").unwrap();
635        assert_eq!(w.ops.len(), 1);
636        let stmt = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
637        assert_eq!(stmt, "hello world");
638        // Inline mode always emits the `input cycle: u64` line so
639        // workloads referencing `{cycle}` validate cleanly. With no
640        // inline expressions the bindings carry just that declaration
641        // and nothing else.
642        let bindings = match &w.ops[0].bindings {
643            crate::model::BindingsDef::PolydatSource(s) => s.clone(),
644            _ => panic!("expected PolydatSource"),
645        };
646        assert_eq!(bindings, "input cycle: u64\n");
647    }
648
649    #[test]
650    fn reference_bind_points_preserved() {
651        let w = synthesize_inline_workload("value={cycle}").unwrap();
652        assert_eq!(w.ops.len(), 1);
653        let stmt = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
654        assert_eq!(stmt, "value={cycle}");
655        // Same as `no_inline_bindings_plain_text`: a bare `{cycle}`
656        // reference doesn't introduce inline expressions, but the
657        // `input cycle: u64` convention line still gets emitted so
658        // the workload-level placeholder validator recognises the
659        // wire name.
660        let bindings = match &w.ops[0].bindings {
661            crate::model::BindingsDef::PolydatSource(s) => s.clone(),
662            _ => panic!("expected PolydatSource"),
663        };
664        assert_eq!(bindings, "input cycle: u64\n");
665    }
666
667    #[test]
668    fn semicolon_split_multiple_ops() {
669        let w = synthesize_inline_workload("read {{cycle}};write {{mod(cycle, 100)}}").unwrap();
670        assert_eq!(w.ops.len(), 2);
671        assert_eq!(w.ops[0].name, "inline_0");
672        assert_eq!(w.ops[1].name, "inline_1");
673    }
674
675    #[test]
676    fn ratio_prefix() {
677        let w = synthesize_inline_workload("3:read {{cycle}};1:write {{cycle}}").unwrap();
678        assert_eq!(w.ops.len(), 2);
679        assert_eq!(w.ops[0].params.get("ratio").unwrap().as_u64().unwrap(), 3);
680        // ratio=1 is the default, so it's not stored explicitly.
681        assert!(!w.ops[1].params.contains_key("ratio"));
682    }
683
684    #[test]
685    fn ratio_one_not_stored() {
686        let w = synthesize_inline_workload("hello {{cycle}}").unwrap();
687        assert!(!w.ops[0].params.contains_key("ratio"));
688    }
689
690    #[test]
691    fn duplicate_expressions_share_output() {
692        let w = synthesize_inline_workload("a={{hash(cycle)}};b={{hash(cycle)}}").unwrap();
693        // Both ops should reference the same __inline_0.
694        let stmt0 = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
695        let stmt1 = w.ops[1].op.get("stmt").unwrap().as_str().unwrap();
696        assert_eq!(stmt0, "a={__inline_0}");
697        assert_eq!(stmt1, "b={__inline_0}");
698        match &w.ops[0].bindings {
699            BindingsDef::PolydatSource(src) => {
700                // Only one output for hash(cycle).
701                let count = src.matches("__inline_").count();
702                assert_eq!(count, 1);
703            }
704            _ => panic!("expected PolydatSource"),
705        }
706    }
707
708    #[test]
709    fn empty_op_is_error() {
710        assert!(synthesize_inline_workload("").is_err());
711        assert!(synthesize_inline_workload("   ").is_err());
712    }
713
714    #[test]
715    fn mixed_reference_and_inline() {
716        let w = synthesize_inline_workload("id={{mod(hash(cycle), 1000)}} raw={cycle}").unwrap();
717        let stmt = w.ops[0].op.get("stmt").unwrap().as_str().unwrap();
718        assert_eq!(stmt, "id={__inline_0} raw={cycle}");
719    }
720}