Skip to content

Summary of stacked conformability PRs #152

Description

@mns-nordicals

I spent some time earlier in my holiday seeing what the newest frontier models
could actually do (Fable 5, and Codex 5.6 for the review rounds), and pointed
them at something I already cared about.

Back in #58 I argued quickr should be stricter with sizes: that
NA should be disallowed in declare(type())
so dimensions could be checked, and that
conformability should be verified before compiling
via conform_elementwise() / conform_matrix(). In that same comment I also
flagged what was wrong with it — a strict compile-time check errors on valid
inputs, e.g. dims(A) = x_len__ against dims(B) = y_len__ + 1 where
y_len__ = x_len__ - 1.

Working through the corner cases changed my mind, and the runtime guard is what
came out of it: statically wrong shapes are still compile errors, but dimensions
that can't be proven statically get one size() comparison before the BLAS call
rather than being rejected. So NA dims stay legal — just always treated as
unverified, never as equal — and crossprod() stops refusing programs that are
perfectly fine at run time. That, together with the type-promotion and
assignment/subscript work, is what I'm happiest about here.

The rest of this issue is the overview of the 15 PRs. Together they make
compiled functions honor one contract:

quick(f)(x) either returns exactly what f(x) returns — same values,
same typeof(), same shape — or raises an error. Never a third thing.

Today the answers to "what type does this result have?" and "are these
operands conformable?" live in several places that disagree, and several of
the gaps produce silent wrong answers: mixed-mode arithmetic returning the
wrong type, recycling that was accepted but never emitted, BLAS calls
proceeding on unverified shapes, complex operands read as reals. The series
fixes the bugs first, then adds the tests and structure that keep them fixed,
then documents the resulting semantics.

Each PR has its own issue, stands on its own, and lands in order. 11 of the 15
change behavior (1–7, 10–12, 15); the other 4 are tests (8), a refactor (9),
generated-code cleanups (13) and documentation (14).

How to review this

All 15 PRs are open at once so you can see the whole shape, but GitHub can
only diff a fork branch against main
, so PR n also displays PRs 1..n−1's
commits until they merge. Each PR body opens with a note naming its parent and
the commits that are new to it. Reviewing in order (1 → 15) is the only order
in which the diffs read cleanly.

The branches live in a fork with no restrictions on them — push to them,
amend them, rebase them, or reorder them as you see fit.
If you merge PR 1,
I rebase the rest onto the new main in one command and the remaining diffs
shrink accordingly. If you'd rather have fewer, larger PRs, say so and I'll
re-cut them.

Two things worth knowing before you start:

  • PR 9's branch name is stale. conformability/09-operator-table
    originally consolidated the 17 elementwise operators into a descriptor
    table, which PR 15 then moved back out of. That round trip has been
    re-cut away: the table is never built, PR 9 ships only the shared
    conformability helpers, and PR 15 ships only the dedup. The branch keeps
    its name because renaming it would break the PR; the titles are accurate.
  • Nothing here is load-bearing on my judgment alone. The "Decisions
    embedded" section below lists every judgment call, each cheap to un-make.

The series

# Issue PR Branch What
1 #122 #137 conformability/01-side-effects-and-crashes repeated side effects, na.rm=, three crashes
2 #123 #138 conformability/02-literal-kinds wrong-kind Fortran literals
3 #124 #139 conformability/03-type-promotion R's promotion lattice, applied
4 #125 #140 conformability/04-ifelse-t-diag ifelse()/t()/diag() result types
5 #126 #141 conformability/05-subscript-validation subscript validation, OpenMP cancellation
6 #127 #142 conformability/06-recycling no more silent partial recycling
7 #128 #143 conformability/07-blas-guards one policy for linear-algebra shapes
8 #129 #144 conformability/08-conformability-grid the contract, tested as a grid
9 #130 #145 conformability/09-operator-table the shared conformability helpers (neutral)
10 #131 #146 conformability/10-solve-square solve() requires a square matrix
11 #132 #147 conformability/11-short-circuit &&/`
12 #133 #148 conformability/12-refusal-diagnostics clean not-supported errors
13 #134 #149 conformability/13-codegen-cleanups generated-code cleanups
14 #135 #150 conformability/14-semantics-vignette the semantics, documented (docs)
15 #136 #151 conformability/15-maintainability dedup and remaining correctness gaps

1. Small fixes the later PRs build on

floor()/ceiling() and runif() bounds evaluated their argument
expressions more than once — visibly wrong with impure arguments, e.g. two
different random draws from one expression. na.rm = in reductions was
silently ignored rather than rejected. Plus three small crash/diagnostic
fixes. Nothing here touches the type system; it removes hazards the later
diffs would otherwise sit on.

2. Wrong-kind Fortran literals in casts

Three one-line silent-wrong-answer fixes in emitted Fortran. TRUE / FALSE
did integer division on undefined behavior instead of returning Inf, because
1_c_double is an integer literal of kind 8, not a double. Integer %/%
lost precision above 2^24 through a real cast, and Re() dropped its kind.
Spelling fixes only, no policy.

3. R's promotion lattice, applied

The compiler computed R's mode lattice (logical < integer < double < complex) and then ignored it: result modes came from the first non-scalar
operand, so x + 0.5 on integer x silently truncated and runif(1) * 3L
was declared integer. Every combining operation now routes through one
promotion-and-cast path. One new refusal: reassignment that would narrow a
variable's type is a compile error, since Fortran can't re-type a variable
where R would promote.

4. ifelse(), t() and diag() result types

ifelse() with mixed-mode branches emitted invalid Fortran — merge()
requires same-typed branches — and its result shape was never checked against
test. t() and diag() forced double where R preserves the input type
(diag(1:3) is integer). All three now match R, and ifelse() enforces its
branch-shape contract: compile error when provably wrong, runtime check when
lengths are symbolic.

5. Subscript validation, and OpenMP loops that actually cancel

Negative and zero subscripts compiled to out-of-bounds reads; they are now
compile errors, since their R meaning (exclusion, dropping) produces
value-dependent shapes a static compiler can't represent. Assignment
subscripts, which bypassed validation entirely, get the same checks, and
literal subscripts are checked against known extents. Ships with the fix that
makes errors raised inside OpenMP loops cancel the loop, which needs
OMP_CANCELLATION set before the runtime initializes.

6. No more silent partial recycling

Elementwise ops accepted operands of different lengths whenever one length
divided the other, but no recycling was ever emitted — the generated loop just
read out of bounds. Mismatched known lengths are now compile errors,
unverifiable lengths get a runtime check, and the two forms that do work
(scalar broadcast, vector spanning a matrix's rows) are kept. Also follows R's
split for 1×1-matrix operands: recycled in arithmetic, rejected in
comparisons, where R errors too.

7. One policy for linear-algebra shapes

BLAS/LAPACK lowerings reacted three different ways to dimensions they couldn't
verify: warn-and-proceed, hard compile error (crossprod), or — when both
operands had NA dims — no check at all. Mismatches surfaced as garbage reads
or a cryptic DGEMM error code -10. Now: statically wrong shapes are compile
errors, unverifiable ones get a runtime check before the call, and the
compile-time warning is retired.

8. The contract, tested as a grid

New combinatorial test file: modes × shapes (scalar, two vector lengths,
matrix, 1×1 matrix, symbolic) × operators in both operand orders, plus c(),
multi-arg reductions and ifelse(), with plain R as the oracle. Compile cost
is contained by packing all nine mode pairs per cell into one compiled
function: ~1 min by default, full grid behind QUICKR_FULL_GRID=1. Its first
full run found a real pre-existing bug, which is the argument for having it.

9. The shared conformability helpers (behavior-neutral)

The conformability policy was spelled three times — the BLAS emitter, the
elementwise emitter, and an inline copy inside ifelse() whose private
verdict helper had already drifted (it let a statically zero-length branch
through). They converge on one guard_conformable_dims().
check_conformable() was dims_match() in list form and both call sites
now say so; the real-domain floor spelling shared by floor() and double
%/% becomes real_floor_expr(). Evidence of neutrality: zero snapshot
churn and the full grid passes identically. See the branch-name note
above.

10. solve() requires a square matrix

solve(a, b) with a rectangular a fell through to a least-squares dgels
call, returning qr.solve()'s answer where R raises 'a' (3 x 2) must be square. Statically rectangular systems are now compile errors; symbolic
squareness is checked at run time before dgesv. qr.solve() keeps its
least-squares behavior — that is its meaning in R — and the now-dead
rectangular tail of the lowering is deleted.

11. && and || match R's scalar semantics

They compiled exactly like &/|: elementwise over vectors, returning
answers where R errors, with no short-circuit guarantee. Operands must now be
length-1 logicals, and the right operand is evaluated only when the left side
doesn't decide — lowered to a conditional when it could error or have side
effects, kept as infix when provably pure. while conditions get the matching
fix, so while (i <= n && x[i] > 0) no longer reads out of bounds.

12. Clean not-supported errors

Refusals that were correct but unreadable: character declarations died in the
code generator with an S7 object dump; complex order comparisons and complex
%% died with raw gfortran output. All are now clean compile errors, the
complex ones using R's own message text. One behavior change: complex operands
in linear algebra are refused, where they previously flowed into the real
BLAS/LAPACK routines and returned a plausible wrong real answer
(complex %*% complex gave the dot product of the real parts).

13. Generated-code cleanups

Everything whose fix necessarily churns snapshots, landed together once PR 9's
zero-churn gate is past: matrix(scalar, m, n) against a rank-2 operand
broadcasts natively instead of materializing an O(m·n) temporary (claimed dims
still checked), and the generated C extern signature no longer ends every
line in a space — 280 lines on main do today. Also pays off one wart this
series created: the hoist helper PR 1 added over-hoists literals, which this
fixes properly instead of PR 1's local workaround. Churn is mechanical.

14. The semantics, documented (docs only)

New vignette quickr-semantics.Rmd — the package's first — stating the
contract, the result-type table, the three-verdict shape policy, and every
deliberate divergence from R: no NA, no partial recycling, zero-length
rules, fixed variable types, complex being elementwise-only, the
subscript-bounds contract, ifelse() rules, and eager closure arguments. All
chunks are eval = FALSE with verified pasted output, so it adds nothing to
check time and needs no Fortran toolchain to build.

15. Dedup and the remaining correctness gaps

Two separable parts. Deduplication of idioms that existed as N verbatim
copies (behavior-neutral, generated Fortran byte-identical): the
BLAS/LAPACK output-resolution and info-guard boilerplate, the
hoist-materialization idiom, the read/write subscript lowering, the
cbind/rbind twins, and the six comparison handlers, which were
byte-identical apart from the operator symbol. Then the correctness gaps
the earlier passes deferred: nested masked reductions crashed,
reassignment and BLAS output reuse ignored extents, diag(x) ignored R's
length-1 identity form, plus two diagnostics that were inverted and two
pieces of R semantics simply unimplemented (c(<matrix>),
as.vector()).

Decisions embedded in the stack

Flagging the judgment calls so they can be un-made cheaply:

  • Recycling rejection and the narrowing-reassignment error — the two
    original behavior breaks, both with NEWS entries.
  • Rectangular solve() removed (NEWS entry): it answered where R errors,
    the exact class of divergence this series hunts. I introduced it in the
    original linear-algebra PR; qr.solve() is R's spelling for least squares
    and keeps working.
  • &&/|| scalar + short-circuit (NEWS entry): vector operands were
    silently accepted elementwise; they now error, matching R.
  • Runtime guards instead of warnings for unverifiable BLAS shapes, with
    crossprod's hard compile error relaxed to the same guard.
  • Scalar runtime subscripts (x[i]) stay unchecked. Per-element bounds
    checks in hot loops defeat the point of compiling, and this matches
    Fortran's own contract; it's documented in the vignette. An opt-in
    quickr.bounds_check option is a possible future escape hatch. Literal and
    statically-known out-of-range subscripts — reads and writes — are compile
    errors, which cost nothing at run time. This is the one item I'd most like
    your explicit call on.
  • Complex linear algebra is refused rather than implemented (NEWS entry):
    supporting it means the z* BLAS/LAPACK families and complex destination
    inference. Refusing is the honest translation until there's demand. Same
    call for complex type joins in c(), ifelse() and binds.
  • ifelse() with scalar test and array branches is a compile error — R
    shapes the result like test, which isn't representable statically.
  • The operator layout (PRs 9 and 15) is structural. The correctness PRs
    stand without either.
  • Grid compile budget in CI: default tier ~1 min, full grid behind
    QUICKR_FULL_GRID=1. Say the word if even the default is too much for
    routine CI.

Known loose ends (deliberately not in this series)

  • Under the same bounds contract as x[i]: symbolic range subscripts check
    only their lower bound against the extent at run time, and integer-vector
    subscripts (x[idx]) are value-unchecked.
  • Complex widening in the shared cast path, so c(complex, double) could
    return complex as R does — a feature, deliberately not built; the compile
    errors are documented instead.
  • A set of candidate dead-code deletions is held back, since some of it may
    have a future purpose only you can judge. Happy to open a separate PR.
  • x <- numeric(n) followed by a scalar assignment intended as a fill now
    needs x[] <- 0-style code, a consequence of the per-axis reassignment
    check in PR 15.

Test status

At the tip of the stack, on main as of 16aa2c5: 14284 passing, 0
failures, 0 warnings, 0 skips
with QUICKR_FULL_GRID=1, and no
uncommitted snapshot drift. Each behavior change carries a regression test;
the behavior-neutral PRs are gated on byte-identical generated code rather
than on new tests.

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