VarNames and optics

VarNames: an overview

One of the most important parts of AbstractPPL.jl is the VarName type, which is used throughout the TuringLang ecosystem to represent names of random variables.

Fundamentally, a VarName comprises a symbol (which represents the name of the variable itself) and an optic (which tells us which part of the variable we might be interested in). For example, x.a[1] means the first element of the field a of the variable x. Here, x is the symbol, and .a[1] is the optic.

VarNames can be created using the @varname macro:

using AbstractPPL

vn = @varname(x.a[1])
x.a[1]
AbstractPPL.VarNameType
VarName{sym}(optic=identity)

A variable identifier for a symbol sym and optic optic. sym refers to the name of the top-level Julia variable, while optic allows one to specify a particular property or index inside that variable.

VarNames can be manually constructed using the VarName{sym}(optic) constructor, or from an optic expression through the @varname convenience macro.

source
AbstractPPL.@varnameMacro
@varname(expr, concretize=false)

Create a VarName given an expression expr representing a variable or part of it.

Basic examples

In general, VarNames must have a top-level symbol representing the identifier itself, and can then have any number of property accesses or indexing operations chained to it.

julia> @varname(x)
x

julia> @varname(x.a.b.c)
x.a.b.c

julia> @varname(x[1][2][3])
x[1][2][3]

julia> @varname(x.a[1:3].b[2])
x.a[1:3].b[2]

Dynamic indices

Some expressions may involve dynamic indices, specifically, begin, end. These indices cannot be resolved, or 'concretized', until the value being indexed into is known. By default, @varname(...) will not automatically concretize these expressions, and thus the resulting VarName will contain markers for these.

Note that colons are not considered dynamic.

julia> vn = @varname(x[end])
x[DynamicIndex(end)]

julia> vn = @varname(x[1, end-1])
x[1, DynamicIndex(end - 1)]

You can detect whether a VarName contains any dynamic indices using is_dynamic:

julia> vn = @varname(x[1, end-1]); AbstractPPL.is_dynamic(vn)
true

To concretize such expressions, you can call concretize on the resulting VarName. After concretization, the resulting VarName will no longer be dynamic.

julia> x = randn(2, 3);

julia> vn = @varname(x[1, end-1]); vn2 = AbstractPPL.concretize(vn, x)
x[1, 2]

julia> getoptic(vn2).ix  # Just an ordinary tuple.
(1, 2)

julia> AbstractPPL.is_dynamic(vn2)
false

Alternatively, you can pass true as the second positional argument to the @varname macro (note that it is not a keyword argument!). This will automatically call concretize for you, using the top-level symbol to look up the value used for concretization.

julia> x = randn(2, 3);

julia> @varname(x[1:end, end][:], true)
x[1:2, 3][:]

Interpolation

Property names, as well as top-level symbols, can also be constructed from interpolated symbols:

julia> name = :hello; @varname(x.$name)
x.hello

julia> @varname($name)
hello

julia> @varname($name.a.$name[1])
hello.a.hello[1]

For indices, you do not need to use $ to interpolate, just use the variable directly:

julia> ix = 2; @varname(x[ix])
x[2]

Note that if the top-level symbol is interpolated, automatic concretization is not possible:

julia> name = :x; @varname($name[1:end], true)
ERROR: LoadError: cannot automatically concretize VarName with interpolated top-level symbol; call `concretize(vn, val)` manually instead
[...]
source
AbstractPPL.varnameFunction
varname(expr, concretize::Bool)

Implementation of the @varname macro. See the documentation for @varname for details. This function is exported to allow other macros (e.g. in DynamicPPL) to reuse the same logic.

source

You can obtain the components of a VarName using the getsym and getoptic functions:

getsym(vn), getoptic(vn)
(:x, Optic(.a[1]))
AbstractPPL.getsymFunction
getsym(vn::VarName)

Return the symbol of the Julia variable used to generate vn.

Examples

julia> getsym(@varname(x[1][2:3]))
:x

julia> getsym(@varname(y))
:y
source
AbstractPPL.getopticFunction
getoptic(vn::VarName)

Return the optic of the Julia variable used to generate vn.

Examples

julia> getoptic(@varname(x[1][2:3]))
Optic([1][2:3])

julia> getoptic(@varname(y))
Optic()
source

Dynamic indices

VarNames may contain 'dynamic' indices, that is, indices whose meaning is not known until they are resolved against a specific value. For example, x[end] refers to the last element of x; but we don't know what that means until we know what x is.

Specifically, begin and end symbols in indices are treated as dynamic indices. This is also true for any expression that contains begin or end, such as end-1 or 1:3:end.

Dynamic indices are represented using an internal type, AbstractPPL.DynamicIndex.

vn_dyn = @varname(x[1:2:end])
x[DynamicIndex(1:2:end)]

You can detect whether a VarName contains dynamic indices using the is_dynamic function:

is_dynamic(vn_dyn)
true
AbstractPPL.is_dynamicFunction
is_dynamic(vn::VarName)

Return true if vn contains any dynamic indices (i.e., begin, end, or :). If a VarName has been concretized, this will always return false.

source

These dynamic indices can be resolved, or concretized, by passing a specific value to the concretize function:

x = randn(5)
vn_conc = concretize(vn_dyn, x)
x[1:2:5]
AbstractPPL.concretizeFunction
concretize(vn::VarName, x)

Return vn concretized on x, i.e. any information related to the runtime shape of x is evaluated. This will convert any begin and end indices in vn to concrete indices with information about the length of the dimension being indexed into.

source

Optics

The optics used in AbstractPPL.jl are represented as a linked list. For example, the optic .a[1] is a Property optic that contains an Index optic as its child. That means that the 'elements' of the linked list can be read from left-to-right:

Property{:a} -> Index{1} -> Iden

All optic linked lists are terminated with an Iden optic, which represents the identity function.

optic = getoptic(@varname x.a[1])
dump(optic)
Property{:a, Index{Tuple{Int64}, @NamedTuple{}, Iden}}
  child: Index{Tuple{Int64}, @NamedTuple{}, Iden}
    ix: Tuple{Int64}
      1: Int64 1
    kw: @NamedTuple{} NamedTuple()
    child: Iden Optic()
AbstractPPL.AbstractOpticType
AbstractOptic

An abstract type that represents the non-symbol part of a VarName, i.e., the section of the variable that is of interest. For example, in x.a[1][2], the AbstractOptic represents the .a[1][2] part.

source
AbstractPPL.PropertyType
Property{sym}(child=Iden())

A property access optic representing access to property sym. A VarName{:x} with this optic represents access to x.sym. The child optic represents any further indexing or property access after this property access operation.

source
AbstractPPL.IndexType
Index(ix, kw, child=Iden())

An indexing optic representing access to indices ix, which may also take the form of keyword arguments kw. A VarName{:x} with this optic represents access to x[ix..., kw...]. The child optic represents any further indexing or property access after this indexing operation.

source
AbstractPPL.IdenType
Iden()

The identity optic. This is the optic used when we are referring to the entire variable. It is also the base case for composing optics.

source

Instead of calling getoptic(@varname(...)), you can directly use the @opticof macro to create optics:

optic = @opticof(_.a[1])
Optic(.a[1])
AbstractPPL.@opticofMacro
@opticof(expr, concretize=false)

Extract the optic from @varname(expr, concretize). This is a thin wrapper around getoptic(@varname(...)).

If you don't need to concretize, you should use _ as the top-level symbol to indicate that it is not relevant:

julia> @opticof(_.a.b)
Optic(.a.b)

If you need to concretize, then you can provide a real variable name (which is then used to look up the value for concretization):

julia> x = randn(3, 4); @opticof(x[1:end, end], true)
Optic([1:3, 4])

Note that concretization with @opticof has the same limitations as with @varname, specifically, if the top-level symbol is interpolated, automatic concretization is not possible.

source

Getting and setting

Optics are callable structs, and when passed a value will extract the relevant part of that value.

data = (a=[10, 20, 30], b="hello")
optic = @opticof(_.a[2])
optic(data)
20

You can set values using Accessors.set (which AbstractPPL re-exports). Note, though, that this will not mutate the original value. Furthermore, you cannot use the handy macros like Accessors.@set, since those will use the optics from Accessors.jl.

new_data = set(data, optic, 99)
new_data, data
((a = [10, 99, 30], b = "hello"), (a = [10, 20, 30], b = "hello"))

If you want to try to mutate values, you can wrap an optic using with_mutation.

optic_mut = with_mutation(optic)
set(data, optic_mut, 99)
data
(a = [10, 99, 30], b = "hello")
AbstractPPL.with_mutationFunction
with_mutation(o::AbstractOptic)

Create a version of the optic o which attempts to mutate its input where possible.

On their own, AbstractOptics are non-mutating:

julia> optic = @opticof(_[1])
Optic([1])

julia> x = [0.0, 0.0];

julia> set(x, optic, 1.0); x
2-element Vector{Float64}:
 0.0
 0.0

With this function, we can create a mutating version of the optic:

julia> optic_mut = with_mutation(@opticof(_[1]))
Optic!!([1])

julia> x = [0.0, 0.0];

julia> set(x, optic_mut, 1.0); x
2-element Vector{Float64}:
 1.0
 0.0

Thanks to the BangBang.jl package, this optic will gracefully fall back to non-mutating behaviour if mutation is not possible. For example, if we try to use it on a tuple:

julia> optic_mut = with_mutation(@opticof(_[1]))
Optic!!([1])

julia> x = (0.0, 0.0);

julia> set(x, optic_mut, 1.0); x
(0.0, 0.0)
source

Composing and decomposing optics

If you have two optics, you can compose them using the operator:

optic1 = @opticof(_.a)
optic2 = @opticof(_[1])
composed = optic2 ∘ optic1
Optic(.a[1])

Notice the order of composition here, which can be counterintuitive: optic2 ∘ optic1 means "first apply optic1, then apply optic2", and thus this represents the optic .a[1] (not .[1].a).

Base.:∘Method
∘(outer::AbstractOptic, inner::AbstractOptic)

Compose two AbstractOptics together.

julia> p1 = @opticof(_.a[1])
Optic(.a[1])

julia> p2 = @opticof(_.b[2, 3])
Optic(.b[2, 3])

julia> p1 ∘ p2
Optic(.b[2, 3].a[1])
source

Base.cat(optics...) is also provided, which composes optics in a more intuitive sense (indeed, if you think of an optic as a linked list, this can be thought of as concatenating the lists). The following is equivalent to the previous example:

composed2 = Base.cat(optic1, optic2)
Optic(.a[1])
Base.catMethod
cat(optics::AbstractOptic...)

Compose multiple AbstractOptics together. The optics should be provided from innermost to outermost, i.e., cat(o1, o2, o3) corresponds to o3 ∘ o2 ∘ o1.

source

Several functions are provided to decompose optics, which all stem from their linked-list structure. Their names directly mirror Haskell's functions for decomposing lists, but are prefixed with o:

AbstractPPL.oheadFunction
ohead(optic::AbstractOptic)

Get the innermost layer of an optic. For all optics, we have that otail(optic) ∘ ohead(optic) == optic.

julia> ohead(@opticof _.a[1][2])
Optic(.a)

julia> ohead(@opticof _)
Optic()
source
AbstractPPL.otailFunction
otail(optic::AbstractOptic)

Get everything but the innermost layer of an optic. For all optics, we have that otail(optic) ∘ ohead(optic) == optic.

julia> otail(@opticof _.a[1][2])
Optic([1][2])

julia> otail(@opticof _)
Optic()
source
AbstractPPL.oinitFunction
oinit(optic::AbstractOptic)

Get everything but the outermost layer of an optic. For all optics, we have that olast(optic) ∘ oinit(optic) == optic.

julia> oinit(@opticof _.a[1][2])
Optic(.a[1])

julia> oinit(@opticof _)
Optic()
source
AbstractPPL.olastFunction
olast(optic::AbstractOptic)

Get the outermost layer of an optic. For all optics, we have that olast(optic) ∘ oinit(optic) == optic.

julia> olast(@opticof _.a[1][2])
Optic([2])

julia> olast(@opticof _)
Optic()
source

For example, ohead returns the first element of the optic linked list, and otail returns the rest of the list after removing the head:

optic = @opticof(_.a[1].b[2])
ohead(optic), otail(optic)
(Optic(.a), Optic([1].b[2]))

Convesely, oinit returns the optic linked list without its last element, and olast returns the last element:

oinit(optic), olast(optic)
(Optic(.a[1].b), Optic([2]))

If the optic only has a single element, then oinit and otail return Iden, while ohead and olast return the optic itself:

optic_single = @opticof(_.a)
oinit(optic_single), olast(optic_single), ohead(optic_single), otail(optic_single)
(Optic(), Optic(.a), Optic(.a), Optic())

Converting VarNames to optics and back

Sometimes it is useful to treat a VarName's top level symbol as if it were part of the optic. For example, when indexing into a NamedTuple nt, we might want to treat the entire VarName x.a[1] as an optic that can be applied to a NamedTuple: i.e., we want to access the nt.x field rather than the variable x itself. This can be achieved with:

AbstractPPL.optic_to_varnameFunction
optic_to_varname(optic::Property{sym}) where {sym}

Convert a Property optic to a VarName, by converting the top-level property to a symbol. This fails for all other optics.

source

Subsumption

Sometimes, we want to check whether one VarName 'subsumes' another; that is, whether a VarName refers to a part of another VarName. This is done using the subsumes function:

vn1 = @varname(x.a)
vn2 = @varname(x.a[1])
subsumes(vn1, vn2)
true
AbstractPPL.subsumesFunction
subsumes(parent::VarName, child::VarName)

Check whether the variable name child describes a sub-range of the variable parent, i.e., is contained within it.

julia> subsumes(@varname(x), @varname(x[1, 2]))
true

julia> subsumes(@varname(x[1, 2]), @varname(x[1, 2][3]))
true

This is done by recursively comparing each layer of the VarNames' optics.

Note that often this is not possible to determine statically, and so the results should not be over-interpreted. In particular, Index optics pose a problem. An i::Index will only subsume j::Index if:

  1. They have the same number of positional indices (i.ix and j.ix);
  2. Each positional index in i can be determined to comprise the corresponding positional index in j; and
  3. The keyword indices of i (i.kw) are a superset of those in j.kw).

In all other cases, subsumes will conservatively return false, even though in practice it might well be that i does subsume j. Some examples where subsumption cannot be determined statically are:

  • Subsumption between different forms of indexing is not supported, e.g. x[4] and x[2, 2] are not considered to subsume each other, even though they might in practice (e.g. if x is a 2x2 matrix).
  • When dynamic indices (that are not equal) are present. (Dynamic indices that are equal do subsume each other.)
  • Non-standard indices, e.g. Not(4), 2..3, etc. Again, these only subsume each other when they are equal.
source

Prefixing and unprefixing

Composing two optics can be done using the operator, as shown above. But what if we want to compose two VarNames? This is used, for example, in DynamicPPL's submodel functionality.

AbstractPPL.prefixFunction
prefix(vn::VarName, prefix::VarName)

Add a prefix to a VarName.

julia> prefix(@varname(x), @varname(y))
y.x

julia> prefix(@varname(x.a), @varname(y))
y.x.a

julia> prefix(@varname(x.a), @varname(y[1]))
y[1].x.a
source
AbstractPPL.unprefixFunction
unprefix(vn::VarName, prefix::VarName)

Remove a prefix from a VarName.

julia> unprefix(@varname(y.x), @varname(y))
x

julia> unprefix(@varname(y.x.a), @varname(y))
x.a

julia> unprefix(@varname(y[1].x), @varname(y[1]))
x

julia> unprefix(@varname(y), @varname(n))
ERROR: ArgumentError: cannot remove prefix n from VarName y
[...]

julia> unprefix(@varname(y[1]), @varname(y))
ERROR: ArgumentError: optic_to_varname: can only convert Property optics to VarName
[...]
source

VarName leaves

The following functions are used to extract the 'leaves' of a VarName, that is, the atomic components of a VarName that do not have any further substructure. For example, for a vector variable x, the leaves would be x[1], x[2], etc.

AbstractPPL.varname_leavesFunction
varname_leaves(vn::VarName, val)

Return an iterator over all varnames that are represented by vn on val.

Examples

julia> using AbstractPPL: varname_leaves

julia> foreach(println, varname_leaves(@varname(x), rand(2)))
x[1]
x[2]

julia> foreach(println, varname_leaves(@varname(x[1:2]), rand(2)))
x[1:2][1]
x[1:2][2]

julia> x = (y = 1, z = [[2.0], [3.0]]);

julia> foreach(println, varname_leaves(@varname(x), x))
x.y
x.z[1][1]
x.z[2][1]
source
AbstractPPL.varname_and_value_leavesFunction
varname_and_value_leaves(vn::VarName, val)

Return an iterator over all varname-value pairs that are represented by vn on val.

Examples

julia> using AbstractPPL: varname_and_value_leaves

julia> foreach(println, varname_and_value_leaves(@varname(x), 1:2))
(x[1], 1)
(x[2], 2)

julia> foreach(println, varname_and_value_leaves(@varname(x[1:2]), 1:2))
(x[1:2][1], 1)
(x[1:2][2], 2)

julia> x = (y = 1, z = [[2.0], [3.0]]);

julia> foreach(println, varname_and_value_leaves(@varname(x), x))
(x.y, 1)
(x.z[1][1], 2.0)
(x.z[2][1], 3.0)

There is also some special handling for certain types:

julia> using LinearAlgebra

julia> x = reshape(1:4, 2, 2);

julia> # `LowerTriangular`
       foreach(println, varname_and_value_leaves(@varname(x), LowerTriangular(x)))
(x[1, 1], 1)
(x[2, 1], 2)
(x[2, 2], 4)

julia> # `UpperTriangular`
       foreach(println, varname_and_value_leaves(@varname(x), UpperTriangular(x)))
(x[1, 1], 1)
(x[1, 2], 3)
(x[2, 2], 4)

julia> # `Cholesky` with lower-triangular
       foreach(println, varname_and_value_leaves(@varname(x), Cholesky([1.0 0.0; 0.0 1.0], 'L', 0)))
(x.L[1, 1], 1.0)
(x.L[2, 1], 0.0)
(x.L[2, 2], 1.0)

julia> # `Cholesky` with upper-triangular
       foreach(println, varname_and_value_leaves(@varname(x), Cholesky([1.0 0.0; 0.0 1.0], 'U', 0)))
(x.U[1, 1], 1.0)
(x.U[1, 2], 0.0)
(x.U[2, 2], 1.0)
source
varname_and_value_leaves(container)

Return an iterator over all varname-value pairs that are represented by container.

This is the same as varname_and_value_leaves(vn::VarName, x) but over a container containing multiple varnames.

See also: varname_and_value_leaves(vn::VarName, x).

Examples

julia> using AbstractPPL: varname_and_value_leaves

julia> using OrderedCollections: OrderedDict

julia> # With an `AbstractDict` (we use `OrderedDict` here
       # to ensure consistent ordering in doctests)
       dict = OrderedDict(@varname(y) => 1, @varname(z) => [[2.0], [3.0]]);

julia> foreach(println, varname_and_value_leaves(dict))
(y, 1)
(z[1][1], 2.0)
(z[2][1], 3.0)

julia> # With a `NamedTuple`
       nt = (y = 1, z = [[2.0], [3.0]]);

julia> foreach(println, varname_and_value_leaves(nt))
(y, 1)
(z[1][1], 2.0)
(z[2][1], 3.0)
source

Reading from a container with a VarName (or optic)

AbstractPPL.canviewFunction
canview(optic, container)

Return true if optic can be used to view container, and false otherwise.

Examples

julia> AbstractPPL.canview(@opticof(_.a), (a = 1.0, ))
true

julia> AbstractPPL.canview(@opticof(_.a), (b = 1.0, )) # property `a` does not exist
false

julia> AbstractPPL.canview(@opticof(_.a[1]), (a = [1.0, 2.0], ))
true

julia> AbstractPPL.canview(@opticof(_.a[3]), (a = [1.0, 2.0], )) # out of bounds
false
source
AbstractPPL.hasvalueFunction
hasvalue(
    vals::Union{AbstractDict,NamedTuple},
    vn::VarName,
    dist::Distribution;
    error_on_incomplete::Bool=false
)

Check if vals contains values for vn that is compatible with the distribution dist.

This is a more general version of hasvalue(vals, vn), in that even if vn itself is not inside vals, it further checks if vals contains sub-values of vn that can be used to reconstruct vn given dist.

The error_on_incomplete flag can be used to detect cases where some of the values needed for vn are present, but others are not. This may help to detect invalid cases where the user has provided e.g. data of the wrong shape.

Note that this check is only possible if a Dict is passed, because the key type of a NamedTuple (i.e., Symbol) is not rich enough to carry indexing information. If this method is called with a NamedTuple, it will just defer to hasvalue(vals, vn).

For example:

julia> d = Dict(@varname(x[1]) => 1.0, @varname(x[2]) => 2.0);

julia> hasvalue(d, @varname(x), MvNormal(zeros(2), I))
true

julia> hasvalue(d, @varname(x), MvNormal(zeros(3), I))
false

julia> hasvalue(d, @varname(x), MvNormal(zeros(3), I); error_on_incomplete=true)
ERROR: only partial values for `x` found in the dictionary provided
[...]
source
hasvalue(vals::NamedTuple, vn::VarName)
hasvalue(vals::AbstractDict{<:VarName}, vn::VarName)

Determine whether vals contains a value for a given vn.

Examples

With x as a NamedTuple:

julia> hasvalue((x = 1.0, ), @varname(x))
true

julia> hasvalue((x = 1.0, ), @varname(x[1]))
false

julia> hasvalue((x = [1.0],), @varname(x))
true

julia> hasvalue((x = [1.0],), @varname(x[1]))
true

julia> hasvalue((x = [1.0],), @varname(x[2]))
false

With x as a AbstractDict:

julia> hasvalue(Dict(@varname(x) => 1.0, ), @varname(x))
true

julia> hasvalue(Dict(@varname(x) => 1.0, ), @varname(x[1]))
false

julia> hasvalue(Dict(@varname(x) => [1.0]), @varname(x))
true

julia> hasvalue(Dict(@varname(x) => [1.0]), @varname(x[1]))
true

julia> hasvalue(Dict(@varname(x) => [1.0]), @varname(x[2]))
false

In the AbstractDict case we can also have keys such as v[1]:

julia> vals = Dict(@varname(x[1]) => [1.0,]);

julia> hasvalue(vals, @varname(x[1])) # same as `haskey`
true

julia> hasvalue(vals, @varname(x[1][1])) # different from `haskey`
true

julia> hasvalue(vals, @varname(x[1][2]))
false

julia> hasvalue(vals, @varname(x[2][1]))
false
source
AbstractPPL.getvalueFunction
getvalue(
    vals::Union{AbstractDict,NamedTuple},
    vn::VarName,
    dist::Distribution
)

Retrieve the value of vn from vals, using the distribution dist to reconstruct the value if necessary.

This is a more general version of getvalue(vals, vn), in that even if vn itself is not inside vals, it can still reconstruct the value of vn from sub-values of vn that are present in vals.

Note that this reconstruction is only possible if a Dict is passed, because the key type of a NamedTuple (i.e., Symbol) is not rich enough to carry indexing information. If this method is called with a NamedTuple, it will just defer to getvalue(vals, vn).

For example:

julia> d = Dict(@varname(x[1]) => 1.0, @varname(x[2]) => 2.0);

julia> getvalue(d, @varname(x), MvNormal(zeros(2), I))
2-element Vector{Float64}:
 1.0
 2.0

julia> # Use `hasvalue` to check for this case before calling `getvalue`.
       getvalue(d, @varname(x), MvNormal(zeros(3), I))
ERROR: `x` was not found in the dictionary provided
[...]
source
getvalue(vals::NamedTuple, vn::VarName)
getvalue(vals::AbstractDict{<:VarName}, vn::VarName)

Return the value(s) in vals represented by vn.

Examples

For NamedTuple:

julia> vals = (x = [1.0],);

julia> getvalue(vals, @varname(x)) # same as `getindex`
1-element Vector{Float64}:
 1.0

julia> getvalue(vals, @varname(x[1])) # different from `getindex`
1.0

julia> getvalue(vals, @varname(x[2]))
ERROR: x[2] was not found in the NamedTuple provided
[...]

For AbstractDict:

julia> vals = Dict(@varname(x) => [1.0]);

julia> getvalue(vals, @varname(x)) # same as `getindex`
1-element Vector{Float64}:
 1.0

julia> getvalue(vals, @varname(x[1])) # different from `getindex`
1.0

julia> getvalue(vals, @varname(x[2]))
ERROR: x[2] was not found in the dictionary provided
[...]

In the AbstractDict case we can also have keys such as v[1]:

julia> vals = Dict(@varname(x[1]) => [1.0,]);

julia> getvalue(vals, @varname(x[1])) # same as `getindex`
1-element Vector{Float64}:
 1.0

julia> getvalue(vals, @varname(x[1][1])) # different from `getindex`
1.0

julia> getvalue(vals, @varname(x[1][2]))
ERROR: x[1][2] was not found in the dictionary provided
[...]

julia> getvalue(vals, @varname(x[2][1]))
ERROR: x[2][1] was not found in the dictionary provided
[...]

julia> getvalue(vals, @varname(x))
ERROR: x was not found in the dictionary provided
[...]

Dictionaries can present ambiguous cases where the same variable is specified twice at different levels. In such a situation, getvalue attempts to find an exact match, and if that fails it returns the value with the most specific key.

Note

It is the user's responsibility to avoid such cases by ensuring that the dictionary passed in does not contain the same value specified multiple times.

julia> vals = Dict(@varname(x) => [[1.0]], @varname(x[1]) => [2.0]);

julia> # Here, the `x[1]` key is not used because `x` is an exact match.
       getvalue(vals, @varname(x))
1-element Vector{Vector{Float64}}:
 [1.0]

julia> # Likewise, the `x` key is not used because `x[1]` is an exact match.
       getvalue(vals, @varname(x[1]))
1-element Vector{Float64}:
 2.0

julia> # No exact match, so the most specific key, i.e. `x[1]`, is used.
       getvalue(vals, @varname(x[1][1]))
2.0
source

Serializing VarNames

AbstractPPL.index_to_dictFunction
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.

source
AbstractPPL.dict_to_indexFunction
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. 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.

source
AbstractPPL.varname_to_stringFunction
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 VarNames 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 for instructions on how to do this.

julia> varname_to_string(@varname(x))
"{\"optic\":{\"type\":\"Iden\"},\"sym\":\"x\"}"

julia> varname_to_string(@varname(x.a))
"{\"optic\":{\"child\":{\"type\":\"Iden\"},\"field\":\"a\",\"type\":\"Property\"},\"sym\":\"x\"}"
source
AbstractPPL.string_to_varnameFunction
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.

source