Skip to content

Dynamic Effect Type Inference - #717

Draft
datvo06 wants to merge 17 commits into
masterfrom
effect-typing-engine
Draft

Dynamic Effect Type Inference#717
datvo06 wants to merge 17 commits into
masterfrom
effect-typing-engine

Conversation

@datvo06

@datvo06 datvo06 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Aims to close #448 and #664.

As noted in some of our discussions, static effect typing is pretty impossible given how dynamic we are in selecting handlers and dispatching effects. So this PR adds a dynamic effect type inference. Even though it is dynamic, it should be able to do what we intended, specifically requiring or restricting side effects.

It does this by folding over apply: we install an interpretation of apply that records which ops fire, then evaluate the term under it to get the term's effect row: the set of operations it performs.
To help with this process, I created usesof (sibling of fvsof and typeof) which records the effect set.

Concretely the PR adds:

  • usesof(term) (aliased effectsof): the effect row of a term.
  • effect_type(term): the pair $\langle \tau, \epsilon \rangle$ of the result type from typeof and the effect row from usesof, folded over the same apply.
  • Uses[...] on a return type, read by a new @final Operation.__uses_rule__ method, plus check_uses(op, body), which returns the effects body performs that the op's declared Uses[...] does not cover. This is the Support effect type annotations #448 checker: empty means the declaration holds, and it is transitively closed because usesof unions the whole term.
  • Computation on a callback argument, so a higher-order op folds the effects hidden inside its callback instead of the checker hard-coding which ops are higher-order.
  • Requires(...) on an argument plus check_requires(term) (this is from Supporting Refinement Type in conjunction with Effect type for stronger security emphasis #664 but I think this is quite small so I left it in): an argument satisfies Requires(X) iff X is in usesof(arg).
  • toolsof / reachable_tools / check_tools in handlers/llm: the static tool graph an LLM template can reach, computed by reifying to a term and reading .tools, with no LLM call. check_tools(fn, *allowed) is the tool-graph analog of check_uses.

…s + __uses_rule__

Branches off master (ops/ only; #694 touches handlers/llm/ only). Explicit upstream
changes in one reviewable module; final placement noted in the module docstring.
- usesof/effectsof: fold over apply, sibling of typeof/fvsof
- Uses[()] pure opt-out; Computation folds callback args; UndeclaredCallable loud-fail
- Requires/check_requires: value provenance (#664) = usesof over an arg's Term
@datvo06

datvo06 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

A few honest caveats, since dynamic inference is not free:

  • usesof is path insensitive over the reified term, so it is an over-approximation and it needs the term to be straight-line: native Python control flow gets resolved when the term is built, so a branchy function can under-approximate. The same precondition applies to reachable_tools.
  • The Computation callback runs the callback on an opaque placeholder and raises an error (UnsoundCallbackFold) when the callback inspects that placeholder through the operator protocol (==, arithmetic, len, calling it, indexing). But object identity (is), type() and isinstance bypass the protocol and it cannot catch them, so a callback branching on those under-approximates silently. I've put these edge cases into the tests.

@datvo06 datvo06 closed this Jul 19, 2026
datvo06 added 15 commits July 19, 2026 15:30
…spection

- replace permissive _hole (swallowed native-if branches + attr access) with _Opaque
  that raises UnsoundCallbackFold on bool/getattr/iter/getitem; pass-through folds
- regression tests: branch-dropping + structure-inspecting lambdas now raise (were silent)
- _member_ops: raise on unsupported Uses member instead of silent frozenset()
…x.py

Mirror __type_rule__/__fvs_rule__ exactly: __uses_rule__ is a @Final method
on Operation (types.py) reading the declared Uses[...] row off the return
annotation (default {self}); Uses moves to syntax.py next to Scoped, with
Uses.declared() the shared annotation reader. usesof now calls op.__uses_rule__();
the free uses_rule and the effects-local Uses/_member_ops/_declared_uses are gone.

Closes eb8680 P0 #1 (placement, kill free uses_rule). 9/9 effect tests; 3871
core ops/syntax/types/unification tests pass.
handlers/llm/governance.py: toolsof(tool) = the transitive tool graph over
.tools; reachable_tools(fn) reifies fn under defdata (no tool body runs, no LLM)
and structurally walks the term for Tool nodes, expanding via toolsof. This is
the L0 static core of plan §5 (reachable_tools(fn) <= declared = tool-safety check).

Uses a structural term walk, not usesof: Tool/Template subclass Operation and
override __apply__, so a called tool sits in an apply node's args, not as its op
(usesof only folds base-apply ops). 3 governance tests; 113 template+effects+gov pass.

Closes eb8680 P1 #4 (land L0 toolsof/reachable_tools).
Cold eb8680 review P1: reachable_tools used handler({apply: defdata}) (merge),
so an ambient Tool.__apply__ interpretation would win dispatch and a called tool
would execute concretely instead of reifying -> silently missed. This violates
the plan's own §0 do-not-regress invariant (the reifier must REPLACE apply).
Switch to interpreter({apply: defdata}); add test_reachable_tools_ignores_ambient
_apply_handler locking in the soundness law. Also drop redundant __name__-string
asserts (P2) in favor of identity/subset checks. 4 governance tests pass.
The tool-graph analogue of check_uses — the static tool-safety leak check
(plan §5 L2), completing the L0->L2 static arc. 6 governance + 9 effects tests pass.
Template.tools intersects the lexical capture with the declared Uses[...] row
when present, so the LLM is offered only the allow-listed tools; no Uses = today's
behavior (backward-compatible). The restriction flows transitively into toolsof/
reachable_tools. Completes plan §5 L0->L1->L2. 107 governance+template tests pass.
The infer_annotations classmethods were dead (never dispatched — effectful calls
each annotation's infer_annotations explicitly by name where consumed, and nothing
consumed these). They were kept alive only by the Annotation ABC. Uses already set
the precedent that effect annotations which are *read* (not signature-transforming)
are plain metadata; make Computation/Requires uniform with it and drop the ABC base
+ dead methods + the now-unused _is_callable_annotation helper and inspect/
collections.abc/Annotation imports. Soundness is unchanged: _fold_computation_args
still fails loudly (UndeclaredCallable) on an unclassified callable at fold time.
73 effects+governance+types+syntax tests pass.
…tream-clean docs

Cold review (CHANGES-REQUESTED) on the L1 .tools mutation:
- P0: restriction on the .tools property is the wrong layer and silently drops
  synthetic LexicalReaders (identity allow-list conflates 'not allow-listed' with
  'is a synthetic reader'). Revert it. Tool restriction is a runtime concern: an
  off-by-default handler that filters the offered tool set (and must leave readers
  untouched) — not a property/return-annotation mutation. Removed the .tools change
  and its test; documented the restriction design as future work.
- P2: import Callable from collections.abc.

The branch keeps the static, sound, no-LLM core it should: toolsof / reachable_tools
/ check_tools + the __uses_rule__ placement + the Computation/Requires demotion. Also
scrubbed docstrings of design-doc section refs and stale 'folds with usesof' wording
(reachable_tools walks the reified term structurally). 115 governance+template+effects,
69 governance+effects+syntax pass.
P0-1 (silent under-approximation): _Opaque only guarded 4 dunders, so a
Computation callback inspecting its arg via __eq__ (identity -> False dropped a
branch) or arithmetic/len/call leaked a raw TypeError or silently under-collected.
Make _Opaque default-deny: bind every inspection dunder (comparisons, arithmetic +
reflected/inplace, len/iter/index, call, attr, format, ...) to a loud
UnsoundCallbackFold; exclude _Opaque from the undeclared-callable check so
pass-through still folds. Adversarial tests cover the operator families.

P0-2 (overclaimed guarantee): reachable_tools runs fn's own body, so native
control flow is resolved at analysis time. Drop 'compile-time/provable' absolutes;
document the straight-line precondition honestly.

P1-1 (readers flagged as leaks): toolsof/_tools_in swept in synthetic
LexicalReaders tools. Add _is_governed to exclude them; law test: the governed
graph is invariant to whether readers are exposed.

P1-3 (thin Requires coverage): add law-based tests (transitive provenance,
partial-missing, per-arg/keyword, absent-annotation) — keeps it symmetric with
check_uses. P2: parenthesize set precedence in tests; import Uses from its home
(syntax); drop the effects re-export. 178 tests pass; ruff clean.
Adversarial re-review found the default-deny claim still leaked: x.__class__
(existing attr, __getattr__ only fires on a miss) and identity/type checks
(x is None, id(x), type(x), isinstance) bypass the dunder protocol and cannot be
intercepted — a blanket __getattribute__ override was tried but breaks the fold's
own isinstance(arg, Operation) dispatch, so pass-through write(x) would wrongly
refuse. Reframe _Opaque honestly: it is a best-effort tripwire (operator/dunder
protocol + missing-attr access refuse loudly), NOT a soundness guarantee; the real
contract is the straight-line precondition. Drop the 'fails closed' language across
_Opaque/UnsoundCallbackFold/usesof docstrings. Add a documented, pinned
known-limitation test asserting the identity/type/isinstance under-approximation so
it is visible, not hidden. 121 tests pass; ruff clean.
… hole

APPROVE-WITH-NITS follow-up:
- P1: check_tools function docstring still said 'provably cannot reach ... even
  through nested templates' (the module docstring was already honest). Align it with
  the straight-line-precondition wording — no un-caveated 'provable' on the public API.
- P2: callable(x) is a silent under-approximation (the tripwire binds __call__, so
  callable(_Opaque()) is True). Pin it in the known-limitation test and name it in the
  _Opaque docstring alongside type()/isinstance. 20 tests pass; ruff clean.
@datvo06 datvo06 reopened this Jul 19, 2026
@datvo06 datvo06 changed the title Effect-row inference (usesof) + Uses/Computation/Requires (the ε engine) Dynamic Effect Type Inference Jul 19, 2026
- mypy: annotate _INSPECTION_DUNDERS as tuple[str, ...] (the += widened a
  fixed-length tuple type); drop a now-unused type: ignore in test_effects.
- ruff: import Callable from collections.abc (UP035); use favorite_city in the
  reader test so it is not an unused local (F841); ruff format the two touched
  test/impl files. Also scrub a stale branch/#694 line from the test docstring.
mypy + ruff check + ruff format --diff all clean; 20 tests pass.
@datvo06

datvo06 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

I deferred the following LLM-specific features pending on #694 :

  • The LLM tool restriction from #448 (only offering a template the tools it lists) belongs on the tool-assembl, not on Template.tools, so it lands separately once that seam is in place, I think after Add doctest validation stage to Callable decoding #694 landed, I can wire these in easily.
  • Uses members that are Operation subclasses or string self-references, the build-time validation of annotations at op construction, and the usage multiset (usagesof) plus handler discharge.

@datvo06

datvo06 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

I checked most of the logics here, but I think I can review more until marking it ready for review.

@eb8680

eb8680 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

This looks interesting, but I think the specification of the intended behavior from #448 and #664 needs to be a lot clearer and a lot more grounded in relevant PL literature and the semantics of effectful before we can actually implement anything (or trust anything cooked up by Claude).

I also think there are many outstanding issues in effectful and downstream that are higher priority, especially given that lexical scoping already solves closely related problems (#590) and the large amount of work this will inevitably need before it's useable in practice.

@datvo06

datvo06 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

This looks interesting, but I think the specification of the intended behavior from #448 and #664 needs to be a lot clearer and a lot more grounded in relevant PL literature and the semantics of effectful before we can actually implement anything.

Got it. I ended up at this after failing to find a good relevant paper and then designing it based on stateless (we use apply instead of yield). But I will keep looking at PL literature to see if something relevant come up.

Will also work on #590!

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.

Support effect type annotations

2 participants