1use 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}