Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions src/simlin-engine/src/array_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3474,6 +3474,106 @@ mod vector_select_action_tests {
mod vector_elm_map_tests {
use crate::test_common::TestProject;

// GH #578: a SCALAR source element reference (`x[three]`, a single
// fully-collapsed element) plus an arithmetic per-element OFFSET
// expression (`(DimA - 1)`, a dimension-position term). Genuine Vensim
// maps over the source variable's FULL storage from the base the element
// reference establishes: result[i] = x_full[base(three) + round(off_i)].
//
// x[DimX] = 1,2,3,4,5 (DimX = one,two,three,four,five; full storage
// flat 0..4)
// base(three) = 2 (0-based flat index of element `three`)
// off_i = (DimA - 1): A1->0, A2->1, A3->2 (DimA is the 1-based position)
// y[A1] = x_full[2+0] = 3
// y[A2] = x_full[2+1] = 4
// y[A3] = x_full[2+2] = 5
// This is the `y` variable of test/sdeverywhere/models/vector/vector.dat
// (y[A1]=3, y[A2]=4, y[A3]=5), previously excluded from the genuine gate.
fn make_scalar_source_dim_offset_project(name: &str) -> TestProject {
TestProject::new(name)
.named_dimension("DimA", &["A1", "A2", "A3"])
.named_dimension("DimX", &["one", "two", "three", "four", "five"])
.array_with_ranges(
"x[DimX]",
vec![
("one", "1"),
("two", "2"),
("three", "3"),
("four", "4"),
("five", "5"),
],
)
.array_aux("y[DimA]", "vector_elm_map(x[three], (DimA - 1))")
}

#[test]
fn scalar_source_dim_offset_vm() {
let project = make_scalar_source_dim_offset_project("vem_scalar_src_vm");
project.assert_vm_result_incremental("y", &[3.0, 4.0, 5.0]);
}

#[test]
fn scalar_source_dim_offset_monolithic() {
let project = make_scalar_source_dim_offset_project("vem_scalar_src_mono");
project.assert_vm_result("y", &[3.0, 4.0, 5.0]);
}

// GH #578 nested: the same scalar-source ELM MAP wrapped in arithmetic
// (`10 + ...`). The fold recurses through the surrounding expression, so
// each element becomes `10 + x_full[2 + off_i]` = 13, 14, 15.
#[test]
fn scalar_source_dim_offset_nested_vm() {
let project = TestProject::new("vem_scalar_src_nested_vm")
.named_dimension("DimA", &["A1", "A2", "A3"])
.named_dimension("DimX", &["one", "two", "three", "four", "five"])
.array_with_ranges(
"x[DimX]",
vec![
("one", "1"),
("two", "2"),
("three", "3"),
("four", "4"),
("five", "5"),
],
)
.array_aux("z[DimA]", "10 + vector_elm_map(x[three], (DimA - 1))");
project.assert_vm_result_incremental("z", &[13.0, 14.0, 15.0]);
}

// GH #578 out-of-range: a scalar source whose base + per-element offset
// walks off the end of the source's full storage yields :NA: (NaN). Here
// base(four) = 3, off_i = (DimA-1) = 0,1,2 -> flat 3,4,5; flat 5 is past
// x's 5-element storage -> NaN.
// y2[A1] = x_full[3] = 4
// y2[A2] = x_full[4] = 5
// y2[A3] = x_full[5] = :NA: (NaN)
#[test]
fn scalar_source_dim_offset_oob_returns_nan_vm() {
let project = TestProject::new("vem_scalar_src_oob_vm")
.named_dimension("DimA", &["A1", "A2", "A3"])
.named_dimension("DimX", &["one", "two", "three", "four", "five"])
.array_with_ranges(
"x[DimX]",
vec![
("one", "1"),
("two", "2"),
("three", "3"),
("four", "4"),
("five", "5"),
],
)
.array_aux("y2[DimA]", "vector_elm_map(x[four], (DimA - 1))");
let vals = project.vm_result_incremental("y2");
assert_eq!(vals.len(), 3);
assert!((vals[0] - 4.0).abs() < 1e-9, "y2[A1]: {}", vals[0]);
assert!((vals[1] - 5.0).abs() < 1e-9, "y2[A2]: {}", vals[1]);
assert!(
vals[2].is_nan(),
"y2[A3] (flat 5 OOB): expected NaN, got {}",
vals[2]
);
}

// source[D] = [10, 20, 30] (3 elements, valid indices 0..2)
// offsets[D] = [0, 2, 5] -- index 5 exceeds source length, so third element -> NaN
fn make_oob_project(name: &str) -> TestProject {
Expand Down Expand Up @@ -3643,6 +3743,79 @@ mod vector_elm_map_tests {
}
}

// GH #579: end-to-end numeric gate on `full_source_len` for a STRICT-SLICE
// ELM MAP source (the cross-dimension `d[DimA,B1]` shape, base != 0). The
// existing `out_of_bounds_element_returns_nan_*` tests use a *full-array*
// source (`source[*]`, base 0, `source_is_full_array == true`); this one
// covers the other branch, where `full_source_len` drives BOTH the
// full-array-vs-strict-slice decision AND the out-of-range -> :NA: guard.
//
// Fixture: same `d`/shape as `cross_dimension_source_resolves_full_array`,
// but `a[A3] = 5` pushes the A3 row's lookup past the source's full
// 6-element storage:
// d full storage (row-major [DimA,DimB], strides [2,1]):
// 0=d11=1 1=d12=4 2=d21=2 3=d22=5 4=d31=3 5=d32=6
// A1,* : base = 0, offset 0 -> flat 0 -> d[0]=1
// A2,* : base = 2, offset 1 -> flat 3 -> d[3]=5
// A3,* : base = 4, offset 5 -> flat 9 >= full_source_len(6) -> :NA: (NaN)
// f row-major [DimA,DimB], broadcast across DimB: [1,1, 5,5, NaN,NaN].
//
// Why this catches a corrupted `full_source_len`: inflating it (e.g. to 99)
// makes flat 9 pass the `[0, full_len)` guard and read past `d`'s storage
// instead of yielding NaN; deflating it to the 3-element slice size flips
// `source_is_full_array` to true (base forced to 0), changing every row.
// Either way the A3 elements stop being NaN, so the assertion fails --
// unlike the genuine-Vensim `.dat` simulate corpus, which has no
// out-of-range offset on a strict-slice source.
fn make_strict_slice_oob_project(name: &str) -> TestProject {
TestProject::new(name)
.named_dimension("DimA", &["A1", "A2", "A3"])
.named_dimension("DimB", &["B1", "B2"])
.array_with_ranges("a[DimA]", vec![("A1", "0"), ("A2", "1"), ("A3", "5")])
.array_with_ranges(
"d[DimA,DimB]",
vec![
("A1,B1", "1"),
("A2,B1", "2"),
("A3,B1", "3"),
("A1,B2", "4"),
("A2,B2", "5"),
("A3,B2", "6"),
],
)
.array_aux("f[DimA,DimB]", "vector_elm_map(d[DimA,B1], a[DimA])")
}

fn assert_strict_slice_oob(vals: &[f64]) {
assert_eq!(vals.len(), 6, "expected 6 elements (DimA x DimB)");
let finite_expected = [(0usize, 1.0), (1, 1.0), (2, 5.0), (3, 5.0)];
for &(i, want) in &finite_expected {
assert!(
(vals[i] - want).abs() < 1e-9,
"f[{i}]: expected {want}, got {} (full vals: {vals:?})",
vals[i]
);
}
assert!(
vals[4].is_nan() && vals[5].is_nan(),
"f[A3,*] (base 4 + offset 5 = flat 9 >= full source len 6): expected NaN, got [{}, {}]",
vals[4],
vals[5]
);
}

#[test]
fn strict_slice_source_oob_returns_nan_vm() {
let project = make_strict_slice_oob_project("vem_slice_oob_vm");
assert_strict_slice_oob(&project.vm_result_incremental("f"));
}

#[test]
fn strict_slice_source_oob_returns_nan_monolithic() {
let project = make_strict_slice_oob_project("vem_slice_oob_mono");
assert_strict_slice_oob(&project.vm_result("f"));
}

// AC6.4 defense-in-depth: a 1-D source[*] argument has base = 0 for all
// result elements (no ActiveDimRef element reference), so genuine Vensim
// reduces to result[i] = source[round(offset[i])] over the full source.
Expand Down
61 changes: 34 additions & 27 deletions src/simlin-engine/src/compiler/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,21 @@ impl Context<'_> {
sim_err!(DoesNotExist, "Variable not found by offset".to_string())
}

/// Full element count of the variable whose storage *begins* at `base_off`
/// (the product of its declared dimensions; 1 for a scalar). Returns `None`
/// if no variable owns that exact base offset. This is the compile-time
/// twin of `codegen::full_source_len`, used by the GH #578 scalar-source
/// VECTOR ELM MAP fold to bound the per-element static read.
pub(super) fn full_var_len_for_base(&self, base_off: usize) -> Option<usize> {
let md = self.get_variable_metadata_by_offset(base_off).ok()?;
Some(
md.var
.get_dimensions()
.map(|dims| dims.iter().map(|d| d.len()).product::<usize>().max(1))
.unwrap_or(1),
)
}

pub(super) fn build_stock_update_expr(&self, stock_off: usize, var: &Variable) -> Result<Expr> {
if let Variable::Stock {
inflows, outflows, ..
Expand Down Expand Up @@ -1387,33 +1402,25 @@ impl Context<'_> {
return Ok(Expr::StaticSubscript(off, preserved_result.view, *loc));
} else {
if view.dims.is_empty() {
// Inside array-producing builtins, a fully-collapsed
// subscript like b[B1] should be promoted back to the
// full source array. The Single ops came from named
// element subscripts (not ActiveDimRef resolution), so
// promoting them to Wildcard restores the array view
// that VectorElmMap/VectorSortOrder expect.
let has_single_ops = self.promote_active_dim_ref
&& operations.iter().any(|op| matches!(op, IndexOp::Single(_)));
if has_single_ops {
let promoted_ops: Vec<IndexOp> = operations
.iter()
.map(|op| match op {
IndexOp::Single(_) => IndexOp::Wildcard,
other => other.clone(),
})
.collect();
let promoted_result = build_view_from_ops(
&promoted_ops,
&orig_dims,
&orig_strides,
&view_config,
)?;
return Ok(Expr::StaticSubscript(
off,
promoted_result.view,
*loc,
));
// Inside an array-producing builtin a fully-
// collapsed source element reference (e.g.
// `x[three]` or `b[B1]`) must keep the element's
// base offset so the builtin maps over the source
// variable's FULL storage starting from that base
// (genuine-Vensim VECTOR ELM MAP, GH #578).
// Return the collapsed scalar view as a
// StaticSubscript -- its `view.offset` is the
// element's flat index, and the variable base
// (`off`) lets `codegen::full_source_len` recover
// the full source length. The earlier code
// promoted the Single ops back to a whole-array
// Wildcard view, which discarded the base and was
// only correct when the element is index 0 (the
// historical `b[B1]` case, where base == 0).
if self.promote_active_dim_ref
&& operations.iter().any(|op| matches!(op, IndexOp::Single(_)))
{
return Ok(Expr::StaticSubscript(off, view, *loc));
}
return Ok(Expr::Var(off + view.offset, *loc));
}
Expand Down
93 changes: 93 additions & 0 deletions src/simlin-engine/src/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1885,6 +1885,12 @@ fn expand_a2a_hoisted(
temp_id = temp_id.max(max + 1);
}

// GH #578: fold any scalar-source / constant-offset ELM MAP
// nested in this element's expression to a direct read before
// the array-builtin hoister runs; whatever array-producing
// builtins remain are hoisted normally.
let elem_main = fold_scalar_source_elm_maps(ctx, elem_main);

let mut hoisted = Vec::new();
let elem_rewritten = replace_nested_builtins_for_element(
elem_main,
Expand Down Expand Up @@ -1940,6 +1946,80 @@ fn expand_a2a_hoisted(
}
}

/// GH #578: fold a single element of `VECTOR ELM MAP(scalar_source, offset)`
/// into a direct read when the source is a fully-collapsed element reference
/// and the per-element offset is a compile-time constant.
///
/// Genuine Vensim maps the result over the source variable's FULL row-major
/// storage from the base the element reference establishes:
/// `result = source_full[base + round(offset)]`. When `source` is a scalar
/// `StaticSubscript` (its `view.offset` is the element's flat index and `off`
/// is the variable base) and `offset` folds to a constant, the whole read is
/// known at compile time: it is the variable slot `off + base + round(offset)`,
/// or `:NA:` (NaN) if that flat index is outside `[0, full_source_len)`.
///
/// This is what lets a scalar-source / expression-offset ELM MAP compile at
/// all: the array-producing ELM MAP opcode needs a *view* offset, but here the
/// per-element offset lowers to a `Const` (e.g. `(DimA - 1)` -> `0, 1, 2`),
/// which is not a view. Returns `None` for any shape this fold does not cover
/// (non-scalar source, non-constant offset), leaving the normal path to run.
fn try_fold_scalar_source_elm_map(ctx: &Context, main_expr: &Expr) -> Option<Expr> {
let Expr::App(BuiltinFn::VectorElmMap(source, offset), loc) = main_expr else {
return None;
};
// Source must be a fully-collapsed (scalar) element reference carrying its
// base: a StaticSubscript with no remaining dimensions, whose `off` is the
// variable base and `view.offset` the element's flat index within it.
let (base_off, elem_flat) = match source.as_ref() {
Expr::StaticSubscript(off, view, _) if view.dims.is_empty() => (*off, view.offset),
_ => return None,
};
// The per-element offset must be a compile-time constant (it is not a view,
// so the ELM MAP opcode could not consume it anyway).
let Expr::Const(offset_val, _) = fold::fold_constants((**offset).clone()) else {
return None;
};
let full_len = ctx.full_var_len_for_base(base_off)?;
// round() matches the VM's `vm_vector_elm_map` per-element offset rounding.
let flat = elem_flat as i64 + offset_val.round() as i64;
if flat < 0 || flat >= full_len as i64 {
Some(Expr::Const(f64::NAN, *loc))
} else {
Some(Expr::Var(base_off + flat as usize, *loc))
}
}

/// Recursively apply [`try_fold_scalar_source_elm_map`] through the
/// scalar-value wrappers of a per-element expression, so a scalar-source ELM
/// MAP nested in arithmetic (`10 + VECTOR ELM MAP(x[three], (DimA-1))`) folds
/// too (GH #578).
///
/// Recursion is restricted to `Op2`/`If`, which propagate a scalar-value
/// context to their operands in this per-element lowering (unary minus is
/// already lowered to `Op2(Sub, 0, x)`). It deliberately does NOT descend into
/// `Expr::App` arguments: a reducer like `SUM(elm_map_array)` consumes the ELM
/// MAP as an *array*, and folding it to a single element there would be wrong.
fn fold_scalar_source_elm_maps(ctx: &Context, expr: Expr) -> Expr {
if let Some(folded) = try_fold_scalar_source_elm_map(ctx, &expr) {
return folded;
}
match expr {
Expr::Op2(op, l, r, loc) => Expr::Op2(
op,
Box::new(fold_scalar_source_elm_maps(ctx, *l)),
Box::new(fold_scalar_source_elm_maps(ctx, *r)),
loc,
),
Expr::If(c, t, f, loc) => Expr::If(
Box::new(fold_scalar_source_elm_maps(ctx, *c)),
Box::new(fold_scalar_source_elm_maps(ctx, *t)),
Box::new(fold_scalar_source_elm_maps(ctx, *f)),
loc,
),
other => other,
}
}

/// Per-element hoisting for array-producing builtins whose scalar arguments
/// depend on the active dimension (e.g. `vector_sort_order(vals[*], dir[D])`).
/// Each element gets its own AssignTemp so the builtin is re-evaluated with
Expand Down Expand Up @@ -1991,6 +2071,19 @@ fn expand_a2a_per_element_hoisted(
main
};

// GH #578: a scalar-source ELM MAP with a per-element constant offset
// folds to a direct slot read (or :NA:), so the array-producing opcode
// -- which requires a *view* offset the constant cannot supply -- is
// skipped entirely. If the fold collapses the whole element expression
// to a scalar (no array-producing builtin left), emit it directly with
// no temp consumed.
let main_expr = fold_scalar_source_elm_maps(ctx, main_expr);
if !is_array_producing_builtin(&main_expr) && !contains_array_producing_builtin(&main_expr)
{
result.push(Expr::AssignCurr(off + i, Box::new(main_expr)));
continue;
}

let temp_id = next_tid;
next_tid = temp_id + 1;
let builtin_view = find_expr_array_view(&main_expr).unwrap_or_else(|| var_view.clone());
Expand Down
Loading
Loading