numbox.core.variable
Overview
Framework for Directed Acyclic Graph (DAG) in pure Python. While this module does not contain any JIT-compiled bits in particular, or anything imported from numba in general, computationally heavy parts can be put on this graph as JIT-compiled functions via the formula key of the graph variables specifications (see below).
Modules
numbox.core.variable.variable
Overview
A graph can be defined as follows:
from numbox.core.variable.variable import Graph
def derive_x(y_):
return 2 * y_
def derive_a(x_):
return x_ - 74
def derive_u(a_):
return 2 * a_
x = {"name": "x", "inputs": {"y": "basket"}, "formula": derive_x}
a = {"name": "a", "inputs": {"x": "variables1"}, "formula": derive_a}
u = {"name": "u", "inputs": {"a": "variables1"}, "formula": derive_u}
graph = Graph(
variables_lists={
"variables1": [x, a],
"variables2": [u],
},
external_source_names=["basket"]
)
Here we have the variable y sourced externally from the basket, and calculated variables x and a in the variables1 namespace, and u in the variables2 namespace.
The dictionaries x, a, and u are called variable specifications. These specs on their own are agnostic about what namespace they can be put in. The namespaces however need to be specified via the variables_lists argument given to the Graph at the initialization time.
The full and unambiguous way to denote the variables is via their qualified names, applicable both to externally sourced variables, basket.y, as well as the calculated ones, variables1.x, variables1.a, variables2.u.
One of the variables specifications, designated with the key formula, specifies the function with the parameters that match the input variables (this graph node’s dependencies) that are in turn designated with the key inputs. While the names of the parameters of the function assigned to the formula key do not have to match the names of the inputs, their order is expected to follow one-to-one correspondence. This way the graph is instructed which inputs to use to get the values to be assigned to the parameters of the formula.
The Python function specified by the formula can be a wrapper around numba JIT-compiled function, i.e., a proxy to the numba’s FunctionType or CPUDispatcher objects [1].
The variable specification for inputs (if any) includes both the names of the dependencies variables required to calculate the given variable via the function given by the formula, as well as the namespaces where these variables are going to be looked for in.
Graph end nodes, located at the edge of the graph (a.k.a., leaf nodes) have neither inputs nor formula in their specifications. Specifying formula without inputs will not result in an exception, accommodating for the case of a function that computes and returns a value independent of any input parameters. It is also possible to specify inputs but no formula, which technically defines the placement of the node on the graph but leaves it up to the developer to defer specifying the node’s calculation logic until later in the runtime.
Names of the ‘external’ sources (of data values) need to be given to the Graph as well,
via the external_source_names argument.
When the numbox.core.variable.variable.Graph is compiled
to the numbox.core.variable.variable.CompiledGraph, it will automatically figure out which variables need to be sourced
from each of the specified external sources (such as, ‘basket’) in order to perform the
required calculation:
from numbox.core.variable.variable import CompiledGraph
# What is required from this calculation, the names of qualified variables
required = ["variables2.u"]
# Compile the graph for the required variables
compiled = graph.compile(required)
assert isinstance(compiled, CompiledGraph)
# The graph will figure out what external variables it needs to do the calculation
required_external_variables = compiled.required_external_variables
assert list(required_external_variables.keys()) == ["basket"]
basket = required_external_variables["basket"]
assert list(basket.keys()) == ["y"]
assert basket["y"].name == "y"
Graph uses the variable specifications given to it to create instances of numbox.core.variable.variable.Variable.
Namespaces of calculated Variable s are numbox.core.variable.variable.Variables.
Namespaces of externally sourced Variable s are
numbox.core.variable.variable.External .
Semantically, each Variable is defined by its scoped name, that is, a tuple of its namespace / source name and its own name.
In DAG terminology, External scopes contain variables with no inputs, that is, edge (or end / leaf) nodes.
Instances of Variable s and External are stored in the Graph’s instance’s registry:
from numbox.core.variable.variable import Variables, Variable
registry = graph.registry
# Get the namespaces...
variables1 = registry["variables1"]
variables2 = registry["variables2"]
# ... and the variables defined in these namespaces
assert list(variables1.variables.keys()) == ["x", "a"]
assert list(variables2.variables.keys()) == ["u"]
assert isinstance(variables1, Variables)
assert isinstance(variables1.variables["x"], Variable)
basket_ = registry["basket"]
... # same `basket` as above
assert basket_["y"] is basket["y"]
That is, users are not expected to instantiate neither Variable s nor Variables s, although they are certainly allowed to do so if needed (it is recommended to design one’s code so that Variable instances when needed are simply retrieved from the registry of the Graph instance). Instead, users provide variable specifications, as the dictionaries x, u, a in the example above (and the variable name “y” that is referred to and implied to be ‘external’) that are given to the Graph. The Graph then creates instances of Variables (one per namespace) and instances of External (one per an ‘external’ source). Finally, Variables and External in turn create instances of Variable s and store them.
To calculate the required variables, one first needs to instantiate the execution-scope instance
of the storage numbox.core.variable.variable.Values of the values of all variables
scoped in Variables and External namespaces. This storage will get automatically populated
with all calculated nodes
as a mapping from the corresponding Variable to instances of numbox.core.variable.variable.Value.
The latter wraps the data. All the data of non-external variables is initialized to
the instance _null of the numbox.core.variable.variable._Null.
Then, one needs to supply external_values of the leaf nodes that are needed for the calculation. As discussed above, these required external variables are identified programmatically. Provided values for these have been provided, one can calculate the graph as:
from numbox.core.variable.variable import Values
# Instantiate the storage
values = Values()
# Request the calculation by executing the graph
compiled.execute(
external_values={"basket": {"y": 137}},
values=values,
)
This populates the values with the correct data:
x_var = variables1["x"]
a_var = variables1["a"]
u_var = variables2["u"]
assert values.get(x_var).value == 274
assert values.get(a_var).value == 200
assert values.get(u_var).value == 400
The graph can be recomputed if some of its nodes have been changed. Only the affected nodes will be re-evaluated:
compiled.recompute({"basket": {"y": 1}}, values)
assert values.get(basket["y"]).value == 1
assert values.get(x_var).value == 2
assert values.get(a_var).value == -72
assert values.get(u_var).value == -144
References
- class numbox.core.variable.variable.CompiledGraph(ordered_nodes: list[numbox.core.variable.variable.CompiledNode], required_external_variables: dict[str, dict[str, numbox.core.variable.variable.Variable]], debug: bool = False, dependents: dict[numbox.core.variable.variable.Variable, list[numbox.core.variable.variable.CompiledNode]] = <factory>, affected_cache: dict[frozenset[numbox.core.variable.variable.Variable], list[numbox.core.variable.variable.CompiledNode]] = <factory>)[source]
Bases:
object- affected_cache: dict[frozenset[Variable], list[CompiledNode]]
- debug: bool = False
- dependents: dict[Variable, list[CompiledNode]]
- execute(external_values: dict[str, dict[str, Any]], values: Storage)[source]
Main entry point to calculate values of nodes of the compiled graph. Calculation requires the following inputs:
- Parameters:
external_values – actual values of all required external variables, this can be a superset of what is really needed for the calculation. The map is first from the name of the external namespace and then from the name of the variable within that source to the variable’s actual value.
values – runtime storage of all values, e.g., an instance of Values.
- ordered_nodes: list[CompiledNode]
- recompute(changed: dict[str, dict[str, Any]], values: Storage)[source]
- Parameters:
changed – dict of sources to names to new values of changed Variable instances coming from either External or Variables source.
values – storage of all Variable values.
Recompute takes priority: each node in the affected downstream cone is reset and recomputed from its formula, so the graph structure decides the final values. A supplied value persists only for a node not downstream of any other change; a co-changed downstream value is recomputed, not held.
- class numbox.core.variable.variable.CompiledNode(variable: numbox.core.variable.variable.Variable, inputs: list[numbox.core.variable.variable.Variable])[source]
Bases:
object
- class numbox.core.variable.variable.External(name: str)[source]
Bases:
NamespaceAn ‘external’ namespace that facilitates discovery of requested names.
When requesting a Variable with the given name via a typical __getitem__ call, if the Variable is not found, it will be created and added to this dictionary. This way the graph will be able to infer which variables are required from the external source abstracted by this namespace.
- class numbox.core.variable.variable.Graph(variables_lists: dict[str, list[VarSpec]], external_source_names: list[str])[source]
Bases:
object- compile(required: list[str] | str, debug: bool = False) CompiledGraph[source]
- Required:
list of qualified variables names that need to be calculated.
- dependents_of(qual_names: list[str] | set[str] | str) set[str][source]
Return qualified names of Variable`s that directly or indirectly depend on any of `qual_names.
- explain(qual_name: str, right_to_left: bool = True) str[source]
Follow the dependencies chain to explain how the given variable is derived.
Uses metadata of the Variable instances.
- Parameters:
qual_name – qualified name of the Variable.
right_to_left – when True (default), begin explanation with qual_name. That is, move towards the ends of the graph.
- class numbox.core.variable.variable.Params(jitable: bool = True, type: Any = None)[source]
Bases:
objectOptional per-Variable declaration driving static jitability in compile_kernel. jitable=False declares a deliberately plain-Python node; type is the variable’s numba Type (None means undeclared).
Like formula, params must be attached to a node before the first compile() of any required set containing that node: a Graph caches its compiled result, so a params attached afterward is not picked up.
- jitable: bool = True
- type: Any = None
- class numbox.core.variable.variable.Value(variable: ~numbox.core.variable.variable.Variable, value: ~typing.Any | ~numbox.core.variable.variable._Null = <numbox.core.variable.variable._Null object>)[source]
Bases:
objectValue of the corresponding Variable. Best used when created indirectly by the Values storage.
- value: Any | _Null = <numbox.core.variable.variable._Null object>
- class numbox.core.variable.variable.Values[source]
Bases:
objectValues of all Variable instances, computed and external, will be held here.
- class numbox.core.variable.variable.VarSpec[source]
Bases:
VarSpecBase- formula: Callable
- inputs: dict[str, str]
- metadata: str
- name: str
- class numbox.core.variable.variable.Variable(name: str, source: str = '', inputs: ~typing.Mapping[str, str] = <factory>, formula: ~typing.Callable = None, metadata: str | None = None, params: ~numbox.core.variable.variable.Params | None = None)[source]
Bases:
objectAn instance of Variable is anything that can be calculated from the values of the given inputs dependencies using the provided formula (i.e., a Python function).
Calculated value can be None, that is why a non-calculated value is designated with _null.
An instance of Variable is best created within the given Namespace. For example, when the Variables subtype of the Namespace is instantiated, it gets populated with the freshly created Variable instances per the VarSpec specifications passed to it. Or, when the External subtype of the Namespace is queried for the given variable name, if a Variable with such a name is not already present in that external namespace, it will be created and stored there.
- Parameters:
name – name of the Variable instance.
source – name of the Namespace instance which is the namespace / source of this Variable.
inputs – (optional) map from names of the Variable inputs (which are names of other Variable instances) to names of their Namespace instances.
formula – (optional) function that calculates the value of this Variable from its inputs.
metadata – any possible metadata associated with this variable.
- formula: Callable = None
- inputs: Mapping[str, str]
- metadata: str | None = None
- name: str
- qual_name() str[source]
Qualified name of Variable incorporates both the name of the Variable and the name of its source / namespace.
- source: str = ''
- class numbox.core.variable.variable.Variables(name: str, variables: list[VarSpec])[source]
Bases:
Namespace
- numbox.core.variable.variable.make_qual_name(namespace_name: str, var_name: str) str[source]
Each Variable instance is best initialized in and owned by a Namespace object (such as, instances of External and Variables), with the given namespace_name.
This function thereby returns qualified name of the Variable instance.
numbox.core.variable.compile_kernel
Overview
Alongside numbox.core.variable.variable.Graph.compile() (which produces a
numbox.core.variable.variable.CompiledGraph evaluated node-by-node in pure Python),
numbox.core.variable.compile_kernel.compile_kernel() compiles a Graph into
fused @njit kernel code for a requested set of variables. It does not replace
core.work or CompiledGraph; it is an additional, JIT’ed evaluation path.
When every formula is njit-able the graph fuses into one @njit kernel that takes
the required external inputs as positional arguments and returns the requested variables
as a tuple, with every interior graph node lowered to an SSA temporary inside the single
compiled function. No per-node type information needs to be supplied: numba infers every
interior type from the runtime argument types. Plain-Python formulas are auto-wrapped with
njit(). When some formulas are not njit-able for the actual argument types, the first
call detects them and the graph is split into @njit segments orchestrated from Python
(see Graphs with non-jittable nodes below).
Per-node type information is optional. Each Variable may carry a
params (a Params(jitable, type)) declaring whether its formula is
jittable and the variable’s numba type. A node with no params behaves
exactly as above – jitability is discovered at the first call. When every
node in the required cone is declared and every consumed external is typed,
compile_kernel resolves the execution mode at build time instead: an
all-jittable graph compiles eagerly into one fused kernel; a declared
jittable/non-jittable mix compiles eagerly into a static segment plan with no
probing; and CompiledKernel.partition is populated at build, inspectable
before any call. Declaring types moves type errors to build time: a coercible
but wrong params.type (for instance declaring int64 over a body that
naturally returns float64) raises at compile_kernel rather than silently
truncating, because it is caught by an explicit unconstrained return-type probe
of the formula – not by binding the formula to the declared signature, which
numba would silently coerce. A graph that declares nothing is byte-for-byte the
behavior described above.
The call to compile_kernel returns a numbox.core.variable.compile_kernel.CompiledKernel. It exposes
.kernel (the hot-path callable — positional in, tuple out: the bare numba dispatcher
once the graph resolves fully fused, the Python master when the graph is segmented around
non-jittable nodes) and a dict-in / dict-out .execute convenience that mirrors
numbox.core.variable.variable.CompiledGraph.execute(). The qualified names of the
kernel’s positional inputs and tuple outputs are available as .params and .outputs,
the generated kernel text as .source, and the per-variable temporary identifiers as
.identifiers.
This fused path does not honor the cacheable memoization of individual nodes; use
numbox.core.variable.variable.CompiledGraph (or the core.work graph) when that
is needed. It does, however, support incremental recompute of only the affected nodes via
CompiledKernel.recompute (see Incremental recompute below).
Caching. The fused kernel is cached on disk, content-addressed by a
fingerprint of the generated kernel source, every formula’s behavioral
state (bytecode, constants, default values, closure-cell values, referenced
module-level globals including helper functions, defining module), and the
effective jit flags. Changing any of these recompiles instead of reusing a
stale binary; cosmetic edits that do not change behavior (comments, local
renames) do not. The generated source never mentions types, so a declared
graph’s signatures are folded into the digest as well (the consumed external
signature for an eager fused kernel, each segment’s live-in/out signature for
an eager segment): two declared-type variants of one type-free graph therefore
get distinct cache anchors and never reuse each other’s binary. Formulas whose
state cannot be fingerprinted – a cres-compiled callable, or a value with
no canonical form – make that one kernel uncacheable: always recompiled per
process, never wrong. The
cache keyword is tri-state: None (the default) defers to
jit_options["cache"], then the NUMBOX_JIT_OPTIONS environment
default, then True; an explicit True/False wins. Two costs are
worth knowing: a formula that references or closes over a large array
pays a per-compile sha256 over that array’s bytes (proportional to its
size) on every compile_kernel call; and numba itself declines to
disk-cache a kernel that calls a @cfunc formula or references a large
global array – the kernel still computes correctly, it is simply
recompiled in each process regardless of the content-addressed anchor. A
@vectorize (DUFunc) formula, by contrast, caches cleanly.
Practical limits. Graph traversal is recursive: dependency chains
deeper than roughly sys.getrecursionlimit() raise a RecursionError
naming the remedy (raise the limit before compiling). Cold compilation of
the fused kernel costs on the order of 20 ms and ~1 MiB of memory per
formula node (numba 0.65, CPython 3.12); graphs beyond a few thousand
nodes compile increasingly slowly and are better split or evaluated via
numbox.core.variable.variable.CompiledGraph.
A graph can be compiled to a fused kernel as follows:
from numba import njit
from numbox.core.variable.variable import Graph
from numbox.core.variable.compile_kernel import compile_kernel
graph = Graph(
variables_lists={"variables": [
{"name": "x", "inputs": {"y": "basket"}, "formula": njit(lambda y: 2 * y)},
{"name": "u", "inputs": {"x": "variables"}, "formula": njit(lambda x: x - 74)},
]},
external_source_names=["basket"],
)
ck = compile_kernel(graph, ["variables.u"])
assert ck.execute({"basket": {"y": 100}}) == {"variables.u": 126}
assert ck.kernel(100) == (126,)
Here the dict-in / dict-out ck.execute looks up the required external value
basket.y and returns the requested variables.u, while ck.kernel
is called positionally with the external input and returns the output tuple directly.
Graphs with non-jittable nodes
compile_kernel detects non-jittable formulas automatically at the first
call: it first tries to compile the fully fused kernel for the actual
argument types; if that fails, it probes each node against the real
intermediate values, runs the offenders in plain Python, and fuses the
jittable remainder into the minimal number of @njit segments any
topological order permits (one plus the maximum number of jit/Python
alternations along a dependency path). A Python master then threads values
between segments and Python nodes. Compile-time failures demote a node;
runtime errors always propagate.
import json
from numbox.core.variable.compile_kernel import compile_kernel
from numbox.core.variable.variable import Graph
def n3(v):
json.dumps({"k": 1}) # no nopython lowering for the json module
return v * 3.0
graph = Graph(
variables_lists={"calc": [
{"name": "n1", "inputs": {"x": "ext"}, "formula": lambda x: x + 1.0},
{"name": "n2", "inputs": {"n1": "calc"}, "formula": lambda n1: n1 * 2.0},
{"name": "n3", "inputs": {"n2": "calc"}, "formula": n3},
{"name": "n4", "inputs": {"n3": "calc"}, "formula": lambda n3: n3 - 4.0},
{"name": "n5", "inputs": {"n4": "calc"}, "formula": lambda n4: n4 / 2.0},
]},
external_source_names=["ext"],
)
ck = compile_kernel(graph, "calc.n5")
ck.kernel(7.0) # first call: probes, partitions, still correct
print(str(ck.partition)) # 2 jit segments around the python n3, with reasons
ck.partition is None until the first call resolves the mode; a fully
fused graph reports a single jit segment. Each jit segment is cached
content-addressed on disk the same way the fused kernel is; the learned partition
itself is per-process. If a later call’s types break a segment, the partition
is re-learned for those values and replaces the previous plan — workloads
alternating between type families whose partitions differ re-pay discovery on
each alternation.
Incremental recompute
CompiledKernel.recompute is a value-only refresh that re-evaluates only the cone
of nodes affected by a change, reading every unchanged input from a persistent value
store seeded by a prior full call. It mirrors
numbox.core.variable.variable.CompiledGraph.recompute(): same types across calls,
{source: {name: value}} in, a tuple in outputs order out. A changed name may
resolve to an interior node, in which case its value is overridden and only its downstream
cone recomputes. Do not interleave input-changing throughput kernel(...) calls between
recompute calls – the store is seeded once and a throughput call does not update it.
For an undeclared graph a changed value of a different numba type triggers a
one-time flush-and-reseed recovery. A declared kernel enforces a contract
instead: a changed value is checked for numba assignability to the node’s
declared type, so a value numba cannot assign raises a crisp declared type X,
got Y error, while a benign difference numba accepts – a C-contiguous array
against an 'A'-layout array declaration, or a safe scalar promotion – is
accepted. The check is convertibility, not type identity.
from numba import njit
from numbox.core.variable.variable import Graph
from numbox.core.variable.compile_kernel import compile_kernel
graph = Graph(
variables_lists={"variables": [
{"name": "a", "inputs": {"y": "basket"}, "formula": njit(lambda y: y + 1.0)},
{"name": "b", "inputs": {"y": "basket"}, "formula": njit(lambda y: y * 2.0)},
{"name": "u", "inputs": {"a": "variables", "b": "variables"},
"formula": njit(lambda a, b: a + b)},
]},
external_source_names=["basket"],
)
ck = compile_kernel(graph, ["variables.u"])
assert ck.kernel(100.0) == (301.0,) # full call seeds the value store
assert ck.recompute({"basket": {"y": 101.0}}) == (304.0,)
Compile a core.variable Variable graph into fused @njit kernel(s).
Alongside core.work (a structref graph), this turns a Graph/CompiledGraph into JIT-compiled straight-line code. When every formula is njit-able the whole graph becomes a single fused @njit function whose interior nodes are SSA temporaries (no per-node type info needed: numba infers every interior type from the kernel’s runtime argument types). When some formulas are not njit-able, the first call detects them automatically – numba compile errors demote a node to plain Python, runtime errors always propagate – and a Python master orchestrates fused @njit segments around the demoted nodes, with a fusion-maximizing linearization choosing the segment boundaries. The resulting partition is described by CompiledKernel.partition (a PartitionReport with per-node demotion reasons); formulas with no Python fallback (cres/CompileResultWAP, CFunc, DUFunc) are always treated as jittable.
Jitability may be either discovered at the first call (above) or declared up front. Each Variable carries an optional params (Params(jitable, type)). A node with no params is discovered exactly as before – byte-for-byte the same behavior. When every node in the required cone is declared (and every consumed external is typed), compile_kernel() resolves the execution mode at build time instead of at the first call: an all-jittable graph compiles eagerly into one fused kernel (“fused”), a declared jit/Python mix compiles eagerly into a static segment plan (“segmented”) with no probing, and CompiledKernel.partition is populated at build (inspectable before any call). A declared params.type that the formula does not naturally yield is caught at build by an explicit unconstrained return-type probe – not by binding the formula to the declared signature, which would silently coerce a convertible-but-wrong scalar type (a node declared int64 over a float-returning body would otherwise return a truncated value). Any node left undeclared (or only partially typed) keeps the runtime-discovery path; a graph that declares nothing behaves exactly as today.
The on-disk cache is content-addressed per compiled unit (the fused kernel, or each jit segment): the digest fingerprints each formula’s code, constants, default arguments, closure-cell values, referenced globals, and the kernel’s effective jit flags, so a stale binary is never reused and two distinct kernels never collide. The kernel source never mentions types, so declared signatures are appended to the digest as well: two declared-type variants of one graph therefore get distinct cache anchors. A formula with no canonical fingerprint forces its unit uncached (no anchor, no numba cache) – never reused, never wrong.
- class numbox.core.variable.compile_kernel.CompiledKernel(kernel: Dispatcher, params: list[tuple[str, str, str]], outputs: list[str], source: str, identifiers: dict[str, str], ctx: _KernelCtx, required_vars: list[Variable], external_vars: list[Variable], is_declared: bool = False)[source]
Bases:
objectA fused @njit kernel compiled from a Variable graph.
Attributes:
kernel - hot-path callable: resolver before the first call, the bare numba dispatcher once fused, the segmented master otherwise. Positional external args (in `params` order) -> tuple (in `outputs` order). recompute - value-only incremental refresh of only the cone affected by a change, over a store seeded by a prior `kernel` call; returns a tuple in `outputs` order (see `recompute`). params - external input qual_names, kernel-argument order. outputs - requested variable qual_names, return-tuple order. source - generated kernel source text. identifiers - {qual_name: temp identifier} for inspection. partition - PartitionReport describing what runs where. None until the first call for undeclared graphs; set at build time for fully-declared (eager) graphs. is_declared - True when the graph was fully declared and the mode resolved eagerly at build; False for a discovery (undeclared) kernel. Declared kernels enforce the `recompute()` type contract instead of re-discovering.- execute(external_values: dict) dict[source]
Dict-in / dict-out convenience, symmetric with CompiledGraph.execute.
- property kernel: Callable
- recompute(changed: dict) tuple[source]
Incrementally re-evaluate only the cone affected by changed.
Mirrors
numbox.core.variable.variable.CompiledGraph.recompute(): this is a value-only refresh, not a recompile. changed is{source: {name: value}}; the returned tuple is in outputs order. The same variables may carry different values across calls, but their numba types must stay the same as the seeding call (a type change is recovered from once, see below, but the contract is same-types).Precondition: a prior full call (
kernel(...)/execute(...)) must have seeded the value store. Calling recompute first raisesRuntimeError.What it does: it writes the changed values into a persistent value store, collects the downstream cone of the changed nodes, and re-fuses just that cone – reading every unchanged input from the store. The cone sub-plan is compiled on first use and kept in a bounded LRU cache keyed on the cone and its live-in boundary, so a recurring change pattern reuses its compiled plan without re-fusing. Nodes that were demoted to plain Python at seed time stay Python in the cone; the jittable remainder fuses into
@njitsegments.Interior overrides: a changed name may resolve to an interior (computed) node rather than an external input – mirroring the interpreted path. Its value is overridden in the store and only its downstream cone recomputes; the overridden node’s own formula is not re-run – unless the node is itself downstream of another changed input in the same call, in which case graph priority applies and it is recomputed from its formula. The first override of a not-yet-seen interior node expands the change-source set and rebuilds the persisted-node boundary (and invalidates cached cone plans, whose boundaries have shifted).
Limitations:
Do not interleave input-changing
kernel(...)throughput calls between recompute calls. recompute is the stateful entry point: the store is seeded once, and a throughput call does not update it, so a subsequent recompute would read stale unchanged values. Use recompute for the incremental workflow and the bare kernel for independent one-shot calls.An interior plain-Python (demoted) node must return a stable numba type across recomputes. The same-types contract extends to demoted outputs: a demoted node whose output type drifts between recomputes is not supported.
On a live-in type change a cached cone dispatcher fails to compile; the whole cone-plan cache is flushed once, the store re-seeded from the last full call, the change re-applied, and the cone rebuilt against the new types.
Declared kernels enforce a contract instead of recovering. For a kernel built from a fully-declared graph, a changed value is checked for numba assignability to the node’s declared type: a value numba cannot assign to the declared type raises a crisp
declared type X, got Yerror, while a benign difference numba accepts – a C-contiguous array against an'A'-layout array declaration, or a safe scalar promotion – is accepted. The check is convertibility, not type identity, and is scoped to declared (eager) kernels; an individually-declared node inside an otherwise discovered kernel keeps the flush-and-reseed recovery above.
- numbox.core.variable.compile_kernel.compile_kernel(graph: Graph, required: str | list[str], *, jit_options: dict | None = None, cache: bool | None = None) CompiledKernel[source]
Compile graph into a fused @njit kernel for the required variables.
- Parameters:
graph – its dependency structure and formulas are fused into one straight-line @njit function (see CompiledKernel).
required – Order is preserved and fixes the order of CompiledKernel.outputs / the kernel’s return tuple; a duplicate entry raises ValueError (each output is requested once – the return tuple is positional, so a repeat carries no information).
jit_options – merged over numbox’s defaults (NUMBOX_JIT_OPTIONS env) and passed to @njit. All options except cache participate in the content-addressed digest.
cache – tri-state. None (default) defers to jit_options[“cache”], then the NUMBOX_JIT_OPTIONS env default, then True. An explicit True/False wins over both.
Error timing: structural problems raise here (unknown or malformed required entries, non-callable formulas, arity mismatches against the declared inputs, graphs deeper than the recursion limit). For an undeclared (or partially-declared) graph, numba typing problems surface at the kernel’s first call (auto-njit of plain-Python formulas is lazy). For a fully-declared graph (every node carries params, every consumed external is typed) the mode resolves eagerly here, so type errors move to build time: a formula whose natural return at the declared input types is non-convertible to the declared type, a cross-node type mismatch, and – crucially – a coercible-but-wrong params.type. The last is caught by an explicit unconstrained return-type probe that compares the formula’s naturally inferred return type against the declaration; binding the formula to the declared signature does not catch it, because numba silently coerces a convertible scalar (declaring int64 over a x * 1.5 body would otherwise compute 7, not 7.5). Fully-declared graphs thus fail fast at build; any-undeclared graph fails at the first call, exactly as today. Runtime errors never demote – they propagate.
Caching: the kernel digest fingerprints each formula’s bytecode, constants, default values, closure-cell values, referenced module-level globals (including helper functions, recursively), defining module, and the effective jit flags. Because the generated source never mentions types, a declared graph’s signatures are appended to the digest too (the consumed external signature for an eager fused kernel, each segment’s live-in/out signature for an eager segment), so two declared-type variants of one type-free graph get distinct cache anchors and never reuse each other’s binary. A formula whose state cannot be fingerprinted (e.g. cres/CompileResultWAP objects, values with no canonical form) downgrades that one kernel to cache=False: always recompiled, never stale. When caching is enabled, a content-addressed anchor .py file is written under numba’s cache directory; with caching off (or the cache dir unwritable, which warns and degrades) nothing is written.
Non-jittable formulas: for an undeclared graph the first call resolves the execution mode. If the fully fused kernel cannot be typed for the actual argument types, each node is probed against the real intermediate values; nodes whose formulas fail to compile (or whose input values numba cannot type) run in plain Python, and the jittable remainder is fused into segments orchestrated from Python. A declared jitable=False node is instead demoted by declaration (no probing): a graph mixing declared jittable and declared-Python nodes resolves eagerly to a “segmented” plan at build, CompiledKernel.partition populated immediately with per-node reasons. CompiledKernel.partition describes the result, including per-node demotion reasons; it is None before the first call only for an undeclared graph (set at build for a fully-declared one). CompiledKernel.kernel is the hot-path callable: the bare @njit dispatcher once the graph resolves fully fused, the Python master when segmented. For an undeclared graph a later call whose types break a segment re-learns and replaces the partition (one active plan); a declared kernel does not re-discover. The crisp declared type X, got Y contract is enforced at build time and on recompute() (can_convert against each declared type), not on the throughput kernel(…) path: throughput retains numba’s polymorphic widening across calls, and a later kernel(…) whose off-contract type breaks a jit segment raises numba’s own typing error (the declared _demoted is left untouched – no silent re-discovery). Once fully fused, fused is permanent (a later-signature typing failure raises). The discovery call computes jit-node values through per-node dispatchers while later calls use fused segments – identical under default IEEE semantics, but non-default jit_options such as fastmath could in principle differ across fusion boundaries.
The identifier-assignment and formula helpers used by the compiler live in
numbox.core.variable.utils.
Formula and identifier helpers for the fused-kernel compiler.
Shared utilities used by numbox.core.variable.compile_kernel: identifier assignment for generated kernel source, formula njit-wrapping, and formula arity validation. Kept here so they have a stable home as the kernel machinery grows and so a second consumer can reuse them without importing compile_kernel.