Replace runtime-type quotation with splice-into-source typechecking#706
Replace runtime-type quotation with splice-into-source typechecking#706datvo06 wants to merge 2 commits into
Conversation
ba6761e to
c4059f2
Compare
|
My intuition is mypy failed with stress test. Checking.. |
c4059f2 to
0375180
Compare
eb8680
left a comment
There was a problem hiding this comment.
Thanks, this seems like a big improvement. There are several heuristics and silent failures that I worry will undermine its correctness, though.
|
|
||
| # Splice in place: replace the body with the generated body, binding the | ||
| # target against the (source) return annotation via `return`. Strip the | ||
| # app-level decorators (`@Template.define`, …) but KEEP `@staticmethod`/ |
There was a problem hiding this comment.
Why do we need to strip the decorators at all, if the code being spliced is entirely confined to the function body? The less we do to the surrounding code, the more robust this splicing procedure will be.
There was a problem hiding this comment.
Earlier Claude convinced me with some example that if we keep the annotator, then mypy would fail. But checking again carefully, it's not the case.
I'm removing the strip decorator step.
| try: | ||
| module_ast = ast.parse(module_source) | ||
| except SyntaxError: | ||
| logger.warning("skipping type check: cannot parse module source for %r", fn) |
There was a problem hiding this comment.
Why would this fail, and why would we want that failure to be silent? Shouldn't we have confirmed by this point in the computation that the code is syntactically valid?
There was a problem hiding this comment.
It would fail if we parsed the file while the writing was not done. I think I encountered it during one test, but I can't reproduce that since then. Will remove this.
| # (unparse reassigns line numbers), so diagnostics can be scoped to it. | ||
| checked_ast = ast.parse(checked_source) | ||
| spliced = _find_def_by_qualname(checked_ast, fn.__qualname__) | ||
| if spliced is None: # pragma: no cover - the splice is always re-locatable |
There was a problem hiding this comment.
What does this comment mean? When would spliced be None?
There was a problem hiding this comment.
It can't be None if _find_def_by_qualname does not return None. I'm removing this check
|
|
||
| # Locate the spliced def's line span in the *checked* source's coordinates | ||
| # (unparse reassigns line numbers), so diagnostics can be scoped to it. | ||
| checked_ast = ast.parse(checked_source) |
There was a problem hiding this comment.
Why are we unparseing and immediately parseing again here? Does this lose information? Is this doing something on top of what ast.fix_missing_locations is doing to module_ast?
There was a problem hiding this comment.
It needed to do it to remove the comments, otherwise we would have to do comments re-indenting.
There was a problem hiding this comment.
This is the example:
def count_a(s):
return s.count('a')but the template body is indented (inside def the_template(...):, maybe inside a class too). To splice it in, you must add leading spaces to every generated line. That's fine for code, but it corrupts multi-line string literals, because the leading spaces land inside the string:
def gen():
"""line one
line two""" # the string is "line one\nline two" indent by 4 to place it in the body →
def gen():
"""line one
line two""" # the string is now "line one\n line two" ← changed!line two gained 4 spaces inside the docstring. The string's value changed. And LLM-generated functions very often have docstrings, so this isn't a corner case.
| template_def = _find_def_by_qualname(module_ast, fn.__qualname__) | ||
| if template_def is None: | ||
| logger.warning( | ||
| "skipping type check: cannot locate %r in its module source", |
There was a problem hiding this comment.
If we are able to recover the module's source, shouldn't fn have definition site line numbers associated with it that we can use to identify it directly, instead of this potentially failure-prone name matching heuristic _find_def_by_qualname? Under what conditions would we expect to recover module_ast but not be able to locate fn?
There was a problem hiding this comment.
Yes, I now use template_def = _find_def_at_lineno(module_ast, fn.__code__.co_firstlineno), it's the first line comment, so it's both the comment and the function body, we wouldnt have to worry about aligning comment offset.
| "__spec__", | ||
| "_frozen_importlib", | ||
| ) | ||
| def _unwrap(fn: Any) -> Any: |
There was a problem hiding this comment.
Is this really necessary, or can it be removed if we outsource more of the introspection of fn to inspect? The fewer of these heuristics we have the more robust our type-checking step will be.
| return "".join(lines) if lines else None | ||
|
|
||
|
|
||
| def _find_def_by_qualname( |
There was a problem hiding this comment.
As mentioned in another comment, it would be better to remove this heuristic in favor of exact lookup using line numbers that guarantee correctness in all cases.
| return region | ||
|
|
||
|
|
||
| def mypy_type_check(generated: ast.Module, anchor: Any) -> None: |
There was a problem hiding this comment.
I think we probably want to split this into two functions, one that handles splicing and constructing the modified source code and one that just applies mypy to whatever source it's given, whether it came from the splicing function or somewhere else.
| original_error=e, | ||
| raw_message=raw_message, | ||
| ) from e | ||
| # Tool-argument Callables have no source anchor (out of scope for the splice |
There was a problem hiding this comment.
What does this comment mean? When would this apply? Surely if we are synthesizing code to pass to a Tool we have access to its source, and we know it's happening lexically in the body of the Template definition?
| with handler( | ||
| { | ||
| _get_history: lambda: history_copy, | ||
| type_check_anchor: lambda: template.__default__, |
There was a problem hiding this comment.
Rather than managing this synthesis context variable with an operation and handler that are hard to disentangle from Template.__apply__, it would probably be better to route it through Pydantic's context argument during decoding, as we already do with the lexical environment.
0a36f8a to
9dd1252
Compare
Type-check LLM-generated Callables by splicing them into the Template's real module source -- in place at the template def's position -- and running mypy on the whole module, raising only on diagnostics inside the spliced region. This removes the brittle quotation layer (type_to_ast, signature_to_ast, collect_*, _RenameTransformer) and the runtime/local-type stubbing it depended on, which mishandled common types such as Enum and runtime-created Pydantic models. - mypy_type_check now takes (generated, anchor); the anchor (Template.__default__) is provided by the type_check_anchor effect op, bound per call by Template.__apply__ (so nested synthesis sees the right one) and rebound to None around tool-argument decoding, so the argument path skips the check. - Region-scoped diagnostics: unrelated pre-existing errors elsewhere in the module never block synthesis (no spurious retry burn). - Leaves the template def's decorators untouched (mypy checks the body against its declared return type regardless of decorators); explicit pre-scan for non-nestable constructs (star-import, __future__); source recovery via getsourcelines/file/ linecache (REPL-defined templates); fatal mypy errors are surfaced not swallowed. - Name collisions (#542) are handled structurally by nesting/shadowing, so the AST-rename pass is deleted. - Delete the quotation emitters + their unit tests; add behavior tests covering collision shadowing, the gate, closures, method/static/class templates, Enum/dataclass context, injected-name, non-nestable, and linecache recovery.
9dd1252 to
b3ec9c3
Compare
The scan is provider-independent AST validation, not type-checking; lift it into the decode as a precondition. Keep it gated on the anchor -- the constructs it rejects are illegal only once nested by the splice, and legal on the argument path's module-level exec.
Closes #696. Addresses #577, #576.
Currently, for synthesis, we are reconstructing a
Template's lexical context as synthetic type stubs. This is brittle and likely unsound.This PR splices the generated code into the Template module, and type checks the whole file. This come with a few changes
mypy_type_checknow parameterized withgenerated(code) andanchor(the Template). The anchor is used to recover its module source,Note, in practice there is a few more tweaks:
from x import *,__future__) are caught by an explicit AST pre-scan, since mypy silently accepts a nested star-import; a fatal mypy error (exit ≥ 2) is surfaced rather than silently passed; unrecoverable source (no file, nolinecache) is skipped with a logged reason.Name collisions (Synthesized Callable Name shouldn't matter #542) are now handled structurally: the generated def is a function-body local that shadows a colliding context name rather than redefining it at module level, so the AST-rename pass is gone. The whole quotation layer is deleted —
type_to_ast,signature_to_ast,collect_variable_declarations,collect_runtime_type_stubs,collect_imports, and_RenameTransformer/_generate_unique_name(net +510 / −1991).Behavior change. Runtime-only / local types, and names with no lexical presence at the splice point, now raise instead of being (partially, fakily) stubbed. The check is sound on annotated generated code; unannotated bodies are checked at their signature only (
--check-untyped-defsis deliberately off — it rejects runnable heterogeneous-container code).Tests.
Enum+ dataclass context (Decoding runtime-only Pydantic models in Callables fails #576), injected-name, non-nestable,linecache-backed recovery (the REPL tool code should be typechecked when possible #690 path), annotation-collision, and end-to-end decode throughEncodable[Callable].mypy effectful/handlers/llm/clean,ruffclean, full LLM suite green.