Skip to content

Prototype: demand-driven instantiation of generic providers - #236

Draft
SimonHeybrock wants to merge 3 commits into
mainfrom
235-pep695-inference-prototype
Draft

Prototype: demand-driven instantiation of generic providers#236
SimonHeybrock wants to merge 3 commits into
mainfrom
235-pep695-inference-prototype

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Aug 21, 2026

Copy link
Copy Markdown
Member

Exploration for #235 — a draft for discussion, not a final proposal or decision.

Idea

The only place where sciline relies on TypeVar identity across definitions is the constraints lookup. Within a single provider, PEP 695's scoped type variables are self-consistent: all annotations of def foo[Run](x: Raw[Run]) -> Processed[Run] share one Run object, which is all the existing per-provider binding needs.

So instead of requiring constraints to enumerate instantiations eagerly at insertion, this prototype keeps generic providers with unconstrained type variables as templates and instantiates them on demand, by unifying their type patterns with the concrete keys that appear in the pipeline:

  • backward from requested targets (get, compute, __getitem__): Processed[A] unifies with the return pattern Processed[Run], binding Run=A, recursing into the resulting dependencies;
  • forward from present concrete keys where the concrete graph is needed before targets are known (map, output_keys, no-arg visualize).

With this, the example from the issue works verbatim, with no constraints declared anywhere:

import sciline as sl

type A = int
type B = int
class Raw[Run](float): ...
class Processed[Run](float): ...

def foo[Run](x: Raw[Run]) -> Processed[Run]:
    return Processed[Run](x * 2)

pl = sl.Pipeline([foo], params={Raw[A]: Raw[A](1.2)})
pl.compute(Processed[A])  # Processed[A](2.4)

Providers whose type variables are constrained (the class Raw[Run: (A, B)] workaround, old-style constrained TypeVars, and the constraints= argument) are expanded eagerly exactly as before.

What works

See tests/pep695_test.py: provider chains; source providers with no generic inputs (instantiated from targets alone); shadowing by concrete providers and by params; multi-TypeVar providers with transitive instantiation; type Raw[Run] = float aliases; map over a purely generic pipeline (forward chaining from the mapped keys); values for generic keys (params={Raw: Raw(1.2)}), stored as templates and applied on demand to all requested specializations; old-style unconstrained TypeVars now work the same way, making constraints= optional in general. Unsatisfied-requirement error messages reflect the instantiated graph.

Semantic changes and open questions

  • Unconstrained type variables no longer raise at insertion; failure moves to graph build as an unsatisfied requirement (one existing test changed accordingly). Less fail-fast, though the build-time error is clear.
  • When several generic providers match a requested key, the last-inserted one wins, mirroring concrete-provider replacement. There is no "most specific wins" for overlapping patterns such as Processed[R] vs Processed[list[R]].
  • Should constraints= stay (e.g. as validation/restriction of what inference may instantiate), or be deprecated?
  • Forward chaining uses union semantics per argument, which can over-instantiate. This bit once already: unrestricted expansion at map() time instantiated a provider for an unrelated concrete key, creating an extra sink that broke reduce()'s unique-sink requirement. Expansion at map() is now restricted to bindings consuming a mapped key or a key derived from one; other cases remain demand-driven.
  • Precedence among templates is: concrete providers/params, then generic values, then generic providers (latest wins within each). This differs from the last-write-wins semantics of concrete keys.
  • _repr_html_ still shows the unexpanded graph; forward chaining is a naive fixed-point iteration, O(templates × nodes) per round.
  • Side observation: _from_cyclebane now propagates _constraints through map/reduce/__getitem__; previously they were dropped on those paths (only copy() preserved them), which looks like a latent bug.

🤖 Generated with Claude Code

Generic providers whose type variables lack constraints are no longer
rejected at insertion. They are kept as templates and instantiated by
unifying their type patterns with the concrete keys appearing in the
pipeline: backward from requested targets, forward from present concrete
keys when mapping or listing output keys.

This makes PEP 695 generics (scoped type variables with empty
__constraints__) work without declaring constraints anywhere, see #235.
Providers with constrained type variables are expanded eagerly as
before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks this example:

pl = sl.Pipeline([foo], params={Raw: Raw(1.2)})
print(pl.compute(Processed[A]))

On main, we can insert generic parameters that apply to all specialisations.

This means that

setitem with a generic key still requires constraints, since there is nothing to enumerate from.

is anyway broken.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Support for this is now in 2fc0461: a value set for a generic key is stored as a template and matched on demand against requested specializations, symmetric to generic providers — the example above works without constraints. One semantic difference worth noting: precedence is concrete providers/params, then generic values, then generic providers, rather than the last-write-wins order concrete keys have.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So instead of requiring constraints to enumerate instantiations eagerly at insertion, this prototype keeps generic providers with unconstrained type variables as templates and instantiates them on demand,

We need to be really careful here: Data graphs were designed to be concrete graphs without generics. So changing this may break assumptions. And it may break the fundamental design of DataGraph.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is the main risk. The prototype tries to preserve the invariant in a narrower form: the cyclebane graph itself stays concrete at all times — templates live outside it in a side table, and expansion happens before cyclebane sees any operation (map) or on a copy at build time (get, __getitem__). So cyclebane never encounters a generic node. What the design loses is a different guarantee: the graph is only complete after expansion, so every current and future call site that reads the graph must remember the expansion hook, and a forgotten hook fails silently by seeing fewer nodes rather than erroring.

Your concern was borne out once already in a different way: unrestricted forward expansion at map() over-instantiated a provider for an unrelated concrete key, and the extra sink broke reduce()'s unique-sink assumption (fixed in 2fc0461 by restricting expansion to bindings that consume mapped keys). I'll post a comment on the thread with a fuller list of what coexistence of the eager and demand-driven paths costs.

Setting a value for a generic key (e.g. params={Raw: Raw(1.2)}) with
unconstrained type variables now stores the value as a template,
matched on demand against requested specializations, symmetric to
generic providers. This restores the generic-parameter feature for
PEP 695 generics, as raised in review of the prototype.

Forward instantiation at map() time is now restricted to bindings that
consume a mapped key or a key derived from one. Unrestricted forward
expansion instantiated providers for unrelated concrete keys, creating
extra sink nodes that broke cyclebane's unique-sink requirement in
reduce().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SimonHeybrock

Copy link
Copy Markdown
Member Author

Some notes on what maintaining the eager (constraints-based) and demand-driven paths in parallel would cost. It goes deeper than constraints= becoming optional:

Divergent semantics, not just duplicate code. A single pipeline can contain both kinds of provider, and the two paths answer the same questions differently:

  • Replacement/shadowing. On the eager path insertion order decides: a generic provider expanded at insert claims nodes for all constraint values, and whoever inserts last for a key wins — a generic provider can overwrite a concrete one. On the template path, templates never overwrite an existing satisfied node, so a concrete provider always shadows a generic one regardless of order. Pipeline([concrete, generic]) and Pipeline([generic, concrete]) behave identically in one world and differently in the other.
  • Node existence. Eager generics materialize the full cross-product at insert; templates materialize nothing until demanded. This leaks into every API that inspects the graph: _repr_html_, visualize_data_graph, output_keys, get_mapped_node_names, direct iteration over underlying_graph. In a mixed pipeline some generics are "in" the graph and others invisible.
  • Failure timing. Eager fails at construction (bad constraint values, unsatisfiable enumeration); inference fails at graph build as an unsatisfied requirement. Same conceptual mistake, two error moments.
  • Mixed providers. A provider with one constrained and one unconstrained type variable lands on the template path, where the declared constraints are enforced by unify() rejecting bindings rather than by enumeration — a third, hybrid behavior.

An ongoing invariant tax. The demand hooks (__getitem__, map, to_task_graph, output_keys, the error-message path) exist only because of templates. Every future API that consumes concrete keys has to remember to expand first, and a forgotten hook does not error — it silently sees an unexpanded graph. This is the flip side of the concern about DataGraph's concrete-graph design raised in the review: the cyclebane graph stays concrete, but "concrete" is replaced by the weaker invariant "complete only after expansion".

Doubled surface. Tests for generics-related changes need eager, inference, and mixed variants; docs must teach both models and the differences above — for users this is probably the largest cost.

Possible consolidation. Much of the implementation duplication could be collapsed by making eager a special case of inference: treat declared constraints as seed demand, i.e. register the provider as a template and immediately expand it for the constraint combinations at insert. That would leave one mechanism and one shadowing rule, while constrained code keeps its visible behavior (nodes exist at insert, errors fire early). What consolidation cannot remove are the user-visible model differences — node-existence and error timing — which persist as long as both styles are supported. Realistically that is a while, since ess* currently uses constrained Scope-style type variables throughout.

@SimonHeybrock

Copy link
Copy Markdown
Member Author

#237 explores the other end of the coexistence question discussed above: removing the eager path entirely, so demand-driven instantiation is the only mechanism. It is based on this branch, so its diff shows exactly what the consolidation removes (net −118 lines in the engine). The description there includes measured compatibility data from running the existing test suite against the single-model engine: 242 of 248 tests pass unchanged, six tested the removed constraints= argument, and one semantic change remains (specialized providers shadow generic ones regardless of insertion order). Both PRs are proposals for discussion, not decisions.

@SimonHeybrock

Copy link
Copy Markdown
Member Author

Status update: #237 has been retargeted to main and now carries the consolidated end-state proposal (single mechanism, backward-only, deferred mapped-labeling via scipp/cyclebane#32); #238 was merged into it. This PR remains open as the coexistence alternative — i.e., the fallback in case review favors keeping the eager path alongside demand-driven instantiation.

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.

2 participants