Skip to content

Commit fafce1c

Browse files
[4/15] Fix result modes in ifelse(), t(), and diag(); enforce ifelse() branch shapes (t-kalinowski#140)
* Promote ifelse() branches and shape the result like test Fortran's merge() requires same-typed branches, but the ifelse() handler took its result mode from `yes` alone and never cast `no`: mixed-mode branches emitted an invalid mixed-type merge() (ifelse(c, 1L, a) with double `a` failed at the gfortran stage). The result shape also came from the first non-scalar of (test, yes, no), where R documents ifelse() as returning a result shaped like `test`. Promote both branches to their common lattice mode with promote_operands(), and take the result's mode and shape from the promoted branches and the (booleanized) test respectively. A scalar test with array-valued branches is now a compile-time error -- merge() cannot represent R's length-1 result -- instead of silently emitting branch-shaped code. * Preserve input mode in t() and diag() R's t() and diag() preserve their input's type, but the handlers unconditionally cast to double: t(m) and diag(m) on an integer matrix returned doubles, and the constructor forms diag(x) / diag(x, nrow, ncol) lost typeof(x) too. Only the identity forms (diag(n), diag(nrow = n)) are double in R, which is what quickr already emits for them. Drop the maybe_cast_double() calls and carry the input mode through the result Variable, the hoisted temporaries, and the zero fill in diag_matrix(). can_use_output() gains a `mode` argument (default "double"; all other callers unchanged) so an in-place destination is only used when its declared mode matches, and infer_dest_diag() reports the input's mode instead of hard-coding double -- a double-inferred dest would have mislabeled an integer result's declaration. The transpose casts in unwrap_transpose_arg() stay, now with a comment saying why: that path only feeds matrix products, which always return double in R. * Enforce the ifelse() branch-shape contract ifelse() promoted branch modes and rejected scalar-test/array-branch calls, but never validated branch shapes against `test`. Fortran's merge() requires conformable arguments, so a runtime length mismatch read past the shorter branch and returned garbage where R recycles. A non-scalar branch must now match the shape of `test`: statically unequal dims (including rank mismatches) are a compile error, and symbolic dims emit a statement-level runtime size guard, matching the elementwise-operator policy. NA dims always count as unknown -- two unknown lengths are not the same quantity. * Test the ifelse() rank-mismatch error, drop an unreachable guard Coverage flagged both stop() calls in check_ifelse_branch_shape() as untested. The rank check is reachable: a matrix branch under a vector `test`, or the reverse, hits it, and the existing mismatch test only exercises the per-axis verdict. Add a case for each direction. The `is.null(hoist)` arm is not reachable. r2f() replaces a NULL hoist with a fresh one before dispatching to any handler, and the only other dispatch route resolves `f<-`-style names, so the ifelse() handler always has a hoist to emit the size guard into. Delete it and record the invariant in the function's comment rather than testing dead code. * Preserve logical storage through diag() --------- Co-authored-by: Tomasz Kalinowski <kalinowskit@gmail.com>
1 parent 91c8dac commit fafce1c

9 files changed

Lines changed: 777 additions & 23 deletions

File tree

R/r2f-conditionals.R

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,108 @@
11
# r2f-conditionals.R
22
# Handlers for vectorized conditionals: ifelse
33

4+
# --- Local Helpers ---
5+
6+
ifelse_branch_shape_msg <- paste0(
7+
"ifelse() `yes` and `no` must be scalars or match the shape of `test`; ",
8+
"R-style recycling is not supported"
9+
)
10+
11+
# Three-valued conformability verdict for one axis of an ifelse() branch
12+
# against `test`: ok+known (no guard), not-ok+known (compile error), or
13+
# unknown (runtime guard). NA dims are always unknown: two unknown lengths
14+
# are not the same quantity.
15+
ifelse_axis_verdict <- function(test_dim, branch_dim) {
16+
if (is_wholenumber(test_dim) && is_wholenumber(branch_dim)) {
17+
return(list(
18+
ok = identical(as.integer(test_dim), as.integer(branch_dim)),
19+
unknown = FALSE
20+
))
21+
}
22+
if (!is_scalar_na(test_dim) && !is_scalar_na(branch_dim)) {
23+
test_norm <- fortranize_expr_symbols(test_dim)
24+
branch_norm <- fortranize_expr_symbols(branch_dim)
25+
if (identical(test_norm, branch_norm)) {
26+
return(list(ok = TRUE, unknown = FALSE))
27+
}
28+
}
29+
list(ok = TRUE, unknown = TRUE)
30+
}
31+
32+
# Enforce the shape contract for one ifelse() branch: scalars broadcast
33+
# natively; a non-scalar branch must match `test`'s shape, because
34+
# merge() requires conformable arguments and a runtime mismatch would
35+
# read past the shorter branch. Statically unequal dims are a compile
36+
# error; symbolic dims get a statement-level runtime size guard, emitted into
37+
# `hoist` -- always a live hoist context, since r2f() substitutes a fresh one
38+
# before dispatching to any handler.
39+
check_ifelse_branch_shape <- function(branch, mask, hoist, scope) {
40+
if (passes_as_scalar(branch@value)) {
41+
return(invisible())
42+
}
43+
if (branch@value@rank != mask@value@rank) {
44+
stop(ifelse_branch_shape_msg, call. = FALSE)
45+
}
46+
unknown_axes <- integer()
47+
for (axis in seq_len(mask@value@rank)) {
48+
verdict <- ifelse_axis_verdict(
49+
dim_or_one(mask, axis),
50+
dim_or_one(branch, axis)
51+
)
52+
if (!verdict$ok) {
53+
stop(ifelse_branch_shape_msg, call. = FALSE)
54+
}
55+
if (verdict$unknown) {
56+
unknown_axes <- c(unknown_axes, axis)
57+
}
58+
}
59+
if (!length(unknown_axes)) {
60+
return(invisible())
61+
}
62+
# size() is an inquiry, so applying it to operand expression text does
63+
# not evaluate the operands.
64+
condition <- str_flatten(
65+
map_chr(
66+
unknown_axes,
67+
function(axis) glue("size({branch}, {axis}) /= size({mask}, {axis})")
68+
),
69+
" .or. "
70+
)
71+
emit_quickr_error_if(condition, ifelse_branch_shape_msg, hoist, scope)
72+
invisible()
73+
}
74+
475
# --- Handlers ---
576

6-
r2f_handlers[["ifelse"]] <- function(args, scope, ...) {
7-
.[mask, tsource, fsource] <- lapply(args, r2f, scope, ...)
77+
r2f_handlers[["ifelse"]] <- function(args, scope, ..., hoist = NULL) {
78+
.[mask, tsource, fsource] <- lapply(args, r2f, scope, ..., hoist = hoist)
79+
80+
# R: the result is shaped like `test` (branches only contribute values).
81+
# A scalar test with array branches is not representable with merge().
82+
if (
83+
passes_as_scalar(mask@value) &&
84+
!(passes_as_scalar(tsource@value) && passes_as_scalar(fsource@value))
85+
) {
86+
stop(
87+
"ifelse() result takes the shape of `test`; ",
88+
"array-valued yes/no with scalar test is not supported",
89+
call. = FALSE
90+
)
91+
}
92+
93+
# Checked before casts so guards splice the bare operand text.
94+
check_ifelse_branch_shape(tsource, mask, hoist, scope)
95+
check_ifelse_branch_shape(fsource, mask, hoist, scope)
96+
897
mask <- booleanize_logical_as_int(mask)
9-
# (tsource, fsource, mask)
10-
mode <- tsource@value@mode
11-
dims <- conform(mask@value, tsource@value, fsource@value)@dims
12-
Fortran(glue("merge({tsource}, {fsource}, {mask})"), Variable(mode, dims))
98+
99+
# merge() requires same-typed branches; promote both to their common mode.
100+
promoted <- promote_operands(list(tsource, fsource), context = "ifelse()")
101+
.[tsource, fsource] <- promoted$args
102+
mode <- promoted$mode
103+
104+
Fortran(
105+
glue("merge({tsource}, {fsource}, {mask})"),
106+
Variable(mode, mask@value@dims)
107+
)
13108
}

R/r2f-matrix-blas.R

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,21 @@ can_use_output <- function(
261261
input_names = character(),
262262
expected_dims = NULL,
263263
context,
264-
allow_alias = character()
264+
allow_alias = character(),
265+
mode = "double",
266+
logical_is_c_int = FALSE
265267
) {
268+
stopifnot(
269+
is_bool(logical_is_c_int),
270+
!logical_is_c_int || identical(mode, "logical")
271+
)
266272
if (is.null(dest)) {
267273
return(FALSE)
268274
}
269-
if (!identical(dest@mode, "double")) {
275+
if (!identical(dest@mode, mode)) {
276+
return(FALSE)
277+
}
278+
if (!identical(logical_as_int(dest), logical_is_c_int)) {
270279
return(FALSE)
271280
}
272281
assert_dest_dims_compatible(dest, expected_dims, context)
@@ -292,7 +301,8 @@ ensure_blas_operand_name <- function(x, hoist) {
292301
}
293302
tmp <- hoist$declare_tmp(
294303
mode = x@value@mode %||% "double",
295-
dims = x@value@dims
304+
dims = x@value@dims,
305+
logical_as_int = logical_as_int(x@value)
296306
)
297307
hoist$emit(glue("{tmp@name} = {x}"))
298308
tmp@name
@@ -1190,28 +1200,36 @@ lapack_chol2inv <- function(
11901200
diag_extract <- function(x, scope, hoist, dest = NULL, context = "diag") {
11911201
assert_hoist_env(hoist)
11921202

1193-
x <- maybe_cast_double(x)
1203+
# R's diag(<matrix>) preserves the input mode; the copy loop is
1204+
# mode-agnostic.
11941205
assert_rank2_matrix(x, paste0(context, " expects a matrix input"))
11951206

11961207
x_dims <- matrix_dims(x)
11971208
diag_len <- diag_length_expr(x_dims$rows, x_dims$cols, context)
11981209

11991210
x_name <- ensure_blas_operand_name(x, hoist)
1211+
logical_is_c_int <- logical_as_int(x@value)
12001212

12011213
writes_to_dest <- FALSE
12021214
if (
12031215
can_use_output(
12041216
dest,
12051217
input_names = x_name,
12061218
expected_dims = list(diag_len),
1207-
context = context
1219+
context = context,
1220+
mode = x@value@mode,
1221+
logical_is_c_int = logical_is_c_int
12081222
)
12091223
) {
12101224
out_var <- dest
12111225
out_name <- dest@name
12121226
writes_to_dest <- TRUE
12131227
} else {
1214-
out_var <- hoist$declare_tmp(mode = "double", dims = list(diag_len))
1228+
out_var <- hoist$declare_tmp(
1229+
mode = x@value@mode,
1230+
dims = list(diag_len),
1231+
logical_as_int = logical_is_c_int
1232+
)
12151233
out_name <- out_var@name
12161234
}
12171235

@@ -1241,9 +1259,13 @@ diag_matrix <- function(
12411259
) {
12421260
assert_hoist_env(hoist)
12431261

1244-
x <- maybe_cast_double(x)
1262+
# R's diag(x, ...) preserves typeof(x). The identity-matrix callers pass
1263+
# a synthesized 1.0_c_double, which keeps diag(n) double, as in R.
12451264
assert_rank_leq1(x, paste0(context, " expects a vector or scalar input"))
12461265

1266+
mode <- x@value@mode
1267+
logical_is_c_int <- logical_as_int(x@value)
1268+
12471269
diag_len <- diag_length_expr(nrow, ncol, context)
12481270
x_scalar <- passes_as_scalar(x@value)
12491271
x_len <- if (x_scalar) 1L else dim_or_one(x, 1L)
@@ -1256,18 +1278,32 @@ diag_matrix <- function(
12561278
dest,
12571279
input_names = x_name,
12581280
expected_dims = list(nrow, ncol),
1259-
context = context
1281+
context = context,
1282+
mode = mode,
1283+
logical_is_c_int = logical_is_c_int
12601284
)
12611285
) {
12621286
out_var <- dest
12631287
out_name <- dest@name
12641288
writes_to_dest <- TRUE
12651289
} else {
1266-
out_var <- hoist$declare_tmp(mode = "double", dims = list(nrow, ncol))
1290+
out_var <- hoist$declare_tmp(
1291+
mode = mode,
1292+
dims = list(nrow, ncol),
1293+
logical_as_int = logical_is_c_int
1294+
)
12671295
out_name <- out_var@name
12681296
}
12691297

1270-
hoist$emit(glue("{out_name} = 0.0_c_double"))
1298+
zero <- switch(
1299+
mode,
1300+
double = "0.0_c_double",
1301+
integer = "0_c_int",
1302+
logical = if (logical_as_int(out_var)) "0_c_int" else ".false.",
1303+
complex = "(0.0_c_double, 0.0_c_double)",
1304+
stop(context, " does not support mode ", mode, call. = FALSE)
1305+
)
1306+
hoist$emit(glue("{out_name} = {zero}"))
12711307

12721308
idx_i <- hoist$declare_tmp(mode = "integer", dims = NULL)
12731309
value_expr <- if (x_scalar) {

R/r2f-matrix-infer.R

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,18 @@ infer_dest_diag <- function(args, scope) {
311311

312312
# Case: x is a matrix -> extract diagonal (returns vector)
313313
if (!is.null(x) && x@rank == 2L) {
314+
if (is.null(x@mode)) {
315+
return(NULL)
316+
}
314317
x_dims <- matrix_dims_var(x)
315318
diag_len <- diag_length_expr(x_dims$rows, x_dims$cols, "diag")
316-
return(Variable("double", list(diag_len)))
319+
# diag_extract() preserves x's mode; a double-inferred dest would
320+
# mislabel an integer diagonal.
321+
return(Variable(
322+
mode = x@mode,
323+
dims = list(diag_len),
324+
logical_as_int = logical_as_int(x)
325+
))
317326
}
318327

319328
# Case: x is a scalar literal (identity matrix of that size)
@@ -330,7 +339,8 @@ infer_dest_diag <- function(args, scope) {
330339
}
331340

332341
# Case: x is a vector or scalar, construct diagonal matrix
333-
if (!is.null(x) && x@rank <= 1L) {
342+
# (diag_matrix() preserves x's mode, matching R)
343+
if (!is.null(x) && x@rank <= 1L && !is.null(x@mode)) {
334344
if (has_nrow || has_ncol) {
335345
nrow <- if (has_nrow) infer_size(nrow_arg, scope) else NULL
336346
ncol <- if (has_ncol) infer_size(ncol_arg, scope) else NULL
@@ -343,12 +353,20 @@ infer_dest_diag <- function(args, scope) {
343353
if (is.null(ncol)) {
344354
ncol <- nrow
345355
}
346-
return(Variable("double", list(nrow, ncol)))
356+
return(Variable(
357+
mode = x@mode,
358+
dims = list(nrow, ncol),
359+
logical_as_int = logical_as_int(x)
360+
))
347361
}
348362
# No nrow/ncol: square matrix from vector length
349363
if (x@rank == 1L) {
350364
len <- var_dim_or_one(x, 1L)
351-
return(Variable("double", list(len, len)))
365+
return(Variable(
366+
mode = x@mode,
367+
dims = list(len, len),
368+
logical_as_int = logical_as_int(x)
369+
))
352370
}
353371
}
354372

R/r2f-matrix-parse.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Matrix parsing helpers
22

33
# Unwrap t() calls to infer transpose flags and normalize scalars/vectors.
4+
# The double casts here are correct and intentional: this path only feeds
5+
# matrix-multiplication handlers (%*%, crossprod, ...), and R's matrix
6+
# products always return double. The standalone t() handler preserves mode.
47
unwrap_transpose_arg <- function(arg, scope, ..., hoist) {
58
arg_unwrapped <- unwrap_parens(arg)
69
if (is_call(arg_unwrapped, quote(t)) && length(arg_unwrapped) == 2L) {

R/r2f-matrix.R

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,14 @@ register_r2f_handler(
137137
r2f_handlers[["t"]] <- function(args, scope, ..., hoist = NULL) {
138138
stopifnot(length(args) == 1L)
139139
x <- r2f(args[[1L]], scope, ..., hoist = hoist)
140-
x <- maybe_cast_double(x)
140+
# R's t() preserves the input mode. (The transposes feeding matrix
141+
# multiplication go through unwrap_transpose_arg(), not this handler.)
141142
if (x@value@rank == 2) {
142-
val <- Variable("double", list(x@value@dims[[2]], x@value@dims[[1]]))
143+
val <- Variable(x@value@mode, list(x@value@dims[[2]], x@value@dims[[1]]))
143144
return(Fortran(glue("transpose({x})"), val))
144145
} else if (x@value@rank == 1) {
145146
len <- x@value@dims[[1]]
146-
val <- Variable("double", list(1L, len))
147+
val <- Variable(x@value@mode, list(1L, len))
147148
return(Fortran(glue("reshape({x}, [1, int({len})])"), val))
148149
} else if (x@value@rank == 0) {
149150
return(x)

0 commit comments

Comments
 (0)