cascade

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 2 Imported by: 0

README

cascade

A headless dependency-graph recalculation engine for Go — the layer a spreadsheet has above its formula language, extracted and made generic.

Go has excellent expression evaluators. expr-lang/expr and cel-go will run one expression, sandboxed and fast, and they deliberately stop there. But an application that needs recalculation — a pricing model, a metrics rollup, a rules engine, a live dashboard — needs the layer above: which value depends on which, what order to recompute in, what is stale, and what happens when one formula fails. That layer is what gets rewritten badly in every project, and it's what cascade is.

cascade evaluates nothing itself. You supply the compute function, so a formula can be an expr program, a CEL program, or a plain Go closure — and cascade depends on none of them.

  • Recomputes the minimum. Mutating the graph marks the affected node and its transitive dependents stale and stops there. Clean nodes are never recomputed, and a node reachable from a change by several paths is computed exactly once — the diamond property that naive invalidation gets wrong.
  • Ordered and deterministic. Recalculation runs in topological order, so a formula never observes a stale precedent, and the order is stable across runs for a given construction sequence.
  • Cycles are rejected, not discovered at runtime. SetFormula refuses an edge that would close a loop and hands back the path — and leaves the graph exactly as it was, so error handling never has to unwind anything.
  • Failure is a value. A failing formula holds its error, and dependents fail with a wrapped error naming the node that actually broke — #VALUE! semantics, not a panic. A formula that panics fails its own node, not your process.
  • Deep graphs are safe. Marking, sorting and evaluation are all iterative. A chain of 100,000 nodes is a test case, not a stack overflow.
  • Zero dependencies. Go 1.23+.
import "github.com/zkrebbekx/cascade"

Example

g := cascade.New[string, float64]()

g.SetValue("qty", 3)
g.SetValue("unit", 19.99)
g.SetValue("taxRate", 0.2)

g.SetFormula("net", []string{"qty", "unit"}, func(in cascade.Inputs[string, float64]) (float64, error) {
    return in.MustGet("qty") * in.MustGet("unit"), nil
})
g.SetFormula("tax", []string{"net", "taxRate"}, func(in cascade.Inputs[string, float64]) (float64, error) {
    return in.MustGet("net") * in.MustGet("taxRate"), nil
})
g.SetFormula("total", []string{"net", "tax"}, func(in cascade.Inputs[string, float64]) (float64, error) {
    return in.MustGet("net") + in.MustGet("tax"), nil
})

total, _ := g.Get("total") // 71.964

g.SetValue("qty", 10)      // marks net, tax and total stale — computes nothing
total, _ = g.Get("total")  // 239.88, recomputing each of the three once

Nothing computes on mutation. Get pulls what it needs; Recalc settles the whole graph; RecalcFrom settles one region.

Using it with an expression evaluator

cascade owns the graph, your evaluator owns the expression. The compute function is the seam, so wiring expr in is a few lines and adds no dependency to cascade itself:

// program is a compiled expr program; deps were extracted from its AST.
g.SetFormula(cell, deps, func(in cascade.Inputs[string, any]) (any, error) {
    env := make(map[string]any, in.Len())
    for _, k := range in.Keys() {
        env[k] = in.MustGet(k)
    }
    return expr.Run(program, env) // or cel Program.Eval, or plain Go
})

The environment handed to the evaluator contains exactly the declared dependencies — nothing else is reachable, by construction.

Honest edges

A formula reads its inputs through Inputs, which only exposes the keys it declared. Reaching for anything else fails with *UndeclaredError instead of quietly succeeding:

g.SetFormula("net", []string{"qty"}, func(in cascade.Inputs[string, float64]) (float64, error) {
    return in.MustGet("unit"), nil // never declared — fails the node
})

This is the invariant that keeps the graph trustworthy. If undeclared reads were allowed, an edge could be missing from the graph while the dependency was real in the code, and the engine would silently serve stale values forever.

Errors

Failure propagates along the edges like any other value.

_, err := g.Get("total")

var ee *cascade.EvalError[string]
if errors.As(err, &ee) {
    fmt.Println(ee.Key)   // "total" — the node you asked for
    fmt.Println(ee.Cause) // "rate"  — the node that actually broke
}
errors.Is(err, io.ErrUnexpectedEOF) // reaches the original error

Errors are cached exactly like values: a failed node is not retried until it or one of its precedents is marked stale again. A *CycleError carries the path; an *UnresolvedError names a reference to a key that isn't defined — which is also what a node's dependents get after that node is Deleted, rather than a silent zero value.

Forward references are legal. Declare a dependency on a key that doesn't exist yet, define it later, and the dependents go stale and resolve normally — so a graph can be built in any order.

Observability

OnCompute fires once for every node the engine actually computed, and stays silent for nodes skipped as clean. That makes "did my change recompute what I expected, and nothing more?" a question you can answer rather than assume.

g.OnCompute(func(k string, err error) { log.Printf("computed %s: %v", k, err) })

Concurrency

A *Graph is not safe for concurrent use, and cascade deliberately ships no mutex-wrapped variant — locking belongs where your transaction boundaries are, not here. Note that Get can compute and therefore writes, so concurrent Get calls race just as concurrent mutations do. Guard a shared graph with your own lock, or give each goroutine its own with Clone.

Non-goals

  • Not an expression language. cascade never parses or evaluates anything. Bring expr, cel-go, or a Go closure.
  • Not a spreadsheet. No cell addressing, ranges, A1 notation, formatting or file formats. Keys are whatever comparable type you choose.
  • Not persistent or distributed. An in-memory graph in one process, with no storage layer, no snapshots and no cluster coordination.
  • Not concurrent. No internal locking and no parallel evaluation of independent branches; the single-threaded semantics are the contract.
  • No automatic dependency discovery. Dependencies are declared, not traced at runtime. That's the trade that makes the graph statically inspectable.

License

MIT

Documentation

Overview

Package cascade is a headless dependency-graph recalculation engine: the layer a spreadsheet has above its formula language, extracted and made generic.

Expression libraries evaluate one expression safely and deliberately stop there. cascade owns everything above that — which cells depend on which, what order to recompute in, what is stale, and what happens when a formula fails — and evaluates nothing itself. You supply the compute function, so a formula can be an expr-lang program, a CEL program, or a plain Go closure, and cascade depends on none of them.

import "github.com/zkrebbekx/cascade"

Design

A Graph holds keyed nodes. A node is either a value — a constant input you set — or a formula: a list of declared precedents plus a function that turns their values into one value.

g := cascade.New[string, float64]()
g.SetValue("qty", 3)
g.SetValue("unit", 19.99)
_ = g.SetFormula("net", []string{"qty", "unit"}, func(in cascade.Inputs[string, float64]) (float64, error) {
	return in.MustGet("qty") * in.MustGet("unit"), nil
})
net, err := g.Get("net") // 59.97

A formula reads its inputs through Inputs, which only exposes the keys it declared. Reaching for an undeclared key fails with UndeclaredError instead of quietly succeeding, so the recorded edges stay an honest description of the data flow — the graph cannot drift away from the code.

Dirty propagation

Mutating the graph computes nothing. Graph.SetValue, Graph.SetFormula and Graph.Delete mark the affected node and all of its transitive dependents stale, and stop. Work happens on demand: Graph.Get pulls, computing the node and only the stale precedents it needs, while Graph.Recalc pushes, settling every stale node in one pass. Graph.RecalcFrom settles just the region reachable from some keys.

Clean nodes are never recomputed, and a node reachable from a change by several paths is recomputed exactly once. In a diamond where B and C both read A and D reads both B and C, changing A recomputes B, C and D once each — D is not computed twice. Graph.OnCompute reports every node the engine actually computed, which makes that auditable rather than assumed.

Topological order

Recalculation runs in dependency order, so a formula never observes a stale precedent. The order is deterministic for a given construction sequence: no map is iterated on any path that produces output, and both the sort and the dirty marking use explicit stacks rather than recursion, so a chain hundreds of thousands of nodes deep is bounded by the heap and cannot overflow the goroutine stack. Graph.TopoOrder exposes the same ordering for inspection.

Keys are only constrained to be comparable, so cascade cannot sort them. Deterministic here means definition order, not sorted order; sort the result yourself if you need that.

Cycles

Graph.SetFormula rejects any edge that would close a cycle and returns a CycleError carrying the path, so the graph can never enter a state that cannot be evaluated. A rejected call leaves the graph exactly as it was — no partial mutation, so error handling never has to unwind anything.

Error semantics

Failure is a value, not an exception, and it flows along the edges like any other value. A formula returning an error leaves that error on its node, and every transitive dependent fails with an EvalError naming both itself and the node where the failure originated — the spreadsheet #VALUE!/#REF! model, where a broken cell is visible from downstream and you can still see which input actually broke. EvalError.Unwrap reaches through the whole chain, so errors.Is and errors.As find the original cause.

Errors are cached exactly like values: a failed node is not retried until it or one of its precedents is marked stale again.

Declaring a dependency on a key that does not exist yet is legal — forward references are how you build a graph in any order — but evaluating such a node before the key is defined yields an UnresolvedError. Defining the key later marks the dependents stale and they resolve normally. Deleting a node that has dependents puts them back into exactly that state rather than silently substituting a zero value.

A compute function that panics is contained: the panic is recovered and becomes an EvalError with Panic set and the recovered value preserved. Formulas are frequently user-authored, and a bad one must fail its own node, not the process.

Concurrency

A *Graph is not safe for concurrent use, and cascade deliberately ships no mutex-wrapped variant — locking belongs where the transaction boundaries are, which is your code, not here. Note that Graph.Get can compute and therefore writes, so concurrent Get calls race just as concurrent mutations do. Guard a shared Graph with your own lock, or give each goroutine its own via Graph.Clone. The test suite runs clean under the race detector.

Example

Example builds a small invoice model and changes one input.

package main

import (
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, float64]()

	g.SetValue("qty", 3)
	g.SetValue("unit", 20)
	g.SetValue("taxRate", 0.2)

	_ = g.SetFormula("net", []string{"qty", "unit"}, func(in cascade.Inputs[string, float64]) (float64, error) {
		return in.MustGet("qty") * in.MustGet("unit"), nil
	})
	_ = g.SetFormula("tax", []string{"net", "taxRate"}, func(in cascade.Inputs[string, float64]) (float64, error) {
		return in.MustGet("net") * in.MustGet("taxRate"), nil
	})
	_ = g.SetFormula("total", []string{"net", "tax"}, func(in cascade.Inputs[string, float64]) (float64, error) {
		return in.MustGet("net") + in.MustGet("tax"), nil
	})

	total, _ := g.Get("total")
	fmt.Printf("total: %.2f\n", total)

	g.SetValue("qty", 10)
	total, _ = g.Get("total")
	fmt.Printf("total: %.2f\n", total)

}
Output:
total: 72.00
total: 240.00
Example (Batch)

Example_batch groups several edits so the graph settles in one pass.

package main

import (
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	g.SetValue("a", 1)
	g.SetValue("b", 2)

	var computes int
	_ = g.SetFormula("sum", []string{"a", "b"}, func(in cascade.Inputs[string, int]) (int, error) {
		computes++
		return in.MustGet("a") + in.MustGet("b"), nil
	})

	_ = g.Batch(func(b *cascade.Batch[string, int]) error {
		b.SetValue("a", 10)
		b.SetValue("b", 20)
		return nil
	})

	sum, _ := g.Get("sum")
	fmt.Println("sum:", sum, "computes:", computes)
}
Output:
sum: 30 computes: 1
Example (Cycle)

Example_cycle shows that a cyclic definition is rejected up front, with the offending path, and leaves the graph untouched.

package main

import (
	"errors"
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	g.SetValue("a", 1)
	pass := func(k string) func(cascade.Inputs[string, int]) (int, error) {
		return func(in cascade.Inputs[string, int]) (int, error) { return in.MustGet(k), nil }
	}
	_ = g.SetFormula("b", []string{"a"}, pass("a"))
	_ = g.SetFormula("c", []string{"b"}, pass("b"))

	err := g.SetFormula("a", []string{"c"}, pass("c"))

	var ce *cascade.CycleError[string]
	if errors.As(err, &ce) {
		fmt.Println(ce)
		fmt.Println("path:", ce.Path())
	}

	// The rejected call changed nothing.
	a, _ := g.Get("a")
	fmt.Println("a is still", a)

}
Output:
cascade: cycle: a -> c -> b -> a
path: [a c b a]
a is still 1
Example (ErrorPropagation)

Example_errorPropagation shows a failure travelling downstream while still naming the node that actually broke.

package main

import (
	"errors"
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	errNoRate := errors.New("no rate available")

	g := cascade.New[string, float64]()
	g.SetValue("amount", 100)
	_ = g.SetFormula("rate", nil, func(cascade.Inputs[string, float64]) (float64, error) {
		return 0, errNoRate
	})
	_ = g.SetFormula("converted", []string{"amount", "rate"}, func(in cascade.Inputs[string, float64]) (float64, error) {
		return in.MustGet("amount") * in.MustGet("rate"), nil
	})

	_, err := g.Get("converted")

	var ee *cascade.EvalError[string]
	if errors.As(err, &ee) {
		fmt.Println("failed node:", ee.Key)
		fmt.Println("root cause: ", ee.Cause)
	}
	fmt.Println("is errNoRate:", errors.Is(err, errNoRate))

}
Output:
failed node: converted
root cause:  rate
is errNoRate: true
Example (ForwardReference)

Example_forwardReference shows that a dependency may be declared before it exists; the dependent resolves once the key is defined.

package main

import (
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	_ = g.SetFormula("greeting", []string{"name"}, func(in cascade.Inputs[string, int]) (int, error) {
		return in.MustGet("name"), nil
	})

	_, err := g.Get("greeting")
	fmt.Println(err)

	g.SetValue("name", 7)
	v, _ := g.Get("greeting")
	fmt.Println("resolved to", v)

}
Output:
cascade: unresolved: greeting references undefined key name
resolved to 7
Example (MinimalRecompute)

Example_minimalRecompute shows that a change recomputes each affected node exactly once, even where two paths reconverge.

package main

import (
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	g.SetValue("a", 1)

	double := func(k string) func(cascade.Inputs[string, int]) (int, error) {
		return func(in cascade.Inputs[string, int]) (int, error) {
			return in.MustGet(k) * 2, nil
		}
	}
	_ = g.SetFormula("b", []string{"a"}, double("a"))
	_ = g.SetFormula("c", []string{"a"}, double("a"))
	_ = g.SetFormula("d", []string{"b", "c"}, func(in cascade.Inputs[string, int]) (int, error) {
		return in.MustGet("b") + in.MustGet("c"), nil
	})
	_ = g.Recalc()

	// Only report work caused by the change below.
	g.OnCompute(func(k string, _ error) { fmt.Println("computed", k) })
	g.SetValue("a", 5)
	_ = g.Recalc()

	d, _ := g.Get("d")
	fmt.Println("d =", d)

}
Output:
computed b
computed c
computed d
d = 20
Example (PanicContainment)

Example_panicContainment shows a misbehaving formula failing its own node instead of the process.

package main

import (
	"errors"
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	g.SetValue("n", 0)
	_ = g.SetFormula("recip", []string{"n"}, func(in cascade.Inputs[string, int]) (int, error) {
		return 100 / in.MustGet("n"), nil // divides by zero
	})

	_, err := g.Get("recip")

	var ee *cascade.EvalError[string]
	if errors.As(err, &ee) {
		fmt.Println("panicked:", ee.Panic)
		fmt.Println("node:", ee.Key)
	}
	fmt.Println("graph still usable:", g.Has("n"))

}
Output:
panicked: true
node: recip
graph still usable: true
Example (TopoOrder)

Example_topoOrder inspects the evaluation order the engine will use.

package main

import (
	"fmt"

	"github.com/zkrebbekx/cascade"
)

func main() {
	g := cascade.New[string, int]()
	g.SetValue("width", 3)
	g.SetValue("height", 4)
	_ = g.SetFormula("area", []string{"width", "height"}, func(in cascade.Inputs[string, int]) (int, error) {
		return in.MustGet("width") * in.MustGet("height"), nil
	})
	_ = g.SetFormula("volume", []string{"area"}, func(in cascade.Inputs[string, int]) (int, error) {
		return in.MustGet("area") * 10, nil
	})

	order, _ := g.TopoOrder()
	fmt.Println(order)
	fmt.Println("volume depends directly on", g.Precedents("volume"))
	fmt.Println("area feeds", g.Dependents("area"))

}
Output:
[width height area volume]
volume depends directly on [area]
area feeds [volume]

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Batch

type Batch[K comparable, V any] struct {
	// contains filtered or unexported fields
}

Batch groups a set of edits so they settle in a single recalculation pass. It is handed to the function passed to Graph.Batch and is only valid for the duration of that call; using it afterwards panics.

func (*Batch[K, V]) Delete

func (b *Batch[K, V]) Delete(k K)

Delete removes k. See Graph.Delete.

func (*Batch[K, V]) SetFormula

func (b *Batch[K, V]) SetFormula(k K, deps []K, fn func(in Inputs[K, V]) (V, error)) error

SetFormula defines or replaces k as computed from deps. See Graph.SetFormula.

func (*Batch[K, V]) SetValue

func (b *Batch[K, V]) SetValue(k K, v V)

SetValue defines or replaces k as a constant input. See Graph.SetValue.

type CycleError

type CycleError[K comparable] struct {
	// contains filtered or unexported fields
}

CycleError reports that a Graph.SetFormula call was rejected because the declared dependencies would close a cycle. The graph is left unchanged.

func (*CycleError[K]) Error

func (e *CycleError[K]) Error() string

func (*CycleError[K]) Path

func (e *CycleError[K]) Path() []K

Path returns the offending cycle, starting and ending at the same key — for example A, B, C, A means "A depends on B depends on C depends on A". The returned slice is a copy and may be modified freely.

type EvalError

type EvalError[K comparable] struct {
	Key        K
	Cause      K
	Panic      bool
	PanicValue any
	Err        error
}

EvalError reports that a node failed to compute. Key is the node holding this error; Cause is the node where the failure actually originated, which equals Key for a direct failure and names the upstream node for a propagated one — the spreadsheet #VALUE!/#REF! model.

If the compute function panicked, Panic is true and PanicValue holds the recovered value. EvalError.Unwrap exposes the underlying error, so errors.Is and errors.As reach through a whole propagation chain to the original failure.

func (*EvalError[K]) Error

func (e *EvalError[K]) Error() string

func (*EvalError[K]) Unwrap

func (e *EvalError[K]) Unwrap() error

type Graph

type Graph[K comparable, V any] struct {
	// contains filtered or unexported fields
}

Graph is a dependency graph of keyed values, where some values are constants supplied by the caller and others are computed from them by formulas.

A Graph is lazy: mutating it never computes anything, it only marks the affected nodes stale. Values are produced on demand by Graph.Get, or in bulk by Graph.Recalc and Graph.RecalcFrom. Clean nodes are never recomputed, and a node reachable by several paths from a change is recomputed exactly once.

The zero Graph is not usable; call New.

A Graph is not safe for concurrent use. Mutating one from several goroutines, or calling Get concurrently (Get can compute, and therefore writes), is a data race. Guard it with your own lock if you need to share one.

func New

func New[K comparable, V any]() *Graph[K, V]

New returns an empty Graph.

func (*Graph[K, V]) Batch

func (g *Graph[K, V]) Batch(fn func(b *Batch[K, V]) error) error

Batch runs fn against the graph and then performs exactly one Graph.Recalc, returning its error. Because a Graph never computes on mutation, this is a readability and scoping device: it states that the edits inside belong together and that the graph should settle once when they are all applied.

If fn panics, the graph is left in whatever state the applied edits produced — consistently stale, never half-computed — and the panic propagates.

func (*Graph[K, V]) Clone

func (g *Graph[K, V]) Clone() *Graph[K, V]

Clone returns a deep copy of the graph: structure, cached values, cached errors and stale flags. The copy shares the caller's compute functions — they are values, not state — but no other memory, so mutating either graph leaves the other untouched. The OnCompute hook is carried over.

func (*Graph[K, V]) Delete

func (g *Graph[K, V]) Delete(k K)

Delete removes k. Its dependents are not removed and are not zeroed: they become stale and will fail with *UnresolvedError until k is defined again. Deleting an unknown key is a no-op.

func (*Graph[K, V]) Dependents

func (g *Graph[K, V]) Dependents(k K) []K

Dependents returns the keys that directly declared k as a dependency, in the order those edges were created. It reports dependents of a referenced but undefined key too. The returned slice is a copy.

func (*Graph[K, V]) Get

func (g *Graph[K, V]) Get(k K) (V, error)

Get returns the current value of k, computing it and any stale precedents it needs, in dependency order, on demand. Nodes that are already clean are returned from cache without recomputing.

A node that failed keeps its error: Get returns the cached failure rather than retrying, until the node or one of its precedents is marked stale again. Looking up a key that is not defined fails with *UnresolvedError.

func (*Graph[K, V]) Has

func (g *Graph[K, V]) Has(k K) bool

Has reports whether k is defined, as either a value or a formula.

func (*Graph[K, V]) Keys

func (g *Graph[K, V]) Keys() []K

Keys returns every defined key in the order the keys were first defined. The order is deterministic for a given construction sequence. Keys are only constrained to be comparable, so cascade cannot sort them; if you need a sorted view, sort the result yourself.

func (*Graph[K, V]) Len

func (g *Graph[K, V]) Len() int

Len reports the number of defined keys. Placeholders for keys that are referenced but not defined are not counted.

func (*Graph[K, V]) OnCompute

func (g *Graph[K, V]) OnCompute(fn func(k K, err error))

OnCompute installs a hook called once for every node the engine actually computes, with the resulting error (nil on success). It is not called for nodes skipped as already clean, which makes it a direct audit of the work a change caused. Pass nil to remove the hook.

The hook must not mutate the graph.

func (*Graph[K, V]) Precedents

func (g *Graph[K, V]) Precedents(k K) []K

Precedents returns the direct dependencies k declared, in declaration order. It returns nil for values, placeholders and unknown keys. The returned slice is a copy.

func (*Graph[K, V]) Recalc

func (g *Graph[K, V]) Recalc() error

Recalc computes every stale node in the graph, in dependency order, so no formula ever reads a stale precedent. Individual node failures are recorded on their nodes and propagated downstream; Recalc returns the first failure it saw in topological order, or nil if everything computed cleanly.

Inspect Graph.Get per key, or install Graph.OnCompute, when you need the full picture rather than the first error.

func (*Graph[K, V]) RecalcFrom

func (g *Graph[K, V]) RecalcFrom(keys ...K) error

RecalcFrom computes only the part of the graph reachable from the given keys: those keys and everything transitively downstream of them, restricted to what is actually stale. Stale precedents that those nodes need are pulled in as well, so the result is never computed from a stale input, but unrelated stale regions of the graph are left alone.

It returns the first failure in topological order, or nil. Unknown keys are ignored.

func (*Graph[K, V]) SetFormula

func (g *Graph[K, V]) SetFormula(k K, deps []K, fn func(in Inputs[K, V]) (V, error)) error

SetFormula defines or replaces k as computed from deps by fn. Duplicate deps are collapsed, and declaring a dep on a key that does not exist yet is legal: the node evaluates to *UnresolvedError until the key is defined, then resolves normally.

If the edges would close a cycle, SetFormula returns *CycleError and the graph is left exactly as it was — no partial mutation. fn must not be nil.

k and its transitive dependents are marked stale; nothing is computed.

func (*Graph[K, V]) SetValue

func (g *Graph[K, V]) SetValue(k K, v V)

SetValue defines or replaces k as a constant input. Any formula previously attached to k is discarded along with its edges. Every transitive dependent of k is marked stale; nothing is computed.

func (*Graph[K, V]) Stale

func (g *Graph[K, V]) Stale() []K

Stale returns the keys that are currently dirty and would be recomputed by the next Graph.Recalc, in definition order.

func (*Graph[K, V]) TopoOrder

func (g *Graph[K, V]) TopoOrder() ([]K, error)

TopoOrder returns every defined key in dependency order: a key always appears after all of its precedents. The order is deterministic for a given construction sequence.

Since Graph.SetFormula rejects cycles, a well-formed graph never fails here; the error exists so the invariant is checked rather than assumed.

type Inputs

type Inputs[K comparable, V any] struct {
	// contains filtered or unexported fields
}

Inputs is the read-only handle a formula uses to reach its declared dependencies. It is valid only for the duration of the call that received it; retaining it and reading later yields the values captured at call time.

Reads are restricted to the keys the formula declared. Reaching for anything else fails with UndeclaredError rather than silently succeeding, which is what keeps the recorded edges an honest description of the data flow.

func (Inputs[K, V]) Get

func (in Inputs[K, V]) Get(k K) (V, error)

Get returns the value of a declared dependency. It fails with *UndeclaredError if k is not one of the formula's declared dependencies.

func (Inputs[K, V]) Keys

func (in Inputs[K, V]) Keys() []K

Keys returns the declared dependencies in declaration order. The returned slice is a copy and may be modified freely.

func (Inputs[K, V]) Len

func (in Inputs[K, V]) Len() int

Len reports how many dependencies the formula declared.

func (Inputs[K, V]) MustGet

func (in Inputs[K, V]) MustGet(k K) V

MustGet returns the value of a declared dependency and panics if k was not declared. The panic is contained by the engine and surfaces as an *EvalError with Panic set, so it fails the node rather than the process.

type UndeclaredError

type UndeclaredError[K comparable] struct {
	Key K
}

UndeclaredError reports that a formula tried to read a key it did not declare as a dependency. Reads are restricted to declared dependencies so that the graph's edges always describe the real data flow.

func (*UndeclaredError[K]) Error

func (e *UndeclaredError[K]) Error() string

type UnresolvedError

type UnresolvedError[K comparable] struct {
	Key     K
	Missing K
}

UnresolvedError reports a reference to a key that is not defined in the graph. Missing is the undefined key; Key is the node that referenced it. When a key is looked up directly with Graph.Get and does not exist, Key and Missing are the same.

Declaring a dependency on a key that does not exist yet is legal — forward references are supported — but evaluating such a node before the key is defined yields this error. Defining the key later marks dependents stale and they resolve normally.

func (*UnresolvedError[K]) Error

func (e *UnresolvedError[K]) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL