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