runmat_runtime/builtins/strings/core/
compose.rs

1//! MATLAB-compatible `compose` builtin that formats data into string arrays.
2use runmat_builtins::{StringArray, Value};
3use runmat_macros::runtime_builtin;
4
5use crate::builtins::common::spec::{
6    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
7    ReductionNaN, ResidencyPolicy, ShapeRequirements,
8};
9use crate::builtins::strings::core::string::{
10    extract_format_spec, format_from_spec, FormatSpecData,
11};
12#[cfg(feature = "doc_export")]
13use crate::register_builtin_doc_text;
14use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
15
16#[cfg(feature = "doc_export")]
17pub const DOC_MD: &str = r#"---
18title: "compose"
19category: "strings/core"
20keywords: ["compose", "format", "string array", "sprintf", "gpu"]
21summary: "Format numeric, logical, and text data into MATLAB string arrays using printf-style placeholders."
22references:
23  - https://www.mathworks.com/help/matlab/ref/compose.html
24gpu_support:
25  elementwise: false
26  reduction: false
27  precisions: []
28  broadcasting: "none"
29  notes: "Formatting runs on the CPU. GPU inputs are gathered to host memory before substitution."
30fusion:
31  elementwise: false
32  reduction: false
33  max_inputs: 1
34  constants: "inline"
35requires_feature: null
36tested:
37  unit: "builtins::strings::core::compose::tests"
38  integration: "builtins::strings::core::compose::tests::compose_gpu_argument"
39---
40
41# What does the `compose` function do in MATLAB / RunMat?
42`compose(formatSpec, A1, ..., An)` substitutes data into MATLAB-compatible `%` placeholders and
43returns the result as a string array. It combines `sprintf`-style formatting with string-array
44broadcasting so you can generate multiple strings in one call.
45
46## How does the `compose` function behave in MATLAB / RunMat?
47- `formatSpec` must be text: a string scalar, string array, character vector, character array, or
48  cell array of character vectors.
49- If `formatSpec` is scalar and any argument array has more than one element, RunMat broadcasts the
50  scalar specification over the array dimensions.
51- When `formatSpec` is a string or character array with multiple elements, the output has the same
52  shape as the specification. Each element uses the corresponding row or cell during formatting.
53- Arguments can be numeric, logical, string, or text-like cell arrays. Non-text arguments are
54  converted using MATLAB-compatible rules (logical values become `1` or `0`, complex numbers use the
55  `a + bi` form).
56- When you omit additional arguments, `compose(formatSpec)` simply converts the specification into a
57  string array, preserving the original structure.
58- Errors are raised if argument shapes are incompatible with the specification or if format specifiers
59  are incomplete.
60
61## `compose` Function GPU Execution Behaviour
62`compose` is a residency sink. When inputs include GPU-resident tensors, RunMat gathers the data
63back to host memory using the active acceleration provider before performing the formatting logic.
64All formatted strings live in host memory, so acceleration providers do not need compose-specific
65kernels.
66
67## Examples of using the `compose` function in MATLAB / RunMat
68
69### Formatting A Scalar Value Into A Sentence
70```matlab
71msg = compose("The answer is %d.", 42);
72```
73Expected output:
74```matlab
75msg = "The answer is 42."
76```
77
78### Broadcasting A Scalar Format Spec Over A Vector
79```matlab
80result = compose("Trial %d", 1:4);
81```
82Expected output:
83```matlab
84result = 1×4 string
85    "Trial 1"    "Trial 2"    "Trial 3"    "Trial 4"
86```
87
88### Using A String Array Of Formats
89```matlab
90spec = ["max: %0.2f", "min: %0.2f"];
91values = compose(spec, [3.14159, 0.125]);
92```
93Expected output:
94```matlab
95values = 1×2 string
96    "max: 3.14"    "min: 0.12"
97```
98
99### Formatting Each Row Of A Character Array
100```matlab
101C = ['Row %02d'; 'Row %02d'; 'Row %02d'];
102idx = compose(C, (1:3).');
103```
104Expected output:
105```matlab
106idx = 3×1 string
107    "Row 01"
108    "Row 02"
109    "Row 03"
110```
111
112### Combining Real And Imaginary Parts
113```matlab
114Z = [1+2i, 3-4i];
115txt = compose("z = %s", Z);
116```
117Expected output:
118```matlab
119txt = 1×2 string
120    "z = 1+2i"    "z = 3-4i"
121```
122
123### Using A Cell Array Of Format Specs
124```matlab
125specs = {'%0.1f volts', '%0.1f amps'};
126readings = compose(specs, {12.6, 3.4});
127```
128Expected output:
129```matlab
130readings = 2×1 string
131    "12.6 volts"
132    "3.4 amps"
133```
134
135### Formatting GPU-Resident Data
136```matlab
137G = gpuArray([10 20 30]);
138labels = compose("Value %d", G);
139```
140Expected output:
141```matlab
142labels = 1×3 string
143    "Value 10"    "Value 20"    "Value 30"
144```
145RunMat gathers `G` from the GPU before formatting, so the behaviour matches CPU inputs.
146
147## FAQ
148
149### What happens if the number of format arguments does not match the placeholders?
150RunMat raises `compose: format data arguments must be scalars or match formatSpec size`. Ensure that
151each placeholder has a corresponding value or broadcast the specification appropriately.
152
153### Can `compose` handle complex numbers?
154Yes. Complex numbers use MATLAB's canonical `a + bi` formatting, so `%s` specifiers receive the
155string form of the complex scalar.
156
157### How does `compose` treat logical inputs?
158Logical values are converted to numeric `1` or `0` before formatting so they work with `%d`, `%i`,
159or `%f` placeholders.
160
161### Does `compose` modify the shape of the output?
162No. The output matches the broadcasted size between `formatSpec` and the input arguments. Scalar
163specifications broadcast across non-scalar arguments.
164
165### What if I pass GPU arrays?
166Inputs that reside on the GPU are automatically gathered to host memory before formatting. The
167resulting string array always lives on the CPU.
168
169### How do I emit literal percent signs?
170Use `%%` inside `formatSpec` just like `sprintf`. The formatter converts `%%` into a single `%`.
171
172### Can I mix scalars and arrays in the arguments list?
173Yes, as long as non-scalar arguments all share the same number of elements or match the size of
174`formatSpec`. Scalars broadcast across the target shape.
175
176### What happens when `formatSpec` is empty?
177`compose(formatSpec)` returns an empty string array with the same shape as `formatSpec`. When
178`formatSpec` and arguments have zero elements, the output is `0×0`.
179
180## See Also
181`string`, `sprintf`, `strcat`, `join`
182"#;
183
184pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
185    name: "compose",
186    op_kind: GpuOpKind::Custom("format"),
187    supported_precisions: &[],
188    broadcast: BroadcastSemantics::None,
189    provider_hooks: &[],
190    constant_strategy: ConstantStrategy::InlineLiteral,
191    residency: ResidencyPolicy::GatherImmediately,
192    nan_mode: ReductionNaN::Include,
193    two_pass_threshold: None,
194    workgroup_size: None,
195    accepts_nan_mode: false,
196    notes: "Formatting always executes on the CPU; GPU tensors are gathered before substitution.",
197};
198
199register_builtin_gpu_spec!(GPU_SPEC);
200
201pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
202    name: "compose",
203    shape: ShapeRequirements::Any,
204    constant_strategy: ConstantStrategy::InlineLiteral,
205    elementwise: None,
206    reduction: None,
207    emits_nan: false,
208    notes: "Formatting builtin; not eligible for fusion and materialises host string arrays.",
209};
210
211register_builtin_fusion_spec!(FUSION_SPEC);
212
213#[cfg(feature = "doc_export")]
214register_builtin_doc_text!("compose", DOC_MD);
215
216#[runtime_builtin(
217    name = "compose",
218    category = "strings/core",
219    summary = "Format values into MATLAB string arrays using printf-style placeholders.",
220    keywords = "compose,format,string array,gpu",
221    accel = "sink"
222)]
223fn compose_builtin(format_spec: Value, rest: Vec<Value>) -> Result<Value, String> {
224    let format_value = gather_if_needed(&format_spec).map_err(|e| format!("compose: {e}"))?;
225    let mut gathered_args = Vec::with_capacity(rest.len());
226    for arg in rest {
227        let gathered = gather_if_needed(&arg).map_err(|e| format!("compose: {e}"))?;
228        gathered_args.push(gathered);
229    }
230
231    if gathered_args.is_empty() {
232        let spec = extract_format_spec(format_value).map_err(compose_error)?;
233        let array = format_spec_data_to_string_array(spec)?;
234        return Ok(Value::StringArray(array));
235    }
236
237    let formatted = format_from_spec(format_value, gathered_args).map_err(compose_error)?;
238    Ok(Value::StringArray(formatted))
239}
240
241fn compose_error(err: String) -> String {
242    if let Some(rest) = err.strip_prefix("string:") {
243        format!("compose:{rest}")
244    } else if err.starts_with("compose:") {
245        err
246    } else {
247        format!("compose: {err}")
248    }
249}
250
251fn format_spec_data_to_string_array(spec: FormatSpecData) -> Result<StringArray, String> {
252    let shape = if spec.shape.is_empty() {
253        match spec.specs.len() {
254            0 => vec![0, 0],
255            1 => vec![1, 1],
256            len => vec![len, 1],
257        }
258    } else {
259        spec.shape
260    };
261    StringArray::new(spec.specs, shape).map_err(|e| format!("compose: {e}"))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::builtins::common::test_support;
268    use runmat_builtins::{IntValue, Tensor};
269
270    #[test]
271    fn compose_scalar_numeric() {
272        let result = compose_builtin(Value::from("Count %d"), vec![Value::Int(IntValue::I32(7))])
273            .expect("compose");
274        match result {
275            Value::StringArray(sa) => {
276                assert_eq!(sa.shape, vec![1, 1]);
277                assert_eq!(sa.data, vec!["Count 7".to_string()]);
278            }
279            other => panic!("expected string array, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn compose_broadcasts_scalar_spec() {
285        let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
286        let result = compose_builtin(Value::from("Item %0.0f"), vec![Value::Tensor(tensor)])
287            .expect("compose");
288        match result {
289            Value::StringArray(sa) => {
290                assert_eq!(sa.shape, vec![1, 2]);
291                assert_eq!(sa.data, vec!["Item 1".to_string(), "Item 2".to_string()]);
292            }
293            other => panic!("expected string array, got {other:?}"),
294        }
295    }
296
297    #[test]
298    fn compose_zero_arguments_returns_spec() {
299        let spec = Value::StringArray(
300            StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap(),
301        );
302        let result = compose_builtin(spec, Vec::new()).expect("compose");
303        match result {
304            Value::StringArray(sa) => {
305                assert_eq!(sa.shape, vec![1, 2]);
306                assert_eq!(sa.data, vec!["alpha".to_string(), "beta".to_string()]);
307            }
308            other => panic!("expected string array, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn compose_mismatched_lengths_errors() {
314        let spec = Value::StringArray(
315            StringArray::new(vec!["%d".into(), "%d".into()], vec![1, 2]).unwrap(),
316        );
317        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
318        let err = compose_builtin(spec, vec![Value::Tensor(tensor)]).unwrap_err();
319        assert!(
320            err.starts_with("compose: "),
321            "expected compose prefix, got {err}"
322        );
323        assert!(
324            err.contains("format data arguments must be scalars or match formatSpec size"),
325            "unexpected error text: {err}"
326        );
327    }
328
329    #[test]
330    fn compose_gpu_argument() {
331        test_support::with_test_provider(|provider| {
332            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
333            let view = runmat_accelerate_api::HostTensorView {
334                data: &tensor.data,
335                shape: &tensor.shape,
336            };
337            let handle = provider.upload(&view).expect("upload");
338            let result =
339                compose_builtin(Value::from("Value %0.0f"), vec![Value::GpuTensor(handle)])
340                    .expect("compose");
341            match result {
342                Value::StringArray(sa) => {
343                    assert_eq!(sa.shape, vec![1, 3]);
344                    assert_eq!(
345                        sa.data,
346                        vec![
347                            "Value 1".to_string(),
348                            "Value 2".to_string(),
349                            "Value 3".to_string()
350                        ]
351                    );
352                }
353                other => panic!("expected string array, got {other:?}"),
354            }
355        });
356    }
357
358    #[test]
359    #[cfg(feature = "doc_export")]
360    fn doc_examples_parse() {
361        let blocks = test_support::doc_examples(DOC_MD);
362        assert!(!blocks.is_empty());
363    }
364
365    #[test]
366    #[cfg(feature = "wgpu")]
367    fn compose_wgpu_numeric_tensor_matches_cpu() {
368        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
369            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
370        );
371        let tensor = Tensor::new(vec![1.25, 2.5, 3.75], vec![1, 3]).unwrap();
372        let cpu = compose_builtin(
373            Value::from("Value %0.2f"),
374            vec![Value::Tensor(tensor.clone())],
375        )
376        .expect("cpu compose");
377        let view = runmat_accelerate_api::HostTensorView {
378            data: &tensor.data,
379            shape: &tensor.shape,
380        };
381        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
382        let handle = provider.upload(&view).expect("gpu upload");
383        let gpu = compose_builtin(Value::from("Value %0.2f"), vec![Value::GpuTensor(handle)])
384            .expect("gpu compose");
385        match (cpu, gpu) {
386            (Value::StringArray(expect), Value::StringArray(actual)) => {
387                assert_eq!(actual.shape, expect.shape);
388                assert_eq!(actual.data, expect.data);
389            }
390            other => panic!("unexpected results {other:?}"),
391        }
392    }
393}