Skip to content

Add a BroadcastStyle for AbstractFill - #385

Open
jishnub wants to merge 46 commits into
masterfrom
jishnub/broadcaststyle
Open

Add a BroadcastStyle for AbstractFill#385
jishnub wants to merge 46 commits into
masterfrom
jishnub/broadcaststyle

Conversation

@jishnub

@jishnub jishnub commented Aug 26, 2024

Copy link
Copy Markdown
Member

Fixes #40
Fixes #58
Fixes #143
Fixes #145
Fixes #188

After this,

julia> Broadcast.BroadcastStyle(typeof(Fill(2,3)))
FillArrays.FillStyle{1}()

Often, we will have AbstractFill return types returned now in broadcasting operations, and --- where possible --- ones whose values are statically known:

julia> exp.(Zeros(4))
4-element Ones{Float64}

julia> (x -> x^2).(Zeros(4))
4-element Zeros{Float64}

julia> .!(Trues(4))
4-element Zeros{Bool}

This does not change the fact that broadcasting over a Fill is evaluated before others in a fused broadcast:

julia> ones(1,5) .+ (ones(1) .+ (_ -> rand()).(Fill("vec", 2)))
2×5 Matrix{Float64}:
 2.76296  2.76296  2.76296  2.76296  2.76296
 2.76296  2.76296  2.76296  2.76296  2.76296

This is achieved by recursively walking through a Broadcasted object, and evaluating the components that produce AbstractFills eagerly.

@jishnub
jishnub marked this pull request as draft August 26, 2024 10:19
@codecov

codecov Bot commented Aug 26, 2024

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.92%. Comparing base (7b96cc2) to head (3d4bc39).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #385   +/-   ##
=======================================
  Coverage   99.91%   99.92%           
=======================================
  Files           9        9           
  Lines        1238     1269   +31     
=======================================
+ Hits         1237     1268   +31     
  Misses          1        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.

@putianyi889

Copy link
Copy Markdown
Contributor

This could also fix #394

jishnub and others added 4 commits July 27, 2026 17:49
An `AbstractFillStyle` now resolves conflicts the same way that a
`DefaultArrayStyle` of the same dimension does: styles that win against
`DefaultArrayStyle` (lazy, banded, block) win against a fill style too,
and those that defer to it (e.g. `StaticArrayStyle`) keep deferring.
Neither side has to define a rule for `FillStyle`/`ZerosStyle`.

Packages that opt out of their own style for fills by forwarding to
`DefaultArrayStyle` keep obtaining a fill: such `broadcasted` calls are
re-dispatched on the arguments alone. Where the arguments carry a foreign
style, only the style-independent rules are applied, recorded by the new
`has_fill_rule` table, so the forwarding package stays in control.

Also add the styleless `Ones op Ones -> Ones` rules for `*`, `/` and `\`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`x .^ k` lowers to a three-argument `literal_pow` broadcast, which none of
the `DefaultArrayStyle` shapes that packages forward to us matched, so such
calls materialized densely instead of obtaining a fill. Unwrap the `Ref`s so
that the styleless rules apply, and record the rule in `has_fill_rule` so
that arguments carrying a foreign style (e.g. an infinite fill, which
`InfiniteArrays` marks as lazy) are routed to it as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paths that packages defining their own broadcast style rely on weren't
exercised by the test suite, as nothing in it carries a foreign style. Give
the `InfiniteArrays` test helper the lazy style and the forwards to
`DefaultArrayStyle` that `InfiniteArrays` obtains through `LazyArrays`, and
test that fills, ranges and `literal_pow` keep being simplified through it,
that the unforwarded cases stay lazy, and that an array with a
dimension-agnostic style wins against a fill.

`Ones{Int}(∞) .* (1:∞)` threw along the way, as `_range_convert` rebuilds a
unit range out of its endpoints, which an infinite range can't convert. Return
a range whose eltype already matches as-is instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jishnub
jishnub marked this pull request as ready for review July 29, 2026 15:19
jishnub and others added 4 commits July 29, 2026 21:34
Commenting out the `has_fill_rule` methods that record which rules hold
whatever the style of the other argument is left the test suite passing, so
nothing exercised the one shape they uniquely protect: `Zeros` absorbing an
argument that is neither a fill nor something we can size or materialize.
Without the fast path such a call fails to find a `similar` method rather
than collapsing to `Zeros`, so test it through the `DefaultArrayStyle` entry
point that packages forward to.

Drop the `_range_convert` method for a `OneTo` whose eltype already matches.
The diagonal `where T` makes it lose against the `OneTo` method that converts
the endpoint, so it was never dispatched to, and the comment above it named
the wrong methods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commenting each entry out in turn showed which ones a test would notice.
The `+`/`-` entries for a `Zeros` against an arbitrary vector, and the
`literal_pow` one, are needed whenever the other argument can't be
materialized, so give the test helper an infinite vector that is neither a
fill nor a range and exercise all three through the `DefaultArrayStyle` entry
point. `.^` on a fill is caught by the styleless rule before a style is ever
consulted, so the forwarded three-argument form needs testing on its own.

The `AbstractOnes`/`AbstractOnes` entry is neither observable nor needed to
resolve an ambiguity, as two fills let the caller evaluate the operation on
the fill values instead, so drop it and record the criterion. The remaining
two-fill entries stay: without them the one-sided entries they sit next to
are ambiguous, which is what they are now documented as being for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither entry can be dropped — `Zeros`/`Zeros` and `ZerosVector`/`ZerosVector`
disambiguate the one-sided entries beside them, and Aqua reports the
ambiguities as soon as either goes — but nothing reached them either, as the
infix tests forward through a layer that lets them be resolved before the call
is made. Call the forwarded form directly, as a package would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jishnub and others added 22 commits August 12, 2026 16:17
For any other size, Base's `*(::AbstractArray, ::Number)` broadcasts, and
the broadcast rules already produce the same result: a 306-probe sweep over
fill types, element types and scalar types is unchanged. Zero-dimensional
fills still need the methods, as Base's `broadcast_preserving_zero_d`
mishandles what our rules return.
`Base.broadcast_preserving_zero_d` mishandles what our broadcast rules return, so
`Fill(2)/2` and `2\Fill(2)` errored on a missing `similar` while `Zeros()/2` came
back as a zero-dimensional `Array` wrapping a `Zeros`. Specialize both the way `*`
already is. Separately, `fill_add` broadcast with `.+`, which unwraps a
zero-dimensional result to the element, so `Fill(2) + fill(3)` returned an `Int`
where Base returns a container.

A 1416-probe sweep over the fill types, element types, scalar types and sizes
changes on exactly the 120 zero-dimensional entries and nowhere else.
It assumed `Base.broadcasted` returns a lazy `Broadcasted`, which holds only
while every argument type whose rules simplify eagerly is intercepted by a more
specific `real`/`imag`/`conj` method — an invariant nothing enforces. Fed such
an argument it wrapped a result that was already a container:
`broadcast_preserving_0d(/, Zeros(), 2)` gave `Fill(Zeros())`.
They named `Zeros`/`Ones` outright, so a package overloading
`broadcasted_zeros`/`broadcasted_ones` to produce its own types got a plain
FillArrays type back from `real` and `imag`. The results are unchanged for
every type that leaves the hooks alone — a 55-probe sweep over the fill types,
element types and sizes is identical — and the new testset covers the hooks,
which had none.

`fillsimilar` cannot serve here: it carries the argument's eltype, with no way
to ask for `real(eltype(A))`.
Finding 1 (silent wrong answer): `transpose`/`adjoint` are recursive, so a
wrapper's elements are the parent's fill value with the operation applied.
Reading the parent's value skipped that, and `Fill(1+2im,3)' .+ Fill(0,1,3)`
came back unconjugated — but only when the other operand was itself a fill.

Finding 3 (StackOverflowError): `DefaultArrayStyle` in the `_dispatch_on_fills`
`Union` is unreachable in-tree, since a fill among the arguments always resolves
to a fill style, but a downstream style mapping fills onto it recursed until the
stack blew. Dropped; such calls now take the foreign-style branch.

Finding 2 (latent): a fill-specific method may rewrite the arguments and stay
lazy, as the fill-against-a-range rules do. That result already carries the
caller's style, so keep it rather than rebuilding a `DefaultArrayStyle` wrapper
around the original arguments.

Finding 5 nit: two `ArgumentError`s lost the "Convert ... to a Vector first"
hint when they were rewritten to report a size instead of the array.

Finding 4: pin the structured-matrix contract with tests. Structure is lost
under `*` for `Bidiagonal`/`Tridiagonal`/`SymTridiagonal`/`UpperTriangular`/`Eye`
as well as `Diagonal`, and kept under the operations that have no `Zeros` rule.
Both were verified against scripts outside the suite, which left nothing in
tree to catch a reintroduction. `LazyRange` is a range with a style of its own
that keeps `scalar .* range` lazy — the shape where the fill-against-a-range
rewrite has to survive, and the reason real ranges never reach that branch.
`DeferVec` resolves fills to `DefaultArrayStyle`, which used to recurse.

Against the pre-fix source these give 4 failures and 2 `StackOverflowError`s.
`_fallback_copy` collapses the fill components before handing the rest to
`DefaultArrayStyle`, which may expose a rule that returns an array outright
and leave nothing to `convert`, so `[1.0,2,3] .* (Ones(3) .- Ones(3))` threw.
Guard it the way `broadcast_preserving_0d` already is.

`isfill` recurses through any `Transpose`/`Adjoint` parent while
`broadcast_getindex_value` demanded an `AbstractFill` there, so a doubly
wrapped fill threw. Recurse in both.

Zero-dimensional broadcasts kept their container except where `copy` ran,
which left `Fill(2) .+ 1` unwrapping to the element while `Fill(2) .^ 2`
stayed a `Fill`. Drop the specialization and preserve the container
throughout, as every earlier release did.

The `+`/`-` rules against `Zeros` return the other argument itself, so
attaching them to the operation made them pre-empt a style that would have
built a container of its own: `BlockArray .+ Zeros` handed back the very
`BlockArray`, and mutating the result mutated the input. Attach them to the
fill styles, which win only once every other style has deferred, and add
`fill_rule` so the calls packages forward still reach them.

`_copy_fill` recomputed the fill value in each of `_iszeros` and `_isones`,
evaluating the broadcasted function three times over `Zeros`/`Ones`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SymTridiagonal` only began preserving its structure under broadcasting in
v1.12: on the LTS even `S .+ S` and `S .* 2` materialize, with no `FillArrays`
involved at all. The style resolves as intended there — `Zeros` still defers to
`StructuredMatrixStyle{SymTridiagonal}` — so there is simply no structure for
`similar` to keep. Expect what Base itself produces rather than dropping the
case, so the shape stays covered on every supported version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was introduced to hold the hand-written conflict rules against
`StructuredMatrixStyle` and `FillStyle`. Resolving those generically through
`_fillstyle_result` left it with a single merge rule keeping it alive when a
`Zeros` meets another `Zeros`, and nothing at all reading the distinction: the
absorption rules match `AbstractZeros` in argument position before any style is
consulted, and `_copy_fill` decides from the value, as it must, since
`exp.(Zeros(3))` is `Ones` while `Zeros .+ Ones` already collapsed to
`FillStyle`. That collapse also made it a trap to build on, firing for
`Zeros .* Zeros` but not `Zeros .* Ones`.

`AbstractFillStyle` stays as the handle to dispatch on, and `_fillstyle_defer`
still rebuilds whatever concrete style it was handed, so a package adding one of
its own keeps its identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`(1:5) .+ 1` is `2:6`, but `(1:5) .+ Ones(5)` materialized, even though
`(1:5) .* Ones(5)` has kept the range all along. Rewrite `+` and `-` against a
range the way `*` is rewritten, to the fill value against the range, and let the
range decide what it becomes.

`Zeros` needs no rule of its own, being a fill whose value is zero, but a range
is a vector and `Zeros` is a fill, so the pair overlaps its rules with neither
the more specific. The two methods breaking that tie do the same rewrite, which
also stops `(1:5) .+ Zeros(5)` materializing where `(1:5) .+ Fill(0.0,5)` and
`(1:5) .+ 0.0` both stay a range.

`fill_rule` still returns the argument untouched, as the forwarded path needs:
`0 .+ OneToInf()` is an `InfUnitRange` rather than an `OneToInf`, so rewriting
there would lose `Zeros .+ r ≡ r`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`imag` of a real fill is zero by the eltype, but the generic path has only the
value to go on, and `_copy_fill` will not read `Zeros` out of a runtime value,
so `imag(Fill(4))` was `Zeros{Int}()` while `imag.(Fill(4))` was `Fill(0)`.
`Ones` and `Zeros` already agreed, their value being static, as did the complex
fills, whose imaginary part is a genuine runtime quantity.

State it once for the broadcast too. Going through `broadcasted_zeros` keeps a
package customizing the hook its own type here, as the applied form already
did. Nothing needs a `has_fill_rule` entry: a forwarded call falls through to
`broadcasted(FillStyle{N}(), …)`, and the array it returns comes straight back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`imag.(Fill(4))` was made `Zeros` to agree with `imag(Fill(4))`, but the rule
keys on the function, so it never fires for a call that merely wraps it:
`(x -> imag(x)).(Fill(4))` stayed `Fill(0)`. Trading a disagreement between the
applied and broadcast forms for one between two spellings of the same broadcast
is a worse deal, and every other operation-specific rule here agrees with what
the generic path would have produced anyway.

`imag` cannot: it is zero by the eltype, and `_copy_fill` has only the value,
which `has_static_value(::Fill)` deliberately will not report on. So leave the
containers differing and pin that in the tests instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Base.broadcast_preserving_zero_d` re-wraps a zero-dimensional result on the
assumption that broadcasting unwrapped it to the element. Our rules hand back a
container, so the re-wrap nested it: `Fill(4) * 2` gave a zero-dimensional array
whose element was `Fill(8)`, and `Day(1) * Fill(2)` a `MethodError` for the
`similar` we do not define. Defining `similar` does not help — a correct one,
returning a mutable `Array`, still yields `Array{Fill{Day,0},0}`.

Extend the function instead of shielding its callers. Base reaches it from eight
operations, all of which had or needed a hand-written method here, but it is not
private to Base: `Dates` routes `Period * AbstractArray` through it, so that
enumeration can never be complete. `LinearAlgebra` extends it the same way for
adjoint and transpose vectors. This lets the zero-dimensional `*`, `/` and `\`
methods go.

`broadcast_preserving_0d` now materializes rather than copying, since its
arguments need not all be fills and instantiating is what checks the shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of the twelve were redundant. The `AbstractFill` three did exactly what Base
now reaches through the extended `broadcast_preserving_zero_d`. The
`AbstractFill{<:Real}` three restated Base's own `real(::AbstractArray{<:Real})`
and `imag(::AbstractArray{<:Real}) = zero(A)`, which give the same answers for a
fill; those had been redundant all along.

What is left is the `Zeros`/`Ones` half, which is not: it routes through the
`broadcasted_zeros`/`broadcasted_ones` hooks, while the broadcast fallback names
`Zeros`/`Ones` outright and so loses a package's own fill type. Removing those
six changes seven results, all on the custom types the tests define.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_copy_fill` named `Fill`/`Zeros`/`Ones` outright, so a package customizing
`broadcasted_fill`/`broadcasted_zeros`/`broadcasted_ones` got its own type back
only from the operations with a rule of their own. Everything reaching the
generic path lost it: `exp.(Z)`, `sin.(Z)`, `real.(Z)`, `max.(Z, Z)`, the
broadcast forms of `real`/`imag`/`conj`. `fill_rule` dropped it for `Z .+ Z` and
`Z .- Z` the same way.

Dispatch on the number of arguments, since the hooks take one or two arrays, and
name the type outright only past that. Over a 29-expression sweep the hook now
fires for 27, against 7 before; the two left are fused broadcasts over three
fills, which would need an n-ary hook.

A broadcast now agrees with the applied form on a package's own types, as
`real.(TaggedZeros{ComplexF64}(4))` did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hooks were fixed at one or two arrays, which is why the generic path named
the type outright past that: a fused broadcast over three fills had no hook to
call. Varargs have to come last, so the value and axes move ahead of them, and
the two arities collapse into one method each.

A rule now passes whatever arguments it has, and a package matches on the ones
it cares about with a `rest...` tail. Over the 29-expression sweep the hook
fires for all 29, against 27 with the arities fixed; the nested arguments reach
it as the `Broadcasted` they still are, which a package can dispatch on.

Nothing outside FillArrays overloads these, and they are neither exported nor
documented, so the signature change breaks no known caller. The internal
`_broadcasted_zeros(f, a, b)` that BandedMatrices imports is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `T` list generating the `Zeros` scaling rules named `AbstractFill{<:Number}`,
`AbstractOnes` and `AbstractRange` alongside `AbstractArray{<:Number}`. The first is
a strict subtype of it, and the other two only reach further for element types that
are not numbers, which the rules cannot serve anyway, as the comment above them says.

`real`/`imag`/`conj` on a `Zeros` or a `Ones` restated what Base already reaches
through the broadcast, which applies the same hooks. Only `imag` on a `Ones` did
more: Base sends a real element type to `zero`, which names `Zeros` outright.

`has_fill_rule` was a second table that had to agree with the rules themselves,
entry for entry. Holding the rules on `fill_rule` alone, with `nothing` for "no
rule", leaves one table and no way for the two to drift apart.

The `*`-against-a-range rules were `_shift_range` with a different operator, so
they join its loop, and `_iszeros`/`_isones` fold into their only caller, which
was evaluating their shared guard twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_range_convert` reached for `OneTo` and the colon constructors, which need more of
the element type than arithmetic: an integer to count up to, and a value that can be
counted from one endpoint to the other. An element type offering neither threw
instead of returning a range, so `Ones{ComplexF64}(3) .* (1:3)` and even
`Ones{Float64}(3) .* OneTo(3)` were errors.

`_rebuild_range` now dispatches on the element type and falls through to the same
`steprangelen` that `cumsum` builds, which asks only for arithmetic and which
InfiniteArrays.jl already overloads. Each rule still hands back the most structured
range its element type supports, so `OneTo` and `UnitRange` results are unchanged.

Dispatching on the element type inside a separate function, rather than narrowing the
`_range_convert` methods themselves, keeps their order intact: narrowing them to
`T<:Real` let the converting method beat the one that hands back a range whose element
type already matches, which broke `Ones{Int}(∞) .* OneToInf()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Zeros`-against-a-range entries in the loop exist to break a tie with the
`Zeros` rules attached to the fill styles, which only `+` and `-` have. Scaling
resolves before any style is consulted, on the styleless rule for a `Zeros`
against an `AbstractArray{<:Number}`, so its two entries never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jishnub and others added 5 commits August 16, 2026 12:51
The method breaking the tie between the two one-fill methods was the only
line of the branch the test suite never ran: nothing in Base reaches it, as
`+` and `-` on a pair of fills have methods of their own. Call it directly,
the way the neighbouring zero-dimensional tests already do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Ones .* Ones`, and the `/` and `\` forms alongside it, took the general path
to the same answer: the fill value is one, every argument has a static value,
so `_copy_fill` reaches `broadcasted_ones` on its own. `_broadcasted_ones` had
no other caller once they were gone.

`_range_convert` for an `AbstractUnitRange{T}` was a strict subtype of the
`AbstractRange{T}` method above it and returned the same thing. Its comment
described the `AbstractUnitRange` method that `_rebuild_range` has since
replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The methods catching the calls a package forwards through `DefaultArrayStyle`
named their arguments one by one, so only a unary or binary broadcast reached
the fill rules; `+.(F, F, F)` under such a style rendered an `Array`.

Dispatch cannot ask whether any argument is a fill, only whether the one in a
given position is, and covering the first k positions costs a method per
non-empty subset of them. Taking the arguments as a vararg from the second
position on covers every arity for three methods rather than four, and a fill
behind two non-fills stays Base's to render.

The `literal_pow` shape now matches the second of them, and is still needed to
unwrap the `Ref`s that hide its fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither operand loses precision on the way: the fill path evaluates the
operation once on the fill values and the dense path evaluates the same
operation elementwise, so the two agree bit for bit. A tolerance would hide a
wrong value, which is the whole of what these two lines check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jishnub
jishnub requested a review from dlfivefifty August 16, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants