use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use arrow::array::{Array, FixedSizeListArray, Float32Array};
use arrow::datatypes::{DataType, Field};
use datafusion::common::ScalarValue;
use datafusion::error::{DataFusionError, Result};
use datafusion::logical_expr::{
ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility,
};
use oxmera::Tensor;
use oxmera::nn::{Linear, Module, Sequential};
use safetensors::SafeTensors;
pub const PREDICT: &str = "predict";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSpec {
SequentialMlpRelu,
}
pub struct Model {
module: Sequential,
pub in_features: usize,
pub out_features: usize,
}
impl Model {
fn forward(&self, input: &Tensor) -> oxmera::Result<Tensor> {
self.module.forward(input)
}
}
static MODELS: OnceLock<Mutex<HashMap<String, Arc<Model>>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, Arc<Model>>> {
MODELS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn model(path: &str, spec: ModelSpec) -> Result<Arc<Model>> {
let mut guard = cache()
.lock()
.map_err(|_| DataFusionError::Execution(format!("{PREDICT}: model cache poisoned")))?;
if let Some(found) = guard.get(path) {
return Ok(Arc::clone(found));
}
let loaded = Arc::new(load(path, spec)?);
guard.insert(path.to_string(), Arc::clone(&loaded));
Ok(loaded)
}
fn load(path: &str, spec: ModelSpec) -> Result<Model> {
let ModelSpec::SequentialMlpRelu = spec;
let shapes = layer_shapes(path)?;
let ([(first_in, _), ..], [.., (_, last_out)]) = (&shapes[..], &shapes[..]) else {
return Err(exec(format!(
"{PREDICT}: {path} declares no `N.weight` tensors, so there is no \
architecture to rebuild"
)));
};
let (first_in, last_out) = (*first_in, *last_out);
let mut stack = Sequential::new();
for (i, &(in_features, out_features)) in shapes.iter().enumerate() {
stack = stack.push(Linear::new(in_features, out_features, i as u64));
}
oxmera::nn::serialize::load(&stack, path)
.map_err(|e| exec(format!("{PREDICT}: loading {path}: {e}")))?;
Ok(Model {
module: stack,
in_features: first_in,
out_features: last_out,
})
}
fn layer_shapes(path: &str) -> Result<Vec<(usize, usize)>> {
let bytes = std::fs::read(path).map_err(|e| exec(format!("{PREDICT}: reading {path}: {e}")))?;
let file = SafeTensors::deserialize(&bytes)
.map_err(|e| exec(format!("{PREDICT}: {path} is not a safetensors file: {e}")))?;
let mut by_index: HashMap<usize, (usize, usize)> = HashMap::new();
for (name, view) in file.tensors() {
let shape = view.shape();
let Some((index, "weight")) = name.split_once('.') else {
continue; };
let Ok(index) = index.parse::<usize>() else {
continue;
};
let [out_features, in_features] = shape[..] else {
return Err(exec(format!(
"{PREDICT}: {path}: `{name}` has {} dimensions, expected 2",
shape.len()
)));
};
by_index.insert(index, (in_features, out_features));
}
let mut shapes = Vec::with_capacity(by_index.len());
for i in 0..by_index.len() {
let found = by_index.get(&i).ok_or_else(|| {
exec(format!(
"{PREDICT}: {path}: layers are numbered with a gap at {i}; \
expected 0..{} as `oxmera::nn::Sequential` writes them",
by_index.len()
))
})?;
shapes.push(*found);
}
for pair in shapes.windows(2) {
let [(_, out), (next_in, _)] = pair else {
continue;
};
if out != next_in {
return Err(exec(format!(
"{PREDICT}: {path}: a layer produces {out} features and the next \
expects {next_in}"
)));
}
}
Ok(shapes)
}
fn exec(message: String) -> DataFusionError {
DataFusionError::Execution(message)
}
#[derive(Debug, PartialEq, Eq, Hash)]
struct PredictUdf {
signature: Signature,
}
impl PredictUdf {
fn new() -> Self {
Self {
signature: Signature::user_defined(Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for PredictUdf {
fn name(&self) -> &str {
PREDICT
}
fn signature(&self) -> &Signature {
&self.signature
}
fn coerce_types(&self, arg_types: &[DataType]) -> Result<Vec<DataType>> {
let [path, features] = arg_types else {
return Err(DataFusionError::Plan(format!(
"{PREDICT} takes 2 arguments (model path, features), got {}",
arg_types.len()
)));
};
if !matches!(
path,
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
) {
return Err(DataFusionError::Plan(format!(
"{PREDICT}: the first argument is the model path as a string, got {path:?}"
)));
}
let dim = match features {
DataType::FixedSizeList(_, n) => *n,
other => {
return Err(DataFusionError::Plan(format!(
"{PREDICT}: features must be a FixedSizeList so the width is \
known before execution, got {other:?}"
)));
}
};
Ok(vec![
DataType::Utf8,
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), dim),
])
}
fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
Ok(DataType::List(Arc::new(Field::new(
"item",
DataType::Float32,
true,
))))
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
let [path, features] = args.args.as_slice() else {
return Err(exec(format!(
"{PREDICT} takes 2 arguments, got {}",
args.args.len()
)));
};
let path = model_path(path)?;
let model = model(&path, ModelSpec::SequentialMlpRelu)?;
let list = feature_list(features)?;
let (values, dim) = feature_values(&list)?;
if dim != model.in_features {
return Err(exec(format!(
"{PREDICT}: {path} expects {} features but the column has {dim}",
model.in_features
)));
}
let rows = args.number_rows;
let mut flat = vec![0.0f32; rows * dim];
let mut null = vec![false; rows];
for row in 0..rows {
if list.is_null(row) {
null[row] = true;
continue;
}
let start = usize::try_from(list.value_offset(row)).unwrap_or(0);
flat[row * dim..(row + 1) * dim].copy_from_slice(&values[start..start + dim]);
}
let input = Tensor::from_vec_f32(flat, [rows, dim])
.map_err(|e| exec(format!("{PREDICT}: building the input batch: {e}")))?;
let out = oxmera::no_grad(|| model.forward(&input))
.map_err(|e| exec(format!("{PREDICT}: {path}: {e}")))?;
let out = out
.to_vec_f32()
.map_err(|e| exec(format!("{PREDICT}: reading the output: {e}")))?;
let width = model.out_features;
let mut builder = arrow::array::ListBuilder::new(Float32Array::builder(rows * width));
for (row, is_null) in null.iter().enumerate() {
if *is_null {
builder.append_null();
continue;
}
builder
.values()
.append_slice(&out[row * width..(row + 1) * width]);
builder.append(true);
}
Ok(ColumnarValue::Array(Arc::new(builder.finish())))
}
}
fn model_path(value: &ColumnarValue) -> Result<String> {
match value {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s)))
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(s)))
| ColumnarValue::Scalar(ScalarValue::Utf8View(Some(s))) => Ok(s.clone()),
_ => Err(DataFusionError::Plan(format!(
"{PREDICT}: the model path must be a constant string, not a column"
))),
}
}
fn feature_list(value: &ColumnarValue) -> Result<FixedSizeListArray> {
match value {
ColumnarValue::Array(array) => array
.as_any()
.downcast_ref::<FixedSizeListArray>()
.cloned()
.ok_or_else(|| {
exec(format!(
"{PREDICT}: expected FixedSizeList features, got {:?}",
array.data_type()
))
}),
ColumnarValue::Scalar(ScalarValue::FixedSizeList(array)) => Ok(array.as_ref().clone()),
ColumnarValue::Scalar(other) => Err(exec(format!(
"{PREDICT}: expected FixedSizeList features, got {:?}",
other.data_type()
))),
}
}
fn feature_values(list: &FixedSizeListArray) -> Result<(&[f32], usize)> {
let values = list
.values()
.as_any()
.downcast_ref::<Float32Array>()
.ok_or_else(|| {
exec(format!(
"{PREDICT}: feature elements are {:?}, expected Float32",
list.value_type()
))
})?;
let dim = usize::try_from(list.value_length())
.map_err(|_| exec(format!("{PREDICT}: negative list size")))?;
Ok((values.values(), dim))
}
pub fn predict_udf() -> ScalarUDF {
ScalarUDF::from(PredictUdf::new())
}