Skip to main content

runmat_runtime/builtins/strings/transform/
lower.rs

1//! MATLAB-compatible `lower` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6};
7use runmat_builtins::{BuiltinIntegerAuditDescriptor, BuiltinIntegerAuditKind};
8use runmat_macros::runtime_builtin;
9use runmat_value::{CellArray, CharArray, StringArray, Value};
10
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::strings::common::{char_row_to_string_slice, lowercase_preserving_missing};
17use crate::builtins::strings::type_resolvers::text_preserve_type;
18use crate::{build_runtime_error, gather_if_needed_async, make_cell, BuiltinResult, RuntimeError};
19
20#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::lower")]
21pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
22    name: "lower",
23    op_kind: GpuOpKind::Custom("string-transform"),
24    supported_precisions: &[],
25    broadcast: BroadcastSemantics::None,
26    provider_hooks: &[],
27    constant_strategy: ConstantStrategy::InlineLiteral,
28    residency: ResidencyPolicy::GatherImmediately,
29    nan_mode: ReductionNaN::Include,
30    two_pass_threshold: None,
31    workgroup_size: None,
32    accepts_nan_mode: false,
33    notes:
34        "Executes on the CPU; GPU-resident inputs are gathered to host memory before conversion.",
35};
36
37#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::lower")]
38pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
39    name: "lower",
40    shape: ShapeRequirements::Any,
41    constant_strategy: ConstantStrategy::InlineLiteral,
42    elementwise: None,
43    reduction: None,
44    emits_nan: false,
45    notes: "String transformation builtin; not eligible for fusion and always gathers GPU inputs.",
46};
47
48const BUILTIN_NAME: &str = "lower";
49
50const LOWER_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
51    name: "out",
52    ty: BuiltinParamType::Any,
53    arity: BuiltinParamArity::Required,
54    default: None,
55    description: "Lowercased text preserving input container kind and shape.",
56}];
57
58const LOWER_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
59    name: "str",
60    ty: BuiltinParamType::Any,
61    arity: BuiltinParamArity::Required,
62    default: None,
63    description: "String/char/cell text input to transform.",
64}];
65
66const LOWER_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
67    label: "out = lower(str)",
68    inputs: &LOWER_INPUTS,
69    outputs: &LOWER_OUTPUT,
70}];
71
72const LOWER_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
73    code: "RM.LOWER.INVALID_INPUT",
74    identifier: Some("RunMat:lower:InvalidInput"),
75    when: "Input is not a string array, character array, or cell array of text scalars.",
76    message:
77        "lower: first argument must be a string array, character array, or cell array of character vectors",
78};
79
80const LOWER_ERROR_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
81    code: "RM.LOWER.CELL_ELEMENT",
82    identifier: Some("RunMat:lower:CellElement"),
83    when: "Cell array contains a non-text element or non-row char array element.",
84    message: "lower: cell array elements must be string scalars or character vectors",
85};
86
87const LOWER_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
88    code: "RM.LOWER.INTERNAL",
89    identifier: Some("RunMat:lower:InternalError"),
90    when: "Internal output container construction failed.",
91    message: "lower: internal error",
92};
93
94const LOWER_ERRORS: [BuiltinErrorDescriptor; 3] = [
95    LOWER_ERROR_INVALID_INPUT,
96    LOWER_ERROR_CELL_ELEMENT,
97    LOWER_ERROR_INTERNAL,
98];
99
100pub const LOWER_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
101    signatures: &LOWER_SIGNATURES,
102    output_mode: BuiltinOutputMode::Fixed,
103    completion_policy: BuiltinCompletionPolicy::Public,
104    errors: &LOWER_ERRORS,
105};
106
107pub const LOWER_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
108    kind: BuiltinIntegerAuditKind::NotApplicable,
109    canonical_builtin: None,
110    notes: "lower accepts string arrays, character arrays, or cell arrays of character vectors. Numeric and integer inputs reject without implicit text conversion or provider access.",
111};
112
113fn map_flow(err: RuntimeError) -> RuntimeError {
114    map_control_flow_with_builtin(err, BUILTIN_NAME)
115}
116
117fn lower_error_with_message(
118    message: impl Into<String>,
119    error: &'static BuiltinErrorDescriptor,
120) -> RuntimeError {
121    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
122    if let Some(identifier) = error.identifier {
123        builder = builder.with_identifier(identifier);
124    }
125    builder.build()
126}
127
128fn lower_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
129    lower_error_with_message(error.message, error)
130}
131
132#[runtime_builtin(
133    name = "lower",
134    category = "strings/transform",
135    summary = "Convert strings, character arrays, and cell arrays of character vectors to lowercase.",
136    keywords = "lower,lowercase,strings,character array,text",
137    accel = "sink",
138    type_resolver(text_preserve_type),
139    descriptor(crate::builtins::strings::transform::lower::LOWER_DESCRIPTOR),
140    integer_audit(crate::builtins::strings::transform::lower::LOWER_INTEGER_AUDIT),
141    builtin_path = "crate::builtins::strings::transform::lower"
142)]
143async fn lower_builtin(value: Value) -> BuiltinResult<Value> {
144    if crate::dispatcher::value_contains_gpu(&value) {
145        return Err(lower_error(&LOWER_ERROR_INVALID_INPUT));
146    }
147    let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
148    match gathered {
149        Value::String(text) => Ok(Value::String(lowercase_preserving_missing(text))),
150        Value::StringArray(array) => lower_string_array(array),
151        Value::CharArray(array) => lower_char_array(array),
152        Value::Cell(cell) => lower_cell_array(cell),
153        _ => Err(lower_error(&LOWER_ERROR_INVALID_INPUT)),
154    }
155}
156
157fn lower_string_array(array: StringArray) -> BuiltinResult<Value> {
158    let StringArray { data, shape, .. } = array;
159    let lowered = data
160        .into_iter()
161        .map(lowercase_preserving_missing)
162        .collect::<Vec<_>>();
163    let lowered_array = StringArray::new(lowered, shape).map_err(|e| {
164        lower_error_with_message(format!("{BUILTIN_NAME}: {e}"), &LOWER_ERROR_INTERNAL)
165    })?;
166    Ok(Value::StringArray(lowered_array))
167}
168
169fn lower_char_array(array: CharArray) -> BuiltinResult<Value> {
170    let CharArray {
171        data,
172        shape,
173        rows,
174        cols,
175    } = array;
176    if rows == 0 || cols == 0 {
177        return Ok(Value::CharArray(CharArray {
178            data,
179            shape,
180            rows,
181            cols,
182        }));
183    }
184
185    let mut lowered_rows = Vec::with_capacity(rows);
186    let mut target_cols = cols;
187    for row in 0..rows {
188        let text = char_row_to_string_slice(&data, cols, row).to_lowercase();
189        let len = text.chars().count();
190        target_cols = target_cols.max(len);
191        lowered_rows.push(text);
192    }
193
194    let mut lowered_data = Vec::with_capacity(rows * target_cols);
195    for row_text in lowered_rows {
196        let mut chars: Vec<char> = row_text.chars().collect();
197        if chars.len() < target_cols {
198            chars.resize(target_cols, ' ');
199        }
200        lowered_data.extend(chars.into_iter());
201    }
202
203    CharArray::new(lowered_data, rows, target_cols)
204        .map(Value::CharArray)
205        .map_err(|e| {
206            lower_error_with_message(format!("{BUILTIN_NAME}: {e}"), &LOWER_ERROR_INTERNAL)
207        })
208}
209
210fn lower_cell_array(cell: CellArray) -> BuiltinResult<Value> {
211    let CellArray {
212        data, rows, cols, ..
213    } = cell;
214    let mut lowered_values = Vec::with_capacity(rows * cols);
215    for row in 0..rows {
216        for col in 0..cols {
217            let idx = row * cols + col;
218            let lowered = lower_cell_element(&data[idx])?;
219            lowered_values.push(lowered);
220        }
221    }
222    make_cell(lowered_values, rows, cols).map_err(|e| {
223        lower_error_with_message(format!("{BUILTIN_NAME}: {e}"), &LOWER_ERROR_INTERNAL)
224    })
225}
226
227fn lower_cell_element(value: &Value) -> BuiltinResult<Value> {
228    match value {
229        Value::String(text) => Ok(Value::String(lowercase_preserving_missing(text.clone()))),
230        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Value::String(
231            lowercase_preserving_missing(sa.data[0].clone()),
232        )),
233        Value::CharArray(ca) if ca.rows <= 1 => lower_char_array(ca.clone()),
234        Value::CharArray(_) => Err(lower_error(&LOWER_ERROR_CELL_ELEMENT)),
235        _ => Err(lower_error(&LOWER_ERROR_CELL_ELEMENT)),
236    }
237}
238
239#[cfg(test)]
240pub(crate) mod tests {
241    use super::*;
242    use runmat_builtins::{ResolveContext, Type};
243
244    fn run_lower(value: Value) -> BuiltinResult<Value> {
245        futures::executor::block_on(lower_builtin(value))
246    }
247
248    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
249    #[test]
250    fn lower_string_scalar_value() {
251        let result = run_lower(Value::String("RunMat".into())).expect("lower");
252        assert_eq!(result, Value::String("runmat".into()));
253    }
254
255    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
256    #[test]
257    fn lower_string_array_preserves_shape() {
258        let array = StringArray::new(
259            vec![
260                "GPU".into(),
261                "ACCEL".into(),
262                "<missing>".into(),
263                "MiXeD".into(),
264            ],
265            vec![2, 2],
266        )
267        .unwrap();
268        let result = run_lower(Value::StringArray(array)).expect("lower");
269        match result {
270            Value::StringArray(sa) => {
271                assert_eq!(sa.shape, vec![2, 2]);
272                assert_eq!(
273                    sa.data,
274                    vec![
275                        String::from("gpu"),
276                        String::from("accel"),
277                        String::from("<missing>"),
278                        String::from("mixed")
279                    ]
280                );
281            }
282            other => panic!("expected string array, got {other:?}"),
283        }
284    }
285
286    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
287    #[test]
288    fn lower_char_array_multiple_rows() {
289        let data: Vec<char> = vec!['C', 'A', 'T', 'D', 'O', 'G'];
290        let array = CharArray::new(data, 2, 3).unwrap();
291        let result = run_lower(Value::CharArray(array)).expect("lower");
292        match result {
293            Value::CharArray(ca) => {
294                assert_eq!(ca.rows, 2);
295                assert_eq!(ca.cols, 3);
296                assert_eq!(ca.data, vec!['c', 'a', 't', 'd', 'o', 'g']);
297            }
298            other => panic!("expected char array, got {other:?}"),
299        }
300    }
301
302    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
303    #[test]
304    fn lower_char_vector_handles_padding() {
305        let array = CharArray::new_row("HELLO ");
306        let result = run_lower(Value::CharArray(array)).expect("lower");
307        match result {
308            Value::CharArray(ca) => {
309                assert_eq!(ca.rows, 1);
310                assert_eq!(ca.cols, 6);
311                let expected: Vec<char> = "hello ".chars().collect();
312                assert_eq!(ca.data, expected);
313            }
314            other => panic!("expected char array, got {other:?}"),
315        }
316    }
317
318    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
319    #[test]
320    fn lower_char_array_unicode_expansion_extends_width() {
321        let data: Vec<char> = vec!['İ', 'A'];
322        let array = CharArray::new(data, 1, 2).unwrap();
323        let result = run_lower(Value::CharArray(array)).expect("lower");
324        match result {
325            Value::CharArray(ca) => {
326                assert_eq!(ca.rows, 1);
327                assert_eq!(ca.cols, 3);
328                let expected: Vec<char> = vec!['i', '\u{307}', 'a'];
329                assert_eq!(ca.data, expected);
330            }
331            other => panic!("expected char array, got {other:?}"),
332        }
333    }
334
335    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
336    #[test]
337    fn lower_cell_array_mixed_content() {
338        let cell = CellArray::new(
339            vec![
340                Value::CharArray(CharArray::new_row("RUN")),
341                Value::String("Mat".into()),
342            ],
343            1,
344            2,
345        )
346        .unwrap();
347        let result = run_lower(Value::Cell(cell)).expect("lower");
348        match result {
349            Value::Cell(out) => {
350                let first = out.get(0, 0).unwrap();
351                let second = out.get(0, 1).unwrap();
352                assert_eq!(first, Value::CharArray(CharArray::new_row("run")));
353                assert_eq!(second, Value::String("mat".into()));
354            }
355            other => panic!("expected cell array, got {other:?}"),
356        }
357    }
358
359    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
360    #[test]
361    fn lower_errors_on_invalid_input() {
362        let err = run_lower(Value::Num(1.0)).unwrap_err();
363        assert_eq!(err.to_string(), LOWER_ERROR_INVALID_INPUT.message);
364    }
365
366    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
367    #[test]
368    fn lower_cell_errors_on_invalid_element() {
369        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).unwrap();
370        let err = run_lower(Value::Cell(cell)).unwrap_err();
371        assert_eq!(err.to_string(), LOWER_ERROR_CELL_ELEMENT.message);
372    }
373
374    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
375    #[test]
376    fn lower_preserves_missing_string() {
377        let result = run_lower(Value::String("<missing>".into())).expect("lower");
378        assert_eq!(result, Value::String("<missing>".into()));
379    }
380
381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
382    #[test]
383    fn lower_cell_allows_empty_char_vector() {
384        let empty_char = CharArray::new(Vec::new(), 1, 0).unwrap();
385        let cell = CellArray::new(vec![Value::CharArray(empty_char.clone())], 1, 1).unwrap();
386        let result = run_lower(Value::Cell(cell)).expect("lower");
387        match result {
388            Value::Cell(out) => {
389                let element = out.get(0, 0).unwrap();
390                assert_eq!(element, Value::CharArray(empty_char));
391            }
392            other => panic!("expected cell array, got {other:?}"),
393        }
394    }
395
396    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
397    #[test]
398    #[cfg(feature = "wgpu")]
399    fn lower_gpu_tensor_input_gathers_then_errors() {
400        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
401            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
402        );
403        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
404        let data = [1.0f64, 2.0];
405        let shape = [2usize, 1usize];
406        let handle = provider
407            .upload(&runmat_accelerate_api::HostTensorView {
408                data: &data,
409                shape: &shape,
410            })
411            .expect("upload");
412        let err = run_lower(Value::GpuTensor(handle.clone())).unwrap_err();
413        assert_eq!(err.to_string(), LOWER_ERROR_INVALID_INPUT.message);
414        provider.free(&handle).ok();
415    }
416
417    #[test]
418    fn lower_type_preserves_text() {
419        assert_eq!(
420            text_preserve_type(&[Type::String], &ResolveContext::new(Vec::new())),
421            Type::String
422        );
423    }
424}