Repository navigation
Fix OffsetArray-correct indexing in ArrayPartition - #672
ChrisRackauckas-Claude wants to merge 1 commit 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 |
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)