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