Repository navigation
Fix OffsetArray-correct indexing in ArrayPartition - #672
ChrisRackauckas merged 3 commits into
Conversation
getindex/setindex!(::ArrayPartition, ::Int) computed the within-partition index as length(x) + i (1-based), silently reading/writing the wrong element of a non-1-based partition (e.g. an OffsetArray) and going out of bounds under --check-bounds=yes. Use firstindex(x) + length(x) + i - 1. copyto!(dest::AbstractArray, A::ArrayPartition) always started writing dest at linear index 1, silently misplacing the data for a non-1-based dest. Start from firstindex(dest) instead. The inner per-partition assignment also broke for a non-1-based partition, since `dest[range] .= vec(partition)` checks that the (1-based) range and the partition's own axes match; switched to the axis-agnostic 3-arg copyto!(dest, doffs, src). @inbounds is kept; the fix is within the existing bounds-checking structure. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Round-2 review (Grok 4.7, pr-667/round2): the i >= 1 guard turned a previously correct call into a throw for any AbstractVector whose firstindex isn't 1. copyat_or_push! is exported and its docstring promises to copy into a[i] "when that element exists" -- on an OffsetArray with indices 0:1, index 0 exists and master updates it; the i >= 1 guard threw BoundsError there instead. Use i >= firstindex(a), which is false only when i is below the array's own first valid index, matching master's behavior for every case the original fix needed to cover (the i=0/i=-1 segfault on a plain, 1-based Vector) while leaving a 0-based array's own index 0 untouched. Added a minimal 0-based AbstractVector test type (ZeroVec, avoiding an OffsetArrays test dependency that would conflict with SciML#672's Project.toml changes) and a regression test that copyat_or_push!(z, 0, ...) still updates index 0 while copyat_or_push!(z, -1, ...) still throws. Fails against the i >= 1 code (BoundsError at a correct, in-bounds index 0) and passes after this fix, on both Julia 1.10 and 1.12. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Independent review (Devin CLI 3000.11.3, model fusion-claude-opus-5-5-high-sidekick-swe-2-medium): MERGE, risk low. Full reviewVERDICT: MERGE Reviewed head Blocking findingsNone. All three changes are correct for every case I tested:
Non-blocking findings
What I ranSetup:
What I did not verify
🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (review), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium |
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Devin <noreply@cognition.ai> Agent-Harness: Devin CLI 3000.11.3 (conflict resolution), Claude Code 2.1.285 (orchestration) Agent-Model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium Agent-Session: local Claude session 3c311569-dda6-4687-bc9f-65febb212c33 (fleet master); Devin log /Users/chrisrackauckas/sandbox/master/fusion-fixes/SciML_RecursiveArrayTools.jl-672/log.txt # Conflicts: # test/Core/partitions_test.jl
Merged the default branch into this PR to resolve conflicts (head c4d4523). Re-review the resolution before merging. Conflict resolution reportSTATUS: RESOLVED PR: #672 (branch Conflicting files
All other files auto-merged. I checked Tests (Julia 1.12.7, default juliaup channel)Result: 🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (merge), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium |
Independent review (Codex CLI 0.166.0 / gpt-6.1-sol), session 01a19787-e19a-7fd3-8f3f-eaf27fe65d10, reviewed head 8716769
Review written by the reviewer named above in a session other than the PR author's, and copied here by the posting agent without change to its findings. The posting agent did not re-run the reviewer's commands. Summary: Corrects offset-index handling in ArrayPartition scalar indexing, assignment, and copying. The focused regression file fails on the merge-base and passes on head; no blocking defect was found, with one comment-hygiene suggestion. Blast radius: ArrayPartition linear reads/writes and copyto! into AbstractArray or PermutedDimsArray destinations; ordinary arrays, offset arrays, and partition-backed downstream solver state. Evidence: Blind assessment recorded and hashed before author context. Julia 1.13.2 focused partition tests: head 136/136, exit 0; identical head test file against merge-base 130 pass, 2 fail, 2 error, exit 1. Core, AD, GPU, and package Downstream CI pass. The failing SciMLSensitivity Core4 job has the identical Enzyme assertion in a completed master run. The additional reviewer probe has an invalid destination-comparison assertion, detailed below. Blocking findingsNone. Non-blocking findings
What I ranAll three Julia processes ran sequentially with Julia 1.13.2, four Julia threads, one OpenBLAS thread, an isolated temporary directory, and the supplied writable depot followed by the default depots. No package suite or Pkg.test() was run. Environment paths below are represented by variables to avoid machine-specific paths. gh pr view 672 --repo SciML/RecursiveArrayTools.jl --json number,url,state,isDraft,headRefOid,baseRefName,baseRefOid
git -C src rev-parse HEAD
git -C src fetch origin '+refs/pull/672/head:refs/remotes/pr/672' master
git -C src diff origin/master...HEAD
git -C src merge-base origin/master HEAD
git -C src diff 76bec79f7a09ce9546709376a0d7eb95deaf90f9...HEAD --numstat
sha256sum INITIAL.md > INITIAL.sha256
gh pr view 672 --repo SciML/RecursiveArrayTools.jl --json title,body,statusCheckRollup
gh api --paginate repos/SciML/RecursiveArrayTools.jl/pulls/672/commits --jq '.[] | {sha: .sha, parents: [.parents[].sha], message: .commit.message}'
gh pr checks 672 --repo SciML/RecursiveArrayTools.jl
gh run list --repo SciML/RecursiveArrayTools.jl --branch master --event push --limit 5 --json databaseId,workflowName,conclusion,headSha,url
gh run list --repo SciML/RecursiveArrayTools.jl --branch master --event push --workflow IntegrationTest --limit 5 --json databaseId,conclusion,headSha,url
XDG_CACHE_HOME="$REVIEW_CACHE" gh run view 38872096841 --repo SciML/RecursiveArrayTools.jl --log-failed
XDG_CACHE_HOME="$REVIEW_CACHE" gh run view 41050637371 --repo SciML/RecursiveArrayTools.jl --log-failedGit/API/log retrieval commands exited 0; The focused driver activates a separate temporary environment for each checkout, develops RecursiveArrayTools and its local ShorthandConstructors sublibrary, adds OffsetArrays/Statistics/ArrayInterface/Adapt, and includes the head's unchanged JULIA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 JULIA_PKG_PRECOMPILE_AUTO=0 TMPDIR="$REVIEW_TMP" JULIA_DEPOT_PATH="$SHARED_DEPOT:" timeout 3600 julia +1.13.2 --startup-file=no --check-bounds=yes validation/focused.jl head
JULIA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 JULIA_PKG_PRECOMPILE_AUTO=0 TMPDIR="$REVIEW_TMP" JULIA_DEPOT_PATH="$SHARED_DEPOT:" timeout 3600 julia +1.13.2 --startup-file=no --check-bounds=yes validation/focused.jl base
JULIA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 JULIA_PKG_PRECOMPILE_AUTO=0 TMPDIR="$REVIEW_TMP" JULIA_DEPOT_PATH="$SHARED_DEPOT:" timeout 3600 julia +1.13.2 --startup-file=no --check-bounds=yes --project=env-head validation/probe.jlHead, exit 0: Final probe, exit 1: Inspected PR CI, PR IntegrationTest, and master IntegrationTest. In both integration logs, SciMLSensitivity Core4's EnsembleThreads test at Author trailers come from What I did not verifyI did not independently run GPU, AD, downstream solver suites, benchmarks, QA, other Julia versions, or other platforms; CI exercises GPU and AD. No AD rules or GPU kernels are changed. The base regression testset aborts at setindex!, so offset-destination failing-before was not separately established. The third run's shape/axes mistake prevents an all-pass independent copy-content result for its multidimensional and offset destinations; I did not repair and rerun it because of the three-run limit. I did not reproduce the downstream Enzyme failure locally. No prior review file was read and no upstream write was made. 🤖 Posted by an AI agent — harness: Claude Code · model: claude-sonnet-5-5 |
…667) * Fix two silent memory-safety bugs in utils.jl recursivecopy!(b::AbstractArray{<:StaticArray}, a) looped over eachindex(a), so a shorter dest silently wrote out of bounds instead of throwing. copyat_or_push! only checked length(a) >= i, so i=0 (or negative) fell into the @inbounds update branch and corrupted memory instead of throwing a BoundsError. recursivecopy! now uses eachindex(b, a) (throws DimensionMismatch on a length mismatch, matching Base's own idiom). copyat_or_push! now rejects i < 1 with a BoundsError before the @inbounds block. Before the fix, both cases segfault the Julia process on 1.10 and 1.12 (see PR description for the exact crash traces). After the fix they throw DimensionMismatch / BoundsError and the existing test suite (Core group, 120/120 Utils Tests) still passes unchanged on both versions. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv * Fix copyat_or_push! bounds guard to use firstindex, not literal 1 Round-2 review (Grok 4.7, pr-667/round2): the i >= 1 guard turned a previously correct call into a throw for any AbstractVector whose firstindex isn't 1. copyat_or_push! is exported and its docstring promises to copy into a[i] "when that element exists" -- on an OffsetArray with indices 0:1, index 0 exists and master updates it; the i >= 1 guard threw BoundsError there instead. Use i >= firstindex(a), which is false only when i is below the array's own first valid index, matching master's behavior for every case the original fix needed to cover (the i=0/i=-1 segfault on a plain, 1-based Vector) while leaving a 0-based array's own index 0 untouched. Added a minimal 0-based AbstractVector test type (ZeroVec, avoiding an OffsetArrays test dependency that would conflict with #672's Project.toml changes) and a regression test that copyat_or_push!(z, 0, ...) still updates index 0 while copyat_or_push!(z, -1, ...) still throws. Fails against the i >= 1 code (BoundsError at a correct, in-bounds index 0) and passes after this fix, on both Julia 1.10 and 1.12. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv * Use lastindex for the copyat_or_push! update branch copyat_or_push! decided between updating a[i] and appending with `length(a) >= i`, which is only equivalent to `i <= lastindex(a)` for 1-based vectors. For a 0-based vector, `i == length(a)` took the update branch and wrote one past the end under @inbounds; for a vector with axes 2:3, the in-bounds index 3 was appended instead of updated. The comparison now uses lastindex and sits outside @inbounds, and the docstring states the BoundsError for i < firstindex(a). The test helper vector type now takes an arbitrary first index, and the recursivecopy! StaticArray test also pins DimensionMismatch for a longer destination and for a destination with different axes. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.301 Agent-Model: claude-opus-5-5 Agent-Session: local session ID 5ab5a912-8ddf-4104-80aa-8ed04790f627 (headless claude -p, no URL) * Keep recursivecopy! prefix copy for longer StaticArray destinations Looping over eachindex(b, a) made recursivecopy! of StaticArray elements throw DimensionMismatch for a longer destination, where master copies into the leading elements. That changes defined behavior of an exported function. Loop over eachindex(a) as master does and call checkbounds(b, eachindex(a)) before the @inbounds loop: a destination missing any source index throws BoundsError before anything is written, and a longer destination keeps the prefix copy. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.301 Agent-Model: claude-opus-5-5 Agent-Session: local session ID 5ab5a912-8ddf-4104-80aa-8ed04790f627 (headless claude -p, no URL) --------- Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude <noreply@anthropic.com>
What and why
Three related
ArrayPartitionindexing paths assumed every partition (andevery
copyto!destination) is 1-based, and silently compute the wronglinear offset for a partition with non-1-based indices (e.g. an
OffsetArray):getindex/setindex!(::ArrayPartition, i::Int)(src/array_partition.jl:308,337)compute the within-partition position as
length(x) + i, which is onlycorrect when
firstindex(x) == 1. Fixed tofirstindex(x) + length(x) + i - 1.copyto!(dest::AbstractArray, A::ArrayPartition)(:238) always startswriting
destat linear index1. Fixed to start atfirstindex(dest).dest[cur:(cur+length(x)-1)] .= vec(x), additionally breaks for anon-1-based partition
x:vecon anOffsetArraypreserves its offsetaxes, so the dot-assignment's axis check fails against the (always
1-based) destination range. Switched to the 3-arg
copyto!(dest, cur, x), which copies by position regardless ofx's ownaxes.
@inboundsis kept in bothgetindex/setindex!; the fix stays inside theexisting bounds-checking structure (
@boundscheck checkbounds(A, i)stillruns first).
Correctness: failing-before / passing-after
Reused the audit's probes (
scratch/probes/p_ap_offset_getindex.jl,p_ap_offset_setindex.jl,p_ap_copyto_offsetdest.jl).getindex,
ArrayPartition([1.0, 2.0], OffsetArray([10.0,20.0,30.0], 0:2)),reading
A[3], A[4], A[5](expected10.0, 20.0, 30.0):(20.0, 30.0, 6.9412553390231e-310)(garbage from reading oneelement past the
OffsetArray'sparent, i.e. silently wrong, plusundefined memory on the last read).
(10.0, 20.0, 30.0).setindex!,
A[5] = 99.0intoArrayPartition([1.0, 2.0], OffsetArray(zeros(3), 0:2)):parent(o) = [0.0, 0.0, 0.0]under default--check-bounds=auto(write landed one slot past the end of
o's backingVector, so it'sinvisible via
parent, but is an out-of-bounds write — confirmed itthrows
BoundsErrorunder--check-bounds=yes).parent(o) = [0.0, 0.0, 99.0], and--check-bounds=yespasses.copyto!,
copyto!(OffsetArray(zeros(4), 0:3), ArrayPartition([1.0,2.0],[3.0,4.0])):parent(dest) = [0.0, 1.0, 2.0, 3.0](everything shifted one slot;4.0dropped). ThrowsBoundsErrorunder--check-bounds=yes.parent(dest) = [1.0, 2.0, 3.0, 4.0], and--check-bounds=yespasses.All three re-confirmed on Julia 1.12.4 and 1.10.12, with and without
--check-bounds=yes.Also checked
collect(A)for anArrayPartitionwith anOffsetArraypartition (exercises the
copyto!(dest::AbstractArray, A::ArrayPartition)path via
Base.collect/_collect_indices), which the first version of thisfix (changing only the starting index, not the inner assignment) still broke
with
DimensionMismatch: arrays could not be broadcast to a common sizeunder the
dest[range] .= vec(x)form;collect(A) == [1.0, 2.0, 10.0, 20.0, 30.0]now.
Normal (non-offset, same-size, mismatched-size, scalar-partition) indexing
and
copyto!behavior is unchanged — re-ranscratch/probes/p_ap_mismatch.jland got byte-identical output before/after.
Performance
Candidate 13 in the audit noted a possible
+2.5%, within noisecost for thegetindex/setindex!change. Measured directly (Julia 1.12.4 and 1.10.12,@benchmark5000 samples,ArrayPartition(rand(1000), rand(1000)),reading/writing index 1500):
No measurable difference (
firstindexon aVectoris a compile-timeconstant
1, so the extra term constant-folds away).Test results
Added
test/Core/partitions_test.jl::"OffsetArray partitions index correctly",using
OffsetArrays.jl(newly added to[extras]/[targets]/[compat]inProject.toml, as this package had no existingOffsetArraystestdependency).
GROUP=Core: all 12 Core testsets pass (Partitions Tests: 135 Pass, 0 Fail).GROUP=QA:QA: 20 Pass, 0 Fail.Both re-run on Julia 1.12.4 and 1.10.12.
What was not verified
OffsetArraysupport elsewhere in the package (vector_of_array.jl) is aseparate, already-known issue (candidate 16/18 in the audit, ragged
VectorOfArray+OffsetArrayinner arrays) and is out of scope here.PermutedDimsArraydestinations specifically (the samefor type in [AbstractArray, PermutedDimsArray]loop defines both methods,so the fix applies to both, but the regression test only exercises
OffsetArray/Vector).What a reviewer should push back on
Whether
copyto!(dest, cur, x)(3-arg) is the right general replacement fordest[range] .= vec(x)for everyxtype this loop can see (plainArray,OffsetArray, and whatever otherAbstractArraypartitions appear inpractice) — it relies on
copyto!'s own linear-index contract forsrcrather than broadcasting, which should be strictly more permissive, but is
worth double-checking against any partition type with unusual iteration
order.
Please ignore until reviewed by @ChrisRackauckas.
Risk assessment
src/array_partition.jl, three methods(
getindex/setindex!(::ArrayPartition, ::Int),copyto!(dest::AbstractArray/PermutedDimsArray, ::ArrayPartition)); nosignature or public API change. Only changes behavior for non-1-based
partitions/destinations, which previously silently corrupted data or threw
under
--check-bounds=yes; 1-based (the common case) is unaffected.collectcheck, both with and without
--check-bounds=yes, on Julia 1.12.4 and1.10.12; no performance change; Core and QA test groups pass.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Independent review: Devin Fusion (fusion-claude-opus-5-5-high-sidekick-swe-2-medium) rated it low, verdict MERGE: #672 (comment)