Skip to content

Prototype: demand-driven generics as the only mechanism - #237

Draft
SimonHeybrock wants to merge 16 commits into
mainfrom
235-pep695-single-model-prototype
Draft

Prototype: demand-driven generics as the only mechanism#237
SimonHeybrock wants to merge 16 commits into
mainfrom
235-pep695-single-model-prototype

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Aug 21, 2026

Copy link
Copy Markdown
Member

Consolidated end-state proposal for #235, now targeting main. Draft for discussion — depends on scipp/cyclebane#32; CI is red until that lands and is released. #238 has been merged into this PR; #236 remains open as the coexistence alternative. Full context, derivation, measured evidence, and a decision log are in docs/developer/architecture-and-design/demand-driven-generics.md (part of this PR); the commit history preserves one decision per commit.

What this is

Generic providers are resolved by demand-driven instantiation (structural unification of type patterns with requested keys), replacing eager constraint-based expansion:

import sciline as sl

type A = 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)
  • PEP 695 generics (classes, functions, type X[T] = ... aliases) work with zero ceremony; the example from Support for Python 3.12 generics #235 runs verbatim. Old-style unconstrained TypeVars work the same way. Fixes the blocker behind Scope does not work with Python 3.12 generics #233.
  • The engine is backward-chaining only: a demanded key unifies with generic return types, dependencies are demanded recursively. There is no forward chaining.
  • Coherence instead of insertion-order dependence: a template with an equivalent pattern replaces the previous one; a strictly more specific pattern wins; incomparable overlapping matches raise. Concrete providers and params always shadow generic ones.
  • Values for generic keys (params={Raw: value}) are templates applied on demand to all requested specializations.
  • map() needs no special handling: Derive mapped-node labeling at compile time cyclebane#32 keeps plain node names and derives mapped-node labeling at task-graph build time, so providers instantiated after mapping receive their indices automatically.

Semantic changes vs main

  • The constraints= argument is removed. Constraints declared on type variables (TypeVar('T', int, float), class Raw[Run: (A, B)]) still work, as filters during unification.
  • Unconstrained type variables no longer raise at insertion; unsatisfiable setups fail at graph build as unsatisfied requirements.
  • A specialized provider shadows a generic one regardless of insertion order (restores pre-cyclebane-rewrite semantics; previously a generic provider inserted later replaced specialized ones).
  • reduce() requires an explicit key when the reduced sink would come from a generic provider; fully concrete pipelines are unaffected.
  • Node names are strictly unique (Derive mapped-node labeling at compile time cyclebane#32): reduce(name=<name of the reduced node>) and pipeline[C] = pipeline[C].map(...).reduce(func=merge) now raise clear errors; use a distinct name for the mapped node vs. its reduction. This also turns a silent self-loop bug on cyclebane main into an error.
  • output_keys() returns concrete sinks plus return-type patterns of generic providers not consumed by other generic providers; no-argument visualize() shows the concrete part of the graph. Inspection surfaces show demanded/derived state, not the constraint cross-product.

Evidence

Of the 248 tests before this change, 242 pass unchanged; six tested the removed constraints= argument. All 143 cyclebane tests pass with identical to_networkx() output. mypy clean (sciline), one pre-existing error fewer (cyclebane). New tests cover PEP 695 classes and aliases, chains, target-only instantiation, shadowing, multi-TypeVar providers, coherence errors, mapped pipelines with post-map instantiation, and generic params.

Before this could land

🤖 Generated with Claude Code

SimonHeybrock and others added 4 commits August 21, 2026 05:18
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>
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>
Remove the eager constraints-based expansion and the 'constraints'
Pipeline argument. All generic providers and generic-key params are now
templates, instantiated on demand; constraints declared on type
variables act as filters during unification instead of driving eager
enumeration.

Closes the gaps found when running the test suite against the
demand-driven path:
- Bare generic classes (e.g. 'def foo() -> A' for generic A) are
  normalized to subscripted patterns.
- Pydantic generic models, whose metaclass hides type parameters from
  typing introspection, are unified via their generic metadata.
- Generic providers and generic-key values share one insertion-ordered
  template list, so last-write-wins holds among generics.

Semantic change: a specialized provider now shadows a generic one
regardless of insertion order (previously a generic provider inserted
later replaced specialized providers). This restores the specialization
priority sciline had before the cyclebane rewrite.

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

SimonHeybrock commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

No description provided.

SimonHeybrock and others added 4 commits August 21, 2026 07:17
Living document for the discussion around issue 235 and the two
prototypes: problem statement, mechanism, measured compatibility
evidence, cost accounting vs main, the three open design questions
(forward chaining vs deferred map, coherence among rules, rule-graph
inspection), and a decision log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolution among generic providers/values matching a demanded key is
now order-independent:

- Templates with equivalent patterns (equal up to renaming of type
  variables, across provider/value kinds) replace each other at
  registration, preserving the explicit-override idiom.
- Among matches, a strictly more specific pattern wins, determined by
  one-sided subsumption. Constrained type variables are more specific
  than unconstrained ones.
- Incomparable overlapping matches raise AmbiguousProvider (new,
  exported) instead of silently picking the latest registration.

test_multiple_matching_partial_providers_uses_latest documented the
order-dependent behavior and now asserts the error instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reading cyclebane shows map() does not duplicate dependents: it stores
values on the side and symbolically relabels descendants as MappedNode;
per-index duplication happens only in to_networkx(), i.e. at build time.
The only eager part is the labeling, which is derivable at compile time
from reachability and the stored node values. Replace the
record-and-replay option with a concrete proposal: derive mapped
labeling in to_networkx(), making map() order-independent and deleting
forward chaining from sciline. List open points (reduce sink
resolution, mapped roots as satisfied keys, index-order compatibility,
groupby) and note the coupling with Q3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SimonHeybrock and others added 2 commits August 21, 2026 08:31
With cyclebane deriving mapped-node labeling at task-graph build time
(scipp/cyclebane#32), dependents of mapped nodes no longer need to
exist when map() is called: providers instantiated on demand after
mapping receive their indices from the derivation. Remove the
forward-instantiation hook from map() and the seed-restriction
machinery; forward chaining remains only behind output_keys().

Mapped roots count as satisfied keys, so backward instantiation never
provides them. reduce() with an explicit key treats that key as a
demand and instantiates providers for it; without a key, only nodes
present in the graph determine the reduced sink, so reducing a
rule-derived sink requires an explicit key (tests updated accordingly).

get_mapped_node_names uses the new cyclebane named_indices accessor
instead of scanning for MappedNode labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SimonHeybrock and others added 2 commits August 21, 2026 08:57
Q3 decision: output_keys() returns concrete graph sinks plus the
return-type patterns of generic providers not consumed by other generic
providers (approximated by pattern origin); no-argument visualize()
shows the concrete part of the graph, since patterns cannot be demanded.
This removes _instantiate_forward and forward_bindings; the engine is
backward-only.

With cyclebane forbidding reduce results and grafted branches from
shadowing mapped nodes, get_mapped_node_names loses the
multiple-candidates disambiguation; index_names= is now pure validation.
Tests using the shadowing idioms are rewritten with distinct names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SimonHeybrock
SimonHeybrock changed the base branch from 235-pep695-inference-prototype to main August 21, 2026 09:19
pre-commit-ci-lite Bot and others added 2 commits August 21, 2026 09:20
An independent review found three cases where making eager semantics
lazy changed results silently, plus hardening gaps. All fixed with
regression tests:

- reduce() without an explicit key raised no error when the sink could
  come from a not-yet-instantiated generic provider, silently reducing
  the mapped root instead. It now raises unless the unique sink is not
  matched by any generic provider argument; an explicit key is treated
  as a demand as before.
- TypeVar bounds (def f[T: Base]) were silently ignored during
  unification, matching any key; they now act as filters like declared
  constraints.
- A rule whose argument pattern is structurally larger than its return
  pattern (e.g. P[list[T]] -> P[T]) recursed forever; demanded-key
  nesting depth is now capped with a clear error.
- CycleError was raised without a message; it now carries the cycle.
- get_mapped_node_names conflated "not mapped" with an index_names
  mismatch; the messages are split.

Cleanups from the same review: instantiation hooks unified behind a
_demanded helper, index-based dominance check in template resolution,
pattern_origin_and_args made module-public, reduce key hoisted into the
signature.

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

Copy link
Copy Markdown
Member Author

An independent fresh-eyes review of both branches found three silent-wrong-answer holes, all where eager semantics were made lazy without being pinned by tests. Fixed in d0b184e (sciline) and scipp/cyclebane@3727304, each with regression tests: reduce without key now raises when the sink could come from a not-yet-instantiated generic provider (a unique sink not matched by any generic argument still works without key); TypeVar bound= is honored as a unification filter (was silently matching anything); demanded-key nesting depth is capped to catch non-terminating rule chains (P[list[T]] -> P[T]); CycleError now carries the cycle; get_mapped_node_names distinguishes 'not mapped' from an index_names mismatch. Cleanups: the four instantiation hooks are unified behind one _demanded helper. The cyclebane side resolves reduce specs eagerly into index-name sets, restoring main semantics for map-after-reduce, and drops stale reduce specs on branch replacement. Suites: sciline 250, cyclebane 146, both green.

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