diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8f0196a..459b335 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -28,7 +28,7 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: julia-actions/setup-julia@v2 with: @@ -49,8 +49,3 @@ jobs: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true - - - uses: coverallsapp/github-action@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: lcov.info diff --git a/README.md b/README.md index f92ecab..2e3ca90 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![CI](https://github.com/TuringLang/AbstractPPL.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/TuringLang/AbstractPPL.jl/actions/workflows/CI.yml?query=branch%3Amain) [![IntegrationTest](https://github.com/TuringLang/AbstractPPL.jl/actions/workflows/IntegrationTest.yml/badge.svg?branch=main)](https://github.com/TuringLang/AbstractPPL.jl/actions/workflows/IntegrationTest.yml?query=branch%3Amain) -[![Coverage Status](https://coveralls.io/repos/github/TuringLang/AbstractPPL.jl/badge.svg?branch=main)](https://coveralls.io/github/TuringLang/AbstractPPL.jl?branch=main) [![Codecov](https://codecov.io/gh/TuringLang/AbstractPPL.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/TuringLang/AbstractPPL.jl) A light-weight package to factor out interfaces and associated APIs for modelling languages for diff --git a/src/AbstractPPL.jl b/src/AbstractPPL.jl index 30a4f7e..d758578 100644 --- a/src/AbstractPPL.jl +++ b/src/AbstractPPL.jl @@ -29,11 +29,14 @@ export AbstractProbabilisticProgram, # Abstract traces export AbstractModelTrace -include("varname.jl") include("abstractmodeltrace.jl") include("abstractprobprog.jl") include("evaluate.jl") -include("varname_leaves.jl") -include("hasvalue.jl") +include("varname/varname.jl") +include("varname/subsumes.jl") +include("varname/hasvalue.jl") +include("varname/leaves.jl") +include("varname/prefix.jl") +include("varname/serialize.jl") end # module diff --git a/src/hasvalue.jl b/src/varname/hasvalue.jl similarity index 100% rename from src/hasvalue.jl rename to src/varname/hasvalue.jl diff --git a/src/varname_leaves.jl b/src/varname/leaves.jl similarity index 100% rename from src/varname_leaves.jl rename to src/varname/leaves.jl diff --git a/src/varname/prefix.jl b/src/varname/prefix.jl new file mode 100644 index 0000000..25d4485 --- /dev/null +++ b/src/varname/prefix.jl @@ -0,0 +1,116 @@ +### Functionality for prefixing and unprefixing VarNames. + +""" + optic_to_vn(optic) + +Convert an Accessors optic to a VarName. This is best explained through +examples. + +```jldoctest; setup=:(using Accessors) +julia> AbstractPPL.optic_to_vn(Accessors.@o _.a) +a + +julia> AbstractPPL.optic_to_vn(Accessors.@o _.a.b) +a.b + +julia> AbstractPPL.optic_to_vn(Accessors.@o _.a[1]) +a[1] +``` + +The outermost layer of the optic (technically, what Accessors.jl calls the +'innermost') must be a `PropertyLens`, or else it will fail. This is because a +VarName needs to have a symbol. + +```jldoctest; setup=:(using Accessors) +julia> AbstractPPL.optic_to_vn(Accessors.@o _[1]) +ERROR: ArgumentError: optic_to_vn: could not convert optic `(@o _[1])` to a VarName +[...] +``` +""" +function optic_to_vn(::Accessors.PropertyLens{sym}) where {sym} + return VarName{sym}() +end +function optic_to_vn( + o::ComposedFunction{Outer,Accessors.PropertyLens{sym}} +) where {Outer,sym} + return VarName{sym}(o.outer) +end +optic_to_vn(o::ComposedFunction) = optic_to_vn(normalise(o)) +function optic_to_vn(@nospecialize(o)) + msg = "optic_to_vn: could not convert optic `$o` to a VarName" + throw(ArgumentError(msg)) +end + +unprefix_optic(o, ::typeof(identity)) = o # Base case +function unprefix_optic(optic, optic_prefix) + # Technically `unprefix_optic` only receives optics that were part of + # VarNames, so the optics should already be normalised (in the inner + # constructor of the VarName). However I guess it doesn't hurt to do it + # again to be safe. + optic = normalise(optic) + optic_prefix = normalise(optic_prefix) + # strip one layer of the optic and check for equality + head = _head(optic) + head_prefix = _head(optic_prefix) + if head != head_prefix + msg = "could not remove prefix $(optic_prefix) from optic $(optic)" + throw(ArgumentError(msg)) + end + # recurse + return unprefix_optic(_tail(optic), _tail(optic_prefix)) +end + +""" + unprefix(vn::VarName, prefix::VarName) + +Remove a prefix from a VarName. + +```jldoctest +julia> AbstractPPL.unprefix(@varname(y.x), @varname(y)) +x + +julia> AbstractPPL.unprefix(@varname(y.x.a), @varname(y)) +x.a + +julia> AbstractPPL.unprefix(@varname(y[1].x), @varname(y[1])) +x + +julia> AbstractPPL.unprefix(@varname(y), @varname(n)) +ERROR: ArgumentError: could not remove prefix n from VarName y +[...] +``` +""" +function unprefix( + vn::VarName{sym_vn}, prefix::VarName{sym_prefix} +) where {sym_vn,sym_prefix} + if sym_vn != sym_prefix + msg = "could not remove prefix $(prefix) from VarName $(vn)" + throw(ArgumentError(msg)) + end + optic_vn = getoptic(vn) + optic_prefix = getoptic(prefix) + return optic_to_vn(unprefix_optic(optic_vn, optic_prefix)) +end + +""" + prefix(vn::VarName, prefix::VarName) + +Add a prefix to a VarName. + +```jldoctest +julia> AbstractPPL.prefix(@varname(x), @varname(y)) +y.x + +julia> AbstractPPL.prefix(@varname(x.a), @varname(y)) +y.x.a + +julia> AbstractPPL.prefix(@varname(x.a), @varname(y[1])) +y[1].x.a +``` +""" +function prefix(vn::VarName{sym_vn}, prefix::VarName{sym_prefix}) where {sym_vn,sym_prefix} + optic_vn = getoptic(vn) + optic_prefix = getoptic(prefix) + new_optic_vn = optic_vn ∘ PropertyLens{sym_vn}() ∘ optic_prefix + return VarName{sym_prefix}(new_optic_vn) +end diff --git a/src/varname/serialize.jl b/src/varname/serialize.jl new file mode 100644 index 0000000..e27da78 --- /dev/null +++ b/src/varname/serialize.jl @@ -0,0 +1,174 @@ +### Serialisation to JSON / string + +using JSON: JSON + +# String constants for each index type that we support serialisation / +# deserialisation of +const _BASE_INTEGER_TYPE = "Base.Integer" +const _BASE_VECTOR_TYPE = "Base.Vector" +const _BASE_UNITRANGE_TYPE = "Base.UnitRange" +const _BASE_STEPRANGE_TYPE = "Base.StepRange" +const _BASE_ONETO_TYPE = "Base.OneTo" +const _BASE_COLON_TYPE = "Base.Colon" +const _CONCRETIZED_SLICE_TYPE = "AbstractPPL.ConcretizedSlice" +const _BASE_TUPLE_TYPE = "Base.Tuple" + +""" + index_to_dict(::Integer) + index_to_dict(::AbstractVector{Int}) + index_to_dict(::UnitRange) + index_to_dict(::StepRange) + index_to_dict(::Colon) + index_to_dict(::ConcretizedSlice{T, Base.OneTo{I}}) where {T, I} + index_to_dict(::Tuple) + +Convert an index `i` to a dictionary representation. +""" +index_to_dict(i::Integer) = Dict("type" => _BASE_INTEGER_TYPE, "value" => i) +index_to_dict(v::Vector{Int}) = Dict("type" => _BASE_VECTOR_TYPE, "values" => v) +function index_to_dict(r::UnitRange) + return Dict("type" => _BASE_UNITRANGE_TYPE, "start" => r.start, "stop" => r.stop) +end +function index_to_dict(r::StepRange) + return Dict( + "type" => _BASE_STEPRANGE_TYPE, + "start" => r.start, + "stop" => r.stop, + "step" => r.step, + ) +end +function index_to_dict(r::Base.OneTo{I}) where {I} + return Dict("type" => _BASE_ONETO_TYPE, "stop" => r.stop) +end +index_to_dict(::Colon) = Dict("type" => _BASE_COLON_TYPE) +function index_to_dict(s::ConcretizedSlice{T,R}) where {T,R} + return Dict("type" => _CONCRETIZED_SLICE_TYPE, "range" => index_to_dict(s.range)) +end +function index_to_dict(t::Tuple) + return Dict("type" => _BASE_TUPLE_TYPE, "values" => map(index_to_dict, t)) +end + +""" + dict_to_index(dict) + dict_to_index(symbol_val, dict) + +Convert a dictionary representation of an index `dict` to an index. + +Users can extend the functionality of `dict_to_index` (and hence `VarName` +de/serialisation) by extending this method along with [`index_to_dict`](@ref). +Specifically, suppose you have a custom index type `MyIndexType` and you want +to be able to de/serialise a `VarName` containing this index type. You should +then implement the following two methods: + +1. `AbstractPPL.index_to_dict(i::MyModule.MyIndexType)` should return a + dictionary representation of the index `i`. This dictionary must contain the + key `"type"`, and the corresponding value must be a string that uniquely + identifies the index type. Generally, it makes sense to use the name of the + type (perhaps prefixed with module qualifiers) as this value to avoid + clashes. The remainder of the dictionary can have any structure you like. + +2. Suppose the value of `index_to_dict(i)["type"]` is `"MyModule.MyIndexType"`. + You should then implement the corresponding method + `AbstractPPL.dict_to_index(::Val{Symbol("MyModule.MyIndexType")}, dict)`, + which should take the dictionary representation as the second argument and + return the original `MyIndexType` object. + +To see an example of this in action, you can look in the the AbstractPPL test +suite, which contains a test for serialising OffsetArrays. +""" +function dict_to_index(dict) + t = dict["type"] + if t == _BASE_INTEGER_TYPE + return dict["value"] + elseif t == _BASE_VECTOR_TYPE + return collect(Int, dict["values"]) + elseif t == _BASE_UNITRANGE_TYPE + return dict["start"]:dict["stop"] + elseif t == _BASE_STEPRANGE_TYPE + return dict["start"]:dict["step"]:dict["stop"] + elseif t == _BASE_ONETO_TYPE + return Base.OneTo(dict["stop"]) + elseif t == _BASE_COLON_TYPE + return Colon() + elseif t == _CONCRETIZED_SLICE_TYPE + return ConcretizedSlice(Base.Slice(dict_to_index(dict["range"]))) + elseif t == _BASE_TUPLE_TYPE + return tuple(map(dict_to_index, dict["values"])...) + else + # Will error if the method is not defined, but this hook allows users + # to extend this function + return dict_to_index(Val(Symbol(t)), dict) + end +end + +optic_to_dict(::typeof(identity)) = Dict("type" => "identity") +function optic_to_dict(::PropertyLens{sym}) where {sym} + return Dict("type" => "property", "field" => String(sym)) +end +optic_to_dict(i::IndexLens) = Dict("type" => "index", "indices" => index_to_dict(i.indices)) +function optic_to_dict(c::ComposedFunction) + return Dict( + "type" => "composed", + "outer" => optic_to_dict(c.outer), + "inner" => optic_to_dict(c.inner), + ) +end + +function dict_to_optic(dict) + if dict["type"] == "identity" + return identity + elseif dict["type"] == "index" + return IndexLens(dict_to_index(dict["indices"])) + elseif dict["type"] == "property" + return PropertyLens{Symbol(dict["field"])}() + elseif dict["type"] == "composed" + return dict_to_optic(dict["outer"]) ∘ dict_to_optic(dict["inner"]) + else + error("Unknown optic type: $(dict["type"])") + end +end + +function varname_to_dict(vn::VarName) + return Dict("sym" => getsym(vn), "optic" => optic_to_dict(getoptic(vn))) +end + +function dict_to_varname(dict::Dict{<:AbstractString,Any}) + return VarName{Symbol(dict["sym"])}(dict_to_optic(dict["optic"])) +end + +""" + varname_to_string(vn::VarName) + +Convert a `VarName` as a string, via an intermediate dictionary. This differs +from `string(vn)` in that concretised slices are faithfully represented (rather +than being pretty-printed as colons). + +For `VarName`s which index into an array, this function will only work if the +indices can be serialised. This is true for all standard Julia index types, but +if you are using custom index types, you will need to implement the +`index_to_dict` and `dict_to_index` methods for those types. See the +documentation of [`dict_to_index`](@ref) for instructions on how to do this. + +```jldoctest +julia> varname_to_string(@varname(x)) +"{\\"optic\\":{\\"type\\":\\"identity\\"},\\"sym\\":\\"x\\"}" + +julia> varname_to_string(@varname(x.a)) +"{\\"optic\\":{\\"field\\":\\"a\\",\\"type\\":\\"property\\"},\\"sym\\":\\"x\\"}" + +julia> y = ones(2); varname_to_string(@varname(y[:])) +"{\\"optic\\":{\\"indices\\":{\\"values\\":[{\\"type\\":\\"Base.Colon\\"}],\\"type\\":\\"Base.Tuple\\"},\\"type\\":\\"index\\"},\\"sym\\":\\"y\\"}" + +julia> y = ones(2); varname_to_string(@varname(y[:], true)) +"{\\"optic\\":{\\"indices\\":{\\"values\\":[{\\"range\\":{\\"stop\\":2,\\"type\\":\\"Base.OneTo\\"},\\"type\\":\\"AbstractPPL.ConcretizedSlice\\"}],\\"type\\":\\"Base.Tuple\\"},\\"type\\":\\"index\\"},\\"sym\\":\\"y\\"}" +``` +""" +varname_to_string(vn::VarName) = JSON.json(varname_to_dict(vn)) + +""" + string_to_varname(str::AbstractString) + +Convert a string representation of a `VarName` back to a `VarName`. The string +should have been generated by `varname_to_string`. +""" +string_to_varname(str::AbstractString) = dict_to_varname(JSON.parse(str)) diff --git a/src/varname/subsumes.jl b/src/varname/subsumes.jl new file mode 100644 index 0000000..0af5cb4 --- /dev/null +++ b/src/varname/subsumes.jl @@ -0,0 +1,236 @@ +""" + inspace(vn::Union{VarName, Symbol}, space::Tuple) + +Check whether `vn`'s variable symbol is in `space`. The empty tuple counts as the "universal space" +containing all variables. Subsumption (see [`subsumes`](@ref)) is respected. + +## Examples + +```jldoctest +julia> inspace(@varname(x[1][2:3]), ()) +true + +julia> inspace(@varname(x[1][2:3]), (:x,)) +true + +julia> inspace(@varname(x[1][2:3]), (@varname(x),)) +true + +julia> inspace(@varname(x[1][2:3]), (@varname(x[1:10]), :y)) +true + +julia> inspace(@varname(x[1][2:3]), (@varname(x[:][2:4]), :y)) +true + +julia> inspace(@varname(x[1][2:3]), (@varname(x[1:10]),)) +true +``` +""" +inspace(vn, space::Tuple{}) = true # empty tuple is treated as universal space +inspace(vn, space::Tuple) = vn in space +inspace(vn::VarName, space::Tuple{}) = true +inspace(vn::VarName, space::Tuple) = any(_in(vn, s) for s in space) + +_in(vn::VarName, s::Symbol) = getsym(vn) == s +_in(vn::VarName, s::VarName) = subsumes(s, vn) + +""" + subsumes(u::VarName, v::VarName) + +Check whether the variable name `v` describes a sub-range of the variable `u`. Supported +indexing: + + - Scalar: + + ```jldoctest + julia> subsumes(@varname(x), @varname(x[1, 2])) + true + + julia> subsumes(@varname(x[1, 2]), @varname(x[1, 2][3])) + true + ``` + + - Array of scalar: basically everything that fulfills `issubset`. + + ```jldoctest + julia> subsumes(@varname(x[[1, 2], 3]), @varname(x[1, 3])) + true + + julia> subsumes(@varname(x[1:3]), @varname(x[2][1])) + true + ``` + + - Slices: + + ```jldoctest + julia> subsumes(@varname(x[2, :]), @varname(x[2, 10][1])) + true + ``` + +Currently _not_ supported are: + + - Boolean indexing, literal `CartesianIndex` (these could be added, though) + - Linear indexing of multidimensional arrays: `x[4]` does not subsume `x[2, 2]` for a matrix `x` + - Trailing ones: `x[2, 1]` does not subsume `x[2]` for a vector `x` +""" +function subsumes(u::VarName, v::VarName) + return getsym(u) == getsym(v) && subsumes(getoptic(u), getoptic(v)) +end + +# Idea behind `subsumes` for `Lens` is that we traverse the two lenses in parallel, +# checking `subsumes` for every level. This for example means that if we are comparing +# `PropertyLens{:a}` and `PropertyLens{:b}` we immediately know that they do not subsume +# each other since at the same level/depth they access different properties. +# E.g. `x`, `x[1]`, i.e. `u` is always subsumed by `t` +subsumes(::typeof(identity), ::typeof(identity)) = true +subsumes(::typeof(identity), ::ALLOWED_OPTICS) = true +subsumes(::ALLOWED_OPTICS, ::typeof(identity)) = false + +function subsumes(t::ComposedFunction, u::ComposedFunction) + return subsumes(t.outer, u.outer) && subsumes(t.inner, u.inner) +end + +# If `t` is still a composed lens, then there is no way it can subsume `u` since `u` is a +# leaf of the "lens-tree". +subsumes(t::ComposedFunction, u::PropertyLens) = false +# Here we need to check if `u.inner` (i.e. the next lens to be applied from `u`) is +# subsumed by `t`, since this would mean that the rest of the composition is also subsumed +# by `t`. +subsumes(t::PropertyLens, u::ComposedFunction) = subsumes(t, u.inner) + +# For `PropertyLens` either they have the same `name` and thus they are indeed the same. +subsumes(t::PropertyLens{name}, u::PropertyLens{name}) where {name} = true +# Otherwise they represent different properties, and thus are not the same. +subsumes(t::PropertyLens, u::PropertyLens) = false + +# Indices subsumes if they are subindices, i.e. we just call `_issubindex`. +# FIXME: Does not support `DynamicIndexLens`. +# FIXME: Does not correctly handle cases such as `subsumes(x, x[:])` +# (but neither did old implementation). +function subsumes( + t::Union{IndexLens,ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}}, + u::Union{IndexLens,ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}}, +) + return subsumes_indices(t, u) +end + +""" + subsumedby(t, u) + +True if `t` is subsumed by `u`, i.e., if `subsumes(u, t)` is true. +""" +subsumedby(t, u) = subsumes(u, t) +uncomparable(t, u) = t ⋢ u && u ⋢ t +const ⊒ = subsumes +const ⊑ = subsumedby +const ⋣ = !subsumes +const ⋢ = !subsumedby +const ≍ = uncomparable + +# Since expressions such as `x[:][:][:][1]` and `x[1]` are equal, +# the indexing behavior must be considered jointly. +# Therefore we must recurse until we reach something that is NOT +# indexing, and then consider the sequence of indices leading up to this. +""" + subsumes_indices(t, u) + +Return `true` if the indexing represented by `t` subsumes `u`. + +This is mostly useful for comparing compositions involving `IndexLens` +e.g. `_[1][2].a[2]` and `_[1][2].a`. In such a scenario we do the following: +1. Combine `[1][2]` into a `Tuple` of indices using [`combine_indices`](@ref). +2. Do the same for `[1][2]`. +3. Compare the two tuples from (1) and (2) using `subsumes_indices`. +4. Since we're still undecided, we call `subsume(@o(_.a[2]), @o(_.a))` + which then returns `false`. + +# Example +```jldoctest; setup=:(using Accessors; using AbstractPPL: subsumes_indices) +julia> t = @o(_[1].a); u = @o(_[1]); + +julia> subsumes_indices(t, u) +false + +julia> subsumes_indices(u, t) +true + +julia> # `identity` subsumes all. + subsumes_indices(identity, t) +true + +julia> # None subsumes `identity`. + subsumes_indices(t, identity) +false + +julia> AbstractPPL.subsumes(@o(_[1][2].a[2]), @o(_[1][2].a)) +false + +julia> AbstractPPL.subsumes(@o(_[1][2].a), @o(_[1][2].a[2])) +true +``` +""" +function subsumes_indices(t::ALLOWED_OPTICS, u::ALLOWED_OPTICS) + t_indices, t_next = combine_indices(t) + u_indices, u_next = combine_indices(u) + + # If we already know that `u` is not subsumed by `t`, return early. + if !subsumes_indices(t_indices, u_indices) + return false + end + + if t_next === nothing + # Means that there's nothing left for `t` and either nothing + # or something left for `u`, i.e. `t` indeed `subsumes` `u`. + return true + elseif u_next === nothing + # If `t_next` is not `nothing` but `u_next` is, then + # `t` does not subsume `u`. + return false + end + + # If neither is `nothing` we continue. + return subsumes(t_next, u_next) +end + +""" + combine_indices(optic) + +Return sequential indexing into a single `Tuple` of indices, +e.g. `x[:][1][2]` becomes `((Colon(), ), (1, ), (2, ))`. + +The result is compatible with [`subsumes_indices`](@ref) for `Tuple` input. +""" +combine_indices(optic::ALLOWED_OPTICS) = (), optic +combine_indices(optic::IndexLens) = (optic.indices,), nothing +function combine_indices(optic::ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}) + indices, next = combine_indices(optic.outer) + return (optic.inner.indices, indices...), next +end + +""" + subsumes_indices(left_indices::Tuple, right_indices::Tuple) + +Return `true` if `right_indices` is subsumed by `left_indices`. `left_indices` is assumed to be +concretized and consist of either `Int`s or `AbstractArray`s of scalar indices that are supported +by array A. + +Currently _not_ supported are: +- Boolean indexing, literal `CartesianIndex` (these could be added, though) +- Linear indexing of multidimensional arrays: `x[4]` does not subsume `x[2, 2]` for a matrix `x` +- Trailing ones: `x[2, 1]` does not subsume `x[2]` for a vector `x` +""" +subsumes_indices(::Tuple{}, ::Tuple{}) = true # x subsumes x +subsumes_indices(::Tuple{}, ::Tuple) = true # x subsumes x... +subsumes_indices(::Tuple, ::Tuple{}) = false # x... does not subsume x +function subsumes_indices(t1::Tuple, t2::Tuple) # does x[i]... subsume x[j]...? + first_subsumed = all(Base.splat(subsumes_index), zip(first(t1), first(t2))) + return first_subsumed && subsumes_indices(Base.tail(t1), Base.tail(t2)) +end + +subsumes_index(i::Colon, ::Colon) = error("Colons cannot be subsumed") +subsumes_index(i, ::Colon) = error("Colons cannot be subsumed") +# Necessary to avoid ambiguity errors. +subsumes_index(::AbstractVector, ::Colon) = error("Colons cannot be subsumed") +subsumes_index(i::Colon, j) = true +subsumes_index(i::AbstractVector, j) = issubset(j, i) +subsumes_index(i, j) = i == j diff --git a/src/varname.jl b/src/varname/varname.jl similarity index 53% rename from src/varname.jl rename to src/varname/varname.jl index fca1004..c2916de 100644 --- a/src/varname.jl +++ b/src/varname/varname.jl @@ -1,6 +1,5 @@ using Accessors using Accessors: PropertyLens, IndexLens, DynamicIndexLens -using JSON: JSON # nb. ComposedFunction is the same as Accessors.ComposedOptic const ALLOWED_OPTICS = Union{typeof(identity),PropertyLens,IndexLens,ComposedFunction} @@ -232,243 +231,6 @@ Symbol("x[1][:]") """ Base.Symbol(vn::VarName) = Symbol(string(vn)) # simplified symbol -""" - inspace(vn::Union{VarName, Symbol}, space::Tuple) - -Check whether `vn`'s variable symbol is in `space`. The empty tuple counts as the "universal space" -containing all variables. Subsumption (see [`subsumes`](@ref)) is respected. - -## Examples - -```jldoctest -julia> inspace(@varname(x[1][2:3]), ()) -true - -julia> inspace(@varname(x[1][2:3]), (:x,)) -true - -julia> inspace(@varname(x[1][2:3]), (@varname(x),)) -true - -julia> inspace(@varname(x[1][2:3]), (@varname(x[1:10]), :y)) -true - -julia> inspace(@varname(x[1][2:3]), (@varname(x[:][2:4]), :y)) -true - -julia> inspace(@varname(x[1][2:3]), (@varname(x[1:10]),)) -true -``` -""" -inspace(vn, space::Tuple{}) = true # empty tuple is treated as universal space -inspace(vn, space::Tuple) = vn in space -inspace(vn::VarName, space::Tuple{}) = true -inspace(vn::VarName, space::Tuple) = any(_in(vn, s) for s in space) - -_in(vn::VarName, s::Symbol) = getsym(vn) == s -_in(vn::VarName, s::VarName) = subsumes(s, vn) - -""" - subsumes(u::VarName, v::VarName) - -Check whether the variable name `v` describes a sub-range of the variable `u`. Supported -indexing: - - - Scalar: - - ```jldoctest - julia> subsumes(@varname(x), @varname(x[1, 2])) - true - - julia> subsumes(@varname(x[1, 2]), @varname(x[1, 2][3])) - true - ``` - - - Array of scalar: basically everything that fulfills `issubset`. - - ```jldoctest - julia> subsumes(@varname(x[[1, 2], 3]), @varname(x[1, 3])) - true - - julia> subsumes(@varname(x[1:3]), @varname(x[2][1])) - true - ``` - - - Slices: - - ```jldoctest - julia> subsumes(@varname(x[2, :]), @varname(x[2, 10][1])) - true - ``` - -Currently _not_ supported are: - - - Boolean indexing, literal `CartesianIndex` (these could be added, though) - - Linear indexing of multidimensional arrays: `x[4]` does not subsume `x[2, 2]` for a matrix `x` - - Trailing ones: `x[2, 1]` does not subsume `x[2]` for a vector `x` -""" -function subsumes(u::VarName, v::VarName) - return getsym(u) == getsym(v) && subsumes(getoptic(u), getoptic(v)) -end - -# Idea behind `subsumes` for `Lens` is that we traverse the two lenses in parallel, -# checking `subsumes` for every level. This for example means that if we are comparing -# `PropertyLens{:a}` and `PropertyLens{:b}` we immediately know that they do not subsume -# each other since at the same level/depth they access different properties. -# E.g. `x`, `x[1]`, i.e. `u` is always subsumed by `t` -subsumes(::typeof(identity), ::typeof(identity)) = true -subsumes(::typeof(identity), ::ALLOWED_OPTICS) = true -subsumes(::ALLOWED_OPTICS, ::typeof(identity)) = false - -function subsumes(t::ComposedFunction, u::ComposedFunction) - return subsumes(t.outer, u.outer) && subsumes(t.inner, u.inner) -end - -# If `t` is still a composed lens, then there is no way it can subsume `u` since `u` is a -# leaf of the "lens-tree". -subsumes(t::ComposedFunction, u::PropertyLens) = false -# Here we need to check if `u.inner` (i.e. the next lens to be applied from `u`) is -# subsumed by `t`, since this would mean that the rest of the composition is also subsumed -# by `t`. -subsumes(t::PropertyLens, u::ComposedFunction) = subsumes(t, u.inner) - -# For `PropertyLens` either they have the same `name` and thus they are indeed the same. -subsumes(t::PropertyLens{name}, u::PropertyLens{name}) where {name} = true -# Otherwise they represent different properties, and thus are not the same. -subsumes(t::PropertyLens, u::PropertyLens) = false - -# Indices subsumes if they are subindices, i.e. we just call `_issubindex`. -# FIXME: Does not support `DynamicIndexLens`. -# FIXME: Does not correctly handle cases such as `subsumes(x, x[:])` -# (but neither did old implementation). -function subsumes( - t::Union{IndexLens,ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}}, - u::Union{IndexLens,ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}}, -) - return subsumes_indices(t, u) -end - -""" - subsumedby(t, u) - -True if `t` is subsumed by `u`, i.e., if `subsumes(u, t)` is true. -""" -subsumedby(t, u) = subsumes(u, t) -uncomparable(t, u) = t ⋢ u && u ⋢ t -const ⊒ = subsumes -const ⊑ = subsumedby -const ⋣ = !subsumes -const ⋢ = !subsumedby -const ≍ = uncomparable - -# Since expressions such as `x[:][:][:][1]` and `x[1]` are equal, -# the indexing behavior must be considered jointly. -# Therefore we must recurse until we reach something that is NOT -# indexing, and then consider the sequence of indices leading up to this. -""" - subsumes_indices(t, u) - -Return `true` if the indexing represented by `t` subsumes `u`. - -This is mostly useful for comparing compositions involving `IndexLens` -e.g. `_[1][2].a[2]` and `_[1][2].a`. In such a scenario we do the following: -1. Combine `[1][2]` into a `Tuple` of indices using [`combine_indices`](@ref). -2. Do the same for `[1][2]`. -3. Compare the two tuples from (1) and (2) using `subsumes_indices`. -4. Since we're still undecided, we call `subsume(@o(_.a[2]), @o(_.a))` - which then returns `false`. - -# Example -```jldoctest; setup=:(using Accessors; using AbstractPPL: subsumes_indices) -julia> t = @o(_[1].a); u = @o(_[1]); - -julia> subsumes_indices(t, u) -false - -julia> subsumes_indices(u, t) -true - -julia> # `identity` subsumes all. - subsumes_indices(identity, t) -true - -julia> # None subsumes `identity`. - subsumes_indices(t, identity) -false - -julia> AbstractPPL.subsumes(@o(_[1][2].a[2]), @o(_[1][2].a)) -false - -julia> AbstractPPL.subsumes(@o(_[1][2].a), @o(_[1][2].a[2])) -true -``` -""" -function subsumes_indices(t::ALLOWED_OPTICS, u::ALLOWED_OPTICS) - t_indices, t_next = combine_indices(t) - u_indices, u_next = combine_indices(u) - - # If we already know that `u` is not subsumed by `t`, return early. - if !subsumes_indices(t_indices, u_indices) - return false - end - - if t_next === nothing - # Means that there's nothing left for `t` and either nothing - # or something left for `u`, i.e. `t` indeed `subsumes` `u`. - return true - elseif u_next === nothing - # If `t_next` is not `nothing` but `u_next` is, then - # `t` does not subsume `u`. - return false - end - - # If neither is `nothing` we continue. - return subsumes(t_next, u_next) -end - -""" - combine_indices(optic) - -Return sequential indexing into a single `Tuple` of indices, -e.g. `x[:][1][2]` becomes `((Colon(), ), (1, ), (2, ))`. - -The result is compatible with [`subsumes_indices`](@ref) for `Tuple` input. -""" -combine_indices(optic::ALLOWED_OPTICS) = (), optic -combine_indices(optic::IndexLens) = (optic.indices,), nothing -function combine_indices(optic::ComposedFunction{<:ALLOWED_OPTICS,<:IndexLens}) - indices, next = combine_indices(optic.outer) - return (optic.inner.indices, indices...), next -end - -""" - subsumes_indices(left_indices::Tuple, right_indices::Tuple) - -Return `true` if `right_indices` is subsumed by `left_indices`. `left_indices` is assumed to be -concretized and consist of either `Int`s or `AbstractArray`s of scalar indices that are supported -by array A. - -Currently _not_ supported are: -- Boolean indexing, literal `CartesianIndex` (these could be added, though) -- Linear indexing of multidimensional arrays: `x[4]` does not subsume `x[2, 2]` for a matrix `x` -- Trailing ones: `x[2, 1]` does not subsume `x[2]` for a vector `x` -""" -subsumes_indices(::Tuple{}, ::Tuple{}) = true # x subsumes x -subsumes_indices(::Tuple{}, ::Tuple) = true # x subsumes x... -subsumes_indices(::Tuple, ::Tuple{}) = false # x... does not subsume x -function subsumes_indices(t1::Tuple, t2::Tuple) # does x[i]... subsume x[j]...? - first_subsumed = all(Base.splat(subsumes_index), zip(first(t1), first(t2))) - return first_subsumed && subsumes_indices(Base.tail(t1), Base.tail(t2)) -end - -subsumes_index(i::Colon, ::Colon) = error("Colons cannot be subsumed") -subsumes_index(i, ::Colon) = error("Colons cannot be subsumed") -# Necessary to avoid ambiguity errors. -subsumes_index(::AbstractVector, ::Colon) = error("Colons cannot be subsumed") -subsumes_index(i::Colon, j) = true -subsumes_index(i::AbstractVector, j) = issubset(j, i) -subsumes_index(i, j) = i == j - """ ConcretizedSlice(::Base.Slice) @@ -787,181 +549,6 @@ function vsym(expr::Expr) end end -### Serialisation to JSON / string - -# String constants for each index type that we support serialisation / -# deserialisation of -const _BASE_INTEGER_TYPE = "Base.Integer" -const _BASE_VECTOR_TYPE = "Base.Vector" -const _BASE_UNITRANGE_TYPE = "Base.UnitRange" -const _BASE_STEPRANGE_TYPE = "Base.StepRange" -const _BASE_ONETO_TYPE = "Base.OneTo" -const _BASE_COLON_TYPE = "Base.Colon" -const _CONCRETIZED_SLICE_TYPE = "AbstractPPL.ConcretizedSlice" -const _BASE_TUPLE_TYPE = "Base.Tuple" - -""" - index_to_dict(::Integer) - index_to_dict(::AbstractVector{Int}) - index_to_dict(::UnitRange) - index_to_dict(::StepRange) - index_to_dict(::Colon) - index_to_dict(::ConcretizedSlice{T, Base.OneTo{I}}) where {T, I} - index_to_dict(::Tuple) - -Convert an index `i` to a dictionary representation. -""" -index_to_dict(i::Integer) = Dict("type" => _BASE_INTEGER_TYPE, "value" => i) -index_to_dict(v::Vector{Int}) = Dict("type" => _BASE_VECTOR_TYPE, "values" => v) -function index_to_dict(r::UnitRange) - return Dict("type" => _BASE_UNITRANGE_TYPE, "start" => r.start, "stop" => r.stop) -end -function index_to_dict(r::StepRange) - return Dict( - "type" => _BASE_STEPRANGE_TYPE, - "start" => r.start, - "stop" => r.stop, - "step" => r.step, - ) -end -function index_to_dict(r::Base.OneTo{I}) where {I} - return Dict("type" => _BASE_ONETO_TYPE, "stop" => r.stop) -end -index_to_dict(::Colon) = Dict("type" => _BASE_COLON_TYPE) -function index_to_dict(s::ConcretizedSlice{T,R}) where {T,R} - return Dict("type" => _CONCRETIZED_SLICE_TYPE, "range" => index_to_dict(s.range)) -end -function index_to_dict(t::Tuple) - return Dict("type" => _BASE_TUPLE_TYPE, "values" => map(index_to_dict, t)) -end - -""" - dict_to_index(dict) - dict_to_index(symbol_val, dict) - -Convert a dictionary representation of an index `dict` to an index. - -Users can extend the functionality of `dict_to_index` (and hence `VarName` -de/serialisation) by extending this method along with [`index_to_dict`](@ref). -Specifically, suppose you have a custom index type `MyIndexType` and you want -to be able to de/serialise a `VarName` containing this index type. You should -then implement the following two methods: - -1. `AbstractPPL.index_to_dict(i::MyModule.MyIndexType)` should return a - dictionary representation of the index `i`. This dictionary must contain the - key `"type"`, and the corresponding value must be a string that uniquely - identifies the index type. Generally, it makes sense to use the name of the - type (perhaps prefixed with module qualifiers) as this value to avoid - clashes. The remainder of the dictionary can have any structure you like. - -2. Suppose the value of `index_to_dict(i)["type"]` is `"MyModule.MyIndexType"`. - You should then implement the corresponding method - `AbstractPPL.dict_to_index(::Val{Symbol("MyModule.MyIndexType")}, dict)`, - which should take the dictionary representation as the second argument and - return the original `MyIndexType` object. - -To see an example of this in action, you can look in the the AbstractPPL test -suite, which contains a test for serialising OffsetArrays. -""" -function dict_to_index(dict) - t = dict["type"] - if t == _BASE_INTEGER_TYPE - return dict["value"] - elseif t == _BASE_VECTOR_TYPE - return collect(Int, dict["values"]) - elseif t == _BASE_UNITRANGE_TYPE - return dict["start"]:dict["stop"] - elseif t == _BASE_STEPRANGE_TYPE - return dict["start"]:dict["step"]:dict["stop"] - elseif t == _BASE_ONETO_TYPE - return Base.OneTo(dict["stop"]) - elseif t == _BASE_COLON_TYPE - return Colon() - elseif t == _CONCRETIZED_SLICE_TYPE - return ConcretizedSlice(Base.Slice(dict_to_index(dict["range"]))) - elseif t == _BASE_TUPLE_TYPE - return tuple(map(dict_to_index, dict["values"])...) - else - # Will error if the method is not defined, but this hook allows users - # to extend this function - return dict_to_index(Val(Symbol(t)), dict) - end -end - -optic_to_dict(::typeof(identity)) = Dict("type" => "identity") -function optic_to_dict(::PropertyLens{sym}) where {sym} - return Dict("type" => "property", "field" => String(sym)) -end -optic_to_dict(i::IndexLens) = Dict("type" => "index", "indices" => index_to_dict(i.indices)) -function optic_to_dict(c::ComposedFunction) - return Dict( - "type" => "composed", - "outer" => optic_to_dict(c.outer), - "inner" => optic_to_dict(c.inner), - ) -end - -function dict_to_optic(dict) - if dict["type"] == "identity" - return identity - elseif dict["type"] == "index" - return IndexLens(dict_to_index(dict["indices"])) - elseif dict["type"] == "property" - return PropertyLens{Symbol(dict["field"])}() - elseif dict["type"] == "composed" - return dict_to_optic(dict["outer"]) ∘ dict_to_optic(dict["inner"]) - else - error("Unknown optic type: $(dict["type"])") - end -end - -function varname_to_dict(vn::VarName) - return Dict("sym" => getsym(vn), "optic" => optic_to_dict(getoptic(vn))) -end - -function dict_to_varname(dict::Dict{<:AbstractString,Any}) - return VarName{Symbol(dict["sym"])}(dict_to_optic(dict["optic"])) -end - -""" - varname_to_string(vn::VarName) - -Convert a `VarName` as a string, via an intermediate dictionary. This differs -from `string(vn)` in that concretised slices are faithfully represented (rather -than being pretty-printed as colons). - -For `VarName`s which index into an array, this function will only work if the -indices can be serialised. This is true for all standard Julia index types, but -if you are using custom index types, you will need to implement the -`index_to_dict` and `dict_to_index` methods for those types. See the -documentation of [`dict_to_index`](@ref) for instructions on how to do this. - -```jldoctest -julia> varname_to_string(@varname(x)) -"{\\"optic\\":{\\"type\\":\\"identity\\"},\\"sym\\":\\"x\\"}" - -julia> varname_to_string(@varname(x.a)) -"{\\"optic\\":{\\"field\\":\\"a\\",\\"type\\":\\"property\\"},\\"sym\\":\\"x\\"}" - -julia> y = ones(2); varname_to_string(@varname(y[:])) -"{\\"optic\\":{\\"indices\\":{\\"values\\":[{\\"type\\":\\"Base.Colon\\"}],\\"type\\":\\"Base.Tuple\\"},\\"type\\":\\"index\\"},\\"sym\\":\\"y\\"}" - -julia> y = ones(2); varname_to_string(@varname(y[:], true)) -"{\\"optic\\":{\\"indices\\":{\\"values\\":[{\\"range\\":{\\"stop\\":2,\\"type\\":\\"Base.OneTo\\"},\\"type\\":\\"AbstractPPL.ConcretizedSlice\\"}],\\"type\\":\\"Base.Tuple\\"},\\"type\\":\\"index\\"},\\"sym\\":\\"y\\"}" -``` -""" -varname_to_string(vn::VarName) = JSON.json(varname_to_dict(vn)) - -""" - string_to_varname(str::AbstractString) - -Convert a string representation of a `VarName` back to a `VarName`. The string -should have been generated by `varname_to_string`. -""" -string_to_varname(str::AbstractString) = dict_to_varname(JSON.parse(str)) - -### Prefixing and unprefixing - """ _head(optic) @@ -1104,118 +691,3 @@ end _init(::Accessors.PropertyLens) = identity _init(::Accessors.IndexLens) = identity _init(::typeof(identity)) = identity - -""" - optic_to_vn(optic) - -Convert an Accessors optic to a VarName. This is best explained through -examples. - -```jldoctest; setup=:(using Accessors) -julia> AbstractPPL.optic_to_vn(Accessors.@o _.a) -a - -julia> AbstractPPL.optic_to_vn(Accessors.@o _.a.b) -a.b - -julia> AbstractPPL.optic_to_vn(Accessors.@o _.a[1]) -a[1] -``` - -The outermost layer of the optic (technically, what Accessors.jl calls the -'innermost') must be a `PropertyLens`, or else it will fail. This is because a -VarName needs to have a symbol. - -```jldoctest; setup=:(using Accessors) -julia> AbstractPPL.optic_to_vn(Accessors.@o _[1]) -ERROR: ArgumentError: optic_to_vn: could not convert optic `(@o _[1])` to a VarName -[...] -``` -""" -function optic_to_vn(::Accessors.PropertyLens{sym}) where {sym} - return VarName{sym}() -end -function optic_to_vn( - o::ComposedFunction{Outer,Accessors.PropertyLens{sym}} -) where {Outer,sym} - return VarName{sym}(o.outer) -end -optic_to_vn(o::ComposedFunction) = optic_to_vn(normalise(o)) -function optic_to_vn(@nospecialize(o)) - msg = "optic_to_vn: could not convert optic `$o` to a VarName" - throw(ArgumentError(msg)) -end - -unprefix_optic(o, ::typeof(identity)) = o # Base case -function unprefix_optic(optic, optic_prefix) - # Technically `unprefix_optic` only receives optics that were part of - # VarNames, so the optics should already be normalised (in the inner - # constructor of the VarName). However I guess it doesn't hurt to do it - # again to be safe. - optic = normalise(optic) - optic_prefix = normalise(optic_prefix) - # strip one layer of the optic and check for equality - head = _head(optic) - head_prefix = _head(optic_prefix) - if head != head_prefix - msg = "could not remove prefix $(optic_prefix) from optic $(optic)" - throw(ArgumentError(msg)) - end - # recurse - return unprefix_optic(_tail(optic), _tail(optic_prefix)) -end - -""" - unprefix(vn::VarName, prefix::VarName) - -Remove a prefix from a VarName. - -```jldoctest -julia> AbstractPPL.unprefix(@varname(y.x), @varname(y)) -x - -julia> AbstractPPL.unprefix(@varname(y.x.a), @varname(y)) -x.a - -julia> AbstractPPL.unprefix(@varname(y[1].x), @varname(y[1])) -x - -julia> AbstractPPL.unprefix(@varname(y), @varname(n)) -ERROR: ArgumentError: could not remove prefix n from VarName y -[...] -``` -""" -function unprefix( - vn::VarName{sym_vn}, prefix::VarName{sym_prefix} -) where {sym_vn,sym_prefix} - if sym_vn != sym_prefix - msg = "could not remove prefix $(prefix) from VarName $(vn)" - throw(ArgumentError(msg)) - end - optic_vn = getoptic(vn) - optic_prefix = getoptic(prefix) - return optic_to_vn(unprefix_optic(optic_vn, optic_prefix)) -end - -""" - prefix(vn::VarName, prefix::VarName) - -Add a prefix to a VarName. - -```jldoctest -julia> AbstractPPL.prefix(@varname(x), @varname(y)) -y.x - -julia> AbstractPPL.prefix(@varname(x.a), @varname(y)) -y.x.a - -julia> AbstractPPL.prefix(@varname(x.a), @varname(y[1])) -y[1].x.a -``` -""" -function prefix(vn::VarName{sym_vn}, prefix::VarName{sym_prefix}) where {sym_vn,sym_prefix} - optic_vn = getoptic(vn) - optic_prefix = getoptic(prefix) - new_optic_vn = optic_vn ∘ PropertyLens{sym_vn}() ∘ optic_prefix - return VarName{sym_prefix}(new_optic_vn) -end