Skip to content

Commit d4bd611

Browse files
committed
Guard symbolic-length vectors against 1x1 matrices instead of scalarizing
R's length-1-array recycling drops the array dims only when the vector's length is not 1; for a length-1 vector the 1x1 dims are kept. Scalarizing whenever the length was not *provably* 1 answered the compile-time question "is the length 1?" with "no" when the truth was "unknown", so a symbolic-length vector that turned out to have length 1 at run time returned a dimensionless vector where R returns a 1x1 matrix -- a silent shape divergence. Scalarize only when the length is statically known and not 1 (the cases where R itself drops the dims, including length 0). Symbolic lengths fall through to the vector-matrix rule: a runtime guard requires length 1, the result is a 1x1 matrix, and longer vectors raise an error where R would recycle (a deprecated behavior in R). Found by codex review (fable-final round); reproduces on upstream main.
1 parent 0f86280 commit d4bd611

3 files changed

Lines changed: 59 additions & 12 deletions

File tree

NEWS.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,22 @@
1515
out of bounds. Statically unequal lengths are now a compile-time error;
1616
lengths that cannot be verified at compile time are checked at run time.
1717

18-
- `1x1`-matrix operands now follow R's rules: in arithmetic they are
19-
treated as scalars (which R allows, with a deprecation warning), while in
20-
comparisons and `&`/`|` they are treated as one-row matrices, so
21-
mismatched shapes are rejected — matching R, which raises an error.
22-
Previously a `1x1` matrix was treated as a scalar everywhere, so e.g.
23-
`x < m` with `m` a `1x1` matrix returned answers where R errors.
18+
- `1x1`-matrix operands now follow R's rules: in arithmetic against a
19+
vector of statically known length other than 1 they are treated as
20+
scalars and the result is a plain vector (which R allows, with a
21+
deprecation warning), while in comparisons and `&`/`|` they are treated
22+
as one-row matrices, so mismatched shapes are rejected — matching R,
23+
which raises an error. When the vector's length is only known at run
24+
time, the result's shape would depend on that value (R keeps the `1x1`
25+
dims for a length-1 vector and drops them otherwise), so arithmetic
26+
also takes the one-row-matrix rule: a runtime check requires length 1
27+
and the result is a `1x1` matrix; longer vectors raise an error where
28+
R would recycle. Previously a `1x1` matrix was scalarized in arithmetic
29+
and not shape-checked at all in comparisons and `&`/`|`, so e.g.
30+
`x < m` with `m` a `1x1` matrix failed to build with a Fortran rank
31+
mismatch instead of a quickr error, an operand needing a cast failed to
32+
build even in arithmetic, and a symbolic-length `x + m` returned a plain
33+
vector where R returns a `1x1` matrix.
2434

2535
# quickr 0.3.0
2636

R/r2f-operators-helpers.R

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ dim_is_one <- function(x) {
128128
is_wholenumber(x) && identical(as.integer(x), 1L)
129129
}
130130

131+
# Check if a dimension expression is statically known and not 1. Symbolic
132+
# dimensions are FALSE: "not provably 1" is not "provably not 1".
133+
# Used by: maybe_reshape_vector_matrix()
134+
dim_known_not_one <- function(x) {
135+
is_wholenumber(x) && !identical(as.integer(x), 1L)
136+
}
137+
131138
# Check if a Fortran value is a 1x1 matrix.
132139
# Used by: r2f-arithmetic.R, r2f-logical.R
133140
is_one_by_one <- function(x) {
@@ -238,10 +245,15 @@ scalarize_matrix <- function(mat) {
238245
# size guard through `hoist`.
239246
#
240247
# `scalarize_one_by_one` mirrors R's split over length-1 arrays: arithmetic
241-
# recycles a 1x1 matrix against a longer vector (deprecated in R but still
242-
# the behavior), while comparisons and & | error. Strict callers pass FALSE
243-
# so the 1x1 falls through to the vector-matrix rule and is rejected or
244-
# guarded like any other 1-row matrix.
248+
# recycles a 1x1 matrix against a vector of statically known length != 1
249+
# (deprecated in R but still the behavior: R drops the array dims). When
250+
# the vector's length is only known at run time, the result's shape would
251+
# depend on that value -- R keeps the 1x1 dims for a length-1 vector and
252+
# drops them otherwise -- so the 1x1 falls through to the vector-matrix
253+
# rule: a runtime guard requires length 1 and the result is a 1x1 matrix,
254+
# an error where R would recycle. Comparisons and & | error in R itself,
255+
# so strict callers pass FALSE and the 1x1 always takes the vector-matrix
256+
# path.
245257
# Used by: r2f-arithmetic.R, r2f-logical.R
246258
maybe_reshape_vector_matrix <- function(
247259
left,
@@ -281,7 +293,7 @@ maybe_reshape_vector_matrix <- function(
281293
is_one_by_one(left)
282294
) {
283295
right_len <- dim_or_one(right, 1L)
284-
if (!dim_is_one(right_len)) {
296+
if (dim_known_not_one(right_len)) {
285297
left <- scalarize_via_hoist(left)
286298
left_rank <- 0L
287299
}
@@ -292,7 +304,7 @@ maybe_reshape_vector_matrix <- function(
292304
is_one_by_one(right)
293305
) {
294306
left_len <- dim_or_one(left, 1L)
295-
if (!dim_is_one(left_len)) {
307+
if (dim_known_not_one(left_len)) {
296308
right <- scalarize_via_hoist(right)
297309
right_rank <- 0L
298310
}

tests/testthat/test-recycling.R

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,31 @@ test_that("1x1 matrix operands follow R: arithmetic scalarizes, strict ops rejec
149149
expect_error(qcmp(c(1, 2, 3), matrix(5)), "matrix first dimension")
150150
})
151151

152+
test_that("1x1 matrix with a symbolic-length vector keeps R's shape", {
153+
# The result's shape depends on the runtime length: R keeps the 1x1
154+
# dims for a length-1 vector and drops them for any other length, so no
155+
# static decision can be right for both. Scalarizing regardless (the
156+
# old behavior) silently returned a dimensionless vector where R
157+
# returns a 1x1 matrix. Symbolic lengths now take the vector-matrix
158+
# rule instead: a runtime guard requires length 1 and the result is a
159+
# 1x1 matrix; longer vectors error where R would recycle (deprecated).
160+
fn <- function(m, x) {
161+
declare(type(m = double(1, 1)), type(x = double(n)))
162+
m + x
163+
}
164+
qfn <- quick(fn)
165+
expect_identical(qfn(matrix(2), 3), fn(matrix(2), 3))
166+
expect_error(qfn(matrix(2), c(1, 2, 3)), "matrix first dimension")
167+
168+
rev_fn <- function(x, m) {
169+
declare(type(x = double(n)), type(m = double(1, 1)))
170+
x + m
171+
}
172+
qrev <- quick(rev_fn)
173+
expect_identical(qrev(3, matrix(2)), rev_fn(3, matrix(2)))
174+
expect_error(qrev(c(1, 2, 3), matrix(2)), "matrix first dimension")
175+
})
176+
152177
test_that("guard text is pinned (one snapshot per mechanism)", {
153178
fn <- function(a, b) {
154179
declare(type(a = double(n)), type(b = double(m)))

0 commit comments

Comments
 (0)