Skip to main content

lex_runtime/
arrow.rs

1//! `std.arrow` — Apache Arrow `RecordBatch` as a first-class `Value`.
2//!
3//! This module is the runtime side of #426. Construction builtins
4//! (`arrow.from_int_columns`, …) take Lex `List[Int]` / `List[Float]` /
5//! `List[Str]` columns and pack them into a flat `RecordBatch`; numeric
6//! reductions (`arrow.col_sum_int`, `arrow.col_mean`, …) run as a single
7//! Rust call over the underlying buffer, bypassing the bytecode VM for
8//! the inner loop.
9//!
10//! The only `Value` shape leaving this module that touches the Arrow
11//! dependency is `Value::ArrowTable(Arc<RecordBatch>)`. Everything else
12//! is plain Lex values.
13
14use arrow_array::{Array, ArrayRef, Float64Array, Int64Array, RecordBatch, RecordBatchReader, StringArray};
15use arrow_csv::ReaderBuilder;
16use arrow_schema::{DataType, Field, Schema};
17use lex_bytecode::Value;
18use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
19use parquet::arrow::arrow_writer::ArrowWriter;
20use parquet::arrow::ProjectionMask;
21use parquet::file::properties::WriterProperties;
22use std::collections::VecDeque;
23use std::fs::File;
24use std::path::Path;
25use std::sync::Arc;
26
27// ---------- helpers ----------
28
29fn err<T>(s: impl Into<String>) -> Result<T, String> {
30    Err(s.into())
31}
32
33fn expect_table(v: Option<&Value>) -> Result<&Arc<RecordBatch>, String> {
34    match v {
35        Some(Value::ArrowTable(t)) => Ok(t),
36        Some(other) => err(format!("expected arrow.Table, got {other:?}")),
37        None => err("expected arrow.Table, got nothing"),
38    }
39}
40
41fn expect_str(v: Option<&Value>) -> Result<&str, String> {
42    match v {
43        Some(Value::Str(s)) => Ok(s.as_str()),
44        Some(other) => err(format!("expected Str, got {other:?}")),
45        None => err("expected Str, got nothing"),
46    }
47}
48
49fn expect_int(v: Option<&Value>) -> Result<i64, String> {
50    match v {
51        Some(Value::Int(n)) => Ok(*n),
52        Some(other) => err(format!("expected Int, got {other:?}")),
53        None => err("expected Int, got nothing"),
54    }
55}
56
57fn expect_list(v: Option<&Value>) -> Result<&VecDeque<Value>, String> {
58    match v {
59        Some(Value::List(items)) => Ok(items),
60        Some(other) => err(format!("expected List, got {other:?}")),
61        None => err("expected List, got nothing"),
62    }
63}
64
65/// Decode `List[(Str, List[T])]` shape used by all `from_*_columns`
66/// constructors. Returns `Vec<(name, values_list)>`.
67fn decode_columns_list(list: &VecDeque<Value>) -> Result<Vec<(&str, &VecDeque<Value>)>, String> {
68    let mut out = Vec::with_capacity(list.len());
69    for (i, item) in list.iter().enumerate() {
70        let pair = match item {
71            Value::Tuple(t) if t.len() == 2 => t,
72            other => {
73                return err(format!(
74                    "from_*_columns: column #{i} must be a (Str, List) tuple, got {other:?}"
75                ))
76            }
77        };
78        let name = match &pair[0] {
79            Value::Str(s) => s.as_str(),
80            other => {
81                return err(format!(
82                    "from_*_columns: column #{i} name must be Str, got {other:?}"
83                ))
84            }
85        };
86        let values = match &pair[1] {
87            Value::List(items) => items,
88            other => {
89                return err(format!(
90                    "from_*_columns: column #{i} (`{name}`) values must be List, got {other:?}"
91                ))
92            }
93        };
94        out.push((name, values));
95    }
96    Ok(out)
97}
98
99fn build_schema_and_check_lengths(cols: &[(&str, ArrayRef)]) -> Result<Schema, String> {
100    if cols.is_empty() {
101        return Ok(Schema::empty());
102    }
103    let nrows = cols[0].1.len();
104    let mut fields = Vec::with_capacity(cols.len());
105    for (name, arr) in cols {
106        if arr.len() != nrows {
107            return err(format!(
108                "from_*_columns: column `{name}` has {} rows, expected {nrows}",
109                arr.len()
110            ));
111        }
112        fields.push(Field::new(*name, arr.data_type().clone(), false));
113    }
114    Ok(Schema::new(fields))
115}
116
117fn pack_table(cols: Vec<(&str, ArrayRef)>) -> Result<Value, String> {
118    let schema = build_schema_and_check_lengths(&cols)?;
119    let arrays: Vec<ArrayRef> = cols.into_iter().map(|(_, a)| a).collect();
120    let batch = RecordBatch::try_new(Arc::new(schema), arrays)
121        .map_err(|e| format!("arrow: failed to build RecordBatch: {e}"))?;
122    Ok(Value::ArrowTable(Arc::new(batch)))
123}
124
125// ---------- constructors ----------
126
127/// `arrow.from_int_columns(List[(Str, List[Int])]) -> Result[Table, Str]`
128fn from_int_columns(args: &[Value]) -> Result<Value, String> {
129    let list = expect_list(args.first())?;
130    let pairs = decode_columns_list(list)?;
131    let mut owned_names: Vec<String> = Vec::with_capacity(pairs.len());
132    let mut arrays: Vec<ArrayRef> = Vec::with_capacity(pairs.len());
133    for (name, values) in &pairs {
134        owned_names.push((*name).to_string());
135        let mut buf: Vec<i64> = Vec::with_capacity(values.len());
136        for v in values.iter() {
137            match v {
138                Value::Int(n) => buf.push(*n),
139                other => {
140                    return err(format!(
141                        "from_int_columns: column `{name}` non-Int element: {other:?}"
142                    ))
143                }
144            }
145        }
146        arrays.push(Arc::new(Int64Array::from(buf)) as ArrayRef);
147    }
148    let cols: Vec<(&str, ArrayRef)> = owned_names.iter().map(|n| n.as_str()).zip(arrays).collect();
149    pack_table(cols)
150}
151
152/// `arrow.from_float_columns(List[(Str, List[Float])]) -> Result[Table, Str]`
153fn from_float_columns(args: &[Value]) -> Result<Value, String> {
154    let list = expect_list(args.first())?;
155    let pairs = decode_columns_list(list)?;
156    let mut owned_names: Vec<String> = Vec::with_capacity(pairs.len());
157    let mut arrays: Vec<ArrayRef> = Vec::with_capacity(pairs.len());
158    for (name, values) in &pairs {
159        owned_names.push((*name).to_string());
160        let mut buf: Vec<f64> = Vec::with_capacity(values.len());
161        for v in values.iter() {
162            match v {
163                Value::Float(f) => buf.push(*f),
164                Value::Int(n) => buf.push(*n as f64),
165                other => {
166                    return err(format!(
167                        "from_float_columns: column `{name}` non-Float element: {other:?}"
168                    ))
169                }
170            }
171        }
172        arrays.push(Arc::new(Float64Array::from(buf)) as ArrayRef);
173    }
174    let cols: Vec<(&str, ArrayRef)> = owned_names.iter().map(|n| n.as_str()).zip(arrays).collect();
175    pack_table(cols)
176}
177
178/// `arrow.from_str_columns(List[(Str, List[Str])]) -> Result[Table, Str]`
179fn from_str_columns(args: &[Value]) -> Result<Value, String> {
180    let list = expect_list(args.first())?;
181    let pairs = decode_columns_list(list)?;
182    let mut owned_names: Vec<String> = Vec::with_capacity(pairs.len());
183    let mut arrays: Vec<ArrayRef> = Vec::with_capacity(pairs.len());
184    for (name, values) in &pairs {
185        owned_names.push((*name).to_string());
186        let mut buf: Vec<String> = Vec::with_capacity(values.len());
187        for v in values.iter() {
188            match v {
189                Value::Str(s) => buf.push(s.to_string()),
190                other => {
191                    return err(format!(
192                        "from_str_columns: column `{name}` non-Str element: {other:?}"
193                    ))
194                }
195            }
196        }
197        arrays.push(Arc::new(StringArray::from(buf)) as ArrayRef);
198    }
199    let cols: Vec<(&str, ArrayRef)> = owned_names.iter().map(|n| n.as_str()).zip(arrays).collect();
200    pack_table(cols)
201}
202
203// ---------- introspection ----------
204
205fn nrows(args: &[Value]) -> Result<Value, String> {
206    Ok(Value::Int(expect_table(args.first())?.num_rows() as i64))
207}
208
209fn ncols(args: &[Value]) -> Result<Value, String> {
210    Ok(Value::Int(expect_table(args.first())?.num_columns() as i64))
211}
212
213fn col_names(args: &[Value]) -> Result<Value, String> {
214    let t = expect_table(args.first())?;
215    let names: VecDeque<Value> = t
216        .schema()
217        .fields()
218        .iter()
219        .map(|f| Value::Str(f.name().as_str().into()))
220        .collect();
221    Ok(Value::List(names))
222}
223
224fn col_type(args: &[Value]) -> Result<Value, String> {
225    let t = expect_table(args.first())?;
226    let name = expect_str(args.get(1))?;
227    match t.schema().column_with_name(name) {
228        None => Ok(none()),
229        Some((_, field)) => Ok(some(Value::Str(format!("{}", field.data_type()).into()))),
230    }
231}
232
233// ---------- column reductions ----------
234
235fn lookup_array<'a>(t: &'a RecordBatch, name: &str) -> Result<&'a ArrayRef, String> {
236    let (idx, _) = t
237        .schema()
238        .column_with_name(name)
239        .ok_or_else(|| format!("arrow: column `{name}` not found"))?;
240    Ok(t.column(idx))
241}
242
243fn as_int64<'a>(arr: &'a ArrayRef, name: &str) -> Result<&'a Int64Array, String> {
244    arr.as_any()
245        .downcast_ref::<Int64Array>()
246        .ok_or_else(|| format!("arrow: column `{name}` is {}, not Int64", arr.data_type()))
247}
248
249fn as_float64<'a>(arr: &'a ArrayRef, name: &str) -> Result<&'a Float64Array, String> {
250    arr.as_any()
251        .downcast_ref::<Float64Array>()
252        .ok_or_else(|| format!("arrow: column `{name}` is {}, not Float64", arr.data_type()))
253}
254
255fn col_sum_int(args: &[Value]) -> Result<Value, String> {
256    let t = expect_table(args.first())?;
257    let name = expect_str(args.get(1))?;
258    let arr = as_int64(lookup_array(t, name)?, name)?;
259    let s: i64 = arrow_arith::aggregate::sum(arr).unwrap_or(0);
260    Ok(Value::Int(s))
261}
262
263fn col_sum_float(args: &[Value]) -> Result<Value, String> {
264    let t = expect_table(args.first())?;
265    let name = expect_str(args.get(1))?;
266    let arr = lookup_array(t, name)?;
267    let s = match arr.data_type() {
268        DataType::Float64 => arrow_arith::aggregate::sum(as_float64(arr, name)?).unwrap_or(0.0),
269        DataType::Int64 => arrow_arith::aggregate::sum(as_int64(arr, name)?).unwrap_or(0) as f64,
270        other => {
271            return err(format!(
272                "col_sum_float: column `{name}` is {other:?}, expected Int64 or Float64"
273            ))
274        }
275    };
276    Ok(Value::Float(s))
277}
278
279fn col_mean(args: &[Value]) -> Result<Value, String> {
280    let t = expect_table(args.first())?;
281    let name = expect_str(args.get(1))?;
282    let arr = lookup_array(t, name)?;
283    let n = arr.len() as f64 - arr.null_count() as f64;
284    if n == 0.0 {
285        return Ok(none());
286    }
287    let total: f64 = match arr.data_type() {
288        DataType::Float64 => arrow_arith::aggregate::sum(as_float64(arr, name)?).unwrap_or(0.0),
289        DataType::Int64 => arrow_arith::aggregate::sum(as_int64(arr, name)?).unwrap_or(0) as f64,
290        other => {
291            return err(format!(
292                "col_mean: column `{name}` is {other:?}, expected Int64 or Float64"
293            ))
294        }
295    };
296    Ok(some(Value::Float(total / n)))
297}
298
299fn col_min_int(args: &[Value]) -> Result<Value, String> {
300    let t = expect_table(args.first())?;
301    let name = expect_str(args.get(1))?;
302    let arr = as_int64(lookup_array(t, name)?, name)?;
303    match arrow_arith::aggregate::min(arr) {
304        Some(v) => Ok(some(Value::Int(v))),
305        None => Ok(none()),
306    }
307}
308
309fn col_max_int(args: &[Value]) -> Result<Value, String> {
310    let t = expect_table(args.first())?;
311    let name = expect_str(args.get(1))?;
312    let arr = as_int64(lookup_array(t, name)?, name)?;
313    match arrow_arith::aggregate::max(arr) {
314        Some(v) => Ok(some(Value::Int(v))),
315        None => Ok(none()),
316    }
317}
318
319fn col_count(args: &[Value]) -> Result<Value, String> {
320    let t = expect_table(args.first())?;
321    let name = expect_str(args.get(1))?;
322    let arr = lookup_array(t, name)?;
323    Ok(Value::Int((arr.len() - arr.null_count()) as i64))
324}
325
326// ---------- slicing ----------
327
328fn head(args: &[Value]) -> Result<Value, String> {
329    let t = expect_table(args.first())?;
330    let n = expect_int(args.get(1))?.max(0) as usize;
331    let take = n.min(t.num_rows());
332    Ok(Value::ArrowTable(Arc::new(t.slice(0, take))))
333}
334
335fn tail(args: &[Value]) -> Result<Value, String> {
336    let t = expect_table(args.first())?;
337    let n = expect_int(args.get(1))?.max(0) as usize;
338    let total = t.num_rows();
339    let take = n.min(total);
340    Ok(Value::ArrowTable(Arc::new(t.slice(total - take, take))))
341}
342
343fn slice(args: &[Value]) -> Result<Value, String> {
344    let t = expect_table(args.first())?;
345    let start = expect_int(args.get(1))?.max(0) as usize;
346    let stop = expect_int(args.get(2))?.max(0) as usize;
347    let total = t.num_rows();
348    let s = start.min(total);
349    let e = stop.min(total).max(s);
350    Ok(Value::ArrowTable(Arc::new(t.slice(s, e - s))))
351}
352
353fn select_cols(args: &[Value]) -> Result<Value, String> {
354    let t = expect_table(args.first())?;
355    let names_list = expect_list(args.get(1))?;
356    let mut indices = Vec::with_capacity(names_list.len());
357    for v in names_list.iter() {
358        let n = match v {
359            Value::Str(s) => s.as_str(),
360            other => {
361                return err(format!(
362                    "select_cols: name list contained non-Str: {other:?}"
363                ))
364            }
365        };
366        let (i, _) = t
367            .schema()
368            .column_with_name(n)
369            .ok_or_else(|| format!("select_cols: column `{n}` not found"))?;
370        indices.push(i);
371    }
372    let projected = t
373        .project(&indices)
374        .map_err(|e| format!("select_cols: {e}"))?;
375    Ok(Value::ArrowTable(Arc::new(projected)))
376}
377
378fn drop_col(args: &[Value]) -> Result<Value, String> {
379    let t = expect_table(args.first())?;
380    let drop_name = expect_str(args.get(1))?;
381    let mut keep = Vec::with_capacity(t.num_columns());
382    for (i, f) in t.schema().fields().iter().enumerate() {
383        if f.name() != drop_name {
384            keep.push(i);
385        }
386    }
387    if keep.len() == t.num_columns() {
388        return err(format!("drop_col: column `{drop_name}` not found"));
389    }
390    let projected = t.project(&keep).map_err(|e| format!("drop_col: {e}"))?;
391    Ok(Value::ArrowTable(Arc::new(projected)))
392}
393
394// ---------- I/O: read_csv (effect [fs_read]) ----------
395
396/// Read a CSV file into an Arrow `RecordBatch`. Header row required;
397/// schema is inferred from the first 100 rows. All batches are
398/// concatenated into one Table — the v1 API surface returns a single
399/// materialised table, not a stream. For 1M-row inputs that's ~50 MB
400/// in memory which is fine for the agentic workloads `lex-frame`
401/// targets; bigger inputs land in a streaming `read_csv_iter` slice
402/// later.
403///
404/// **Path checking is the caller's job.** This function takes an
405/// already-resolved `&Path`; the effect-handler dispatch verifies
406/// `policy.allow_fs_read` before calling here.
407pub fn read_csv_at(path: &Path) -> Result<Value, String> {
408    let file =
409        File::open(path).map_err(|e| format!("arrow.read_csv: open `{}`: {e}", path.display()))?;
410    let (schema, _) = arrow_csv::reader::Format::default()
411        .with_header(true)
412        .infer_schema(&file, Some(100))
413        .map_err(|e| format!("arrow.read_csv: schema inference: {e}"))?;
414    // infer_schema consumed some of the file — reopen so the reader sees row 0.
415    let file = File::open(path)
416        .map_err(|e| format!("arrow.read_csv: reopen `{}`: {e}", path.display()))?;
417    let schema = Arc::new(schema);
418    let reader = ReaderBuilder::new(Arc::clone(&schema))
419        .with_header(true)
420        .build(file)
421        .map_err(|e| format!("arrow.read_csv: reader build: {e}"))?;
422    let mut batches: Vec<RecordBatch> = Vec::new();
423    for batch in reader {
424        batches.push(batch.map_err(|e| format!("arrow.read_csv: row decode: {e}"))?);
425    }
426    let combined = if batches.is_empty() {
427        RecordBatch::new_empty(schema)
428    } else {
429        arrow_select::concat::concat_batches(&schema, &batches)
430            .map_err(|e| format!("arrow.read_csv: concat: {e}"))?
431    };
432    Ok(Value::ArrowTable(Arc::new(combined)))
433}
434
435// ---------- I/O: read_parquet / write_parquet / write_csv ----------
436
437/// Read a Parquet file into an Arrow `RecordBatch`. Schema comes from
438/// the file metadata; all row groups are concatenated into one Table.
439///
440/// Caller is responsible for `[fs_read]` policy enforcement — the
441/// effect-handler dispatch in `handler.rs` verifies the path is in
442/// `--allow-fs-read` before invoking us.
443pub fn read_parquet_at(path: &Path) -> Result<Value, String> {
444    let file = File::open(path)
445        .map_err(|e| format!("arrow.read_parquet: open `{}`: {e}", path.display()))?;
446    let builder = ParquetRecordBatchReaderBuilder::try_new(file)
447        .map_err(|e| format!("arrow.read_parquet: open `{}`: {e}", path.display()))?;
448    let schema = builder.schema().clone();
449    let reader = builder
450        .build()
451        .map_err(|e| format!("arrow.read_parquet: reader build: {e}"))?;
452    let mut batches: Vec<RecordBatch> = Vec::new();
453    for batch in reader {
454        batches.push(batch.map_err(|e| format!("arrow.read_parquet: row-group decode: {e}"))?);
455    }
456    let combined = if batches.is_empty() {
457        RecordBatch::new_empty(schema)
458    } else {
459        arrow_select::concat::concat_batches(&schema, &batches)
460            .map_err(|e| format!("arrow.read_parquet: concat: {e}"))?
461    };
462    Ok(Value::ArrowTable(Arc::new(combined)))
463}
464
465/// `arrow.read_parquet_cols(path, cols)` — projection-pushdown variant.
466/// Only the requested columns are decoded from the file; missing column
467/// names surface as `Err`, not silently dropped.
468pub fn read_parquet_cols_at(path: &Path, cols: &[String]) -> Result<Value, String> {
469    let file = File::open(path)
470        .map_err(|e| format!("arrow.read_parquet_cols: open `{}`: {e}", path.display()))?;
471    let builder = ParquetRecordBatchReaderBuilder::try_new(file)
472        .map_err(|e| format!("arrow.read_parquet_cols: open `{}`: {e}", path.display()))?;
473    // Resolve column names to root indices for ProjectionMask. The
474    // file schema's `root_schema().get_fields()` walks the top-level
475    // columns in declaration order. Scope the borrow before building
476    // the reader (`with_projection` takes ownership of the builder).
477    let mask = {
478        let parquet_schema = builder.parquet_schema();
479        let root_fields = parquet_schema.root_schema().get_fields();
480        let mut indices = Vec::with_capacity(cols.len());
481        for name in cols {
482            let idx = root_fields
483                .iter()
484                .position(|f| f.name() == name.as_str())
485                .ok_or_else(|| format!("arrow.read_parquet_cols: column `{name}` not in file"))?;
486            indices.push(idx);
487        }
488        ProjectionMask::roots(parquet_schema, indices)
489    };
490    let reader = builder
491        .with_projection(mask)
492        .build()
493        .map_err(|e| format!("arrow.read_parquet_cols: reader build: {e}"))?;
494    let projected_schema = reader.schema();
495    let mut batches: Vec<RecordBatch> = Vec::new();
496    for batch in reader {
497        batches
498            .push(batch.map_err(|e| format!("arrow.read_parquet_cols: row-group decode: {e}"))?);
499    }
500    let combined = if batches.is_empty() {
501        RecordBatch::new_empty(projected_schema)
502    } else {
503        arrow_select::concat::concat_batches(&projected_schema, &batches)
504            .map_err(|e| format!("arrow.read_parquet_cols: concat: {e}"))?
505    };
506    Ok(Value::ArrowTable(Arc::new(combined)))
507}
508
509/// Write an Arrow `RecordBatch` to a Parquet file. Default writer
510/// properties (Snappy compression, default page/row-group sizes).
511pub fn write_parquet_at(rb: &RecordBatch, path: &Path) -> Result<Value, String> {
512    let file = File::create(path)
513        .map_err(|e| format!("arrow.write_parquet: create `{}`: {e}", path.display()))?;
514    let props = WriterProperties::builder().build();
515    let mut writer = ArrowWriter::try_new(file, rb.schema(), Some(props))
516        .map_err(|e| format!("arrow.write_parquet: writer init: {e}"))?;
517    writer
518        .write(rb)
519        .map_err(|e| format!("arrow.write_parquet: write: {e}"))?;
520    writer
521        .close()
522        .map_err(|e| format!("arrow.write_parquet: close: {e}"))?;
523    Ok(Value::Unit)
524}
525
526/// Write an Arrow `RecordBatch` to a CSV file (header row + rows).
527/// Bool → "true"/"false"; null → empty cell (arrow-csv default).
528pub fn write_csv_at(rb: &RecordBatch, path: &Path) -> Result<Value, String> {
529    let file = File::create(path)
530        .map_err(|e| format!("arrow.write_csv: create `{}`: {e}", path.display()))?;
531    let mut writer = arrow_csv::WriterBuilder::new()
532        .with_header(true)
533        .build(file);
534    writer
535        .write(rb)
536        .map_err(|e| format!("arrow.write_csv: write: {e}"))?;
537    Ok(Value::Unit)
538}
539
540// ---------- value-conversion helpers (mirror builtins.rs) ----------
541
542fn some(v: Value) -> Value {
543    Value::Variant {
544        name: "Some".into(),
545        args: vec![v],
546    }
547}
548
549fn none() -> Value {
550    Value::Variant {
551        name: "None".into(),
552        args: vec![],
553    }
554}
555
556fn ok(v: Value) -> Value {
557    Value::Variant {
558        name: "Ok".into(),
559        args: vec![v],
560    }
561}
562
563fn err_variant(s: String) -> Value {
564    Value::Variant {
565        name: "Err".into(),
566        args: vec![Value::Str(s.into())],
567    }
568}
569
570/// Lift a kernel that returns `Result<Value, String>` into a Lex
571/// `Result[T, Str]` Value: an inner `Err(s)` becomes `Err(Value::Str)`,
572/// `Ok(v)` becomes `Ok(v)`. Wrap kernels whose Lex signature is
573/// `Result[T, Str]` with this; raw kernels (e.g. `nrows -> Int`) stay
574/// as `Result<Value, String>` and propagate the host error.
575fn lift_result(r: Result<Value, String>) -> Result<Value, String> {
576    match r {
577        Ok(v) => Ok(ok(v)),
578        Err(s) => Ok(err_variant(s)),
579    }
580}
581
582// ---------- public entry point ----------
583
584/// Dispatch an `arrow.*` builtin call. Returns `Some(Result)` if the op
585/// was recognised, `None` if it should fall through to other dispatch
586/// (the caller treats `None` as "unknown op").
587///
588/// Kernels whose Lex signature is `Result[T, Str]` go through `lift_result`
589/// so a host-side `Err(s)` becomes a Lex `Err("...")` Variant, not a
590/// runtime panic. Kernels that return a bare type (`nrows :: Table -> Int`)
591/// don't lift — a bad argument there *is* a programmer error and should
592/// surface as a runtime mismatch.
593pub fn dispatch(op: &str, args: &[Value]) -> Option<Result<Value, String>> {
594    Some(match op {
595        // -- Result-returning constructors / ops --
596        "from_int_columns" => lift_result(from_int_columns(args)),
597        "from_float_columns" => lift_result(from_float_columns(args)),
598        "from_str_columns" => lift_result(from_str_columns(args)),
599        "col_sum_int" => lift_result(col_sum_int(args)),
600        "col_sum_float" => lift_result(col_sum_float(args)),
601        "col_mean" => lift_result(col_mean(args)),
602        "col_min_int" => lift_result(col_min_int(args)),
603        "col_max_int" => lift_result(col_max_int(args)),
604        "col_count" => lift_result(col_count(args)),
605        "select_cols" => lift_result(select_cols(args)),
606        "drop_col" => lift_result(drop_col(args)),
607        // -- bare-return introspection / slicing --
608        "nrows" => nrows(args),
609        "ncols" => ncols(args),
610        "col_names" => col_names(args),
611        "col_type" => col_type(args),
612        "head" => head(args),
613        "tail" => tail(args),
614        "slice" => slice(args),
615        _ => return None,
616    })
617}