runmat-runtime 0.6.0

Core runtime for RunMat with builtins, BLAS/LAPACK integration, and execution APIs
Documentation
//! MATLAB-compatible `bounds` builtin.

use runmat_builtins::{
    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
    ResolveContext, Type, Value,
};
use runmat_macros::runtime_builtin;

use crate::builtins::common::gpu_helpers;
use crate::builtins::common::random_args::keyword_of;
use crate::builtins::math::reduction::type_resolvers::reduce_numeric_type;
use crate::{build_runtime_error, BuiltinResult, RuntimeError};

fn bounds_type(args: &[Type], ctx: &ResolveContext) -> Type {
    reduce_numeric_type(args, ctx)
}

const OUTPUT_MIN: BuiltinParamDescriptor = BuiltinParamDescriptor {
    name: "S",
    ty: BuiltinParamType::NumericArray,
    arity: BuiltinParamArity::Required,
    default: None,
    description: "Smallest values.",
};

const OUTPUT_MAX: BuiltinParamDescriptor = BuiltinParamDescriptor {
    name: "L",
    ty: BuiltinParamType::NumericArray,
    arity: BuiltinParamArity::Required,
    default: None,
    description: "Largest values.",
};

const OUTPUTS: [BuiltinParamDescriptor; 2] = [OUTPUT_MIN, OUTPUT_MAX];

const INPUT_A: BuiltinParamDescriptor = BuiltinParamDescriptor {
    name: "A",
    ty: BuiltinParamType::Any,
    arity: BuiltinParamArity::Required,
    default: None,
    description: "Input array.",
};

const INPUT_DIM: BuiltinParamDescriptor = BuiltinParamDescriptor {
    name: "dim",
    ty: BuiltinParamType::Any,
    arity: BuiltinParamArity::Optional,
    default: None,
    description: "Dimension selector, vector of dimensions, or \"all\".",
};

const INPUT_NANFLAG: BuiltinParamDescriptor = BuiltinParamDescriptor {
    name: "nanflag",
    ty: BuiltinParamType::StringScalar,
    arity: BuiltinParamArity::Optional,
    default: Some("\"omitnan\""),
    description: "NaN handling mode: \"includenan\" or \"omitnan\".",
};

const INPUTS_A: [BuiltinParamDescriptor; 1] = [INPUT_A];
const INPUTS_A_DIM: [BuiltinParamDescriptor; 2] = [INPUT_A, INPUT_DIM];
const INPUTS_A_NANFLAG: [BuiltinParamDescriptor; 2] = [INPUT_A, INPUT_NANFLAG];
const INPUTS_A_DIM_NANFLAG: [BuiltinParamDescriptor; 3] = [INPUT_A, INPUT_DIM, INPUT_NANFLAG];

const BOUNDS_SIGNATURES: [BuiltinSignatureDescriptor; 5] = [
    BuiltinSignatureDescriptor {
        label: "[S, L] = bounds(A)",
        inputs: &INPUTS_A,
        outputs: &OUTPUTS,
    },
    BuiltinSignatureDescriptor {
        label: "[S, L] = bounds(A, dim)",
        inputs: &INPUTS_A_DIM,
        outputs: &OUTPUTS,
    },
    BuiltinSignatureDescriptor {
        label: "[S, L] = bounds(A, vecdim)",
        inputs: &INPUTS_A_DIM,
        outputs: &OUTPUTS,
    },
    BuiltinSignatureDescriptor {
        label: "[S, L] = bounds(A, nanflag)",
        inputs: &INPUTS_A_NANFLAG,
        outputs: &OUTPUTS,
    },
    BuiltinSignatureDescriptor {
        label: "[S, L] = bounds(A, dim, nanflag)",
        inputs: &INPUTS_A_DIM_NANFLAG,
        outputs: &OUTPUTS,
    },
];

const BOUNDS_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
    code: "RM.BOUNDS.INVALID_ARGUMENT",
    identifier: Some("RunMat:bounds:InvalidArgument"),
    when: "Dimension selectors, nanflags, or argument ordering are invalid.",
    message: "bounds: invalid argument",
};

const BOUNDS_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
    code: "RM.BOUNDS.INTERNAL",
    identifier: Some("RunMat:bounds:Internal"),
    when: "Min/max evaluation fails internally.",
    message: "bounds: internal failure",
};

const BOUNDS_ERRORS: [BuiltinErrorDescriptor; 2] =
    [BOUNDS_ERROR_INVALID_ARGUMENT, BOUNDS_ERROR_INTERNAL];

pub const BOUNDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
    signatures: &BOUNDS_SIGNATURES,
    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
    completion_policy: BuiltinCompletionPolicy::Public,
    errors: &BOUNDS_ERRORS,
};

#[runtime_builtin(
    name = "bounds",
    category = "math/reduction",
    summary = "Return smallest and largest elements along dimensions.",
    keywords = "bounds,min,max,reduction,omitnan",
    type_resolver(bounds_type),
    descriptor(crate::builtins::math::reduction::bounds::BOUNDS_DESCRIPTOR),
    builtin_path = "crate::builtins::math::reduction::bounds"
)]
async fn bounds_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
    evaluate_bounds(value, &rest).await?.into_value()
}

#[derive(Debug)]
struct BoundsEvaluation {
    smallest: Value,
    largest: Value,
}

impl BoundsEvaluation {
    fn into_value(self) -> BuiltinResult<Value> {
        if let Some(out_count) = crate::output_count::current_output_count() {
            if out_count == 0 {
                return Ok(Value::OutputList(Vec::new()));
            }
            return Ok(crate::output_count::output_list_with_padding(
                out_count,
                vec![self.smallest, self.largest],
            ));
        }
        Ok(Value::OutputList(vec![self.smallest, self.largest]))
    }
}

async fn evaluate_bounds(value: Value, rest: &[Value]) -> BuiltinResult<BoundsEvaluation> {
    let rest = normalize_bounds_args(rest)?;
    let value = match value {
        Value::GpuTensor(handle) => Value::Tensor(
            gpu_helpers::gather_tensor_async(&handle)
                .await
                .map_err(|err| bounds_internal(err.message()))?,
        ),
        other => other,
    };
    let min_eval = super::min::evaluate(value.clone(), &rest)
        .await
        .map_err(|err| map_bounds_reduction_error("min", err))?;
    let max_eval = super::max::evaluate(value, &rest)
        .await
        .map_err(|err| map_bounds_reduction_error("max", err))?;
    Ok(BoundsEvaluation {
        smallest: min_eval.into_value(),
        largest: max_eval.into_value(),
    })
}

fn normalize_bounds_args(rest: &[Value]) -> BuiltinResult<Vec<Value>> {
    if rest.len() > 2 {
        return Err(bounds_invalid_argument(
            "bounds accepts at most a dimension selector and nanflag",
        ));
    }
    let has_nanflag = rest.iter().any(is_nanflag);
    let mut out = Vec::with_capacity(rest.len() + 2);
    out.push(empty_placeholder());
    out.extend(rest.iter().cloned());
    if !has_nanflag {
        out.push(Value::from("omitnan"));
    }
    Ok(out)
}

fn is_nanflag(value: &Value) -> bool {
    matches!(
        keyword_of(value).as_deref(),
        Some("omitnan" | "includenan" | "omitmissing" | "includemissing")
    )
}

fn map_bounds_reduction_error(label: &str, err: RuntimeError) -> RuntimeError {
    let detail = format!("{label} evaluation failed: {}", err.message());
    match err.identifier() {
        Some(identifier)
            if identifier.contains("InvalidArgument")
                || identifier.contains("InvalidInput")
                || identifier.contains("SizeMismatch") =>
        {
            bounds_invalid_argument(detail)
        }
        _ => bounds_internal(detail),
    }
}

fn empty_placeholder() -> Value {
    Value::Tensor(runmat_builtins::Tensor::new(Vec::new(), vec![0, 0]).unwrap())
}

fn bounds_error(
    descriptor: &'static BuiltinErrorDescriptor,
    detail: impl AsRef<str>,
) -> RuntimeError {
    let mut builder = build_runtime_error(format!("{}: {}", descriptor.message, detail.as_ref()))
        .with_builtin("bounds");
    if let Some(identifier) = descriptor.identifier {
        builder = builder.with_identifier(identifier);
    }
    builder.build()
}

fn bounds_invalid_argument(detail: impl AsRef<str>) -> RuntimeError {
    bounds_error(&BOUNDS_ERROR_INVALID_ARGUMENT, detail)
}

fn bounds_internal(detail: impl AsRef<str>) -> RuntimeError {
    bounds_error(&BOUNDS_ERROR_INTERNAL, detail)
}

#[cfg(test)]
mod tests {
    use super::*;
    use runmat_builtins::Tensor;

    fn tensor(data: Vec<f64>, shape: Vec<usize>) -> Value {
        Value::Tensor(Tensor::new(data, shape).unwrap())
    }

    #[test]
    fn bounds_descriptor_declares_runtime_nanflag_default() {
        assert_eq!(INPUT_NANFLAG.default, Some("\"omitnan\""));
    }

    #[tokio::test]
    async fn bounds_default_returns_min_and_max() {
        let input = tensor(vec![3.0, 1.0, 4.0, 2.0], vec![2, 2]);
        let result = evaluate_bounds(input, &[]).await.unwrap();
        let Value::Tensor(mins) = result.smallest else {
            panic!("expected min tensor");
        };
        assert_eq!(mins.data, vec![1.0, 2.0]);
        let Value::Tensor(maxes) = result.largest else {
            panic!("expected max tensor");
        };
        assert_eq!(maxes.data, vec![3.0, 4.0]);
    }

    #[tokio::test]
    async fn bounds_supports_all_and_omitnan() {
        let input = tensor(vec![f64::NAN, 3.0, -1.0, 5.0], vec![2, 2]);
        let result = evaluate_bounds(input, &[Value::from("all"), Value::from("omitnan")])
            .await
            .unwrap();
        assert_eq!(result.smallest, Value::Num(-1.0));
        assert_eq!(result.largest, Value::Num(5.0));
    }

    #[tokio::test]
    async fn bounds_omits_nan_by_default() {
        let input = tensor(vec![f64::NAN, 3.0, -1.0, 5.0], vec![2, 2]);
        let result = evaluate_bounds(input, &[Value::from("all")]).await.unwrap();
        assert_eq!(result.smallest, Value::Num(-1.0));
        assert_eq!(result.largest, Value::Num(5.0));
    }

    #[tokio::test]
    async fn bounds_invalid_reduction_arguments_use_bounds_identifier() {
        let input = tensor(vec![1.0, 2.0], vec![2, 1]);
        let err = evaluate_bounds(input, &[Value::Num(0.0)])
            .await
            .unwrap_err();
        assert_eq!(err.identifier(), Some("RunMat:bounds:InvalidArgument"));
    }

    #[tokio::test]
    async fn bounds_builtin_pads_requested_outputs() {
        let input = tensor(vec![1.0, 4.0], vec![2, 1]);
        let _guard = crate::output_count::push_output_count(Some(3));
        let result = bounds_builtin(input, Vec::new()).await.unwrap();
        let Value::OutputList(values) = result else {
            panic!("expected output list");
        };
        assert_eq!(values.len(), 3);
        assert_eq!(values[0], Value::Num(1.0));
        assert_eq!(values[1], Value::Num(4.0));
    }
}