Skip to main content

runmat_runtime/builtins/strings/core/
compose.rs

1//! MATLAB-compatible `compose` builtin that formats data into text arrays.
2use runmat_builtins::{
3    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
4    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
5    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
6    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
7    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
8    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
9};
10use runmat_macros::runtime_builtin;
11use runmat_value::{CellArray, CharArray, StringArray, Value};
12
13use crate::builtins::common::map_control_flow_with_builtin;
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::strings::core::string::{
19    extract_format_spec, format_from_spec, FormatSpecData,
20};
21use crate::builtins::strings::type_resolvers::string_array_type;
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
23
24#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::compose")]
25pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
26    name: "compose",
27    op_kind: GpuOpKind::Custom("format"),
28    supported_precisions: &[],
29    broadcast: BroadcastSemantics::None,
30    provider_hooks: &[],
31    constant_strategy: ConstantStrategy::InlineLiteral,
32    residency: ResidencyPolicy::GatherImmediately,
33    nan_mode: ReductionNaN::Include,
34    two_pass_threshold: None,
35    workgroup_size: None,
36    accepts_nan_mode: false,
37    notes: "Formatting always executes on the CPU; GPU tensors are gathered before substitution.",
38};
39
40#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::compose")]
41pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
42    name: "compose",
43    shape: ShapeRequirements::Any,
44    constant_strategy: ConstantStrategy::InlineLiteral,
45    elementwise: None,
46    reduction: None,
47    emits_nan: false,
48    notes: "Formatting builtin; not eligible for fusion and materialises host text arrays.",
49};
50
51const BUILTIN_NAME: &str = "compose";
52
53pub const COMPOSE_RESIDENT_INPUT_EXTENSION: BuiltinExtensionDescriptor =
54    BuiltinExtensionDescriptor {
55        id: "compose-resident-input",
56        mode: BuiltinExtensionMode::RunMatOnly,
57        description: "compose with resident array arguments is a RunMat extension",
58        error_identifier: Some("RunMat:compatibility:ComposeResidentInputExtension"),
59    };
60
61pub const COMPOSE_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [COMPOSE_RESIDENT_INPUT_EXTENSION];
62
63const COMPOSE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
64    name: "A...",
65    classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
66    availability: BuiltinIntegerInputAvailability::Documented,
67    scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
68    notes: "All eight integer classes are formatted directly from native storage, including exact int64 and uint64 values above flintmax.",
69}];
70
71pub const COMPOSE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] = [BuiltinIntegerCapabilityDescriptor {
72    form: "S = compose(formatSpec, integer_A...)",
73    inputs: &COMPOSE_INTEGER_INPUTS,
74    computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific,
75    output_class: BuiltinIntegerOutputClassRule::NotApplicable,
76    overflow: BuiltinIntegerOverflowRule::NotApplicable,
77    backend: BuiltinIntegerBackendRule::HostOnly,
78    overload: BuiltinIntegerOverloadKind::Multiple,
79    notes: "Integer substitution is textual and exact; the format-spec family selects a host string array or cell array of character vectors. Resident arguments are a separately gated RunMat extension and gather before formatting.",
80}];
81
82const COMPOSE_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
83    name: "S",
84    ty: BuiltinParamType::Any,
85    arity: BuiltinParamArity::Required,
86    default: None,
87    description:
88        "Formatted string array or cell array of character vectors, selected by formatSpec.",
89}];
90
91const COMPOSE_INPUT_NO_ARGS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
92    name: "formatSpec",
93    ty: BuiltinParamType::Any,
94    arity: BuiltinParamArity::Required,
95    default: None,
96    description: "Format text or array returned in the corresponding string or cellstr family when no data arguments are supplied.",
97}];
98
99const COMPOSE_INPUT_WITH_ARGS: [BuiltinParamDescriptor; 2] = [
100    BuiltinParamDescriptor {
101        name: "formatSpec",
102        ty: BuiltinParamType::Any,
103        arity: BuiltinParamArity::Required,
104        default: None,
105        description: "Format template text.",
106    },
107    BuiltinParamDescriptor {
108        name: "A...",
109        ty: BuiltinParamType::Any,
110        arity: BuiltinParamArity::Variadic,
111        default: None,
112        description: "Values substituted into formatSpec placeholders.",
113    },
114];
115
116const COMPOSE_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
117    BuiltinSignatureDescriptor {
118        label: "S = compose(formatSpec)",
119        inputs: &COMPOSE_INPUT_NO_ARGS,
120        outputs: &COMPOSE_OUTPUT,
121    },
122    BuiltinSignatureDescriptor {
123        label: "S = compose(formatSpec, A...)",
124        inputs: &COMPOSE_INPUT_WITH_ARGS,
125        outputs: &COMPOSE_OUTPUT,
126    },
127];
128
129const COMPOSE_ERROR_INVALID_FORMAT_SPEC: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
130    code: "RM.COMPOSE.INVALID_FORMAT_SPEC",
131    identifier: Some("RunMat:compose:InvalidFormatSpec"),
132    when: "formatSpec is not valid text input for compose formatting.",
133    message: "compose: invalid formatSpec",
134};
135
136const COMPOSE_ERROR_ARGUMENT_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
137    code: "RM.COMPOSE.ARGUMENT_MISMATCH",
138    identifier: Some("RunMat:compose:ArgumentMismatch"),
139    when: "Data arguments are not scalar or broadcast-compatible with formatSpec.",
140    message: "compose: format data arguments must be scalars or match formatSpec size",
141};
142
143const COMPOSE_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
144    code: "RM.COMPOSE.INTERNAL",
145    identifier: Some("RunMat:compose:InternalError"),
146    when: "Internal string-array construction failed.",
147    message: "compose: internal error",
148};
149
150const COMPOSE_ERROR_INVALID_DATA: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
151    code: "RM.COMPOSE.INVALID_DATA",
152    identifier: Some("RunMat:compose:InvalidData"),
153    when: "A data argument is outside compose's documented numeric, logical, character, or string domain.",
154    message: "compose: unsupported data argument",
155};
156
157const COMPOSE_ERRORS: [BuiltinErrorDescriptor; 4] = [
158    COMPOSE_ERROR_INVALID_FORMAT_SPEC,
159    COMPOSE_ERROR_ARGUMENT_MISMATCH,
160    COMPOSE_ERROR_INTERNAL,
161    COMPOSE_ERROR_INVALID_DATA,
162];
163
164pub const COMPOSE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
165    signatures: &COMPOSE_SIGNATURES,
166    output_mode: BuiltinOutputMode::Fixed,
167    completion_policy: BuiltinCompletionPolicy::Public,
168    errors: &COMPOSE_ERRORS,
169};
170
171fn compose_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
172    compose_error_with_message(error.message, error)
173}
174
175fn compose_error_with_message(
176    message: impl Into<String>,
177    error: &'static BuiltinErrorDescriptor,
178) -> RuntimeError {
179    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
180    if let Some(identifier) = error.identifier {
181        builder = builder.with_identifier(identifier);
182    }
183    builder.build()
184}
185
186fn remap_compose_flow(mut err: RuntimeError) -> RuntimeError {
187    err = map_control_flow_with_builtin(err, BUILTIN_NAME);
188    if let Some(message) = err.message.strip_prefix("string: ") {
189        err.message = format!("compose: {message}");
190        return err;
191    }
192    if !err.message.starts_with("compose: ") {
193        err.message = format!("compose: {}", err.message);
194    }
195    err
196}
197
198#[runtime_builtin(
199    name = "compose",
200    category = "strings/core",
201    summary = "Format values into text arrays using printf-style placeholders.",
202    keywords = "compose,format,string array,gpu",
203    accel = "sink",
204    type_resolver(string_array_type),
205    extensions(COMPOSE_EXTENSIONS),
206    integer_capabilities(COMPOSE_INTEGER_CAPABILITIES),
207    descriptor(crate::builtins::strings::core::compose::COMPOSE_DESCRIPTOR),
208    builtin_path = "crate::builtins::strings::core::compose"
209)]
210async fn compose_builtin(format_spec: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
211    if !rest.is_empty() && matches!(format_spec, Value::Cell(_)) {
212        return Err(compose_error_with_message(
213            "compose: cellstr formatSpec is supported only by compose(formatSpec)",
214            &COMPOSE_ERROR_INVALID_FORMAT_SPEC,
215        ));
216    }
217    if rest.iter().any(|value| matches!(value, Value::Cell(_))) {
218        return Err(compose_error_with_message(
219            "compose: cell-array data arguments are outside the documented input domain",
220            &COMPOSE_ERROR_INVALID_DATA,
221        ));
222    }
223    if matches!(format_spec, Value::GpuTensor(_))
224        || rest
225            .iter()
226            .any(|value| matches!(value, Value::GpuTensor(_)))
227    {
228        crate::compatibility::ensure_builtin_extension_enabled(
229            &COMPOSE_RESIDENT_INPUT_EXTENSION,
230            BUILTIN_NAME,
231        )?;
232    }
233    let char_format = matches!(format_spec, Value::CharArray(_) | Value::Cell(_));
234    let format_value = gather_if_needed_async(&format_spec)
235        .await
236        .map_err(remap_compose_flow)?;
237    let mut gathered_args = Vec::with_capacity(rest.len());
238    for arg in rest {
239        let gathered = gather_if_needed_async(&arg)
240            .await
241            .map_err(remap_compose_flow)?;
242        gathered_args.push(gathered);
243    }
244
245    if gathered_args.is_empty() {
246        let spec = extract_format_spec(format_value)
247            .await
248            .map_err(remap_compose_flow)?;
249        let array = format_spec_data_to_string_array(spec)?;
250        if char_format {
251            return string_array_to_cellstr(array);
252        }
253        return Ok(Value::StringArray(array));
254    }
255
256    let formatted = format_from_spec(format_value, gathered_args)
257        .await
258        .map_err(remap_compose_flow)?;
259    if char_format {
260        return string_array_to_cellstr(formatted);
261    }
262    Ok(Value::StringArray(formatted))
263}
264
265fn string_array_to_cellstr(formatted: StringArray) -> BuiltinResult<Value> {
266    let rows = formatted.shape.first().copied().unwrap_or(1);
267    let cols = formatted.shape.get(1).copied().unwrap_or(1);
268    let values = formatted
269        .data
270        .into_iter()
271        .map(|text| Value::CharArray(CharArray::new_row(&text)))
272        .collect();
273    CellArray::new(values, rows, cols)
274        .map(Value::Cell)
275        .map_err(|_| compose_error(&COMPOSE_ERROR_INTERNAL))
276}
277
278fn format_spec_data_to_string_array(spec: FormatSpecData) -> BuiltinResult<StringArray> {
279    let shape = if spec.shape.is_empty() {
280        match spec.specs.len() {
281            0 => vec![0, 0],
282            1 => vec![1, 1],
283            len => vec![len, 1],
284        }
285    } else {
286        spec.shape
287    };
288    StringArray::new(spec.specs, shape).map_err(|_| compose_error(&COMPOSE_ERROR_INTERNAL))
289}
290
291#[cfg(test)]
292pub(crate) mod tests {
293    use super::*;
294    use crate::builtins::common::test_support;
295    use runmat_builtins::{ResolveContext, Type};
296    use runmat_value::{IntValue, IntegerStorage, Tensor};
297
298    fn compose_builtin(format_spec: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
299        futures::executor::block_on(super::compose_builtin(format_spec, rest))
300    }
301
302    fn error_message(err: crate::RuntimeError) -> String {
303        err.message().to_string()
304    }
305
306    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
307    #[test]
308    fn compose_scalar_numeric() {
309        let result = compose_builtin(Value::from("Count %d"), vec![Value::Int(IntValue::I32(7))])
310            .expect("compose");
311        match result {
312            Value::StringArray(sa) => {
313                assert_eq!(sa.shape, vec![1, 1]);
314                assert_eq!(sa.data, vec!["Count 7".to_string()]);
315            }
316            other => panic!("expected string array, got {other:?}"),
317        }
318    }
319
320    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
321    #[test]
322    fn compose_broadcasts_scalar_spec() {
323        let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
324        let result = compose_builtin(Value::from("Item %0.0f"), vec![Value::Tensor(tensor)])
325            .expect("compose");
326        match result {
327            Value::StringArray(sa) => {
328                assert_eq!(sa.shape, vec![1, 2]);
329                assert_eq!(sa.data, vec!["Item 1".to_string(), "Item 2".to_string()]);
330            }
331            other => panic!("expected string array, got {other:?}"),
332        }
333    }
334
335    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
336    #[test]
337    fn compose_zero_arguments_returns_spec() {
338        let spec = Value::StringArray(
339            StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap(),
340        );
341        let result = compose_builtin(spec, Vec::new()).expect("compose");
342        match result {
343            Value::StringArray(sa) => {
344                assert_eq!(sa.shape, vec![1, 2]);
345                assert_eq!(sa.data, vec!["alpha".to_string(), "beta".to_string()]);
346            }
347            other => panic!("expected string array, got {other:?}"),
348        }
349    }
350
351    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
352    #[test]
353    fn compose_mismatched_lengths_errors() {
354        let spec = Value::StringArray(
355            StringArray::new(vec!["%d".into(), "%d".into()], vec![1, 2]).unwrap(),
356        );
357        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
358        let err = error_message(compose_builtin(spec, vec![Value::Tensor(tensor)]).unwrap_err());
359        assert!(
360            err.starts_with("compose: "),
361            "expected compose prefix, got {err}"
362        );
363        assert!(
364            err.contains("format data arguments must be scalars or match formatSpec size"),
365            "unexpected error text: {err}"
366        );
367    }
368
369    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
370    #[test]
371    fn compose_gpu_argument() {
372        test_support::with_test_provider(|provider| {
373            let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
374            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
375            let view = runmat_accelerate_api::HostTensorView {
376                data: &tensor.materialize_f64(),
377                shape: &tensor.shape,
378            };
379            let handle = provider.upload(&view).expect("upload");
380            let result =
381                compose_builtin(Value::from("Value %0.0f"), vec![Value::GpuTensor(handle)])
382                    .expect("compose");
383            match result {
384                Value::StringArray(sa) => {
385                    assert_eq!(sa.shape, vec![1, 3]);
386                    assert_eq!(
387                        sa.data,
388                        vec![
389                            "Value 1".to_string(),
390                            "Value 2".to_string(),
391                            "Value 3".to_string()
392                        ]
393                    );
394                }
395                other => panic!("expected string array, got {other:?}"),
396            }
397        });
398    }
399
400    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
401    #[test]
402    #[cfg(feature = "wgpu")]
403    fn compose_wgpu_numeric_tensor_matches_cpu() {
404        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
405        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
406            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
407        );
408        let tensor = Tensor::new(vec![1.25, 2.5, 3.75], vec![1, 3]).unwrap();
409        let cpu = compose_builtin(
410            Value::from("Value %0.2f"),
411            vec![Value::Tensor(tensor.clone())],
412        )
413        .expect("cpu compose");
414        let view = runmat_accelerate_api::HostTensorView {
415            data: &tensor.materialize_f64(),
416            shape: &tensor.shape,
417        };
418        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
419        let handle = provider.upload(&view).expect("gpu upload");
420        let gpu = compose_builtin(Value::from("Value %0.2f"), vec![Value::GpuTensor(handle)])
421            .expect("gpu compose");
422        match (cpu, gpu) {
423            (Value::StringArray(expect), Value::StringArray(actual)) => {
424                assert_eq!(actual.shape, expect.shape);
425                assert_eq!(actual.data, expect.data);
426            }
427            other => panic!("unexpected results {other:?}"),
428        }
429    }
430
431    #[test]
432    fn compose_formats_all_integer_classes_and_wide_values_without_f64_materialization() {
433        let cases = vec![
434            (IntegerStorage::I8(vec![i8::MIN]), i8::MIN.to_string()),
435            (IntegerStorage::I16(vec![i16::MIN]), i16::MIN.to_string()),
436            (IntegerStorage::I32(vec![i32::MIN]), i32::MIN.to_string()),
437            (IntegerStorage::I64(vec![i64::MIN]), i64::MIN.to_string()),
438            (IntegerStorage::U8(vec![u8::MAX]), u8::MAX.to_string()),
439            (IntegerStorage::U16(vec![u16::MAX]), u16::MAX.to_string()),
440            (IntegerStorage::U32(vec![u32::MAX]), u32::MAX.to_string()),
441            (IntegerStorage::U64(vec![u64::MAX]), u64::MAX.to_string()),
442        ];
443        for (storage, expected) in cases {
444            let tensor = Tensor::new_integer(storage, vec![1, 1]).expect("integer tensor");
445            let Value::StringArray(result) =
446                compose_builtin(Value::from("%d"), vec![Value::Tensor(tensor)]).expect("compose")
447            else {
448                panic!("expected strings");
449            };
450            assert_eq!(result.data, vec![expected]);
451        }
452    }
453
454    #[test]
455    fn compose_resident_input_is_mode_gated_before_gather() {
456        test_support::with_test_provider(|provider| {
457            let tensor = Tensor::new(vec![1.0], vec![1, 1]).expect("tensor");
458            let handle = provider
459                .upload(&runmat_accelerate_api::HostTensorView {
460                    data: &tensor.materialize_f64(),
461                    shape: &tensor.shape,
462                })
463                .expect("upload");
464            let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
465            let error = compose_builtin(Value::from("%d"), vec![Value::GpuTensor(handle)])
466                .expect_err("resident compose is extension");
467            assert_eq!(
468                error.identifier(),
469                COMPOSE_RESIDENT_INPUT_EXTENSION.error_identifier
470            );
471        });
472    }
473
474    #[test]
475    fn compose_rejects_cell_data_before_nested_integer_or_resident_conversion() {
476        let wide = Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX]), vec![1, 1])
477            .expect("wide integer");
478        let cell = runmat_value::CellArray::new(vec![Value::Tensor(wide)], 1, 1).expect("cell");
479        let error = compose_builtin(Value::from("%d"), vec![Value::Cell(cell)])
480            .expect_err("cell data is outside compose's public domain");
481        assert_eq!(error.identifier(), COMPOSE_ERROR_INVALID_DATA.identifier);
482    }
483
484    #[test]
485    fn compose_char_format_returns_cellstr_while_string_format_returns_string_array() {
486        let char_spec = Value::CharArray(CharArray::new_row("%d"));
487        let Value::Cell(cell) =
488            compose_builtin(char_spec, vec![Value::Int(IntValue::I32(7))]).expect("cellstr")
489        else {
490            panic!("expected cellstr");
491        };
492        assert!(
493            matches!(&cell.data[0], Value::CharArray(value) if value.data.iter().collect::<String>() == "7")
494        );
495        assert!(matches!(
496            compose_builtin(Value::from("%d"), vec![Value::Int(IntValue::I32(7))])
497                .expect("strings"),
498            Value::StringArray(_)
499        ));
500        assert!(matches!(
501            compose_builtin(Value::CharArray(CharArray::new_row("plain")), Vec::new())
502                .expect("zero-data cellstr"),
503            Value::Cell(_)
504        ));
505    }
506
507    #[test]
508    fn compose_cellstr_format_spec_is_unary_only() {
509        let cell = CellArray::new(vec![Value::CharArray(CharArray::new_row("plain"))], 1, 1)
510            .expect("cellstr");
511        assert!(matches!(
512            compose_builtin(Value::Cell(cell.clone()), Vec::new()).expect("unary cellstr"),
513            Value::Cell(_)
514        ));
515        let error = compose_builtin(Value::Cell(cell), vec![Value::Num(1.0)])
516            .expect_err("cellstr formatting is not public");
517        assert_eq!(
518            error.identifier(),
519            COMPOSE_ERROR_INVALID_FORMAT_SPEC.identifier
520        );
521    }
522
523    #[test]
524    fn compose_type_is_string_array() {
525        assert_eq!(
526            string_array_type(&[Type::String], &ResolveContext::new(Vec::new())),
527            Type::cell_of(Type::String)
528        );
529    }
530}