Skip to content

[6/15] Elementwise length mismatches truncate silently; c() fill constructors and matrix(scalar, m, n) emit invalid Fortran; 1×1-matrix operands don't follow R's recycling rules #127

Description

@mns-nordicals

What happens

Four holes in how elementwise operations and constructors handle shapes.

1. Vector operands of different lengths silently truncate. R recycles
(with a warning when lengths aren't multiples); Fortran assigns through the
shape of the destination:

fn <- function(a, b) {
  declare(type(a = double(n)), type(b = double(m)))
  a + b
}
fn(c(1, 2), c(10, 20, 30, 40))         #> c(11, 22, 31, 42) (recycles, warns)
quick(fn)(c(1, 2), c(10, 20, 30, 40))  #> c(11, 22) -- silent truncation

The generated statement is out_ = (a + b) with out_ declared length
n — elements past min(n, m) of the longer operand are simply never
read, and nothing is diagnosed. When both lengths are known constants the
mismatch surfaces, but as a raw gfortran "Shapes for operands are not
conformable" build failure rather than a quickr diagnostic — and
check_recyclable_pair() explicitly blesses known divisible lengths
(length 2 vs length 4) that it has no code to implement. Comparison
operators (<, ==, ...), &/|, %%, and %/% don't consult any
length check at all, so the same silent truncation applies there.

2. Fill constructors leak a single scalar into c().

fn <- function(x) {
  declare(type(x = double(3)))
  c(numeric(2), x)
}
fn(as.double(1:3))  #> c(0, 0, 1, 2, 3)

numeric(2) lowers to the scalar text 0 with a claimed length of 2, so
c() emits out_ = [ 0, x ] — four elements where the declared output
length is five. Today this happens to fail the build (the untyped integer
literal 0 next to a double x makes a mixed-type constructor gfortran
rejects), but that is an accident of the second bug hiding the first: the
fill literals are also mode-wrong (0 for numeric(k) instead of
0.0_c_double), which additionally poisons any other context that splices
them.

3. matrix(scalar, m, n) is a scalar wearing rank-2 dims.

fn <- function() {
  sum(matrix(2, 2, 3))
}
fn()  #> 12

quick(fn) generates out_ = sum(2.0_c_double) — the handler rebadges the
scalar's Variable with dims (2, 3) and returns the scalar expression
text. That works only where Fortran's scalar broadcast applies (direct
whole-array assignment); inside any intrinsic that requires an array it is
invalid Fortran and the build fails.

4. 1×1 matrices against vectors: builds fail where R answers, and the one
case that does work is shape-wrong.
quickr scalarizes a 1×1-matrix operand
so it broadcasts like a scalar — but only in arithmetic, the one operator
class that consults a shape check at all. Three distinct problems:

  • When the 1×1 operand needs a cast or booleanization, the scalarizer
    appends (1, 1) to the already wrapped expression text:

    ! double(3) + logical(1, 1) -- legal in R, fails the build
    out_ = (a + merge(1.0_c_double, 0.0_c_double, (b/=0))(1, 1))

    gfortran rejects that as an unclassifiable statement.

  • Comparisons and &/| never reach the shape check, so a 1×1 operand
    arrives at gfortran as a bare rank mismatch — out_ = (a < b)
    "Inconsistent ranks for operator". R refuses these too ("dims [product 1]
    do not match the length of object"), so the verdict is right and only the
    diagnostic is wrong. But the split matters going forward: R recycles a
    length-1 array in arithmetic (deprecated, still working) and errors in
    comparisons and &/|, so routing every operator class through one
    shared shape check — which fixing defect 1 requires — would make
    x < m11 start answering where R refuses.

  • Scalarizing is shape-wrong whenever the vector's length is only known at
    run time. R keeps the 1×1 dims when the vector has length 1 and drops them
    otherwise; the scalarized code always returns a plain vector, so
    double(1, 1) + double(n) called with a length-1 vector returns 5 where
    R returns matrix(5, 1, 1).

Why it happens

  • R/r2f-operators-helpers.R: check_recyclable_pair() returns ok = TRUE
    for known divisible lengths (recycling that is never implemented), for any
    zero length, and for differing symbolic lengths (unknown = TRUE, which
    the caller maybe_reshape_vector_matrix() ignores). Arithmetic handlers
    are the only ones that even call it.
  • R/r2f-constructors.R: the logical()/integer()/double()/numeric()
    handlers return one scalar literal (untyped, and integer-typed for the
    double fills) carrying array dims; c() splices the text but sizes the
    result from the dims. array() already solves this with an implied-do
    spread for fill constructors — c() never got the same treatment.
  • R/r2f-constructors.R: the matrix() handler's passes_as_scalar()
    short-circuit returns the scalar text unchanged with rank-2 dims.
  • R/r2f-operators-helpers.R: scalarize_matrix() indexes whatever
    expression text it is handed, so a cast wrapper becomes unindexable; and
    maybe_reshape_vector_matrix() — which only the arithmetic handlers call —
    applies it for any vector length not provably 1, including symbolic
    lengths whose runtime value decides the result shape in R.

Expected behavior

  • Operand lengths that are known to differ (including length 0, including
    the divisible case) are a compile-time error: "elementwise vector
    operations require equal lengths or a scalar operand; R-style recycling is
    not supported". Scalar broadcast is unaffected. This applies uniformly to
    arithmetic, comparisons, &/|, %%, and %/%. Implementing true
    R recycling would need per-element modulo indexing and, for non-multiple
    lengths, a value-dependent warning — poor value for the surface it adds;
    an informative refusal keeps quick(f) honest.
  • Lengths that cannot be compared at compile time (symbolic vs symbolic,
    symbolic vs constant) get a runtime guard — one scalar size()
    comparison per statement through the existing error machinery, raising
    the same message as an ordinary R error. Identical symbolic length
    expressions stay guard-free. The same policy covers elementwise
    matrix-matrix operands axis by axis, and the vector-matrix row rule
    (a vector combined column-wise with a matrix whose rows it spans),
    which previously refused at compile time whenever the row count could
    not be verified statically.
  • c(numeric(k), x) works and matches R: fills spread as implied-dos
    ([(0.0_c_double, i = 1, k), x]) with mode-correct literals.
  • matrix(scalar, m, n) works in any context: it materializes into a
    hoisted rank-2 temporary where an array is required, and keeps today's
    free scalar broadcast when directly assigned.
  • 1×1-matrix operands follow R's split: arithmetic keeps the scalarized
    broadcast when the vector's length is statically known and not 1
    (hoisting cast wrappers to a temporary before indexing, so the cells R
    supports compile), while comparisons and &/| treat the 1×1 as an
    ordinary one-row matrix — known longer vectors are a compile error,
    unknown lengths get the runtime guard (length 1 still conforms, as in
    R). A vector whose length is only known at run time takes the
    one-row-matrix rule in arithmetic too, since the result's shape would
    otherwise depend on the runtime value: length 1 passes the guard and
    yields a 1×1 matrix as in R, longer vectors error where R would
    recycle (deprecated).

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