Skip to content

idea[havogt]: ambient values bound at execution time (closure-variable stretch goal) - #29

Open
havogt wants to merge 3 commits into
mainfrom
ambient-values-in-closure-vars
Open

idea[havogt]: ambient values bound at execution time (closure-variable stretch goal)#29
havogt wants to merge 3 commits into
mainfrom
ambient-values-in-closure-vars

Conversation

@havogt

@havogt havogt commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Records an idea next to the proposal it actually belongs to, rather than as a
separate document: closure-variable-resolution already has a
"Stretch goal: closing over read-only fields" appendix, and this is the same
mechanism with a later binding time.

The idea

A user declares a value once, globally, and any field operator reaches it
without it appearing in a signature — a ContextVar filled at
program-execution time (JIT time for compiled backends).

The motivating case is mesh properties: scalars on a Cartesian grid (dx, dy)
and fields on an unstructured one (connectivities plus their weights). Both are
fixed for the lifetime of the application, and both must today be carried
explicitly — as extra operator parameters, or as the offset_provider threaded
down the call chain. Every operator between the caller and the one that actually
needs a weight has to name it.

Why it is mostly unification, not invention

  • ContextVars are already how gt4py carries such values:
    next/embedded/context.py holds _offset_provider and
    _closure_column_range, with an update() context manager — internal and
    embedded-only today.
  • The scalar case already works, but only through a namespace object:
    ConstantPythonNamespaceObject = eve_utils.FrozenNamespace | enum.EnumMeta
    (type_translation.py), folded in closure_var_folding.py, fingerprint-aware
    in fingerprinting.py.
  • The field case is the existing stretch goal: hidden parameter via
    __gt_implicit_args__(), and bind the binding, not the buffer, so only the
    type/domain descriptor enters the compiled cache key.

What late binding adds over the decoration-time form is that the same operators
can run against a different mesh in one process; binding at decoration time
fixes the value before any operator is defined.

Open questions recorded, not answered

What "static for the lifetime of the application" means operationally (per
process, per ContextVar scope, or per compiled program — and what happens on
rebinding); which part of an ambient field enters the fingerprint; how an ambient
declaration is typed, and whether an operator's type should reflect its ambient
dependencies at all; and the standard objection to dynamic scoping — errors when
nothing is bound must be good, and there must be a way to see what an operator
depends on ambiently.

The ceiling it aims at is a mesh concept on top (linked to
mesh-and-first-class-halos), and it is a plausible substrate for
discretization-independent-fd-syntax, whose mesh-invariant surface needs
weights and connectivity to come from a declared mesh property rather than from
operator arguments.

Index keywords updated to match the extended tags; no new index entry, since
this is an appendix section rather than a proposal of its own.

…e stretch goal

Records the ContextVar variant next to the read-only-field stretch goal it
extends: a globally declared value bound at program-execution time, so mesh
properties (Cartesian scalars, unstructured connectivities and weights) need
not be threaded through signatures. Late binding is what lets one set of
operators run against a second mesh in a process.
…goal

Records what the gt4py branch established: a declaration carries its own type so
no type-system change is needed; the reference becomes a synthesised program
parameter added once at definition; and the only difference between the two
forms is whether that parameter is static. Adds the measured variant counts and
the three things the design turned out not to need.
@havogt

havogt commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Updated with what the prototype (havogt/gt4py#71) actually established. The section is now marked prototyped, with measured claims separated from open ones.

The mechanism turned out simpler than the original text assumed. A declaration carries its own type (Static[float] implements __gt_type__, which type_translation.from_value already dispatches on), so an operator referring to an ambient value type-checks when it is defined, with nothing bound — no type-system change. The reference then becomes a synthesised program parameter, added once in func_to_past, and from there travels the ordinary path.

That makes the two forms differ in exactly one place: whether the parameter is listed as static. Extern[T] stays a runtime argument; Static[T] becomes a static argument, so the existing fold and the existing StaticArg key do the work. Measured on gtfn and dace: Static compiles 2 variants for 2 values, Extern 1, both correct.

Three things the design does not need, each assumed at some point:

  • a new FOAST specialization step — the existing static-argument fold suffices, so no dependency on the dtype/dim-generics work;
  • threading the parameter into operator signatures and call sites — a free symbol in a lowered operator resolves against the program's parameters, and both gtfn and dace codegen fine that way;
  • a per-operator inspection pass — _get_closure_vars_recursively already collects declarations transitively through nested operators.

Binding time is restated. "Static for the lifetime of the application" is the wrong unit; the useful one is static for a jitted program. That turns the two forms into a declaration-site choice of what identifies a value in the jit key, which also dissolves the apparent conflict with the JAX connectivities proposal (#25): a connectivity wants descriptor-identity, a scalar wants value-identity, and nothing forces one policy on both.

Flagged as open, most importantly: synthesised parameters currently take the closure variable's local name, so two operators naming the same declaration differently — or two modules that both use dx — collide. Declarations need explicit names (Static[float]("dx")); a generated counter will not do, since the name lands in the compiled signature and would shift with import order. Also open: ambient fields (which now look like a plain Extern[Field[...]]), and readonly freezing, which the gtfn bindings reject because they take mutable ndarray parameters.

…ansition

Records that connectivities and values now bind by one rule — the declaration is
the key — which retires the name-harvesting 'Namespace' and its collision check.
Splits the transition: moving the binding surface is done and touches nothing
below it; making connectivities ordinary ambient values is a separate question,
since the offset provider's type drives domain inference and connectivity
typing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant