Skip to main content

runmat_runtime/builtins/strings/core/
int2str.rs

1//! MATLAB-compatible `int2str` builtin with GPU-aware host formatting.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CharArray, IntValue, IntegerStorage, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::gpu_helpers;
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::common::tensor;
17use crate::builtins::strings::type_resolvers::string_scalar_type;
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19
20const BUILTIN_NAME: &str = "int2str";
21
22const INT2STR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "chr",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Character array containing the rounded integer text.",
28}];
29
30const INT2STR_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "N",
32    ty: BuiltinParamType::Any,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Numeric or logical scalar, vector, or matrix input.",
36}];
37
38const INT2STR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
39    label: "chr = int2str(N)",
40    inputs: &INT2STR_INPUT,
41    outputs: &INT2STR_OUTPUT,
42}];
43
44const INT2STR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
45    code: "RM.INT2STR.INVALID_INPUT",
46    identifier: Some("RunMat:int2str:InvalidInput"),
47    when: "Input value is not a supported numeric/logical scalar, vector, or matrix.",
48    message: "int2str: unsupported input type",
49};
50
51const INT2STR_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
52    code: "RM.INT2STR.INVALID_OPTION",
53    identifier: Some("RunMat:int2str:InvalidOption"),
54    when: "Arguments are malformed or too many were supplied.",
55    message: "int2str: invalid option arguments",
56};
57
58const INT2STR_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
59    code: "RM.INT2STR.INTERNAL",
60    identifier: Some("RunMat:int2str:InternalError"),
61    when: "Internal char-array assembly failed.",
62    message: "int2str: internal error",
63};
64
65const INT2STR_ERRORS: [BuiltinErrorDescriptor; 3] = [
66    INT2STR_ERROR_INVALID_INPUT,
67    INT2STR_ERROR_INVALID_OPTION,
68    INT2STR_ERROR_INTERNAL,
69];
70
71pub const INT2STR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
72    signatures: &INT2STR_SIGNATURES,
73    output_mode: BuiltinOutputMode::Fixed,
74    completion_policy: BuiltinCompletionPolicy::Public,
75    errors: &INT2STR_ERRORS,
76};
77
78fn int2str_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
79    int2str_error_with_message(error.message, error)
80}
81
82fn int2str_error_with_message(
83    message: impl Into<String>,
84    error: &'static BuiltinErrorDescriptor,
85) -> RuntimeError {
86    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
87    if let Some(identifier) = error.identifier {
88        builder = builder.with_identifier(identifier);
89    }
90    builder.build()
91}
92
93fn remap_int2str_flow(err: RuntimeError) -> RuntimeError {
94    map_control_flow_with_builtin(err, BUILTIN_NAME)
95}
96
97#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::int2str")]
98pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
99    name: "int2str",
100    op_kind: GpuOpKind::Custom("conversion"),
101    supported_precisions: &[],
102    broadcast: BroadcastSemantics::None,
103    provider_hooks: &[],
104    constant_strategy: ConstantStrategy::InlineLiteral,
105    residency: ResidencyPolicy::GatherImmediately,
106    nan_mode: ReductionNaN::Include,
107    two_pass_threshold: None,
108    workgroup_size: None,
109    accepts_nan_mode: false,
110    notes: "Accepts GPU inputs but gathers them to host memory before rounding and formatting.",
111};
112
113#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::int2str")]
114pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
115    name: "int2str",
116    shape: ShapeRequirements::Any,
117    constant_strategy: ConstantStrategy::InlineLiteral,
118    elementwise: None,
119    reduction: None,
120    emits_nan: false,
121    notes:
122        "Conversion builtin; not eligible for fusion and always materialises host character arrays.",
123};
124
125#[runtime_builtin(
126    name = "int2str",
127    category = "strings/core",
128    summary = "Convert rounded integer values to a character array.",
129    keywords = "int2str,integer to string,number to string,round",
130    examples = "chr = int2str([5 10 20; 100 200 400]);",
131    type_resolver(string_scalar_type),
132    descriptor(crate::builtins::strings::core::int2str::INT2STR_DESCRIPTOR),
133    builtin_path = "crate::builtins::strings::core::int2str"
134)]
135async fn int2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
136    if !rest.is_empty() {
137        return Err(int2str_error_with_message(
138            "int2str: too many input arguments",
139            &INT2STR_ERROR_INVALID_OPTION,
140        ));
141    }
142
143    let gathered = gather_if_needed_async(&value)
144        .await
145        .map_err(remap_int2str_flow)?;
146    let data = extract_numeric_data(gathered).await?;
147    Ok(Value::CharArray(format_numeric_data(data)?))
148}
149
150enum NumericData {
151    Real {
152        data: Vec<f64>,
153        rows: usize,
154        cols: usize,
155    },
156    Integer {
157        storage: IntegerStorage,
158        rows: usize,
159        cols: usize,
160    },
161}
162
163async fn extract_numeric_data(value: Value) -> BuiltinResult<NumericData> {
164    match value {
165        Value::Num(n) => Ok(NumericData::Real {
166            data: vec![n],
167            rows: 1,
168            cols: 1,
169        }),
170        Value::Int(i) => Ok(NumericData::Integer {
171            storage: integer_storage_from_scalar(i),
172            rows: 1,
173            cols: 1,
174        }),
175        Value::Bool(b) => Ok(NumericData::Real {
176            data: vec![if b { 1.0 } else { 0.0 }],
177            rows: 1,
178            cols: 1,
179        }),
180        Value::Tensor(tensor) => tensor_to_numeric_data(tensor),
181        Value::LogicalArray(logical) => {
182            let tensor = tensor::logical_to_tensor(&logical)
183                .map_err(|_| int2str_error(&INT2STR_ERROR_INVALID_INPUT))?;
184            tensor_to_numeric_data(tensor)
185        }
186        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(int2str_error_with_message(
187            "int2str: complex input is not supported",
188            &INT2STR_ERROR_INVALID_INPUT,
189        )),
190        Value::GpuTensor(handle) => {
191            let gathered = gpu_helpers::gather_tensor_async(&handle)
192                .await
193                .map_err(remap_int2str_flow)?;
194            tensor_to_numeric_data(gathered)
195        }
196        other => Err(int2str_error_with_message(
197            format!(
198                "{} {:?}; expected numeric or logical values",
199                INT2STR_ERROR_INVALID_INPUT.message, other
200            ),
201            &INT2STR_ERROR_INVALID_INPUT,
202        )),
203    }
204}
205
206fn tensor_to_numeric_data(tensor: Tensor) -> BuiltinResult<NumericData> {
207    if tensor.shape.len() > 2 {
208        return Err(int2str_error_with_message(
209            "int2str: input must be scalar, vector, or 2-D matrix",
210            &INT2STR_ERROR_INVALID_INPUT,
211        ));
212    }
213    let rows = tensor.rows();
214    let cols = tensor.cols();
215    match tensor.integer_data {
216        Some(storage) => Ok(NumericData::Integer {
217            storage,
218            rows,
219            cols,
220        }),
221        None => Ok(NumericData::Real {
222            data: tensor.data,
223            rows,
224            cols,
225        }),
226    }
227}
228
229#[derive(Clone)]
230struct CellEntry {
231    text: String,
232    width: usize,
233}
234
235fn format_numeric_data(data: NumericData) -> BuiltinResult<CharArray> {
236    match data {
237        NumericData::Real { data, rows, cols } => format_real_matrix(&data, rows, cols),
238        NumericData::Integer {
239            storage,
240            rows,
241            cols,
242        } => format_integer_matrix(&storage, rows, cols),
243    }
244}
245
246fn format_real_matrix(data: &[f64], rows: usize, cols: usize) -> BuiltinResult<CharArray> {
247    if rows == 0 || cols == 0 {
248        return char_array(Vec::new(), 0, 0);
249    }
250
251    format_entries(rows, cols, |row, col| {
252        let idx = row + col * rows;
253        format_rounded_real(data.get(idx).copied().unwrap_or(0.0))
254    })
255}
256
257fn format_integer_matrix(
258    storage: &IntegerStorage,
259    rows: usize,
260    cols: usize,
261) -> BuiltinResult<CharArray> {
262    if rows == 0 || cols == 0 {
263        return char_array(Vec::new(), 0, 0);
264    }
265
266    format_entries(rows, cols, |row, col| {
267        integer_storage_string(storage, row + col * rows)
268    })
269}
270
271fn integer_storage_from_scalar(value: IntValue) -> IntegerStorage {
272    match value {
273        IntValue::I8(value) => IntegerStorage::I8(vec![value]),
274        IntValue::I16(value) => IntegerStorage::I16(vec![value]),
275        IntValue::I32(value) => IntegerStorage::I32(vec![value]),
276        IntValue::I64(value) => IntegerStorage::I64(vec![value]),
277        IntValue::U8(value) => IntegerStorage::U8(vec![value]),
278        IntValue::U16(value) => IntegerStorage::U16(vec![value]),
279        IntValue::U32(value) => IntegerStorage::U32(vec![value]),
280        IntValue::U64(value) => IntegerStorage::U64(vec![value]),
281    }
282}
283
284fn integer_storage_string(storage: &IntegerStorage, index: usize) -> String {
285    match storage {
286        IntegerStorage::I8(values) => values[index].to_string(),
287        IntegerStorage::I16(values) => values[index].to_string(),
288        IntegerStorage::I32(values) => values[index].to_string(),
289        IntegerStorage::I64(values) => values[index].to_string(),
290        IntegerStorage::U8(values) => values[index].to_string(),
291        IntegerStorage::U16(values) => values[index].to_string(),
292        IntegerStorage::U32(values) => values[index].to_string(),
293        IntegerStorage::U64(values) => values[index].to_string(),
294    }
295}
296
297fn format_entries<F>(rows: usize, cols: usize, mut value_at: F) -> BuiltinResult<CharArray>
298where
299    F: FnMut(usize, usize) -> String,
300{
301    let total_cells = rows.checked_mul(cols).ok_or_else(|| {
302        int2str_error_with_message(
303            "int2str: output dimensions are too large",
304            &INT2STR_ERROR_INTERNAL,
305        )
306    })?;
307    if total_cells > isize::MAX as usize {
308        return Err(int2str_error_with_message(
309            "int2str: output dimensions are too large",
310            &INT2STR_ERROR_INTERNAL,
311        ));
312    }
313
314    let mut entries = vec![
315        vec![
316            CellEntry {
317                text: String::new(),
318                width: 0,
319            };
320            cols
321        ];
322        rows
323    ];
324    let mut col_widths = vec![0usize; cols];
325
326    for (col, width) in col_widths.iter_mut().enumerate() {
327        for (row, row_entries) in entries.iter_mut().enumerate() {
328            let text = value_at(row, col);
329            let entry_width = text.chars().count();
330            row_entries[col] = CellEntry {
331                text,
332                width: entry_width,
333            };
334            *width = (*width).max(entry_width);
335        }
336    }
337
338    if cols > 1 {
339        for (idx, width) in col_widths.iter_mut().enumerate() {
340            if idx > 0 {
341                *width += 1;
342            }
343        }
344    }
345
346    rows_to_char_array(assemble_rows(entries, col_widths))
347}
348
349fn format_rounded_real(value: f64) -> String {
350    format_integer_like(matlab_round(value))
351}
352
353fn matlab_round(value: f64) -> f64 {
354    if value.is_finite() {
355        value.round()
356    } else {
357        value
358    }
359}
360
361fn format_integer_like(value: f64) -> String {
362    if value.is_nan() {
363        return "NaN".to_string();
364    }
365    if value.is_infinite() {
366        return if value.is_sign_negative() {
367            "-Inf".to_string()
368        } else {
369            "Inf".to_string()
370        };
371    }
372    if value == 0.0 {
373        return "0".to_string();
374    }
375    if value >= i64::MIN as f64 && value <= i64::MAX as f64 {
376        return format!("{}", value as i64);
377    }
378    format!("{value:.0}")
379}
380
381fn assemble_rows(entries: Vec<Vec<CellEntry>>, col_widths: Vec<usize>) -> Vec<String> {
382    entries
383        .into_iter()
384        .map(|row_entries| {
385            row_entries
386                .into_iter()
387                .enumerate()
388                .fold(String::new(), |mut acc, (col, entry)| {
389                    if col > 0 {
390                        acc.push(' ');
391                    }
392                    let target = col_widths[col];
393                    let pad = target.saturating_sub(entry.width);
394                    acc.extend(std::iter::repeat_n(' ', pad));
395                    acc.push_str(&entry.text);
396                    acc
397                })
398        })
399        .collect()
400}
401
402fn rows_to_char_array(rows: Vec<String>) -> BuiltinResult<CharArray> {
403    if rows.is_empty() {
404        return char_array(Vec::new(), 0, 0);
405    }
406
407    let row_count = rows.len();
408    let col_count = rows
409        .iter()
410        .map(|row| row.chars().count())
411        .max()
412        .unwrap_or(0);
413    let capacity = row_count.checked_mul(col_count).ok_or_else(|| {
414        int2str_error_with_message(
415            "int2str: output dimensions are too large",
416            &INT2STR_ERROR_INTERNAL,
417        )
418    })?;
419
420    let mut data = Vec::with_capacity(capacity);
421    for row in rows {
422        let mut chars: Vec<char> = row.chars().collect();
423        if chars.len() < col_count {
424            chars.extend(std::iter::repeat_n(' ', col_count - chars.len()));
425        }
426        data.extend(chars);
427    }
428    char_array(data, row_count, col_count)
429}
430
431fn char_array(data: Vec<char>, rows: usize, cols: usize) -> BuiltinResult<CharArray> {
432    let expected = rows.checked_mul(cols).ok_or_else(|| {
433        int2str_error_with_message(
434            "int2str: output dimensions are too large",
435            &INT2STR_ERROR_INTERNAL,
436        )
437    })?;
438    if expected != data.len() {
439        return Err(int2str_error(&INT2STR_ERROR_INTERNAL));
440    }
441    CharArray::new(data, rows, cols).map_err(|_| int2str_error(&INT2STR_ERROR_INTERNAL))
442}
443
444#[cfg(test)]
445pub(crate) mod tests {
446    use super::*;
447    use crate::builtins::common::test_support;
448    use runmat_builtins::{IntValue, LogicalArray, ResolveContext, Type};
449
450    fn int2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
451        futures::executor::block_on(super::int2str_builtin(value, rest))
452    }
453
454    fn char_rows(value: Value) -> Vec<String> {
455        match value {
456            Value::CharArray(ca) => ca
457                .data
458                .chunks(ca.cols)
459                .map(|chunk| chunk.iter().collect())
460                .collect(),
461            other => panic!("expected char array, got {other:?}"),
462        }
463    }
464
465    fn error_message(err: RuntimeError) -> String {
466        err.message().to_string()
467    }
468
469    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
470    #[test]
471    fn int2str_rounds_scalar_float() {
472        let out = int2str_builtin(Value::Num(3.25), Vec::new()).expect("int2str");
473        assert_eq!(char_rows(out), vec!["3"]);
474
475        let out = int2str_builtin(Value::Num(4.5), Vec::new()).expect("int2str");
476        assert_eq!(char_rows(out), vec!["5"]);
477
478        let out = int2str_builtin(Value::Num(-4.5), Vec::new()).expect("int2str");
479        assert_eq!(char_rows(out), vec!["-5"]);
480    }
481
482    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
483    #[test]
484    fn int2str_preserves_exact_uint64_scalars_and_arrays() {
485        let scalar = int2str_builtin(Value::Int(IntValue::U64(u64::MAX)), Vec::new())
486            .expect("uint64 scalar");
487        assert_eq!(char_rows(scalar), vec![u64::MAX.to_string()]);
488
489        let tensor =
490            Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX, 1_u64 << 63]), vec![1, 2])
491                .expect("uint64 tensor");
492        let matrix = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("uint64 array");
493        assert_eq!(
494            char_rows(matrix),
495            vec![format!("{}  {}", u64::MAX, 1_u64 << 63)]
496        );
497    }
498
499    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
500    #[test]
501    fn int2str_formats_integer_matrix_with_column_alignment() {
502        let tensor =
503            Tensor::new(vec![5.0, 100.0, 10.0, 200.0, 20.0, 400.0], vec![2, 3]).expect("tensor");
504        let out = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("int2str");
505        assert_eq!(char_rows(out), vec!["  5   10   20", "100  200  400"]);
506    }
507
508    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
509    #[test]
510    fn int2str_accepts_int_and_logical_inputs() {
511        let out = int2str_builtin(Value::Int(IntValue::I32(-12)), Vec::new()).expect("int2str int");
512        assert_eq!(char_rows(out), vec!["-12"]);
513
514        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).expect("logical");
515        let out =
516            int2str_builtin(Value::LogicalArray(logical), Vec::new()).expect("int2str logical");
517        assert_eq!(char_rows(out), vec!["1  0  1"]);
518    }
519
520    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
521    #[test]
522    fn int2str_preserves_nonfinite_text() {
523        let tensor = Tensor::new(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY], vec![1, 3])
524            .expect("tensor");
525        let out = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("int2str");
526        assert_eq!(char_rows(out), vec!["NaN  Inf  -Inf"]);
527    }
528
529    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
530    #[test]
531    fn int2str_empty_matrix_shapes() {
532        let empty = Tensor::new(Vec::new(), vec![0, 3]).expect("empty rows");
533        let out = int2str_builtin(Value::Tensor(empty), Vec::new()).expect("int2str");
534        match out {
535            Value::CharArray(ca) => {
536                assert_eq!(ca.rows, 0);
537                assert_eq!(ca.cols, 0);
538                assert!(ca.data.is_empty());
539            }
540            other => panic!("expected char array, got {other:?}"),
541        }
542
543        let empty_cols = Tensor::new(Vec::new(), vec![2, 0]).expect("empty cols");
544        let out = int2str_builtin(Value::Tensor(empty_cols), Vec::new()).expect("int2str");
545        match out {
546            Value::CharArray(ca) => {
547                assert_eq!(ca.rows, 0);
548                assert_eq!(ca.cols, 0);
549                assert!(ca.data.is_empty());
550            }
551            other => panic!("expected char array, got {other:?}"),
552        }
553    }
554
555    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
556    #[test]
557    fn int2str_gpu_tensor_roundtrip() {
558        test_support::with_test_provider(|provider| {
559            let tensor = Tensor::new(vec![10.2, 20.8], vec![1, 2]).expect("tensor");
560            let view = runmat_accelerate_api::HostTensorView {
561                data: &tensor.data,
562                shape: &tensor.shape,
563            };
564            let handle = provider.upload(&view).expect("upload");
565            let out = int2str_builtin(Value::GpuTensor(handle), Vec::new()).expect("int2str");
566            assert_eq!(char_rows(out), vec!["10  21"]);
567        });
568    }
569
570    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
571    #[test]
572    fn int2str_rejects_complex_input() {
573        let err = error_message(int2str_builtin(Value::Complex(3.0, 4.0), Vec::new()).unwrap_err());
574        assert!(err.contains("complex input is not supported"));
575    }
576
577    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
578    #[test]
579    fn int2str_rejects_extra_arguments() {
580        let err =
581            error_message(int2str_builtin(Value::Num(1.0), vec![Value::Num(2.0)]).unwrap_err());
582        assert!(err.contains("too many input arguments"));
583    }
584
585    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
586    #[test]
587    fn int2str_rejects_non_numeric_input() {
588        let err =
589            error_message(int2str_builtin(Value::String("hello".into()), Vec::new()).unwrap_err());
590        assert!(err.contains("unsupported input type"));
591    }
592
593    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
594    #[test]
595    fn int2str_rejects_nd_input() {
596        let tensor = Tensor::new(vec![1.0; 8], vec![2, 2, 2]).expect("tensor");
597        let err = error_message(int2str_builtin(Value::Tensor(tensor), Vec::new()).unwrap_err());
598        assert!(err.contains("scalar, vector, or 2-D matrix"));
599    }
600
601    #[test]
602    fn int2str_type_is_string_scalar() {
603        assert_eq!(
604            string_scalar_type(&[Type::Num], &ResolveContext::new(Vec::new())),
605            Type::String
606        );
607    }
608}