Skip to main content

earthsci_ast/
registered_functions.rs

1//! Closed function registry — Rust binding (esm-tzp / esm-1vr).
2//!
3//! Implements the spec-defined closed function set from esm-spec §9.2:
4//!
5//! * `datetime.year`, `month`, `day`, `hour`, `minute`, `second`,
6//!   `day_of_year`, `julian_day`, `is_leap_year` — proleptic-Gregorian
7//!   calendar decomposition of an IEEE-754 `binary64` UTC scalar
8//!   (seconds since the Unix epoch, no leap-second consultation).
9//! * `interp.searchsorted` — 1-based search-into-sorted-array (Julia
10//!   `searchsortedfirst` semantics with explicit out-of-range / NaN /
11//!   duplicate behavior pinned by spec).
12//! * `interp.linear`, `interp.bilinear` — 1-D and 2-D linear tensor
13//!   interpolation with extrapolate-flat clamping. Implemented as direct
14//!   primitives (rather than as `interp.searchsorted` + `index` +
15//!   AST-arithmetic compositions) to satisfy the §9.2 pinned evaluation
16//!   order `result = a + w * (b - a)` for cross-binding bit-equivalence.
17//!
18//! The set is **closed**: callers MUST reject any `fn`-op `name` outside this
19//! list (diagnostic `unknown_closed_function`). Bindings agreeing on this
20//! contract is what gives v0.3.0 cross-binding bit-equivalence on the
21//! integer-typed outputs (zero ulp drift) and ≤ 1 ulp on `julian_day`.
22
23use crate::diagnostic::codes;
24use std::collections::HashSet;
25use std::sync::OnceLock;
26
27use thiserror::Error;
28
29/// Error type for closed-function dispatch failures (esm-spec §9.1–§9.2).
30///
31/// The `code` field carries one of the stable diagnostic codes pinned by the
32/// spec; downstream tooling pattern-matches on these strings. The set is
33/// kept in lockstep with the Julia / Python / TypeScript / Go bindings.
34#[derive(Error, Debug, Clone, PartialEq, Eq)]
35#[error("ClosedFunctionError({code}): {message}")]
36pub struct ClosedFunctionError {
37    /// Stable diagnostic code: `unknown_closed_function`,
38    /// `closed_function_arity`, `closed_function_overflow`,
39    /// `searchsorted_non_monotonic`, `searchsorted_nan_in_table`,
40    /// `closed_function_arg_type`.
41    pub code: String,
42
43    /// Human-readable diagnostic message.
44    pub message: String,
45}
46
47impl ClosedFunctionError {
48    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
49        Self {
50            code: code.into(),
51            message: message.into(),
52        }
53    }
54}
55
56/// Argument value passed to a closed function. The two cases mirror the
57/// shapes the spec actually permits at call sites: a scalar (any AST node
58/// that evaluates to `f64`) and an inline `const`-op array (used as the
59/// `xs` table for `interp.searchsorted`).
60#[derive(Debug, Clone)]
61pub enum ClosedArg {
62    /// Scalar argument (e.g. `t_utc`, `x`).
63    Scalar(f64),
64
65    /// Inline `const`-op array (e.g. the `xs` table for `searchsorted`,
66    /// or an axis array for `interp.linear` / `interp.bilinear`).
67    Array(Vec<f64>),
68
69    /// Inline `const`-op 2-D array (e.g. the `table` arg for
70    /// `interp.bilinear`). Outer index is the row (axis-x position),
71    /// inner index the column (axis-y position); see esm-spec §9.2.
72    Array2D(Vec<Vec<f64>>),
73}
74
75impl ClosedArg {
76    // `position` is the 0-based argument index; diagnostics render it 1-based
77    // ("arg #1" is the first argument), matching the 1-based element indexing
78    // used by this module's table diagnostics. Only the diagnostic CODE is
79    // pinned cross-binding; the prose is binding-local.
80    fn expect_scalar(&self, name: &str, position: usize) -> Result<f64, ClosedFunctionError> {
81        match self {
82            ClosedArg::Scalar(v) => Ok(*v),
83            ClosedArg::Array(_) | ClosedArg::Array2D(_) => Err(ClosedFunctionError::new(
84                codes::CLOSED_FUNCTION_ARG_TYPE,
85                format!("{name}: arg #{} must be scalar, got array", position + 1),
86            )),
87        }
88    }
89
90    fn expect_array(&self, name: &str, position: usize) -> Result<&[f64], ClosedFunctionError> {
91        match self {
92            ClosedArg::Array(v) => Ok(v.as_slice()),
93            ClosedArg::Scalar(_) | ClosedArg::Array2D(_) => Err(ClosedFunctionError::new(
94                codes::CLOSED_FUNCTION_ARG_TYPE,
95                format!(
96                    "{name}: arg #{} must be 1-D array, got other shape",
97                    position + 1
98                ),
99            )),
100        }
101    }
102
103    fn expect_array2d(
104        &self,
105        name: &str,
106        position: usize,
107    ) -> Result<&[Vec<f64>], ClosedFunctionError> {
108        match self {
109            ClosedArg::Array2D(v) => Ok(v.as_slice()),
110            ClosedArg::Scalar(_) | ClosedArg::Array(_) => Err(ClosedFunctionError::new(
111                codes::CLOSED_FUNCTION_ARG_TYPE,
112                format!(
113                    "{name}: arg #{} must be 2-D array, got other shape",
114                    position + 1
115                ),
116            )),
117        }
118    }
119}
120
121/// Scalar return value from a closed function. Integer-typed entries (every
122/// `datetime.*` except `julian_day`, plus `interp.searchsorted`) are kept
123/// distinct from float entries so callers can recover the spec-pinned integer
124/// contract without ulp loss.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum ClosedValue {
127    /// Signed 32-bit integer result. Boxed as `i32` because the spec pins
128    /// integer outputs to that width and bindings MUST raise
129    /// `closed_function_overflow` if a result would overflow.
130    Integer(i32),
131
132    /// Float result (only `datetime.julian_day` in v0.3.0).
133    Float(f64),
134}
135
136impl ClosedValue {
137    /// Promote to f64 for AST evaluators that drive everything as `binary64`.
138    /// Integer→float conversion is exact for the i32 domain (i32 fits in
139    /// f64's 53-bit mantissa with no loss), so the spec's zero-ulp integer
140    /// contract survives the lift.
141    pub fn as_f64(self) -> f64 {
142        match self {
143            ClosedValue::Integer(i) => i as f64,
144            ClosedValue::Float(f) => f,
145        }
146    }
147}
148
149/// Native handler signature for one closed function: the dotted spec name
150/// (for diagnostics) plus the evaluated argument list.
151type ClosedHandler = fn(&str, &[ClosedArg]) -> Result<ClosedValue, ClosedFunctionError>;
152
153/// The v0.3.0 closed function registry (esm-spec §9.1/§9.2): one row per
154/// primitive, dotted name → native handler. Membership
155/// ([`closed_function_names`]) and dispatch ([`evaluate_closed_function`])
156/// both derive from this single table, so a name can never be registered
157/// without a handler (the old hand-synced set + match could drift and only
158/// failed at runtime). Adding a primitive requires a spec rev.
159const CLOSED_FUNCTIONS: &[(&str, ClosedHandler)] = &[
160    ("datetime.year", cf_datetime_year),
161    ("datetime.month", |name, args| {
162        datetime_field(name, args, |(_, m, _, _, _, _)| m)
163    }),
164    ("datetime.day", |name, args| {
165        datetime_field(name, args, |(_, _, d, _, _, _)| d)
166    }),
167    ("datetime.hour", |name, args| {
168        datetime_field(name, args, |(_, _, _, h, _, _)| h)
169    }),
170    ("datetime.minute", |name, args| {
171        datetime_field(name, args, |(_, _, _, _, mi, _)| mi)
172    }),
173    ("datetime.second", |name, args| {
174        datetime_field(name, args, |(_, _, _, _, _, s)| s)
175    }),
176    ("datetime.day_of_year", cf_datetime_day_of_year),
177    ("datetime.julian_day", cf_datetime_julian_day),
178    ("datetime.is_leap_year", cf_datetime_is_leap_year),
179    ("interp.searchsorted", cf_interp_searchsorted),
180    ("interp.linear", cf_interp_linear),
181    ("interp.bilinear", cf_interp_bilinear),
182];
183
184/// The closed function set. Bindings MUST reject any `fn`-op `name`
185/// not in this set (esm-spec §9.1). Derived from [`CLOSED_FUNCTIONS`].
186pub fn closed_function_names() -> &'static HashSet<String> {
187    static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
188    NAMES.get_or_init(|| {
189        CLOSED_FUNCTIONS
190            .iter()
191            .map(|(n, _)| (*n).to_string())
192            .collect()
193    })
194}
195
196/// Dispatch a closed function call. `name` is the dotted-module spec path
197/// (e.g. `"datetime.julian_day"`); `args` is the list of evaluated argument
198/// values. Returns [`ClosedValue`] so callers can preserve the integer/float
199/// distinction; promotion to `f64` is available via [`ClosedValue::as_f64`].
200pub fn evaluate_closed_function(
201    name: &str,
202    args: &[ClosedArg],
203) -> Result<ClosedValue, ClosedFunctionError> {
204    match CLOSED_FUNCTIONS.iter().find(|(n, _)| *n == name) {
205        Some((n, handler)) => handler(n, args),
206        None => Err(ClosedFunctionError::new(
207            codes::UNKNOWN_CLOSED_FUNCTION,
208            format!(
209                "`fn` name `{name}` is not in the v0.3.0 closed function registry \
210                 (esm-spec §9.2). Adding a primitive requires a spec rev."
211            ),
212        )),
213    }
214}
215
216fn cf_datetime_year(name: &str, args: &[ClosedArg]) -> Result<ClosedValue, ClosedFunctionError> {
217    expect_arity(name, args, 1)?;
218    let t = args[0].expect_scalar(name, 0)?;
219    let (y, _, _, _, _, _) = decompose_utc(t);
220    Ok(ClosedValue::Integer(check_i32(name, y as i64)?))
221}
222
223fn cf_datetime_day_of_year(
224    name: &str,
225    args: &[ClosedArg],
226) -> Result<ClosedValue, ClosedFunctionError> {
227    expect_arity(name, args, 1)?;
228    let t = args[0].expect_scalar(name, 0)?;
229    let (y, m, d, _, _, _) = decompose_utc(t);
230    Ok(ClosedValue::Integer(day_of_year(y, m, d) as i32))
231}
232
233fn cf_datetime_julian_day(
234    name: &str,
235    args: &[ClosedArg],
236) -> Result<ClosedValue, ClosedFunctionError> {
237    expect_arity(name, args, 1)?;
238    let t = args[0].expect_scalar(name, 0)?;
239    Ok(ClosedValue::Float(julian_day(t)))
240}
241
242fn cf_datetime_is_leap_year(
243    name: &str,
244    args: &[ClosedArg],
245) -> Result<ClosedValue, ClosedFunctionError> {
246    expect_arity(name, args, 1)?;
247    let t = args[0].expect_scalar(name, 0)?;
248    let (y, _, _, _, _, _) = decompose_utc(t);
249    Ok(ClosedValue::Integer(if is_leap_year(y) { 1 } else { 0 }))
250}
251
252fn cf_interp_searchsorted(
253    name: &str,
254    args: &[ClosedArg],
255) -> Result<ClosedValue, ClosedFunctionError> {
256    expect_arity(name, args, 2)?;
257    let x = args[0].expect_scalar(name, 0)?;
258    let xs = args[1].expect_array(name, 1)?;
259    Ok(ClosedValue::Integer(check_i32(
260        name,
261        searchsorted(name, x, xs)?,
262    )?))
263}
264
265fn cf_interp_linear(name: &str, args: &[ClosedArg]) -> Result<ClosedValue, ClosedFunctionError> {
266    expect_arity(name, args, 3)?;
267    let table = args[0].expect_array(name, 0)?;
268    let axis = args[1].expect_array(name, 1)?;
269    let x = args[2].expect_scalar(name, 2)?;
270    Ok(ClosedValue::Float(interp_linear(table, axis, x)?))
271}
272
273fn cf_interp_bilinear(name: &str, args: &[ClosedArg]) -> Result<ClosedValue, ClosedFunctionError> {
274    expect_arity(name, args, 5)?;
275    let table = args[0].expect_array2d(name, 0)?;
276    let axis_x = args[1].expect_array(name, 1)?;
277    let axis_y = args[2].expect_array(name, 2)?;
278    let x = args[3].expect_scalar(name, 3)?;
279    let y = args[4].expect_scalar(name, 4)?;
280    Ok(ClosedValue::Float(interp_bilinear(
281        table, axis_x, axis_y, x, y,
282    )?))
283}
284
285/// Evaluate a unary `datetime.*` accessor that returns one `u32` field of the
286/// UTC decomposition as an integer (month, day, hour, minute, second).
287fn datetime_field(
288    name: &str,
289    args: &[ClosedArg],
290    select: impl Fn((i32, u32, u32, u32, u32, u32)) -> u32,
291) -> Result<ClosedValue, ClosedFunctionError> {
292    expect_arity(name, args, 1)?;
293    let t = args[0].expect_scalar(name, 0)?;
294    Ok(ClosedValue::Integer(select(decompose_utc(t)) as i32))
295}
296
297fn expect_arity(name: &str, args: &[ClosedArg], n: usize) -> Result<(), ClosedFunctionError> {
298    if args.len() != n {
299        return Err(ClosedFunctionError::new(
300            codes::CLOSED_FUNCTION_ARITY,
301            format!("{name} expects {n} argument(s), got {}", args.len()),
302        ));
303    }
304    Ok(())
305}
306
307fn check_i32(name: &str, v: i64) -> Result<i32, ClosedFunctionError> {
308    if v < i32::MIN as i64 || v > i32::MAX as i64 {
309        return Err(ClosedFunctionError::new(
310            codes::CLOSED_FUNCTION_OVERFLOW,
311            format!("{name}: result {v} overflows Int32"),
312        ));
313    }
314    Ok(v as i32)
315}
316
317// ---------------------------------------------------------------------------
318// Calendar arithmetic — integer ymd/hms decomposition, Fliegel–van Flandern
319// JDN. The spec pins floored division by 86400 to split (date, time-of-day);
320// we use Python-style floor / mod so negative `t_utc` decomposes the same as
321// Julia's `unix2datetime`.
322// ---------------------------------------------------------------------------
323
324// Floored division/modulus on f64 → integer day count and seconds-of-day.
325// `seconds_in_day` lands in `[0.0, 86400.0)` (inclusive of 0, exclusive of
326// 86400) so the per-component decomposition is unambiguous.
327#[inline]
328fn floor_div_mod(t_utc: f64) -> (i64, f64) {
329    let day = (t_utc / 86400.0).floor();
330    let mut sec = t_utc - day * 86400.0;
331    // Defensive: floating-point rounding may push `sec` to exactly 86400.0
332    // for inputs near a day boundary. Re-map onto the half-open interval.
333    if sec >= 86400.0 {
334        sec -= 86400.0;
335    } else if sec < 0.0 {
336        sec += 86400.0;
337    }
338    (day as i64, sec)
339}
340
341// Decompose a UTC scalar into (year, month, day, hour, minute, second). Uses
342// the Hinnant civil_from_days algorithm (see howardhinnant.github.io/date_algorithms.html)
343// extended to the proleptic-Gregorian calendar; the algorithm is exact on
344// the full i64 day-count domain, far beyond the i32 year contract.
345fn decompose_utc(t_utc: f64) -> (i32, u32, u32, u32, u32, u32) {
346    let (day_count, sec_in_day) = floor_div_mod(t_utc);
347    let (y, m, d) = civil_from_days(day_count);
348    // Truncate fractional seconds — the spec pins integer outputs and
349    // forbids leap-second slots (so seconds ∈ 0..=59).
350    let s = sec_in_day.trunc() as i64;
351    let hour = (s / 3600) as u32;
352    let minute = ((s % 3600) / 60) as u32;
353    let second = (s % 60) as u32;
354    (y, m, d, hour, minute, second)
355}
356
357// Hinnant's algorithm: days since 1970-01-01 → (y, m, d) on the proleptic-
358// Gregorian calendar. Negative day counts produce negative / pre-Common-Era
359// years per ISO 8601 (1 BC = 0, 2 BC = -1).
360fn civil_from_days(z: i64) -> (i32, u32, u32) {
361    let z = z + 719468;
362    let era = if z >= 0 { z } else { z - 146096 } / 146097;
363    let doe = (z - era * 146097) as u64; // [0, 146096]
364    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
365    let y = (yoe as i64) + era * 400;
366    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
367    let mp = (5 * doy + 2) / 153; // [0, 11]
368    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
369    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
370    let y = if m <= 2 { y + 1 } else { y };
371    (y as i32, m, d)
372}
373
374fn is_leap_year(y: i32) -> bool {
375    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
376}
377
378fn day_of_year(y: i32, m: u32, d: u32) -> u32 {
379    const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
380    let mut doy = CUM[(m - 1) as usize] + d;
381    if m > 2 && is_leap_year(y) {
382        doy += 1;
383    }
384    doy
385}
386
387// Fliegel–van Flandern (1968) JDN on the integer date component, plus the
388// fractional-day offset relative to noon UTC. Matches the Julia reference
389// (which uses Julia's `÷` — truncate-toward-zero — for the inner divides) to
390// within ≤ 1 ulp; the only float op is the final divide by 86400. Rust's
391// integer `/` is also truncate-toward-zero, so the inner divides match Julia
392// directly. Using `div_euclid` here would shift the (m − 14)/12 term by one
393// for Jan/Feb (off by ±2 days) and fail epoch — keep `/`.
394fn julian_day(t_utc: f64) -> f64 {
395    let (day_count, sec_in_day) = floor_div_mod(t_utc);
396    let (y, m, d) = civil_from_days(day_count);
397    let y = y as i64;
398    let m = m as i64;
399    let d = d as i64;
400    let jdn = (1461 * (y + 4800 + (m - 14) / 12)) / 4 + (367 * (m - 2 - 12 * ((m - 14) / 12))) / 12
401        - (3 * ((y + 4900 + (m - 14) / 12) / 100)) / 4
402        + d
403        - 32075;
404    (jdn as f64) + (sec_in_day - 43200.0) / 86400.0
405}
406
407// `interp.searchsorted` per esm-spec §9.2: 1-based, left-side bias on
408// duplicates / exact matches; out-of-range below → 1, above → N+1; NaN x →
409// N+1; NaN entries in xs → error; non-monotonic xs → error.
410fn searchsorted(name: &str, x: f64, xs: &[f64]) -> Result<i64, ClosedFunctionError> {
411    // Validate monotonicity + NaN-in-table once per call (matches Julia).
412    let mut prev = f64::NAN;
413    for (i, v) in xs.iter().copied().enumerate() {
414        if v.is_nan() {
415            return Err(ClosedFunctionError::new(
416                codes::SEARCHSORTED_NAN_IN_TABLE,
417                format!(
418                    "{name}: xs[{idx}] is NaN; NaN entries in xs are forbidden",
419                    idx = i + 1
420                ),
421            ));
422        }
423        if i > 0 && v < prev {
424            return Err(ClosedFunctionError::new(
425                codes::SEARCHSORTED_NON_MONOTONIC,
426                format!(
427                    "{name}: xs is not non-decreasing (xs[{idx}]={v} < xs[{prev_idx}]={prev})",
428                    idx = i + 1,
429                    prev_idx = i,
430                ),
431            ));
432        }
433        prev = v;
434    }
435    Ok(searchsorted_at(x, xs))
436}
437
438/// The search half of [`searchsorted`], with the table taken as ALREADY
439/// validated (non-decreasing, NaN-free).
440///
441/// Split out so a caller that validates the table once — the array runtime's
442/// tape, which has a compile-time-constant `xs` and lowers the call to one
443/// instruction — can run the scan per element without re-walking the table
444/// for monotonicity every time. The two callers therefore share one scan
445/// rather than agreeing by inspection.
446pub(crate) fn searchsorted_at(x: f64, xs: &[f64]) -> i64 {
447    let n = xs.len();
448    // Empty table: degenerate "above-range → N+1" with N=0 returns 1.
449    if n == 0 {
450        return 1;
451    }
452    if x.is_nan() {
453        return (n as i64) + 1;
454    }
455    for (i, v) in xs.iter().copied().enumerate() {
456        if v >= x {
457            return (i as i64) + 1;
458        }
459    }
460    (n as i64) + 1
461}
462
463// `interp.linear` per esm-spec §9.2: 1-D linear interpolation with
464// extrapolate-flat clamping. Load-time validation diagnostics:
465// `interp_axis_too_short`, `interp_axis_length_mismatch`,
466// `interp_nan_in_axis`, `interp_non_monotonic_axis`. Evaluation uses the
467// pinned form `result = t[i] + w * (t[i+1] - t[i])` so endpoints are
468// recovered exactly under IEEE-754 round-to-nearest.
469fn interp_linear(table: &[f64], axis: &[f64], x: f64) -> Result<f64, ClosedFunctionError> {
470    validate_axis("interp.linear", "axis", axis)?;
471    if table.len() != axis.len() {
472        return Err(ClosedFunctionError::new(
473            codes::INTERP_AXIS_LENGTH_MISMATCH,
474            format!(
475                "interp.linear: len(table)={} != len(axis)={}",
476                table.len(),
477                axis.len()
478            ),
479        ));
480    }
481    Ok(interp_linear_at(table, axis, x))
482}
483
484/// The blend half of [`interp_linear`], with `table` and `axis` taken as
485/// ALREADY validated (equal lengths ≥ 2, strictly increasing, NaN-free).
486///
487/// Split out for the same reason as [`searchsorted_at`]: the array runtime's
488/// tape validates its compile-time-constant table once, at lowering, and then
489/// evaluates this per element. Keeping the arithmetic in ONE place is what
490/// makes the taped rule bit-identical to the per-cell oracle rather than
491/// merely intended to be.
492pub(crate) fn interp_linear_at(table: &[f64], axis: &[f64], x: f64) -> f64 {
493    let n = axis.len();
494    if x <= axis[0] {
495        return table[0];
496    }
497    if x >= axis[n - 1] {
498        return table[n - 1];
499    }
500    // Strict monotonicity + the in-range tests above guarantee that some
501    // interior cell exists. NaN x falls through both clamps (IEEE-754 ≤ /
502    // ≥ are false on NaN) into the blend, where it propagates via `w`.
503    let mut i = 0usize;
504    if x.is_nan() {
505        // NaN must propagate, not produce a misleading "no cell found"
506        // error; pick any cell, the blend will yield NaN anyway.
507        i = 0;
508    } else {
509        for k in 0..(n - 1) {
510            if axis[k] <= x && x < axis[k + 1] {
511                i = k;
512                break;
513            }
514        }
515    }
516    let w = (x - axis[i]) / (axis[i + 1] - axis[i]);
517    table[i] + w * (table[i + 1] - table[i])
518}
519
520// `interp.bilinear` per esm-spec §9.2: 2-D linear interpolation,
521// row-major (`table[i][j]` lives at `(axis_x[i], axis_y[j])`), with
522// per-axis extrapolate-flat clamping. Pinned evaluation order is two
523// 1-D x-blends followed by one 1-D y-blend, each in the form
524// `a + w * (b - a)`; the clamp-then-cell-locate sequence guarantees a
525// unique cell in `[1, Nx-1] × [1, Ny-1]`.
526fn interp_bilinear(
527    table: &[Vec<f64>],
528    axis_x: &[f64],
529    axis_y: &[f64],
530    x: f64,
531    y: f64,
532) -> Result<f64, ClosedFunctionError> {
533    validate_axis("interp.bilinear", "axis_x", axis_x)?;
534    validate_axis("interp.bilinear", "axis_y", axis_y)?;
535    if table.len() != axis_x.len() {
536        return Err(ClosedFunctionError::new(
537            codes::INTERP_AXIS_LENGTH_MISMATCH,
538            format!(
539                "interp.bilinear: outer len(table)={} != len(axis_x)={}",
540                table.len(),
541                axis_x.len()
542            ),
543        ));
544    }
545    let ny = axis_y.len();
546    for (i, row) in table.iter().enumerate() {
547        if row.len() != ny {
548            return Err(ClosedFunctionError::new(
549                codes::INTERP_AXIS_LENGTH_MISMATCH,
550                format!(
551                    "interp.bilinear: table row {row_idx} has len={got} but len(axis_y)={ny}",
552                    row_idx = i + 1,
553                    got = row.len(),
554                ),
555            ));
556        }
557    }
558    Ok(bilinear_at(|i, j| table[i][j], axis_x, axis_y, x, y))
559}
560
561/// [`interp_bilinear`] over a ROW-MAJOR FLAT table (`table[i * ny + j]`),
562/// with the table and both axes taken as ALREADY validated.
563///
564/// The array runtime's tape holds its constant tables flat, in the same
565/// row-major layout every other slot uses, so this is the entry it evaluates
566/// per element; it shares [`bilinear_at`] with the nested-`Vec` registry
567/// entry rather than restating the blend.
568pub(crate) fn interp_bilinear_at(
569    table: &[f64],
570    axis_x: &[f64],
571    axis_y: &[f64],
572    x: f64,
573    y: f64,
574) -> f64 {
575    let ny = axis_y.len();
576    bilinear_at(|i, j| table[i * ny + j], axis_x, axis_y, x, y)
577}
578
579/// The blend half of [`interp_bilinear`], reading the table through `at(i, j)`
580/// so the nested-`Vec` and flat-slice callers share one copy of the pinned
581/// §9.2 evaluation order.
582fn bilinear_at(
583    at: impl Fn(usize, usize) -> f64,
584    axis_x: &[f64],
585    axis_y: &[f64],
586    x: f64,
587    y: f64,
588) -> f64 {
589    let nx = axis_x.len();
590    let ny = axis_y.len();
591    // Per-axis clamp (extrapolate-flat). NaN falls through both branches
592    // because `<=` / `>=` are false for NaN — the resulting NaN
593    // propagates via the weight into the final blend.
594    let x_q = if x <= axis_x[0] {
595        axis_x[0]
596    } else if x >= axis_x[nx - 1] {
597        axis_x[nx - 1]
598    } else {
599        x
600    };
601    let y_q = if y <= axis_y[0] {
602        axis_y[0]
603    } else if y >= axis_y[ny - 1] {
604        axis_y[ny - 1]
605    } else {
606        y
607    };
608    // Cell location: largest i in [0, nx-2] (0-based) with axis_x[i] <=
609    // x_q. The clamp puts x_q in [axis_x[0], axis_x[nx-1]], so the
610    // search is well-defined whenever x is finite. NaN x produces NaN
611    // x_q; the comparisons all return false, so we fall back to cell 0
612    // and let the NaN weight propagate.
613    let i = locate_cell(axis_x, x_q);
614    let j = locate_cell(axis_y, y_q);
615    let wx = (x_q - axis_x[i]) / (axis_x[i + 1] - axis_x[i]);
616    let wy = (y_q - axis_y[j]) / (axis_y[j + 1] - axis_y[j]);
617    let row_j = at(i, j) + wx * (at(i + 1, j) - at(i, j));
618    let row_jp1 = at(i, j + 1) + wx * (at(i + 1, j + 1) - at(i, j + 1));
619    row_j + wy * (row_jp1 - row_j)
620}
621
622// Largest 0-based index `i` in `[0, axis.len() - 2]` with `axis[i] <=
623// q`. Falls back to 0 when no such index exists (only possible for NaN
624// q after clamping, where the comparisons are all false; the blend
625// then propagates NaN regardless of which cell we pick).
626fn locate_cell(axis: &[f64], q: f64) -> usize {
627    let last = axis.len() - 2;
628    let mut i = 0usize;
629    for (k, v) in axis.iter().take(last + 1).enumerate() {
630        if *v <= q {
631            i = k;
632        } else {
633            break;
634        }
635    }
636    i
637}
638
639// Shared axis-validation helper for `interp.linear` and
640// `interp.bilinear`. Diagnoses (in order of precedence): too-short,
641// NaN-in-axis, non-monotonic. Strict monotonicity (`<`, not `≤`) is
642// required because equal-adjacent entries would zero the blend
643// denominator — `interp.searchsorted` permits non-decreasing because it
644// returns an index, not a blend.
645fn validate_axis(fn_name: &str, label: &str, axis: &[f64]) -> Result<(), ClosedFunctionError> {
646    if axis.len() < 2 {
647        return Err(ClosedFunctionError::new(
648            codes::INTERP_AXIS_TOO_SHORT,
649            format!(
650                "{fn_name}: {label} has {len} entries; need at least 2 (no interval to blend across)",
651                len = axis.len()
652            ),
653        ));
654    }
655    for (i, v) in axis.iter().copied().enumerate() {
656        if v.is_nan() {
657            return Err(ClosedFunctionError::new(
658                codes::INTERP_NAN_IN_AXIS,
659                format!(
660                    "{fn_name}: {label}[{idx}] is NaN; NaN entries in axes are forbidden",
661                    idx = i + 1
662                ),
663            ));
664        }
665    }
666    // NaN was filtered above, so `>=` here is well-defined and detects
667    // both equal-adjacent and decreasing pairs (the strict-monotonicity
668    // requirement that distinguishes interp.linear/bilinear from
669    // searchsorted's non-decreasing rule).
670    for w in axis.windows(2) {
671        if w[0] >= w[1] {
672            return Err(ClosedFunctionError::new(
673                codes::INTERP_NON_MONOTONIC_AXIS,
674                format!(
675                    "{fn_name}: {label} is not strictly increasing (encountered {a} then {b})",
676                    a = w[0],
677                    b = w[1]
678                ),
679            ));
680        }
681    }
682    Ok(())
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    fn s(v: f64) -> ClosedArg {
690        ClosedArg::Scalar(v)
691    }
692    fn a(vs: &[f64]) -> ClosedArg {
693        ClosedArg::Array(vs.to_vec())
694    }
695    fn a2(rows: &[&[f64]]) -> ClosedArg {
696        ClosedArg::Array2D(rows.iter().map(|r| r.to_vec()).collect())
697    }
698
699    #[test]
700    fn unknown_name_diagnostic() {
701        let err = evaluate_closed_function("datetime.century", &[s(0.0)]).unwrap_err();
702        assert_eq!(err.code, "unknown_closed_function");
703    }
704
705    #[test]
706    fn closed_function_names_v030() {
707        let names = closed_function_names();
708        assert_eq!(names.len(), 12);
709        for n in [
710            "datetime.year",
711            "datetime.month",
712            "datetime.day",
713            "datetime.hour",
714            "datetime.minute",
715            "datetime.second",
716            "datetime.day_of_year",
717            "datetime.julian_day",
718            "datetime.is_leap_year",
719            "interp.searchsorted",
720            "interp.linear",
721            "interp.bilinear",
722        ] {
723            assert!(names.contains(n), "missing {n}");
724        }
725    }
726
727    #[test]
728    fn epoch_year() {
729        let v = evaluate_closed_function("datetime.year", &[s(0.0)]).unwrap();
730        assert_eq!(v, ClosedValue::Integer(1970));
731    }
732
733    #[test]
734    fn pre_epoch_year() {
735        // -1 second is 1969-12-31T23:59:59 UTC.
736        let v = evaluate_closed_function("datetime.year", &[s(-1.0)]).unwrap();
737        assert_eq!(v, ClosedValue::Integer(1969));
738    }
739
740    #[test]
741    fn leap_year_2000() {
742        // 2000-02-29T12:00:00 UTC = 951825600 s.
743        let v = evaluate_closed_function("datetime.is_leap_year", &[s(951825600.0)]).unwrap();
744        assert_eq!(v, ClosedValue::Integer(1));
745    }
746
747    #[test]
748    fn not_leap_2100() {
749        // 2100-02-28T23:59:59 UTC.
750        let v = evaluate_closed_function("datetime.is_leap_year", &[s(4107542399.0)]).unwrap();
751        assert_eq!(v, ClosedValue::Integer(0));
752    }
753
754    #[test]
755    fn day_of_year_leap_dec31() {
756        // 2024-12-31 — day 366 in a leap year.
757        let v = evaluate_closed_function("datetime.day_of_year", &[s(1735689599.0)]).unwrap();
758        assert_eq!(v, ClosedValue::Integer(366));
759    }
760
761    #[test]
762    fn searchsorted_exact_boundary() {
763        let v = evaluate_closed_function(
764            "interp.searchsorted",
765            &[s(2.0), a(&[1.0, 2.0, 3.0, 4.0, 5.0])],
766        )
767        .unwrap();
768        assert_eq!(v, ClosedValue::Integer(2));
769    }
770
771    #[test]
772    fn searchsorted_above_range() {
773        let v = evaluate_closed_function(
774            "interp.searchsorted",
775            &[s(10.0), a(&[1.0, 2.0, 3.0, 4.0, 5.0])],
776        )
777        .unwrap();
778        assert_eq!(v, ClosedValue::Integer(6));
779    }
780
781    #[test]
782    fn searchsorted_nan_x() {
783        let v = evaluate_closed_function(
784            "interp.searchsorted",
785            &[s(f64::NAN), a(&[1.0, 2.0, 3.0, 4.0, 5.0])],
786        )
787        .unwrap();
788        assert_eq!(v, ClosedValue::Integer(6));
789    }
790
791    #[test]
792    fn searchsorted_non_monotonic_rejects() {
793        let err = evaluate_closed_function(
794            "interp.searchsorted",
795            &[s(2.0), a(&[1.0, 3.0, 2.0, 4.0, 5.0])],
796        )
797        .unwrap_err();
798        assert_eq!(err.code, "searchsorted_non_monotonic");
799    }
800
801    #[test]
802    fn searchsorted_nan_in_xs_rejects() {
803        let err =
804            evaluate_closed_function("interp.searchsorted", &[s(2.0), a(&[1.0, f64::NAN, 3.0])])
805                .unwrap_err();
806        assert_eq!(err.code, "searchsorted_nan_in_table");
807    }
808
809    #[test]
810    fn searchsorted_duplicates_left_bias() {
811        let v = evaluate_closed_function(
812            "interp.searchsorted",
813            &[s(2.0), a(&[1.0, 2.0, 2.0, 2.0, 3.0])],
814        )
815        .unwrap();
816        assert_eq!(v, ClosedValue::Integer(2));
817    }
818
819    #[test]
820    fn interp_linear_exact_at_knot() {
821        let v = evaluate_closed_function(
822            "interp.linear",
823            &[
824                a(&[10.0, 20.0, 40.0, 80.0, 160.0]),
825                a(&[0.0, 1.0, 2.0, 3.0, 4.0]),
826                s(2.0),
827            ],
828        )
829        .unwrap();
830        assert_eq!(v.as_f64(), 40.0);
831    }
832
833    #[test]
834    fn interp_linear_midpoint_blend() {
835        let v = evaluate_closed_function(
836            "interp.linear",
837            &[
838                a(&[10.0, 20.0, 40.0, 80.0, 160.0]),
839                a(&[0.0, 1.0, 2.0, 3.0, 4.0]),
840                s(0.5),
841            ],
842        )
843        .unwrap();
844        assert_eq!(v.as_f64(), 15.0);
845    }
846
847    #[test]
848    fn interp_linear_below_clamps() {
849        let v = evaluate_closed_function(
850            "interp.linear",
851            &[a(&[3.0, 7.0]), a(&[10.0, 20.0]), s(-100.0)],
852        )
853        .unwrap();
854        assert_eq!(v.as_f64(), 3.0);
855    }
856
857    #[test]
858    fn interp_linear_above_clamps_exact_endpoint() {
859        // Pinned form `t[i] + w*(t[i+1]-t[i])` recovers the endpoint
860        // exactly under round-to-nearest when w = 1.
861        let v = evaluate_closed_function(
862            "interp.linear",
863            &[
864                a(&[10.0, 20.0, 40.0, 80.0, 160.0]),
865                a(&[0.0, 1.0, 2.0, 3.0, 4.0]),
866                s(99.0),
867            ],
868        )
869        .unwrap();
870        assert_eq!(v.as_f64(), 160.0);
871    }
872
873    #[test]
874    fn interp_linear_nan_propagates() {
875        let v = evaluate_closed_function(
876            "interp.linear",
877            &[
878                a(&[10.0, 20.0, 40.0, 80.0, 160.0]),
879                a(&[0.0, 1.0, 2.0, 3.0, 4.0]),
880                s(f64::NAN),
881            ],
882        )
883        .unwrap();
884        assert!(v.as_f64().is_nan());
885    }
886
887    #[test]
888    fn interp_linear_non_monotonic_rejects() {
889        let err = evaluate_closed_function(
890            "interp.linear",
891            &[
892                a(&[10.0, 20.0, 30.0, 40.0]),
893                a(&[0.0, 2.0, 1.0, 3.0]),
894                s(1.5),
895            ],
896        )
897        .unwrap_err();
898        assert_eq!(err.code, "interp_non_monotonic_axis");
899    }
900
901    #[test]
902    fn interp_linear_equal_adjacent_rejects() {
903        let err = evaluate_closed_function(
904            "interp.linear",
905            &[
906                a(&[10.0, 20.0, 30.0, 40.0]),
907                a(&[0.0, 1.0, 1.0, 2.0]),
908                s(0.5),
909            ],
910        )
911        .unwrap_err();
912        assert_eq!(err.code, "interp_non_monotonic_axis");
913    }
914
915    #[test]
916    fn interp_linear_length_mismatch_rejects() {
917        let err = evaluate_closed_function(
918            "interp.linear",
919            &[a(&[10.0, 20.0, 30.0, 40.0]), a(&[0.0, 1.0, 2.0]), s(1.0)],
920        )
921        .unwrap_err();
922        assert_eq!(err.code, "interp_axis_length_mismatch");
923    }
924
925    #[test]
926    fn interp_linear_nan_in_axis_rejects() {
927        let err = evaluate_closed_function(
928            "interp.linear",
929            &[
930                a(&[10.0, 20.0, 30.0, 40.0]),
931                a(&[0.0, f64::NAN, 2.0, 3.0]),
932                s(1.0),
933            ],
934        )
935        .unwrap_err();
936        assert_eq!(err.code, "interp_nan_in_axis");
937    }
938
939    #[test]
940    fn interp_linear_axis_too_short_rejects() {
941        let err = evaluate_closed_function("interp.linear", &[a(&[42.0]), a(&[0.0]), s(0.0)])
942            .unwrap_err();
943        assert_eq!(err.code, "interp_axis_too_short");
944    }
945
946    #[test]
947    fn interp_bilinear_exact_corner() {
948        let v = evaluate_closed_function(
949            "interp.bilinear",
950            &[
951                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
952                a(&[0.0, 1.0, 2.0]),
953                a(&[0.0, 10.0, 20.0]),
954                s(2.0),
955                s(20.0),
956            ],
957        )
958        .unwrap();
959        assert_eq!(v.as_f64(), 22.0);
960    }
961
962    #[test]
963    fn interp_bilinear_center_blend() {
964        let v = evaluate_closed_function(
965            "interp.bilinear",
966            &[
967                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
968                a(&[0.0, 1.0, 2.0]),
969                a(&[0.0, 10.0, 20.0]),
970                s(0.5),
971                s(5.0),
972            ],
973        )
974        .unwrap();
975        assert_eq!(v.as_f64(), 5.5);
976    }
977
978    #[test]
979    fn interp_bilinear_clamps_to_corner() {
980        let v = evaluate_closed_function(
981            "interp.bilinear",
982            &[
983                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
984                a(&[0.0, 1.0, 2.0]),
985                a(&[0.0, 10.0, 20.0]),
986                s(1000.0),
987                s(1000.0),
988            ],
989        )
990        .unwrap();
991        assert_eq!(v.as_f64(), 22.0);
992    }
993
994    #[test]
995    fn interp_bilinear_nan_x_propagates() {
996        let v = evaluate_closed_function(
997            "interp.bilinear",
998            &[
999                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
1000                a(&[0.0, 1.0, 2.0]),
1001                a(&[0.0, 10.0, 20.0]),
1002                s(f64::NAN),
1003                s(5.0),
1004            ],
1005        )
1006        .unwrap();
1007        assert!(v.as_f64().is_nan());
1008    }
1009
1010    #[test]
1011    fn interp_bilinear_ragged_rows_reject() {
1012        let err = evaluate_closed_function(
1013            "interp.bilinear",
1014            &[
1015                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0], &[20.0, 21.0, 22.0]]),
1016                a(&[0.0, 1.0, 2.0]),
1017                a(&[0.0, 10.0, 20.0]),
1018                s(0.5),
1019                s(5.0),
1020            ],
1021        )
1022        .unwrap_err();
1023        assert_eq!(err.code, "interp_axis_length_mismatch");
1024    }
1025
1026    #[test]
1027    fn interp_bilinear_axis_x_length_mismatch_rejects() {
1028        let err = evaluate_closed_function(
1029            "interp.bilinear",
1030            &[
1031                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
1032                a(&[0.0, 1.0]),
1033                a(&[0.0, 10.0, 20.0]),
1034                s(0.5),
1035                s(5.0),
1036            ],
1037        )
1038        .unwrap_err();
1039        assert_eq!(err.code, "interp_axis_length_mismatch");
1040    }
1041
1042    #[test]
1043    fn interp_bilinear_non_monotonic_axis_rejects() {
1044        let err = evaluate_closed_function(
1045            "interp.bilinear",
1046            &[
1047                a2(&[&[0.0, 1.0, 2.0], &[10.0, 11.0, 12.0], &[20.0, 21.0, 22.0]]),
1048                a(&[0.0, 2.0, 1.0]),
1049                a(&[0.0, 10.0, 20.0]),
1050                s(0.5),
1051                s(5.0),
1052            ],
1053        )
1054        .unwrap_err();
1055        assert_eq!(err.code, "interp_non_monotonic_axis");
1056    }
1057}