Querying object graphs
You can reach one value with a path such as rig.osc.filter.cutoff. What if you want every
cutoff in a rig, every string in a scene, or all the gains above a threshold? You might not even
know how many there are while you're still building the rig.
graphPaths lets you search a live object for values by pattern, rather like XPath searches a
document. It uses reflection, so you can query objects you already have without adding a base
class, annotations or registration.
A graph, addressed
Give graphPaths a pattern and it hands back a PathSet: an ordered collection
of live lenses. Each one is an address plus a getter, and a setter too if the
slot is mutable. Nothing is copied, so if the value changes between two reads,
the second read sees the change.
:::static
class Osc(var freq: Float, var gain: Float, var label: String)
class Rig(val carrier: Osc, val modulator: Osc, var name: String)
val rig = Rig(Osc(220f, 0.8f, "sine"), Osc(440f, 0.2f, "saw"), "rig")
display("main", rig.graphPaths("**"))

** matched everything. Interior edges (carrier) are listed alongside leaves
(carrier/freq), and properties come out in primary-constructor order rather
than alphabetically: you read them in the order you declared them.
The slash shorthand
rig / "**/freq" is shorthand for rig.graphPaths("**/freq"): the same query,
less to type.
// the same query, twice
(rig / "**/freq").whereValueIs<Float> { it > 300f }
rig.graphPaths("**/freq").whereValueIs<Float> { it > 300f }
// and the pattern can be split across several `/` if that reads better —
// this still reports `carrier/freq`, not a bare `freq`
rig / "carrier" / "freq"
Those parentheses on the first line are not optional: . binds tighter than
/, so rig / "**/freq".whereValueIs<Float> { ... } would be looking for that
method on the string.
The shorthand takes no options. When you need a depth cap, private properties
or a custom descendInto, call graphPaths and pass a GraphWalkOptions.
What / does to ordinary division
/ is defined on Any?, but ordinary division is untouched: a member div
always beats an extension, so numbers, vectors, quaternions and signals keep
their own arithmetic, and kotlin.io.path's path / "sub" still joins a
path.
Patterns and predicates
Two wildcards: * matches exactly one segment, ** matches any number of
segments including zero. A * inside a segment globs within that one name, so
"*Alpha" finds contourAlpha and "osc*" finds osc1 and osc2. It never
crosses a /; that is **'s job. Names are matched ignoring case, so
"cutoff" reaches cutOff. If one object really does hold two names differing
only in case, one pattern matches both of them, and they remain two distinct
slots.
A | separates whole patterns and unions what they find:
// everything that reads like an alpha, plus every w component, at any depth
look / "**/*alpha | **/w"
One walk answers both branches, so each slot appears once even when two branches
would have claimed it, and the order is the graph's rather than the pattern's.
Because | separates whole patterns, "**/freq|gain" means "**/freq" or
"gain", not two leaves under **. Repeat the prefix on both sides.
Patterns say where a slot lives. To filter on what it holds, add a predicate.
:::static
class Osc(var freq: Float, var gain: Float, var label: String)
class Rig(val carrier: Osc, val modulator: Osc, var name: String)
val rig = Rig(Osc(220f, 0.8f, "sine"), Osc(440f, 0.2f, "saw"), "rig")
// every freq at any depth, but only the ones above 300
display("main", rig.graphPaths("**/freq").whereValueIs<Float> { it > 300f })

whereValueIs<T> only ever shows the predicate values of type T, so a graph
holding strings and booleans next to your floats can't trip it up. The rest of
the family: where (the whole ref — path, owner and value), whereValue,
ownedBy, nonNull, atDepth, excluding, and writable(). They compose,
and each returns a new set rather than mutating the receiver.
Typed lenses for any leaf type
Numbers aren't special. lenses<T>() narrows a PathSet to the slots holding a
T and gives you typed reads and writes: strings, booleans, enums, vectors,
whatever your graph is made of.
:::static
enum class Mode {
OFF,
ON,
}
class Light(var name: String, var on: Boolean, var mode: Mode, var level: Float)
class Room(val lamp: Light, val strip: Light)
val room = Room(
Light("lamp", true, Mode.ON, 0.4f),
Light("strip", false, Mode.OFF, 0.9f),
)
// flip every boolean in the graph, then read back the enums
room.graphPaths("**").lenses<Boolean>().update { !it }
display("main", room.graphPaths("**").lenses<Mode>())

update is read-modify-write per slot; set assigns one value everywhere;
set(list) assigns positionally. Each returns how many slots it actually wrote,
so a read-only slot is reported rather than silently skipped.
Naming a path is cheap
A pattern with no wildcards doesn't traverse anything: it walks straight down the segments you named, one property read each. A pattern with a literal prefix descends that prefix directly and then searches only the subtree below it. On a graph of any size that is a large difference, and you can measure it:
:::static
var reads = 0
class Node(val label: String) : LiveGraphInspectable {
val kids = linkedMapOf<String, Node>()
var leaf: Float = 1f
override fun liveGraphChildren(): Iterable<LiveGraphEdge> =
kids.map { (name, child) ->
LiveGraphEdge(name) {
reads++
child
}
} +
LiveGraphEdge(
"leaf",
{
reads++
leaf
},
{ v -> leaf = (v as Number).toFloat() },
)
}
fun build(depth: Int, breadth: Int): Node {
val node = Node("n")
if (depth > 0) repeat(breadth) { i -> node.kids["k$i"] = build(depth - 1, breadth) }
return node
}
val root = build(depth = 3, breadth = 5)
reads = 0
root.graphPaths("k1/k2/k3/leaf")
val named = reads
reads = 0
val all = root.graphPaths("**")
val everything = reads
display(
"main",
"""
<table style="font-size:20px;border-spacing:24px 8px">
<tr><td>naming a path</td><td style="text-align:right">$named edge reads</td></tr>
<tr><td>walking all ${all.size} slots</td><td style="text-align:right">$everything edge reads</td></tr>
</table>
""",
)

A within-segment glob ("osc*"), a literal spelled in a different case from the
property, and a pattern with more than one | branch all end the literal prefix,
and the query walks from there.
That example also shows LiveGraphInspectable: implement it and you decide what
your object's children are, instead of letting reflection guess. Custom edges
are merged ahead of reflected ones, so you can rename or hide as much as you
like and still get everything else for free.
If you're going to run the same query repeatedly, compile it once with
GraphQuery.compile(pattern) and call resolve(root) on the result — the
pattern is parsed a single time and the plan is reusable.
Driving numbers: params()
params() narrows a query to the slots that can be driven: Field's Params,
and any writable numeric field. A plain Float or Int property on an ordinary
object can then be set, modulated and supplied like a synth parameter, and you
can change the whole group at once.
:::static
class Osc(var freq: Float, var gain: Float)
class Rig(val a: Osc, val b: Osc, val c: Osc)
val rig = Rig(Osc(220f, 0.5f), Osc(440f, 0.5f), Osc(880f, 0.5f))
// spread the gains evenly, then halve every freq
rig.params("**/gain").rewrite { ramp(0.1f..0.9f) }
rig.params("**/freq").scale(0.5f)
display("main", rig.params("**"))

ParamSet carries the group operations you would expect: set, offset,
scale, clamp, random, choose. It also has a rewrite { } scope for
shaping a set relative to itself, with ramp, normalize, jitter, and the
mean/min/max of the group.
Modulation and suppliers work the same way across the whole set:
// one shared LFO added on top of every cutoff — `matches` is to `params()`
// what `/` is to `graphPaths()`
(rig matches "**/cutoff").modulate(sine * 40f, mode = ParamModulationMode.ADD)
// or a per-entry expression, with the group's statistics in scope
rig.params("**/freq").supply { entry, index, group ->
group.mean + sin(time * (0.2f + index * 0.05f)) * 12f
}
Those attach to the runloop and keep evaluating, so the underlying fields move
on their own. clearModulation() and clearSuppliers() detach them again.
Notes on traversal
What the walk does on a large or unusual graph:
- Descent stops at the obvious leaves — numbers, strings, booleans, enums,
primitive arrays, functions, class objects — and at anything in the JDK's own
packages, so you get your graph rather than the JVM's.
GraphWalkOptionsdescendIntorefines that policy: it receives both the value and the default decision, so{ v, byDefault -> byDefault && v !is Osc }prunes without losing the built-in guards. - Cycles are safe. Detection is path-scoped: a node is skipped only when it
already appears on the current ancestor chain. A node shared by several edges
is therefore reachable under each of its addresses, while
a → b → astill terminates.maxDepthandmaxRefsbound the rest. - Collections are transparent. Maps are keyed by their stringified key,
lists and arrays by index, and mutable ones are writable. A
Setis addressed by position in iteration order and is read-only. - You can browse the same edges.
tree("t", value)andcolumn("c", value)list exactly what this walk sees;pcopies a row's path, and a filter containing/,*or|runsgraphPathsand lists the matches (see Trees and columns). - Non-public classes work. Your own
privateandinternalmodel classes are read and written like any other. - A throwing getter won't derail a walk, but reading that one slot propagates the real exception rather than reporting null.
A pattern addresses slots; it cannot follow an edge and then follow it again. When the question is
recursive (what runs downstream of this box, what does nothing feed into), Datalog in a
Box is the next page, and it can take graphPaths output as its facts.