Dynamic Effect Type Inference - #717
Conversation
…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
|
A few honest caveats, since dynamic inference is not free:
|
…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()
…not build-time); document deferred items
…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.
…t tracked on master)
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.
usesof) + Uses/Computation/Requires (the ε engine)- 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.
|
I deferred the following LLM-specific features pending on #694 :
|
|
I checked most of the logics here, but I think I can review more until marking it ready for review. |
|
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 I also think there are many outstanding issues in |
Got it. I ended up at this after failing to find a good relevant paper and then designing it based on stateless (we use Will also work on #590! |
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 offvsofandtypeof) which records the effect set.Concretely the PR adds:
usesof(term) (aliased effectsof): the effect row of a term.effect_type(term): the pairtypeofand the effect row fromusesof, folded over the same apply.Uses[...]on a return type, read by a new@final Operation.__uses_rule__method, pluscheck_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 becauseusesofunions the whole term.Computationon 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 satisfiesRequires(X)iffXis inusesof(arg).toolsof / reachable_tools / check_toolsinhandlers/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 ofcheck_uses.