Skip to main content

runmat_runtime/builtins/strings/core/
char.rs

1//! MATLAB-compatible `char` builtin with GPU-aware conversion semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CellArray, CharArray, LogicalArray, SparseTensor, StringArray, SymbolicArray, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::map_control_flow_with_builtin;
11use crate::builtins::common::spec::{
12    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
13    ReductionNaN, ResidencyPolicy, ShapeRequirements,
14};
15use crate::builtins::strings::type_resolvers::string_array_type;
16use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
17
18#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::char")]
19pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
20    name: "char",
21    op_kind: GpuOpKind::Custom("conversion"),
22    supported_precisions: &[],
23    broadcast: BroadcastSemantics::None,
24    provider_hooks: &[],
25    constant_strategy: ConstantStrategy::InlineLiteral,
26    residency: ResidencyPolicy::GatherImmediately,
27    nan_mode: ReductionNaN::Include,
28    two_pass_threshold: None,
29    workgroup_size: None,
30    accepts_nan_mode: false,
31    notes:
32        "Conversion always runs on the CPU; GPU tensors are gathered before building the result.",
33};
34
35#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::char")]
36pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
37    name: "char",
38    shape: ShapeRequirements::Any,
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    elementwise: None,
41    reduction: None,
42    emits_nan: false,
43    notes: "Character materialisation runs outside of fusion; results always live on the host.",
44};
45
46const BUILTIN_NAME: &str = "char";
47const CHAR_SPARSE_DENSE_ELEMENT_LIMIT: usize = 10_000_000;
48
49const CHAR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
50    name: "C",
51    ty: BuiltinParamType::Any,
52    arity: BuiltinParamArity::Required,
53    default: None,
54    description: "Character array result.",
55}];
56
57const CHAR_INPUT_SINGLE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58    name: "X",
59    ty: BuiltinParamType::Any,
60    arity: BuiltinParamArity::Required,
61    default: None,
62    description: "Input value to convert into character data.",
63}];
64
65const CHAR_INPUT_VARIADIC: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66    name: "X...",
67    ty: BuiltinParamType::Any,
68    arity: BuiltinParamArity::Variadic,
69    default: None,
70    description: "Multiple inputs converted row-wise and padded.",
71}];
72
73const CHAR_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
74    BuiltinSignatureDescriptor {
75        label: "C = char()",
76        inputs: &[],
77        outputs: &CHAR_OUTPUT,
78    },
79    BuiltinSignatureDescriptor {
80        label: "C = char(X)",
81        inputs: &CHAR_INPUT_SINGLE,
82        outputs: &CHAR_OUTPUT,
83    },
84    BuiltinSignatureDescriptor {
85        label: "C = char(X...)",
86        inputs: &CHAR_INPUT_VARIADIC,
87        outputs: &CHAR_OUTPUT,
88    },
89];
90
91const CHAR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
92    code: "RM.CHAR.INVALID_INPUT",
93    identifier: Some("RunMat:char:InvalidInput"),
94    when: "Input type cannot be converted to character data.",
95    message: "char: invalid input",
96};
97
98const CHAR_ERROR_INVALID_CODEPOINT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
99    code: "RM.CHAR.INVALID_CODEPOINT",
100    identifier: Some("RunMat:char:InvalidCodePoint"),
101    when: "Numeric input is not a finite integer Unicode code point.",
102    message: "char: numeric inputs must be finite Unicode code points",
103};
104
105const CHAR_ERROR_DIMENSION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
106    code: "RM.CHAR.INVALID_DIMENSION",
107    identifier: Some("RunMat:char:InvalidDimension"),
108    when: "Array inputs are not 2-D (or trailing singleton dimensions).",
109    message: "char: inputs must be 2-D",
110};
111
112const CHAR_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
113    code: "RM.CHAR.INTERNAL",
114    identifier: Some("RunMat:char:InternalError"),
115    when: "Internal character array construction failed.",
116    message: "char: internal error",
117};
118
119const CHAR_ERRORS: [BuiltinErrorDescriptor; 4] = [
120    CHAR_ERROR_INVALID_INPUT,
121    CHAR_ERROR_INVALID_CODEPOINT,
122    CHAR_ERROR_DIMENSION,
123    CHAR_ERROR_INTERNAL,
124];
125
126pub const CHAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
127    signatures: &CHAR_SIGNATURES,
128    output_mode: BuiltinOutputMode::Fixed,
129    completion_policy: BuiltinCompletionPolicy::Public,
130    errors: &CHAR_ERRORS,
131};
132
133fn char_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
134    char_error_with_message(error.message, error)
135}
136
137fn char_error_with_message(
138    message: impl Into<String>,
139    error: &'static BuiltinErrorDescriptor,
140) -> RuntimeError {
141    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
142    if let Some(identifier) = error.identifier {
143        builder = builder.with_identifier(identifier);
144    }
145    builder.build()
146}
147
148fn char_flow(message: impl Into<String>) -> RuntimeError {
149    char_error_with_message(message, &CHAR_ERROR_INTERNAL)
150}
151
152fn remap_char_flow(err: RuntimeError) -> RuntimeError {
153    map_control_flow_with_builtin(err, BUILTIN_NAME)
154}
155
156#[runtime_builtin(
157    name = "char",
158    category = "strings/core",
159    summary = "Convert numeric codes and text values into character arrays.",
160    keywords = "char,character,string,gpu",
161    accel = "conversion",
162    type_resolver(string_array_type),
163    descriptor(crate::builtins::strings::core::char::CHAR_DESCRIPTOR),
164    builtin_path = "crate::builtins::strings::core::char"
165)]
166async fn char_builtin(rest: Vec<Value>) -> crate::BuiltinResult<Value> {
167    if rest.is_empty() {
168        let empty =
169            CharArray::new(Vec::new(), 0, 0).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
170        return Ok(Value::CharArray(empty));
171    }
172
173    let mut rows: Vec<Vec<char>> = Vec::new();
174    let mut max_width = 0usize;
175
176    for arg in rest {
177        let gathered = gather_if_needed_async(&arg)
178            .await
179            .map_err(remap_char_flow)?;
180        let mut produced = value_to_char_rows(&gathered)?;
181        for row in &produced {
182            if row.len() > max_width {
183                max_width = row.len();
184            }
185        }
186        rows.append(&mut produced);
187    }
188
189    if rows.is_empty() {
190        let empty =
191            CharArray::new(Vec::new(), 0, 0).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
192        return Ok(Value::CharArray(empty));
193    }
194
195    let cols = max_width;
196    let total_rows = rows.len();
197    let mut data = vec![' '; total_rows * cols];
198    for (row_idx, row) in rows.into_iter().enumerate() {
199        for (col_idx, ch) in row.into_iter().enumerate() {
200            if col_idx < cols {
201                data[row_idx * cols + col_idx] = ch;
202            }
203        }
204    }
205
206    let array =
207        CharArray::new(data, total_rows, cols).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
208    Ok(Value::CharArray(array))
209}
210
211fn value_to_char_rows(value: &Value) -> BuiltinResult<Vec<Vec<char>>> {
212    if let Some(array) = crate::builtins::datetime::datetime_char_array(value)
213        .map_err(|err| char_flow(err.message().to_string()))?
214    {
215        return Ok(char_array_rows(&array));
216    }
217    if let Some(array) = crate::builtins::duration::duration_char_array(value)
218        .map_err(|err| char_flow(err.message().to_string()))?
219    {
220        return Ok(char_array_rows(&array));
221    }
222    match value {
223        Value::CharArray(ca) => Ok(char_array_rows(ca)),
224        Value::String(s) => Ok(vec![s.chars().collect()]),
225        Value::Symbolic(expr) => Ok(vec![expr.to_string().chars().collect()]),
226        Value::SymbolicArray(array) => symbolic_array_rows(array),
227        Value::StringArray(sa) => string_array_rows(sa),
228        Value::Num(n) => Ok(vec![vec![number_to_char(*n)?]]),
229        Value::Int(i) => {
230            let as_double = i.to_f64();
231            Ok(vec![vec![number_to_char(as_double)?]])
232        }
233        Value::Bool(b) => {
234            let code = if *b { 1.0 } else { 0.0 };
235            Ok(vec![vec![number_to_char(code)?]])
236        }
237        Value::Tensor(t) => tensor_rows(t),
238        Value::SparseTensor(s) => {
239            ensure_sparse_dense_conversion(s)?;
240            let dense = s.to_dense().map_err(char_flow)?;
241            tensor_rows(&dense)
242        }
243        Value::LogicalArray(la) => logical_rows(la),
244        Value::Cell(ca) => cell_rows(ca),
245        Value::GpuTensor(_) => Err(char_error(&CHAR_ERROR_INVALID_INPUT)),
246        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(char_error_with_message(
247            "char: complex inputs are not supported",
248            &CHAR_ERROR_INVALID_INPUT,
249        )),
250        Value::Struct(_)
251        | Value::Object(_)
252        | Value::HandleObject(_)
253        | Value::Listener(_)
254        | Value::FunctionHandle(_)
255        | Value::ExternalFunctionHandle(_)
256        | Value::MethodFunctionHandle(_)
257        | Value::BoundFunctionHandle { .. }
258        | Value::Closure(_)
259        | Value::ClassRef(_)
260        | Value::MException(_)
261        | Value::OutputList(_) => Err(char_error_with_message(
262            format!("char: unsupported input type {:?}", value),
263            &CHAR_ERROR_INVALID_INPUT,
264        )),
265    }
266}
267
268fn char_array_rows(ca: &CharArray) -> Vec<Vec<char>> {
269    let mut rows = Vec::with_capacity(ca.rows);
270    for r in 0..ca.rows {
271        let mut row = Vec::with_capacity(ca.cols);
272        for c in 0..ca.cols {
273            row.push(ca.data[r * ca.cols + c]);
274        }
275        rows.push(row);
276    }
277    rows
278}
279
280fn string_array_rows(sa: &StringArray) -> BuiltinResult<Vec<Vec<char>>> {
281    ensure_two_dimensional(&sa.shape, "char")?;
282    if sa.data.is_empty() {
283        return Ok(Vec::new());
284    }
285    let mut rows = Vec::with_capacity(sa.data.len());
286    let rows_count = sa.rows();
287    let cols_count = sa.cols();
288    if rows_count == 0 || cols_count == 0 {
289        return Ok(Vec::new());
290    }
291    for c in 0..cols_count {
292        for r in 0..rows_count {
293            let idx = r + c * rows_count;
294            rows.push(sa.data[idx].chars().collect());
295        }
296    }
297    Ok(rows)
298}
299
300fn symbolic_array_rows(array: &SymbolicArray) -> BuiltinResult<Vec<Vec<char>>> {
301    ensure_two_dimensional(&array.shape, "char")?;
302    let (rows, cols) = infer_rows_cols(&array.shape, array.data.len());
303    if rows == 0 {
304        return Ok(Vec::new());
305    }
306    let mut out = Vec::with_capacity(rows);
307    for r in 0..rows {
308        let mut row = Vec::new();
309        for c in 0..cols {
310            if cols == 0 {
311                continue;
312            }
313            let idx = r + c * rows;
314            row.extend(array.data[idx].to_string().chars());
315        }
316        out.push(row);
317    }
318    Ok(out)
319}
320
321fn tensor_rows(t: &Tensor) -> BuiltinResult<Vec<Vec<char>>> {
322    ensure_two_dimensional(&t.shape, "char")?;
323    let (rows, cols) = infer_rows_cols(&t.shape, t.data.len());
324    if rows == 0 {
325        return Ok(Vec::new());
326    }
327    let mut out = Vec::with_capacity(rows);
328    for r in 0..rows {
329        let mut row = Vec::with_capacity(cols);
330        for c in 0..cols {
331            if cols == 0 {
332                continue;
333            }
334            let idx = r + c * rows;
335            let value = t.data[idx];
336            row.push(number_to_char(value)?);
337        }
338        out.push(row);
339    }
340    Ok(out)
341}
342
343fn logical_rows(la: &LogicalArray) -> BuiltinResult<Vec<Vec<char>>> {
344    ensure_two_dimensional(&la.shape, "char")?;
345    let (rows, cols) = infer_rows_cols(&la.shape, la.data.len());
346    if rows == 0 {
347        return Ok(Vec::new());
348    }
349    let mut out = Vec::with_capacity(rows);
350    for r in 0..rows {
351        let mut row = Vec::with_capacity(cols);
352        for c in 0..cols {
353            if cols == 0 {
354                continue;
355            }
356            let idx = r + c * rows;
357            let code = if la.data[idx] != 0 { 1.0 } else { 0.0 };
358            row.push(number_to_char(code)?);
359        }
360        out.push(row);
361    }
362    Ok(out)
363}
364
365fn cell_rows(ca: &CellArray) -> BuiltinResult<Vec<Vec<char>>> {
366    let mut rows = Vec::with_capacity(ca.data.len());
367    for ptr in &ca.data {
368        let element = (ptr).clone();
369        let mut converted = value_to_char_rows(&element)?;
370        match converted.len() {
371            0 => rows.push(Vec::new()),
372            1 => rows.push(converted.remove(0)),
373            _ => {
374                return Err(char_error_with_message(
375                    "char: cell elements must be character vectors or string scalars",
376                    &CHAR_ERROR_INVALID_INPUT,
377                ))
378            }
379        }
380    }
381    Ok(rows)
382}
383
384fn ensure_sparse_dense_conversion(sparse: &SparseTensor) -> BuiltinResult<()> {
385    let total_elements = sparse.rows.checked_mul(sparse.cols).ok_or_else(|| {
386        char_error_with_message(
387            "char: sparse matrix dimensions overflow",
388            &CHAR_ERROR_INVALID_INPUT,
389        )
390    })?;
391    if total_elements > CHAR_SPARSE_DENSE_ELEMENT_LIMIT {
392        return Err(char_error_with_message(
393            format!(
394                "char: cannot convert sparse tensor {}x{} with {} stored entries to dense character array ({} elements exceeds safe threshold)",
395                sparse.rows,
396                sparse.cols,
397                sparse.nnz(),
398                total_elements
399            ),
400            &CHAR_ERROR_INVALID_INPUT,
401        ));
402    }
403    Ok(())
404}
405
406fn number_to_char(value: f64) -> BuiltinResult<char> {
407    if !value.is_finite() {
408        return Err(char_error_with_message(
409            "char: numeric inputs must be finite",
410            &CHAR_ERROR_INVALID_CODEPOINT,
411        ));
412    }
413    let rounded = value.round();
414    if (value - rounded).abs() > 1e-9 {
415        return Err(char_error_with_message(
416            format!("char: numeric inputs must be integers in the Unicode range (got {value})"),
417            &CHAR_ERROR_INVALID_CODEPOINT,
418        ));
419    }
420    if rounded < 0.0 {
421        return Err(char_error_with_message(
422            format!("char: negative code points are invalid (got {rounded})"),
423            &CHAR_ERROR_INVALID_CODEPOINT,
424        ));
425    }
426    if rounded > 0x10FFFF as f64 {
427        return Err(char_error_with_message(
428            format!("char: code point {} exceeds Unicode range", rounded as u64),
429            &CHAR_ERROR_INVALID_CODEPOINT,
430        ));
431    }
432    let code = rounded as u32;
433    char::from_u32(code).ok_or_else(|| {
434        char_error_with_message(
435            format!("char: invalid code point {code}"),
436            &CHAR_ERROR_INVALID_CODEPOINT,
437        )
438    })
439}
440
441fn ensure_two_dimensional(shape: &[usize], context: &str) -> BuiltinResult<()> {
442    if shape.len() <= 2 {
443        return Ok(());
444    }
445    if shape.iter().skip(2).all(|&d| d == 1) {
446        return Ok(());
447    }
448    Err(char_error_with_message(
449        format!("{context}: inputs must be 2-D"),
450        &CHAR_ERROR_DIMENSION,
451    ))
452}
453
454fn infer_rows_cols(shape: &[usize], len: usize) -> (usize, usize) {
455    match shape.len() {
456        0 => {
457            if len == 0 {
458                (0, 0)
459            } else {
460                (1, 1)
461            }
462        }
463        1 => (1, shape[0]),
464        2 => (shape[0], shape[1]),
465        _ => {
466            let rows = shape[0];
467            let cols = if shape.len() > 1 { shape[1] } else { 1 };
468            (rows, cols)
469        }
470    }
471}
472
473#[cfg(test)]
474pub(crate) mod tests {
475    use super::*;
476    use crate::builtins::common::test_support;
477    use runmat_builtins::{ResolveContext, SymbolicArray, SymbolicExpr, Type};
478
479    fn char_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
480        futures::executor::block_on(super::char_builtin(rest))
481    }
482    use runmat_builtins::StringArray;
483
484    fn error_message(err: crate::RuntimeError) -> String {
485        err.message().to_string()
486    }
487
488    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
489    #[test]
490    fn char_no_arguments_returns_empty() {
491        let result = char_builtin(Vec::new()).expect("char");
492        match result {
493            Value::CharArray(ca) => {
494                assert_eq!(ca.rows, 0);
495                assert_eq!(ca.cols, 0);
496                assert!(ca.data.is_empty());
497            }
498            other => panic!("expected char array, got {other:?}"),
499        }
500    }
501
502    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
503    #[test]
504    fn char_from_string_scalar() {
505        let value = Value::String("RunMat".to_string());
506        let result = char_builtin(vec![value]).expect("char");
507        match result {
508            Value::CharArray(ca) => {
509                assert_eq!(ca.rows, 1);
510                assert_eq!(ca.cols, 6);
511                assert_eq!(ca.data, "RunMat".chars().collect::<Vec<_>>());
512            }
513            other => panic!("expected char array, got {other:?}"),
514        }
515    }
516
517    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
518    #[test]
519    fn char_from_numeric_tensor() {
520        let tensor =
521            Tensor::new(vec![82.0, 85.0, 78.0, 77.0, 65.0, 84.0], vec![1, 6]).expect("tensor");
522        let result = char_builtin(vec![Value::Tensor(tensor)]).expect("char");
523        match result {
524            Value::CharArray(ca) => {
525                assert_eq!(ca.rows, 1);
526                assert_eq!(ca.cols, 6);
527                assert_eq!(ca.data, "RUNMAT".chars().collect::<Vec<_>>());
528            }
529            other => panic!("expected char array, got {other:?}"),
530        }
531    }
532
533    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
534    #[test]
535    fn char_from_string_array_with_padding() {
536        let data = vec!["cat".to_string(), "giraffe".to_string()];
537        let sa = StringArray::new(data, vec![2, 1]).expect("string array");
538        let result = char_builtin(vec![Value::StringArray(sa)]).expect("char from string array");
539        match result {
540            Value::CharArray(ca) => {
541                assert_eq!(ca.rows, 2);
542                assert_eq!(ca.cols, 7);
543                assert_eq!(
544                    ca.data,
545                    vec!['c', 'a', 't', ' ', ' ', ' ', ' ', 'g', 'i', 'r', 'a', 'f', 'f', 'e']
546                );
547            }
548            other => panic!("expected char array, got {other:?}"),
549        }
550    }
551
552    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
553    #[test]
554    fn char_from_cell_array_of_strings() {
555        let cell = CellArray::new(
556            vec![
557                Value::from("north"),
558                Value::from("east"),
559                Value::from("west"),
560            ],
561            3,
562            1,
563        )
564        .expect("cell array");
565        let result = char_builtin(vec![Value::Cell(cell)]).expect("char");
566        match result {
567            Value::CharArray(ca) => {
568                assert_eq!(ca.rows, 3);
569                assert_eq!(ca.cols, 5);
570                assert_eq!(
571                    ca.data,
572                    vec!['n', 'o', 'r', 't', 'h', 'e', 'a', 's', 't', ' ', 'w', 'e', 's', 't', ' ']
573                );
574            }
575            other => panic!("expected char array, got {other:?}"),
576        }
577    }
578
579    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
580    #[test]
581    fn char_numeric_and_text_arguments_concatenate() {
582        let text = Value::String("hi".to_string());
583        let codes = Tensor::new(vec![65.0, 66.0], vec![1, 2]).expect("tensor");
584        let result = char_builtin(vec![text, Value::Tensor(codes)]).expect("char");
585        match result {
586            Value::CharArray(ca) => {
587                assert_eq!(ca.rows, 2);
588                assert_eq!(ca.cols, 2);
589                assert_eq!(ca.data, vec!['h', 'i', 'A', 'B']);
590            }
591            other => panic!("expected char array, got {other:?}"),
592        }
593    }
594
595    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
596    #[test]
597    fn char_gpu_tensor_round_trip() {
598        test_support::with_test_provider(|provider| {
599            let tensor = Tensor::new(vec![82.0, 85.0, 78.0], vec![1, 3]).expect("tensor");
600            let view = runmat_accelerate_api::HostTensorView {
601                data: &tensor.data,
602                shape: &tensor.shape,
603            };
604            let handle = provider.upload(&view).expect("upload");
605            let result = char_builtin(vec![Value::GpuTensor(handle)]).expect("char");
606            match result {
607                Value::CharArray(ca) => {
608                    assert_eq!(ca.rows, 1);
609                    assert_eq!(ca.cols, 3);
610                    assert_eq!(ca.data, vec!['R', 'U', 'N']);
611                }
612                other => panic!("expected char array, got {other:?}"),
613            }
614        });
615    }
616
617    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
618    #[test]
619    fn char_rejects_non_integer_numeric() {
620        let err =
621            error_message(char_builtin(vec![Value::Num(65.5)]).expect_err("non-integer numeric"));
622        assert!(err.contains("integers"), "unexpected error message: {err}");
623    }
624
625    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
626    #[test]
627    fn char_rejects_high_dimension_tensor() {
628        let tensor =
629            Tensor::new(vec![65.0, 66.0], vec![1, 1, 2]).expect("tensor construction failed");
630        let err = error_message(
631            char_builtin(vec![Value::Tensor(tensor)]).expect_err("should reject >2D tensor"),
632        );
633        assert!(err.contains("2-D"), "expected dimension error, got {err}");
634    }
635
636    #[test]
637    fn char_rejects_oversized_sparse_tensor_before_densifying() {
638        let sparse = SparseTensor::zeros(CHAR_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
639        let err = char_builtin(vec![Value::SparseTensor(sparse)]).unwrap_err();
640
641        assert_eq!(err.identifier(), Some("RunMat:char:InvalidInput"));
642        assert!(err.message().contains("exceeds safe threshold"));
643    }
644
645    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
646    #[test]
647    fn char_string_array_column_major_order() {
648        let data = vec![
649            "c0r0".to_string(),
650            "c0r1".to_string(),
651            "c1r0".to_string(),
652            "c1r1".to_string(),
653        ];
654        let sa = StringArray::new(data, vec![2, 2]).expect("string array");
655        let result = char_builtin(vec![Value::StringArray(sa)]).expect("char");
656        match result {
657            Value::CharArray(ca) => {
658                assert_eq!(ca.rows, 4);
659                assert_eq!(ca.cols, 4);
660                assert_eq!(ca.data, "c0r0c0r1c1r0c1r1".chars().collect::<Vec<char>>());
661            }
662            other => panic!("expected char array, got {other:?}"),
663        }
664    }
665
666    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
667    #[test]
668    fn char_symbolic_array_preserves_matrix_rows() {
669        let array = SymbolicArray::new(
670            vec![
671                SymbolicExpr::variable("x"),
672                SymbolicExpr::variable("z"),
673                SymbolicExpr::variable("y"),
674                SymbolicExpr::variable("w"),
675            ],
676            vec![2, 2],
677        )
678        .expect("symbolic array");
679
680        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
681
682        match result {
683            Value::CharArray(ca) => {
684                assert_eq!(ca.rows, 2);
685                assert_eq!(ca.cols, 2);
686                assert_eq!(ca.data, vec!['x', 'y', 'z', 'w']);
687            }
688            other => panic!("expected char array, got {other:?}"),
689        }
690    }
691
692    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
693    #[test]
694    fn char_symbolic_array_pads_multi_character_rows() {
695        let array = SymbolicArray::new(
696            vec![
697                SymbolicExpr::variable("x1"),
698                SymbolicExpr::variable("y"),
699                SymbolicExpr::variable("theta"),
700                SymbolicExpr::variable("z"),
701            ],
702            vec![2, 2],
703        )
704        .expect("symbolic array");
705
706        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
707
708        match result {
709            Value::CharArray(ca) => {
710                assert_eq!(ca.rows, 2);
711                assert_eq!(ca.cols, 7);
712                assert_eq!(ca.data.iter().collect::<String>(), "x1thetayz     ");
713            }
714            other => panic!("expected char array, got {other:?}"),
715        }
716    }
717
718    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
719    #[test]
720    fn char_symbolic_one_dimensional_array_is_row_vector() {
721        let array = SymbolicArray::new(
722            vec![SymbolicExpr::variable("x"), SymbolicExpr::variable("y")],
723            vec![2],
724        )
725        .expect("symbolic array");
726
727        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
728
729        match result {
730            Value::CharArray(ca) => {
731                assert_eq!(ca.rows, 1);
732                assert_eq!(ca.cols, 2);
733                assert_eq!(ca.data, vec!['x', 'y']);
734            }
735            other => panic!("expected char array, got {other:?}"),
736        }
737    }
738
739    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
740    #[test]
741    fn char_rejects_high_dimension_string_array() {
742        let sa = StringArray::new(vec!["a".to_string(), "b".to_string()], vec![1, 1, 2])
743            .expect("string array");
744        let err = error_message(
745            char_builtin(vec![Value::StringArray(sa)]).expect_err("should reject >2D string array"),
746        );
747        assert!(err.contains("2-D"), "expected dimension error, got {err}");
748    }
749
750    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
751    #[test]
752    fn char_rejects_complex_input() {
753        let err =
754            error_message(char_builtin(vec![Value::Complex(1.0, 2.0)]).expect_err("complex input"));
755        assert!(
756            err.contains("complex"),
757            "expected complex error message, got {err}"
758        );
759    }
760
761    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
762    #[test]
763    #[cfg(feature = "wgpu")]
764    fn char_wgpu_numeric_codes_matches_cpu() {
765        use runmat_accelerate::backend::wgpu::provider::{
766            register_wgpu_provider, WgpuProviderOptions,
767        };
768
769        let _ = register_wgpu_provider(WgpuProviderOptions::default());
770
771        let tensor = Tensor::new(vec![82.0, 85.0, 78.0], vec![1, 3]).unwrap();
772        let cpu = char_builtin(vec![Value::Tensor(tensor.clone())]).expect("char cpu");
773
774        let view = runmat_accelerate_api::HostTensorView {
775            data: &tensor.data,
776            shape: &tensor.shape,
777        };
778        let handle = runmat_accelerate_api::provider()
779            .expect("wgpu provider")
780            .upload(&view)
781            .expect("upload");
782        let gpu = char_builtin(vec![Value::GpuTensor(handle)]).expect("char gpu");
783
784        match (cpu, gpu) {
785            (Value::CharArray(expected), Value::CharArray(actual)) => {
786                assert_eq!(actual, expected);
787            }
788            other => panic!("unexpected results {other:?}"),
789        }
790    }
791
792    #[test]
793    fn char_type_is_string_array() {
794        assert_eq!(
795            string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
796            Type::cell_of(Type::String)
797        );
798    }
799}