#![cfg(feature = "predict")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use arrow::array::{Array, FixedSizeListArray, Float32Array, Int32Array, ListArray, RecordBatch};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::prelude::SessionContext;
use oxidelake_compute::predict_udf;
use oxmera::Tensor;
use oxmera::nn::{Linear, Module, Sequential};
const IN: usize = 4;
const HIDDEN: usize = 3;
const OUT: usize = 2;
fn model_on_disk(dir: &std::path::Path) -> (Sequential, String) {
let model = Sequential::new()
.push(Linear::new(IN, HIDDEN, 11))
.push(Linear::new(HIDDEN, OUT, 22));
let path = dir.join("scorer.safetensors");
oxmera::nn::serialize::save(&model, &path).expect("save");
(model, path.to_string_lossy().into_owned())
}
fn features() -> Vec<Option<[f32; IN]>> {
vec![
Some([1.0, 0.0, -2.0, 0.5]),
Some([0.25, 0.25, 0.25, 0.25]),
None, Some([-1.0, 3.0, 0.0, 2.0]),
]
}
fn batch() -> RecordBatch {
let rows = features();
let mut values = Vec::new();
let mut validity = Vec::new();
for row in &rows {
match row {
Some(v) => {
values.extend_from_slice(v);
validity.push(true);
}
None => {
values.extend(std::iter::repeat_n(0.0, IN));
validity.push(false);
}
}
}
let field = Arc::new(Field::new("item", DataType::Float32, true));
let list = FixedSizeListArray::new(
Arc::clone(&field),
IN as i32,
Arc::new(Float32Array::from(values)),
Some(validity.into()),
);
let schema = Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("features", DataType::FixedSizeList(field, IN as i32), true),
]);
RecordBatch::try_new(
Arc::new(schema),
vec![
Arc::new(Int32Array::from((0..rows.len() as i32).collect::<Vec<_>>())),
Arc::new(list),
],
)
.expect("batch")
}
async fn run(sql: &str, path: &str) -> Vec<RecordBatch> {
let ctx = SessionContext::new();
ctx.register_udf(predict_udf());
ctx.register_batch("t", batch()).expect("register");
ctx.sql(&sql.replace("{model}", path))
.await
.expect("plan")
.collect()
.await
.expect("execute")
}
#[tokio::test]
async fn predict_agrees_with_the_model_it_loaded() {
let dir = tempfile::tempdir().expect("tempdir");
let (model, path) = model_on_disk(dir.path());
let out = run(
"SELECT id, predict('{model}', features) AS y FROM t ORDER BY id",
&path,
)
.await;
let batch = &out[0];
let y = batch
.column(1)
.as_any()
.downcast_ref::<ListArray>()
.expect("predict returns a list");
for (row, expected_in) in features().iter().enumerate() {
let Some(input) = expected_in else {
assert!(
y.is_null(row),
"row {row}: a null feature vector must stay null"
);
continue;
};
assert!(
!y.is_null(row),
"row {row}: a real feature vector must produce a value"
);
let want = oxmera::no_grad(|| {
model.forward(&Tensor::from_vec_f32(input.to_vec(), [1, IN]).unwrap())
})
.expect("reference forward")
.to_vec_f32()
.expect("reference values");
let got = y.value(row);
let got = got
.as_any()
.downcast_ref::<Float32Array>()
.expect("float output");
assert_eq!(got.len(), OUT, "row {row}: output width");
for (i, expected) in want.iter().enumerate().take(OUT) {
assert!(
(got.value(i) - expected).abs() < 1e-5,
"row {row}, output {i}: SQL gave {} and the model gives {expected}",
got.value(i),
);
}
}
}
#[tokio::test]
async fn a_single_row_gives_the_same_answer_as_the_batch() {
let dir = tempfile::tempdir().expect("tempdir");
let (_, path) = model_on_disk(dir.path());
let whole = run(
"SELECT predict('{model}', features) AS y FROM t ORDER BY id",
&path,
)
.await;
let one = run(
"SELECT predict('{model}', features) AS y FROM t WHERE id = 3",
&path,
)
.await;
let from_batch = whole[0]
.column(0)
.as_any()
.downcast_ref::<ListArray>()
.unwrap();
let alone = one[0]
.column(0)
.as_any()
.downcast_ref::<ListArray>()
.unwrap();
let a = from_batch.value(3);
let b = alone.value(0);
assert_eq!(
a.as_any().downcast_ref::<Float32Array>().unwrap().values(),
b.as_any().downcast_ref::<Float32Array>().unwrap().values(),
"row 3 read differently in a batch of four than on its own"
);
}
#[tokio::test]
async fn a_missing_model_is_an_error_naming_the_path() {
let ctx = SessionContext::new();
ctx.register_udf(predict_udf());
ctx.register_batch("t", batch()).unwrap();
let err = ctx
.sql("SELECT predict('/nonexistent/model.safetensors', features) FROM t")
.await
.expect("plans fine — the file is only read at execution")
.collect()
.await
.expect_err("executing must fail");
let msg = err.to_string();
assert!(msg.contains("predict"), "{msg}");
assert!(msg.contains("/nonexistent/model.safetensors"), "{msg}");
}
#[tokio::test]
async fn a_feature_width_the_model_does_not_accept_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let wrong = Sequential::new().push(Linear::new(7, OUT, 1));
let path = dir.path().join("wrong.safetensors");
oxmera::nn::serialize::save(&wrong, &path).unwrap();
let ctx = SessionContext::new();
ctx.register_udf(predict_udf());
ctx.register_batch("t", batch()).unwrap();
let err = ctx
.sql(&format!(
"SELECT predict('{}', features) FROM t",
path.to_string_lossy()
))
.await
.unwrap()
.collect()
.await
.expect_err("4 features into a 7-feature model must fail");
let msg = err.to_string();
assert!(msg.contains("expects 7 features"), "{msg}");
assert!(msg.contains("has 4"), "{msg}");
}
#[tokio::test]
async fn a_column_of_model_paths_is_refused() {
let ctx = SessionContext::new();
ctx.register_udf(predict_udf());
ctx.register_batch("t", batch()).unwrap();
let result = ctx
.sql("SELECT predict(CAST(id AS VARCHAR), features) FROM t")
.await;
let err = match result {
Err(e) => e.to_string(),
Ok(df) => df
.collect()
.await
.expect_err("a column of paths must not run")
.to_string(),
};
assert!(err.contains("constant string"), "{err}");
}