Skip to content

Commit 7265587

Browse files
Merge pull request #66 from ChrisRackauckas-Claude/islinear-isquadratic
[WIP – ignore until reviewed by @ChrisRackauckas] Add islinear/isquadratic: polynomial-degree certification by tracer types
2 parents e580452 + 0799363 commit 7265587

8 files changed

Lines changed: 832 additions & 580 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "FunctionProperties"
22
uuid = "f62d2435-5019-4c03-9749-2d4c77af0cbc"
3-
version = "1.0.0"
3+
version = "1.1.0"
44
authors = ["SciML"]
55

66
[deps]

docs/src/api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@
22

33
```@docs
44
hasbranching
5+
islinear
6+
isquadratic
57
is_leaf
68
```

docs/src/assets/Project.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[deps]
2+
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
3+
FunctionProperties = "f62d2435-5019-4c03-9749-2d4c77af0cbc"
4+
5+
[compat]
6+
Documenter = "1"
7+
FunctionProperties = "1"

src/FunctionProperties.jl

Lines changed: 7 additions & 578 deletions
Large diffs are not rendered by default.

src/hasbranching.jl

Lines changed: 578 additions & 0 deletions
Large diffs are not rendered by default.

src/polydegree.jl

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Certification by abstract interpretation over polynomial degrees, in the style of tracer-type
2+
# sparsity detection (Gowda et al., "Sparsity Programming", NeurIPS 2019 program-transformations
3+
# workshop): the tracked arguments are seeded with degree-1 tracer numbers and the program is
4+
# executed on them, propagating a sound upper bound on the real-arithmetic polynomial degree.
5+
# Non-polynomial operations on non-constant values poison the result, and every value-inspecting
6+
# predicate on a tracer (comparisons, `isnan`, rounding, conversion) throws, so any computation
7+
# whose control flow or value flow would need the actual numbers aborts the trace instead of
8+
# producing an unsound certificate. `hasbranching` is additionally required to prove the traced
9+
# path is the only path.
10+
11+
# Degrees saturate at `_NOTPOLY` ("not a polynomial of any bounded degree").
12+
const _NOTPOLY = typemax(Int) ÷ 2
13+
14+
struct PolyDegree <: Real
15+
d::Int
16+
end
17+
18+
_satadd(a::Int, b::Int) = a >= _NOTPOLY || b >= _NOTPOLY ? _NOTPOLY : a + b
19+
_satmul(a::Int, b::Int) = a >= _NOTPOLY || b >= _NOTPOLY ? _NOTPOLY : min(a * b, _NOTPOLY)
20+
21+
Base.promote_rule(::Type{PolyDegree}, ::Type{<:Real}) = PolyDegree
22+
Base.convert(::Type{PolyDegree}, x::PolyDegree) = x
23+
Base.convert(::Type{PolyDegree}, x::Real) = PolyDegree(0)
24+
PolyDegree(x::PolyDegree) = x
25+
# Disambiguators against Base's cross-family `Number` constructors: any value converted into the
26+
# tracer domain is by definition not a seeded variable, hence a constant (degree 0).
27+
PolyDegree(::Base.TwicePrecision) = PolyDegree(0)
28+
PolyDegree(::Complex) = PolyDegree(0)
29+
PolyDegree(::AbstractChar) = PolyDegree(0)
30+
Base.zero(::Type{PolyDegree}) = PolyDegree(0)
31+
Base.zero(::PolyDegree) = PolyDegree(0)
32+
Base.one(::Type{PolyDegree}) = PolyDegree(0)
33+
Base.one(::PolyDegree) = PolyDegree(0)
34+
Base.oneunit(::Type{PolyDegree}) = PolyDegree(0)
35+
Base.float(x::PolyDegree) = x
36+
Base.widen(::Type{PolyDegree}) = PolyDegree
37+
38+
Base.:+(a::PolyDegree, b::PolyDegree) = PolyDegree(max(a.d, b.d))
39+
Base.:-(a::PolyDegree, b::PolyDegree) = PolyDegree(max(a.d, b.d))
40+
Base.:-(a::PolyDegree) = a
41+
Base.:+(a::PolyDegree) = a
42+
Base.:*(a::PolyDegree, b::PolyDegree) = PolyDegree(_satadd(a.d, b.d))
43+
Base.:/(a::PolyDegree, b::PolyDegree) = b.d == 0 ? a : PolyDegree(_NOTPOLY)
44+
Base.:\(a::PolyDegree, b::PolyDegree) = a.d == 0 ? b : PolyDegree(_NOTPOLY)
45+
Base.inv(a::PolyDegree) = a.d == 0 ? a : PolyDegree(_NOTPOLY)
46+
Base.muladd(a::PolyDegree, b::PolyDegree, c::PolyDegree) = a * b + c
47+
Base.fma(a::PolyDegree, b::PolyDegree, c::PolyDegree) = a * b + c
48+
Base.abs2(a::PolyDegree) = a * a
49+
Base.conj(a::PolyDegree) = a
50+
Base.real(a::PolyDegree) = a
51+
52+
function Base.:^(a::PolyDegree, n::Integer)
53+
n == 0 && return PolyDegree(0)
54+
n > 0 && return PolyDegree(_satmul(a.d, Int(n)))
55+
return a.d == 0 ? a : PolyDegree(_NOTPOLY)
56+
end
57+
Base.:^(a::PolyDegree, b::PolyDegree) =
58+
a.d == 0 && b.d == 0 ? PolyDegree(0) : PolyDegree(_NOTPOLY)
59+
60+
# Non-polynomial scalar functions: constants map to constants; anything else poisons.
61+
for fn in (
62+
:sqrt, :cbrt, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, :log1p,
63+
:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, :acosh,
64+
:atanh, :sinpi, :cospi, :sec, :csc, :cot, :abs, :sign,
65+
)
66+
@eval Base.$fn(a::PolyDegree) = a.d == 0 ? a : PolyDegree(_NOTPOLY)
67+
end
68+
Base.atan(a::PolyDegree, b::PolyDegree) =
69+
a.d == 0 && b.d == 0 ? PolyDegree(0) : PolyDegree(_NOTPOLY)
70+
Base.hypot(a::PolyDegree, b::PolyDegree) =
71+
a.d == 0 && b.d == 0 ? PolyDegree(0) : PolyDegree(_NOTPOLY)
72+
Base.mod(a::PolyDegree, b::PolyDegree) = PolyDegree(_NOTPOLY)
73+
Base.rem(a::PolyDegree, b::PolyDegree) = PolyDegree(_NOTPOLY)
74+
75+
struct DegreeTracerError <: Exception
76+
op::Symbol
77+
end
78+
function Base.showerror(io::IO, e::DegreeTracerError)
79+
return print(
80+
io, "DegreeTracerError: `", e.op, "` needs the value of a traced number, which a ",
81+
"degree tracer does not carry; the polynomial-degree certificate is abandoned."
82+
)
83+
end
84+
85+
# Every predicate or conversion that would need the traced VALUE aborts the trace: allowing any
86+
# of these to answer would let value-dependent control or value flow leak into the certificate.
87+
for fn in (:isless, :(==), :<, :(<=), :isequal)
88+
@eval Base.$fn(a::PolyDegree, b::PolyDegree) = throw(DegreeTracerError($(QuoteNode(fn))))
89+
end
90+
for fn in (:isnan, :isinf, :isfinite, :iszero, :isone, :signbit, :isinteger)
91+
@eval Base.$fn(a::PolyDegree) = throw(DegreeTracerError($(QuoteNode(fn))))
92+
end
93+
for fn in (:floor, :ceil, :trunc, :round)
94+
@eval Base.$fn(a::PolyDegree) = throw(DegreeTracerError($(QuoteNode(fn))))
95+
end
96+
97+
_seed(x::Real) = PolyDegree(1)
98+
_seed(x::AbstractArray{<:Real}) = map(_ -> PolyDegree(1), x)
99+
_seed(x) = throw(DegreeTracerError(:seed))
100+
101+
_max_degree(y::PolyDegree) = y.d
102+
_max_degree(y::Real) = 0
103+
_max_degree(y::AbstractArray) = isempty(y) ? 0 : maximum(_max_degree, y)
104+
_max_degree(y::Tuple) = isempty(y) ? 0 : maximum(_max_degree, y)
105+
_max_degree(@nospecialize(y)) = _NOTPOLY
106+
107+
_wrt_indices(wrt::Integer, n) = (Int(wrt),)
108+
_wrt_indices(wrt::Colon, n) = ntuple(identity, n)
109+
_wrt_indices(wrt, n) = Tuple(Int.(collect(wrt)))
110+
111+
# Certified upper bound on the real-arithmetic polynomial degree of `f` in the arguments selected
112+
# by `wrt`, or `_NOTPOLY` when no certificate can be produced (non-polynomial operations reached
113+
# non-constant values, the trace aborted, or `f` branches on values).
114+
function _degree_bound(f, args, wrt)
115+
idx = _wrt_indices(wrt, length(args))
116+
all(i -> 1 <= i <= length(args), idx) || throw(ArgumentError("wrt index out of range"))
117+
d = try
118+
targs = ntuple(i -> i in idx ? _seed(args[i]) : args[i], length(args))
119+
_max_degree(f(targs...))
120+
catch
121+
_NOTPOLY
122+
end
123+
d < _NOTPOLY || return _NOTPOLY
124+
# The trace certifies the executed path; `hasbranching` certifies it is the only path.
125+
return hasbranching(f, args...) ? _NOTPOLY : d
126+
end
127+
128+
"""
129+
islinear(f, x...; wrt = 1) -> Bool
130+
131+
Attempt to *prove* that `f` is an affine (polynomial degree ≤ 1) function of the arguments
132+
selected by `wrt` (an index, collection of indices, or `:` for all; default the first argument),
133+
holding the remaining arguments fixed at the values given. Arrays are tracked elementwise.
134+
135+
`true` is a certificate under real arithmetic: the degree bound is established by abstract
136+
interpretation with degree-tracking tracer numbers, and [`hasbranching`](@ref) additionally
137+
proves the traced path is the only path. `false` means *not proven* -- `f` may still be linear
138+
(e.g. the bound does not model cancellation: `x^2 - x^2 + x` is not certified), so use `false`
139+
as "fall back to the general path", never as a proof of nonlinearity.
140+
141+
```jldoctest
142+
julia> using FunctionProperties
143+
144+
julia> islinear((u, p, t) -> p[1] * u[1] + p[2], [1.0], [2.0, 3.0], 0.0)
145+
true
146+
147+
julia> islinear((u, p, t) -> u[1] * u[2], [1.0, 2.0], nothing, 0.0)
148+
false
149+
```
150+
"""
151+
function islinear(f, x...; wrt = 1)
152+
return _degree_bound(f, x, wrt) <= 1
153+
end
154+
155+
"""
156+
isquadratic(f, x...; wrt = 1) -> Bool
157+
158+
Attempt to *prove* that `f` is a polynomial of degree ≤ 2 in the arguments selected by `wrt`,
159+
holding the remaining arguments fixed. Same certification semantics and conservatism as
160+
[`islinear`](@ref): `true` is a proof under real arithmetic, `false` means not proven.
161+
162+
```jldoctest
163+
julia> using FunctionProperties
164+
165+
julia> isquadratic((u, p, t) -> u[1] * u[2] + p[1] * u[1], [1.0, 2.0], [3.0], 0.0)
166+
true
167+
168+
julia> isquadratic((u, p, t) -> exp(u[1]), [1.0], nothing, 0.0)
169+
false
170+
```
171+
"""
172+
function isquadratic(f, x...; wrt = 1)
173+
return _degree_bound(f, x, wrt) <= 2
174+
end

test/core_tests.jl

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,62 @@ wrap_cmp(x, t) = x > t ? x : t
275275
@test FunctionProperties.hasbranching(Base.Fix1(wrap_cmp, 0.0), 1.0)
276276
@test FunctionProperties.hasbranching(Base.Fix2(wrap_cmp, 0.0), 1.0)
277277
@test !FunctionProperties.hasbranching(Base.Fix2(*, 2.0), 1.0)
278+
279+
# ---------------------------------------------------------------------------------------------
280+
# `islinear` / `isquadratic`: degree certification by tracer-type abstract interpretation.
281+
# `true` is a proof under real arithmetic; `false` is only "not proven".
282+
A_lin = [1.0 2.0; 3.0 4.0]
283+
@test islinear((u, p, t) -> p[1] * u[1] + p[2], [1.0], [2.0, 3.0], 0.0)
284+
@test islinear(u -> A_lin * u, [1.0, 2.0]) # generic matmul certifies
285+
@test islinear(u -> A_lin * u .+ 1.0, [1.0, 2.0])
286+
@test islinear(x -> 2x + 3, 1.0)
287+
@test islinear(x -> 0.0, 1.0) # constants are affine
288+
@test !islinear((u, p, t) -> u[1] * u[2], [1.0, 2.0], nothing, 0.0)
289+
@test isquadratic((u, p, t) -> u[1] * u[2] + p[1] * u[1], [1.0, 2.0], [3.0], 0.0)
290+
@test isquadratic(x -> (x + 1.0)^2, 1.0)
291+
@test !islinear(x -> (x + 1.0)^2, 1.0)
292+
@test !isquadratic(x -> x^3, 1.0)
293+
@test !isquadratic(u -> exp(u[1]), [1.0])
294+
@test !islinear(u -> max.(u, 0.0), [1.0]) # tracer aborts on comparison
295+
@test !islinear(x -> x > 0 ? x : zero(x), 1.0) # relu-style branch
296+
@test !islinear(x -> x * x - x * x + x, 1.0) # cancellation: conservative, documented
297+
# `wrt` semantics: joint degree in the tracked arguments, others held fixed.
298+
@test islinear((u, v) -> u[1] + v[1], [1.0], [1.0]; wrt = (1, 2))
299+
@test !islinear((u, v) -> u[1] * v[1], [1.0], [1.0]; wrt = (1, 2))
300+
@test islinear((u, p) -> u[1] * p[1], [1.0], [2.0]) # linear in u for fixed p
301+
@test islinear((u, p) -> u[1] * p[1], [1.0], [2.0]; wrt = :) == false
302+
# A branch on an UNTRACKED argument still blocks certification (`hasbranching` guard): the
303+
# function is linear in `u` for the given `p`, but the certificate is conservatively withheld.
304+
@test !islinear((u, p, t) -> p[1] > 0 ? u[1] : 2u[1], [1.0], [1.0], 0.0)
305+
# In-place right-hand sides via the closure pattern.
306+
rhs_ip!(du, u, p, t) = (du[1] = p[1] * u[1]; du[2] = u[1] + u[2]; du)
307+
@test islinear(u -> rhs_ip!(similar(u), u, [2.0], 0.0), [1.0, 2.0])
308+
# Certified results are plain Bools; the tracer type does not leak.
309+
@test islinear(x -> 2x, 1.0) isa Bool
310+
311+
# Ground-truth cross-validation: every `true` linear certificate must have exactly vanishing
312+
# second finite differences over exact rational arithmetic (and third differences for quadratic
313+
# certificates) at random rational points -- a soundness check independent of the tracer rules.
314+
let rng = Random.Xoshiro(0x1517)
315+
d2(f, x, h1, h2) = f(x + h1 + h2) - f(x + h1) - f(x + h2) + f(x)
316+
corpusf = [
317+
(x -> 3 // 2 * x + 7, true),
318+
(x -> x * (x + 1) - x * x, true), # cancellation: genuinely linear...
319+
(x -> (x + 2) * 5 - 3, true),
320+
(x -> x^2 + x, false),
321+
(x -> x^3 - x, false),
322+
(x -> x * x * 2 + 1, false),
323+
]
324+
for (f, lin_truth) in corpusf
325+
cert = islinear(f, 1.0)
326+
# soundness: a certificate implies exact linearity at random rational probes
327+
if cert
328+
for _ in 1:3
329+
x, h1, h2 = (Rational{BigInt}(rand(rng, -99:99)) // rand(rng, 1:9) for _ in 1:3)
330+
@test iszero(d2(f, x, h1, h2))
331+
end
332+
end
333+
# no false certificates on the known-nonlinear corpus
334+
lin_truth || @test !cert
335+
end
336+
end

test/qa/qa.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ using SciMLTesting, FunctionProperties, JET, Test
2121
# method with constant argument types". This dependency is deliberately confined behind a
2222
# functional capability probe (`_const_prop_capable`) so the package degrades to the plain
2323
# type scan wherever these internals change shape.
24+
# - `TwicePrecision` appears only in a constructor disambiguation that Aqua requires: Base
25+
# defines `(::Type{T<:Number})(::Base.TwicePrecision)`, which is ambiguous against the
26+
# degree tracer's generic constructor.
2427
# All of these are Core/Base compiler-introspection internals with no public API, so they are
2528
# ignored in the public-API checks.
2629
run_qa(
@@ -32,7 +35,7 @@ run_qa(
3235
ignore = (
3336
:Typeof, :Argument, :CodeInfo, :Compiler, :Const, :GotoNode,
3437
:InferenceResult, :InferenceState, :MethodInstance, :NativeInterpreter,
35-
:NewvarNode, :PartialStruct, :ReturnNode, :SSAValue, :SlotNumber,
38+
:NewvarNode, :PartialStruct, :ReturnNode, :SSAValue, :SlotNumber, :TwicePrecision,
3639
:code_typed_by_type, :get_world_counter, :retrieve_code_info,
3740
:specialize_method, :svec, :typeinf,
3841
),

0 commit comments

Comments
 (0)