Prototype: demand-driven instantiation of generic providers - #236
Prototype: demand-driven instantiation of generic providers#236SimonHeybrock wants to merge 3 commits into
Conversation
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
Some notes on what maintaining the eager (constraints-based) and demand-driven paths in parallel would cost. It goes deeper than Divergent semantics, not just duplicate code. A single pipeline can contain both kinds of provider, and the two paths answer the same questions differently:
An ongoing invariant tax. The demand hooks ( 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 |
|
#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 |
|
Status update: #237 has been retargeted to |
Exploration for #235 — a draft for discussion, not a final proposal or decision.
Idea
The only place where sciline relies on
TypeVaridentity across definitions is the constraints lookup. Within a single provider, PEP 695's scoped type variables are self-consistent: all annotations ofdef foo[Run](x: Raw[Run]) -> Processed[Run]share oneRunobject, 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:
get,compute,__getitem__):Processed[A]unifies with the return patternProcessed[Run], bindingRun=A, recursing into the resulting dependencies;map,output_keys, no-argvisualize).With this, the example from the issue works verbatim, with no constraints declared anywhere:
Providers whose type variables are constrained (the
class Raw[Run: (A, B)]workaround, old-style constrainedTypeVars, and theconstraints=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] = floataliases;mapover 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 unconstrainedTypeVars now work the same way, makingconstraints=optional in general. Unsatisfied-requirement error messages reflect the instantiated graph.Semantic changes and open questions
Processed[R]vsProcessed[list[R]].constraints=stay (e.g. as validation/restriction of what inference may instantiate), or be deprecated?map()time instantiated a provider for an unrelated concrete key, creating an extra sink that brokereduce()'s unique-sink requirement. Expansion atmap()is now restricted to bindings consuming a mapped key or a key derived from one; other cases remain demand-driven._repr_html_still shows the unexpanded graph; forward chaining is a naive fixed-point iteration, O(templates × nodes) per round._from_cyclebanenow propagates_constraintsthroughmap/reduce/__getitem__; previously they were dropped on those paths (onlycopy()preserved them), which looks like a latent bug.🤖 Generated with Claude Code