Skip to content

[1/15] Fix repeated side effects in floor/ceiling and runif bounds, reject na.rm= in reductions, and three small crash/diagnostic fixes - #137

Merged
t-kalinowski merged 7 commits into
t-kalinowski:mainfrom
mns-nordicals:conformability/01-side-effects-and-crashes
Jul 27, 2026
Merged

[1/15] Fix repeated side effects in floor/ceiling and runif bounds, reject na.rm= in reductions, and three small crash/diagnostic fixes#137
t-kalinowski merged 7 commits into
t-kalinowski:mainfrom
mns-nordicals:conformability/01-side-effects-and-crashes

Conversation

@mns-nordicals

Copy link
Copy Markdown
Contributor

Stacked PR 1 of 15 — branch
conformability/01-side-effects-and-crashes, cut from main. It is the
base of the series, so this diff is its own work only. Later PRs in the
series stack on top of it.

Stack overview and suggested review order: #152.

Fixes #122.

What changed

Six small, independent fixes, one commit each (plus a final formatting-only
commit, see notes):

  1. floor() / ceiling() now evaluate their argument exactly once.
    Previously the argument expression was spliced into the emitted Fortran
    three times, so floor(runif(1) * 10) mixed two different random draws
    and advanced the RNG state three times. Non-trivial arguments are now
    hoisted to a block temporary:

    ! before
    out = (aint((unif_rand() * 10.0_c_double)) - merge(1.0_c_double, 0.0_c_double, &
      ((unif_rand() * 10.0_c_double) < aint((unif_rand() * 10.0_c_double)))))
    
    ! after
    block
      real(c_double) :: btmp1_
      btmp1_ = (unif_rand() * 10.0_c_double)
      out = (aint(btmp1_) - merge(1.0_c_double, 0.0_c_double, (btmp1_ < aint(btmp1_))))
    end block

    Bare variable names are passed through unchanged, so side-effect-free
    translations are unaffected.

  2. runif() bounds are now evaluated exactly once — same defect class:
    min was spliced twice, and for array results the implied-do re-evaluated
    spliced bounds once per element, so runif(2L, runif(1L), 10) drew a
    fresh "min" repeatedly where R evaluates it once:

    ! before
    out = [((unif_rand() + (unif_rand() * (10.0_c_double - unif_rand()))), tmp1_=1, 2)]
    
    ! after
    block
      real(c_double) :: btmp1_
      btmp1_ = unif_rand()
      out = [((btmp1_ + (unif_rand() * (10.0_c_double - btmp1_))), tmp1_=1, 2)]
    end block

    Bare-name and literal bounds are unaffected (no new temporaries).

  3. max()/min()/sum()/prod() now reject named arguments with a
    compile-time error. Previously sum(x, na.rm = TRUE) treated
    na.rm = TRUE as a data argument and emitted (sum(x) + .true.), failing
    later at the gfortran stage with an inscrutable type error (or, where the
    operand typed through, computing the wrong thing). This matches the
    existing any()/all() guard.

  4. Assigning an expression that produces no value errors cleanly.
    y <- if (x) 1 else 2 (a value-less statement in the lowering) previously
    crashed with no applicable method for `@` applied to an object of class "NULL"; it now errors with
    cannot assign `if (x) 1 else 2`: expression does not produce a value.

  5. r2size() no longer crashes on a variable whose mode is still being
    inferred
    (@mode NULL): var@mode != "integer" yielded logical(0), the
    enclosing || collapsed to NA, and if aborted with "missing value where
    TRUE/FALSE needed". !identical(var@mode, "integer") reaches the intended
    warning path.

  6. Variable@dims <- NULL now resets the variable to scalar per the
    class's documented "NULL means scalar" convention, instead of being a
    silent no-op that kept stale dims. (r2f-assign.R's mode-inference
    reassignment path assigns var@dims <- value@value@dims, where a
    legitimately-NULL right-hand side means scalar.)

How

  • New shared helper hoist_unless_name(x, hoist) in R/r2f-aab-core.R
    (next to new_hoist()), extracted from the matrix() handler's existing
    hoist-to-temporary logic; matrix() now uses it, and floor()/ceiling()
    (R/r2f-math.R) and the runif() bounds (R/r2f-random.R) call it before
    splicing. Other handlers that splice an operand more than once can adopt it
    incrementally (rev() already hoists inline with the same pattern).

    The helper skips the temporary only for a bare variable name, so it
    over-hoists literalsfloor(2.5) now spends a temporary it does not
    need. That is deliberate here: teaching it about literals changes emitted
    code for every existing caller, which would churn snapshots in a PR whose
    point is a targeted correctness fix. runif() takes a local
    is.atomic() shortcut to avoid the worst of it. PR 13 removes both the
    over-hoisting and the shortcut, in a PR where the snapshot churn is the
    expected content.

  • R/r2f-reductions.R: named-argument guard at the top of the
    max/min/sum/prod handler, copied from any/all.

  • R/r2f-assign.R: NULL-@value check in the new-binding path.

  • R/sizes.R: !identical(var@mode, "integer").

  • R/classes.R: the dims setter assigns the attribute directly on empty
    input (same S7 workaround as the r property) instead of early-returning.

Tests

  • test-hoist-unless-name.R (new): floor/ceiling with runif(1) arguments
    match R under a fixed seed and leave the RNG state identical to R's; a
    translation snapshot pins the single-unif_rand form; a bare-variable case
    asserts no temporary is emitted.
  • test-errors.R: compile-time rejection of na.rm = for all four
    reductions; the clean void-assignment diagnostic.
  • test-runif.R: runif(2L, runif(1L), 10) matches R under a fixed seed
    with identical RNG state after the call; translation snapshot pins the
    hoisted bound.
  • test-internal-utils.R: r2size() warns instead of crashing on a
    deferred-mode variable.
  • test-classes.R: @dims <- NULL yields is_scalar.

Snapshot churn: only the new _snaps/hoist-unless-name.md and an added case
in _snaps/runif.md. No existing snapshots change (floor/ceiling on bare names, the dominant case in the
suite, are untouched).

Notes for review

  • The floor/ceiling hoist happens before the double-cast, so an integer
    bare name still splices as real(x, kind=c_double) (pure) without a
    temporary; only genuinely non-trivial expressions pay for one.
  • The last commit is formatting-only (air format): = assignment becomes
    <- in three pre-existing test files (test-example-convolve.R,
    test-loops.R, test-size-constraint.R). No behavior change.
  • test-internal-utils.R's "print.quickr_ordered_env outputs bindings" test
    fails on a clean checkout of main in my environment (R-version-dependent
    capture.output/str behavior, unrelated to this change) — called out so
    it isn't attributed to this PR.
  • No NEWS entry added; happy to add one if you'd like these user-visible
    diagnostics mentioned.

floor() and ceiling() splice their argument into the emitted Fortran
expression three times (once for aint(), twice for the sign adjustment),
so an impure argument such as runif(1) was evaluated three times: the
result mixed different random draws and the RNG state diverged from R.

Extract the hoist-to-temporary pattern already used by matrix() into a
shared helper, hoist_unless_name(), and use it in floor()/ceiling() to
evaluate non-trivial arguments once. Bare variable names are passed
through unchanged, so existing translations without side effects are
unaffected. matrix() now uses the shared helper.
The reductions handler never inspected argument names, so
`sum(x, na.rm = TRUE)` translated `na.rm = TRUE` as an extra data
argument, emitting `(sum(x) + .true.)`. Reject named arguments with a
clear compile-time error, matching the existing any()/all() guard.
Binding a new variable to an expression that produces no value (e.g.
`y <- if (x) 1 else 2`, where `if` lowers to a statement) crashed with
"no applicable method for `@` applied to an object of class NULL".
Diagnose it at the assignment site instead, naming the offending
expression.
`var@mode != "integer"` fails with "argument is of length zero" when
@mode is NULL (a binding whose mode is still being inferred, as in the
deferred-mode path in r2f-assign.R). Use !identical() so the intended
"size is not an integer" warning path is reached instead.
The dims setter early-returned on empty input, so resetting a variable
to scalar (NULL dims, per the class's own convention) silently kept the
stale dims. r2f-assign.R's deferred-mode path assigns
`var@dims <- value@value@dims` with a legitimately-NULL RHS and relied
on this working. Assign the attribute directly (the same S7 workaround
already used by the `r` property) to avoid recursing through the setter.
Same defect class as the floor()/ceiling() fix: `min` is spliced twice
into the emitted expression, and for array results the implied-do
re-evaluates spliced bounds once per element, so an impure bound such
as `runif(2L, runif(1L), 10)` drew a fresh `min` value repeatedly where
R evaluates it once. Hoist non-trivial bounds via hoist_unless_name();
bare names and literal bounds are unaffected.
@mns-nordicals
mns-nordicals force-pushed the conformability/01-side-effects-and-crashes branch from 583c465 to f3e2460 Compare July 25, 2026 12:56
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.17%. Comparing base (16aa2c5) to head (f3e2460).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #137      +/-   ##
==========================================
+ Coverage   93.14%   93.17%   +0.03%     
==========================================
  Files          28       28              
  Lines        6028     6040      +12     
==========================================
+ Hits         5615     5628      +13     
+ Misses        413      412       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@t-kalinowski t-kalinowski left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much. These are great correctness fixes, and the clearer diagnostics make the failure modes easier to understand. I left two inline comments linked to follow-up issues, but neither needs to be addressed in this PR. Approving.

test_that("Variable@dims can be reset to scalar with NULL", {
v <- quickr:::Variable("double", list(2L, 3L))
expect_identical(v@dims, list(2L, 3L))
v@dims <- NULL

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is consistent with the package’s current representation, where NULL means scalar, so I think it is fine for this PR.

Longer term, I think we should distinguish an uninferred shape (NULL) from a rank-0 scalar (list()). Variable() is used as a placeholder during inference, but its dims = NULL currently makes it report itself as scalar before its shape is known. I opened this follow-up issue to track that refactor; it requires a package-wide audit and does not need to expand this PR.

test_that("assigning an expression that produces no value errors cleanly", {
fn <- function(x) {
declare(type(x = logical(1)))
y <- if (x) 1 else 2

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In R, this if does produce a value: this form always evaluates to either 1 or 2. “Does not produce a value” describes the current lowering limitation rather than the R expression.

I think we should eventually resolve a common type and shape from the two branches and assign the selected branch to y; we need that metadata to infer y anyway. I opened this follow-up issue to track that work. The new diagnostic is still clearer than the current crash, so this does not need to expand this PR.

@t-kalinowski
t-kalinowski merged commit 7a36ea3 into t-kalinowski:main Jul 27, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[1/15] Small correctness/diagnostic fixes: repeated side effects in floor/ceiling and runif bounds, na.rm= mistranslation, and three crash paths

2 participants