Skip to content

engine: reducer over a scalar-shaped expression is refused (SUM(pop * 2) in an apply-to-all body, SUM(s * 2)) where SUM(pop) * 2 compiles -- the materializer declines a 0-d operand; origin/main computes it x|D| #1051

Description

@bpowers

Summary

A reducer whose argument is a scalar-shaped expression is refused by codegen, while the same value spelled as a bare view compiles:

equation (x[region], pop[region] a stock) result
SUM(pop) compiles, pop[e]
SUM(pop) * 2 compiles, pop[e] * 2
SUM(pop * 2) refused
SUM(pop[region] * 2) refused
SUM(pop * w[idx]), SUM(pop * w), SUM(pop + w), SUM(-pop), SUM(IF pop > 150 THEN pop ELSE 0) refused
MAX(pop * 2), MIN(pop * 2), STDDEV(pop * 2), SIZE(pop * 2) refused
MEAN(pop * 2) compiles, pop[e] * 2 (single-argument MEAN has no array position, array_operand.rs comment above materialize_view_operands)
x = SUM(s) (scalar s, scalar equation) compiles, s
x = SUM(s * 2) refused

Exact refusal text (branch compiler-unification-v2, simlin simulate):

assembly error in model 'main': variable 'x' failed to compile: codegen rejected the lowered expressions: SimulationError{generic: an array operand here must be a variable, a subscripted array or an array temp, but it is an arithmetic or comparison expression}
error compiling model 'main': SimulationError{not_simulatable: failed to compile fragments for variables: x}

(... but it is a conditional for the IF operand.) The message names the Rust expression kind; it does not say why the operand has no array shape here (every array reference in it is pinned to the enclosing element) or what spelling would (pop[*]).

Minimal model

<?xml version="1.0" encoding="utf-8"?>
<xmile version="1.0" xmlns="http://docs.oasis-open.org/xmile/ns/XMILE/v1.0">
  <header><name>probe</name><vendor>probe</vendor><product version="1.0">probe</product></header>
  <sim_specs method="Euler" time_units="Month"><start>0</start><stop>2</stop><dt>1</dt></sim_specs>
  <dimensions>
    <dim name="region"><elem name="north"/><elem name="south"/><elem name="east"/></dim>
  </dimensions>
  <model name="main"><variables>
    <stock name="pop">
      <element subscript="north"><eqn>100</eqn></element>
      <element subscript="south"><eqn>200</eqn></element>
      <element subscript="east"><eqn>300</eqn></element>
      <inflow>growth</inflow>
      <dimensions><dim name="region"/></dimensions>
    </stock>
    <flow name="growth"><eqn>pop[region] * 0.1</eqn><dimensions><dim name="region"/></dimensions></flow>
    <aux name="x"><eqn>SUM(pop * 2)</eqn><dimensions><dim name="region"/></dimensions></aux>
  </variables></model>
</xmile>

Swap x's equation for any row of the table above. For the w/idx/s rows add w[region] = {1, 2, 3}, idx = 1, s = 5.

What each binary computes (measured, t = 0, x[north] x[south] x[east])

equation origin/main (d04593e6) Phase 6b (be7adbbf, first branch commit) and branch HEAD
SUM(pop * 2) RAN 600 1200 1800 (= pop[e] * 2 * 3) refused
SUM(pop * w[idx]) RAN 300 600 900 (= pop[e] * w[idx] * 3) refused
SUM(pop * w) RAN 300 1200 2700 (= pop[e] * w[e] * 3) refused
SUM(pop + w) RAN 303 606 909 (= (pop[e] + w[e]) * 3) refused
SUM(-pop) RAN -300 -600 -900 refused
SUM(IF pop > 150 THEN pop ELSE 0) RAN 0 600 900 refused
SIZE(pop * 2) RAN 3 3 3 (beside SIZE(pop) = 1 1 1) refused
MAX(pop * 2) / MIN(pop * 2) RAN 200 400 600 (right by accident: max of three copies) refused
STDDEV(pop * 2) RAN 0 0 0 (right by accident) refused
SUM(pop[region] * 2) refused (Cannot push view for expression type Discriminant(10)) refused
x = SUM(s * 2) refused (same old wording) refused
controls: SUM(pop), SUM(pop) * 2, SUM(pop[region]), SUM(pop * w[*]), SUM(pop[region] * w[*]), SUM(pop[*] * 2), MEAN(pop * 2), x = SUM(pop * 2) (scalar equation, 1200) same values on all three same

So two things are true at once:

  1. origin/main computes a silent wrong number for every bare spelling: the correct per-element term multiplied by |region|. This is GH engine: bare arrayed ref sharing a reducer with a sliced co-source multiplies the result by the bare ref's dimension cardinality #789's mechanism (a bare arrayed reference inside a reducer re-enters the iteration its element already binds; the loads stay slot-resolved, so the result is x|D|, not a cross product). engine: bare arrayed ref sharing a reducer with a sliced co-source multiplies the result by the bare ref's dimension cardinality #789 was closed 2026-06-12 by PR engine: close ltm phase 1 residuals #795, which is LTM-side; the engine side is what the origin/main column above measures, live today. SIZE(pop * 2) = 3 beside SIZE(pop) = 1 is the cleanest witness that the extra factor is a redundant iteration, not a whole-array read.
  2. The branch turned that wrong number into a loud refusal, at Phase 6b (be7adbbf, "engine: one materialization pass over the lowered fragment"). That is the right interim state under the no-silent-wrong-numbers rule, but it is not recorded in the design plan's Phase 6b divergence list (docs/design-plans/2026-08-25-compiler-unification.md, "Phase 6b semantic divergences"): item 9 there calls itself "the one new loud refusal of a base-compiling shape in this phase", and this family is a second. The explicit spelling SUM(pop[region] * 2) and the scalar SUM(s * 2) were refused on main too, so for those two rows the refusal IS pre-existing.

Mechanism (branch)

  • compiler/context.rs::lower_builtin_expr3 lowers a reducer's operand (ArgKind::Array { whole: false }) so the enclosing element pins the axes it names: the bare pop is pass 0's pop[region], which under the region iteration is a StaticSubscript collapsed to one slot. pop * 2 is then an Op2 over a 0-d view.
  • compiler/array_operand.rs::Materializer::materialize_view_operand is the one pass that would turn a computed operand into a temp; it returns the operand unchanged when find_expr_array_view yields a dimensionless view (if source_view.dims.is_empty() { return operand; }, the guard right after the repeated-dimension refusal). Its module doc lists four declining limits and this is a fifth it does not name.
  • Codegen's walk_expr_as_view fallback arm (compiler/codegen.rs, the _ => arm that formats "an array operand here must be ...") then refuses, because a reducer's argument is read through emit_array_reduce as a view.
  • SUM(pop) compiles because a StaticSubscript IS one of the accepted view shapes, even collapsed; MEAN(pop * 2) compiles because single-argument MEAN is the n-ary mean and has no ArgKind::Array position.

On origin/main the same operand went through ast/expr3.rs::Pass1Context::maybe_decompose_array_arg_inner, which defers an operand containing an apply-to-all reference (has_a2a) to the per-element path in context.rs (with_preserved_wildcards), where the bare reference's axis is preserved as an iteration while its load stays pinned -- the x|D|.

Should these shapes compile?

Under the engine's own rule they should, to the degenerate reduce of one value. The rule -- a bare arrayed name inside a reducer in an apply-to-all body reads the enclosing element -- is pinned by the Phase 8.1 48-row table (db::lowering_scope_tests::a_helper_reads_what_the_plain_spelling_reads: SUM(pop) is pop[region], SIZE(pop) is 1) and rests on XMILE v1.0 section 3.7.1's dimension-name binding ("when all indices are dimension names, they can be omitted"; each element's equation has the name bound to its index). The spec does not address a bare name in a reducer argument separately, and what Stella computes for it is unverified (the design plan says so at Phase 8.1). Given that rule, SUM(pop * 2) is SUM over the one value pop[e] * 2, i.e. exactly what SUM(pop) * 2 computes today. Expected values for the table's model: SUM/MIN/MAX/MEAN(pop * 2) = 200 400 600; SIZE(pop * 2) = 1 1 1; STDDEV(pop * 2) = 0 0 0; SUM(pop * w) = 100 400 900; SUM(pop * w[idx]) = 100 200 300; SUM(pop + w) = 101 202 303; SUM(-pop) = -100 -200 -300; SUM(IF pop > 150 THEN pop ELSE 0) = 0 200 300; x = SUM(s * 2) = 10.

If a modeller wanted Vensim's SUM(pop[region!] * 2) (the whole array, 1200) they spell it SUM(pop[*] * 2) in Simlin, which compiles; that is a separate reading and the refusal message could point at it.

Possible approaches

  1. Materialize a 0-d operand into a one-element temp in materialize_view_operand (ArrayView::contiguous(vec![1]) in place of the dims.is_empty() decline). Codegen already lowers an AssignTemp whose body is not an array-producing opcode to a BeginIter loop; a one-element loop evaluates the scalar body once and the reducer reads a one-element view. Keeps the single materialization owner; the per-element regime reissues one temp id, so TempId pressure does not move. Check that codegen::array_view_to_static_temp accepts an unnamed one-axis temp and that join_array_views's "a set including an unnamed axis is None" rule does not bite when the 1-element temp is joined with siblings.
  2. Alternatively, fold the reducer at lowering when its operand is scalar (SUM/MIN/MAX/MEAN identity, SIZE 1, STDDEV 0). Smaller code, but a second statement of reducer semantics beside the VM's; (1) is preferable.
  3. If refusing is kept, the refusal must name the shape: the operand is a scalar expression because pop reads pop[region] inside an apply-to-all body; write pop[*] (or drop the reducer). The current text cannot be acted on without reading the compiler.

Whichever is chosen: record the Phase 6b divergence (main: x|D| wrong number; branch: refusal; after this issue: the degenerate reduce) in the design plan, and update the Phase 8.1 row aggx[region] = PREVIOUS(SUM(pop * scale)) / plainx[region] = SUM(pop * scale) ("refused by codegen on both trees") in lockstep -- a helper must compile exactly as the plain spelling (GH #1035's invariant, db::lowering_scope_tests::a_helper_is_refused_where_the_plain_spelling_is).

Acceptance (TDD)

  • Rows derived from the enumeration reducer class {SUM, MEAN, MIN, MAX, STDDEV, SIZE} x operand form {bare pop * 2, explicit pop[region] * 2, -pop, IF, pop * w, pop * w[idx], scalar-equation s * 2}, asserting the values above on the VM plus ensure_wasm_matches.
  • The controls that compile today keep their values: SUM(pop), SUM(pop) * 2, SUM(pop * w[*]), SUM(pop[*] * 2), MEAN(pop * 2), the scalar-equation SUM(pop * 2) = 1200.
  • LTM: ltm_array_agg's bare-reducer rows (V9b-4) and ltm_augment partials over these spellings keep scoring; no failed to compile warning appears for a fragment that now compiles.
  • Corpus (test/) values and the C-LEARN artifact unchanged (no corpus model has the shape; the sweep should say so).

Components

src/simlin-engine/src/compiler/array_operand.rs (materialize_view_operand, module doc's "What still declines"), src/simlin-engine/src/compiler/codegen.rs (walk_expr_as_view fallback arm, emit_array_reduce), src/simlin-engine/src/compiler/context.rs (lower_builtin_expr3), src/simlin-engine/src/compiler/mod.rs (find_expr_array_view, join_array_views).

Related

Discovery context

Identified during PR #1040 (branch compiler-unification-v2): the V9b report's out-of-scope discoveries and the Phase 8.1 review, which first saw plainx[region] = SUM(pop * scale) refused with the pre-Phase-9 wording Cannot push view for expression type Discriminant(10). Reproduced with simlin simulate on three binaries: origin/main at d04593e6, the branch's first commit be7adbbf (Phase 6b), and branch HEAD; a release CLI built 2026-08-07 (pre-unification main) computes the same x|D| numbers as d04593e6.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions