annoscript 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
annoscript/compiler.py ADDED
@@ -0,0 +1,708 @@
1
+ """Compile a Python function object into AnnoScript IR without executing its body.
2
+
3
+ The public entry point is compile_program(function). Source must be available to
4
+ inspect. Only reachable same-module helpers are compiled; all calls are inlined.
5
+ Captured JSON constants are snapshots, while preconditions remain client-local.
6
+ Annotations and decorators are never evaluated by the compiler.
7
+
8
+ This is a deliberately restricted Python language. It supports structured finite
9
+ loops, conditional control flow, local containers and the documented intrinsics.
10
+ It rejects dynamic calls, recursion, closures, comprehensions, exceptions,
11
+ async, loop exits and writes to inputs/captured values. Definite assignment and
12
+ container ownership checks are conservative, particularly across loops. Runtime
13
+ type errors on input-dependent expressions remain runtime errors.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import builtins
20
+ from dataclasses import dataclass, field, replace
21
+ import inspect
22
+ import json
23
+ import math
24
+ import textwrap
25
+ from types import FunctionType
26
+ from typing import Callable
27
+
28
+ from annoscript import ir
29
+ from annoscript._client_common import JSONValue, get_program_config, precondition, system_prompt
30
+ from annoscript.anno import Example, Anno, constrain
31
+
32
+
33
+ class CompileError(ValueError):
34
+ """A rejected program, with a source location and inline call chain."""
35
+
36
+ def __init__(self, message: str, source: ir.Source | None = None):
37
+ self.message = message
38
+ self.source = source
39
+ location = ""
40
+ if source is not None:
41
+ s = source.span
42
+ location = f"{s.module}:{s.line}:{s.column} ({s.function}): "
43
+ if source.calls:
44
+ message += "\n inlined from " + " -> ".join(
45
+ f"{s.module}:{s.line}:{s.column} ({s.function})" for s in source.calls
46
+ )
47
+ super().__init__(location + message)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _Info:
52
+ origin: str = "unknown"
53
+ container: str | None = None
54
+ items: tuple[_Info, ...] | None = None
55
+ element: _Info | None = None
56
+ response: str | None = None
57
+
58
+
59
+ _UNKNOWN = _Info()
60
+ _SCALAR = _Info("scalar")
61
+ _STRING = _Info("scalar", "str")
62
+ _READONLY = _Info("readonly")
63
+ _CONTEXT = _Info("context")
64
+
65
+
66
+ def _merge(a: _Info, b: _Info) -> _Info:
67
+ if a == b:
68
+ return a
69
+ origin = a.origin if a.origin == b.origin else "unknown"
70
+ container = a.container if a.container == b.container else None
71
+ items = None
72
+ if a.items is not None and b.items is not None and len(a.items) == len(b.items):
73
+ items = tuple(_merge(x, y) for x, y in zip(a.items, b.items))
74
+ element = _merge(a.element, b.element) if a.element and b.element else None
75
+ response = a.response if a.response == b.response else None
76
+ return _Info(origin, container, items, element, response)
77
+
78
+
79
+ def _merge_env(a: dict[str, _Info], b: dict[str, _Info]) -> dict[str, _Info]:
80
+ return {name: _merge(info, b[name]) for name, info in a.items() if name in b}
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class _Value:
85
+ expr: ir.Expr | None # context is a compile-time capability, never a value
86
+ info: _Info = _UNKNOWN
87
+
88
+
89
+ @dataclass
90
+ class _Parsed:
91
+ function: FunctionType
92
+ node: ast.FunctionDef
93
+ signature: inspect.Signature
94
+ lines: list[str]
95
+ start: int
96
+ indentation: int
97
+
98
+
99
+ @dataclass
100
+ class _Frame:
101
+ parsed: _Parsed
102
+ names: dict[str, str]
103
+ env: dict[str, _Info]
104
+ calls: tuple[ir.SourceSpan, ...] = ()
105
+ result: str | None = None
106
+ done: str | None = None
107
+ return_infos: list[_Info] = field(default_factory=list)
108
+ loop_depth: int = 0
109
+
110
+ def fork(self):
111
+ return replace(self, env=dict(self.env))
112
+
113
+
114
+ _OPERATIONS = ("noul", "choice", "score", "extract", "generate", "generate_list")
115
+ _RESPONSES = {
116
+ "noul": {"noul": _SCALAR},
117
+ "choice": {"choice": _STRING, "confidence": _SCALAR, "probabilities": _READONLY},
118
+ "score": {"score": _SCALAR, "confidence": _SCALAR, "probabilities": _READONLY, "legend": _READONLY},
119
+ "extract": {"items": _Info("readonly", "list", element=_STRING)},
120
+ "generate": {"text": _STRING},
121
+ "generate_list": {"items": _Info("readonly", "list", element=_STRING)},
122
+ }
123
+ _COMPARISONS = {
124
+ ast.Eq: "eq", ast.NotEq: "ne", ast.Lt: "lt", ast.LtE: "le",
125
+ ast.Gt: "gt", ast.GtE: "ge", ast.In: "in", ast.NotIn: "not_in",
126
+ ast.Is: "is", ast.IsNot: "is_not",
127
+ }
128
+ _INTRINSICS = ((builtins.len, "len"), (builtins.int, "int"),
129
+ (builtins.range, "range"), (builtins.enumerate, "enumerate"),
130
+ (json.dumps, "json.dumps"))
131
+
132
+
133
+ class _Compiler:
134
+ def __init__(self, entry: FunctionType):
135
+ self.entry = entry
136
+ self.counter = 0
137
+ self.parsed: dict[FunctionType, _Parsed] = {}
138
+ self.active: list[FunctionType] = []
139
+
140
+ def fresh(self, hint: str) -> str:
141
+ self.counter += 1
142
+ if self.counter > 100_000:
143
+ raise CompileError("Expanded program exceeds the 100000-node/name compilation budget")
144
+ return f"v{self.counter}_{hint}"
145
+
146
+ def span(self, f: _Frame, node: ast.AST) -> ir.SourceSpan:
147
+ p = f.parsed
148
+ line = getattr(node, "lineno", p.node.lineno)
149
+ end = getattr(node, "end_lineno", line) or line
150
+
151
+ def column(lineno, offset):
152
+ # Python AST columns are UTF-8 byte offsets, not character offsets.
153
+ content = p.lines[lineno - 1] if lineno <= len(p.lines) else ""
154
+ return len(content.encode("utf-8")[:offset].decode("utf-8")) + p.indentation + 1
155
+
156
+ return ir.SourceSpan(
157
+ p.function.__module__, p.function.__qualname__, p.start + line - 1,
158
+ column(line, getattr(node, "col_offset", 0)), p.start + end - 1,
159
+ column(end, getattr(node, "end_col_offset", 0) or 0),
160
+ )
161
+
162
+ def source(self, f, node):
163
+ return ir.Source(self.span(f, node), f.calls)
164
+
165
+ def fail(self, f, node, message):
166
+ raise CompileError(message, self.source(f, node))
167
+
168
+ def stmt(self, cls, f, node, *args, **kwargs):
169
+ return cls(*args, **kwargs, id=self.fresh("stmt"), source=self.source(f, node))
170
+
171
+ def parse(self, function, caller=None, call=None):
172
+ def error(message):
173
+ if caller is not None:
174
+ self.fail(caller, call, message)
175
+ raise CompileError(message)
176
+
177
+ if not isinstance(function, FunctionType):
178
+ error("Expected an ordinary Python function")
179
+ if function.__closure__:
180
+ error("Closures are not supported")
181
+ if hasattr(function, "__wrapped__") or hasattr(function, "__signature__"):
182
+ error("Wrapped functions and overridden signatures are not supported")
183
+ if function in self.parsed:
184
+ return self.parsed[function]
185
+ try:
186
+ lines, start = inspect.getsourcelines(function)
187
+ raw = "".join(lines)
188
+ source = textwrap.dedent(raw)
189
+ tree = ast.parse(source)
190
+ signature = inspect.signature(function, follow_wrapped=False, eval_str=False)
191
+ except (OSError, IOError, TypeError, SyntaxError, ValueError) as exc:
192
+ error(f"Function source is unavailable or invalid: {exc}")
193
+ if len(tree.body) != 1 or not isinstance(tree.body[0], ast.FunctionDef):
194
+ error("Only synchronous function definitions are supported")
195
+ node = tree.body[0]
196
+ if node.name != function.__code__.co_name:
197
+ error("Source does not match the function's code name")
198
+ if any(p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD) for p in signature.parameters.values()):
199
+ error("Variadic function parameters are not supported")
200
+ indentation = len(lines[0]) - len(lines[0].lstrip())
201
+ parsed = _Parsed(function, node, signature, source.splitlines(), start, indentation)
202
+ self.parsed[function] = parsed
203
+ return parsed
204
+
205
+ def frame(self, parsed, calls=(), helper=False):
206
+ names = dict.fromkeys(parsed.signature.parameters)
207
+ for statement in parsed.node.body:
208
+ for node in ast.walk(statement):
209
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
210
+ names[node.id] = None
211
+ return _Frame(
212
+ parsed, {name: self.fresh(name) for name in names}, {}, calls,
213
+ self.fresh("result") if helper else None,
214
+ self.fresh("done") if helper else None,
215
+ )
216
+
217
+ def raw_name(self, f, node):
218
+ if node.id in f.names:
219
+ self.fail(f, node, "Dynamic calls and function-valued local variables are not supported")
220
+ namespace = f.parsed.function.__globals__
221
+ if node.id in namespace:
222
+ return namespace[node.id]
223
+ namespace = f.parsed.function.__builtins__
224
+ if node.id in namespace:
225
+ return namespace[node.id]
226
+ self.fail(f, node, f"Unknown name: {node.id}")
227
+
228
+ def decorators(self, f, entry=False):
229
+ for decorator in f.parsed.node.decorator_list:
230
+ if not entry:
231
+ self.fail(f, decorator, "Decorated helpers are not supported")
232
+ if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Name):
233
+ self.fail(f, decorator, "Only system_prompt and precondition decorators are supported")
234
+ value = self.raw_name(f, decorator.func)
235
+ if value is not system_prompt and value is not precondition:
236
+ self.fail(f, decorator, "Only system_prompt and precondition decorators are supported")
237
+
238
+ def capture(self, value, f, node, seen=None):
239
+ seen = set() if seen is None else seen
240
+ if type(value) is Example:
241
+ return self.capture(value.to_dict(), f, node, seen)
242
+ if value is None or type(value) in (str, bool, int, float):
243
+ if type(value) is float and not math.isfinite(value):
244
+ self.fail(f, node, "Captured numbers must be finite")
245
+ return _Value(ir.Literal(value), _STRING if type(value) is str else _SCALAR)
246
+ if type(value) not in (list, dict):
247
+ self.fail(f, node, f"Cannot capture {type(value).__name__}; only JSON constants are supported")
248
+ if id(value) in seen:
249
+ self.fail(f, node, "Cyclic constants are not supported")
250
+ seen.add(id(value))
251
+ try:
252
+ if type(value) is list:
253
+ expr = ir.ListExpr(tuple(self.capture(item, f, node, seen).expr for item in value))
254
+ return _Value(expr, _Info("readonly", "list", element=_READONLY))
255
+ if any(type(key) is not str for key in value):
256
+ self.fail(f, node, "Captured dictionaries require string keys")
257
+ expr = ir.DictExpr(tuple(
258
+ (ir.Literal(key), self.capture(item, f, node, seen).expr) for key, item in value.items()
259
+ ))
260
+ return _Value(expr, _Info("readonly", "dict", element=_STRING))
261
+ finally:
262
+ seen.remove(id(value))
263
+
264
+ def data(self, value, f, node):
265
+ if value.expr is None:
266
+ self.fail(f, node, "The injected AnnoScript context cannot be stored in data or returned")
267
+ return value.expr
268
+
269
+ def materialize(self, value, out, f, node, pure=False):
270
+ if pure or value.expr is None:
271
+ return value
272
+ name = self.fresh("tmp")
273
+ out.append(self.stmt(ir.Assign, f, node, name, value.expr))
274
+ return _Value(ir.Var(name), value.info)
275
+
276
+ def value(self, expr, info, out, f, node, pure):
277
+ return self.materialize(_Value(expr, info), out, f, node, pure)
278
+
279
+ def expr(self, node, out, f, pure=False):
280
+ if isinstance(node, ast.Constant):
281
+ return self.capture(node.value, f, node)
282
+ if isinstance(node, ast.Name):
283
+ if node.id in f.names:
284
+ if node.id not in f.env:
285
+ self.fail(f, node, f"Local variable may be unbound: {node.id}")
286
+ info = f.env[node.id]
287
+ return _Value(None if info == _CONTEXT else ir.Var(f.names[node.id]), info)
288
+ return self.materialize(self.capture(self.raw_name(f, node), f, node), out, f, node, pure)
289
+ if isinstance(node, (ast.List, ast.Tuple)):
290
+ values = [self.expr(item, out, f, pure) for item in node.elts]
291
+ expressions = tuple(self.data(v, f, node) for v in values)
292
+ if isinstance(node, ast.Tuple):
293
+ expression = ir.TupleExpr(expressions)
294
+ info = _Info("scalar", "tuple", tuple(v.info for v in values))
295
+ else:
296
+ expression = ir.ListExpr(expressions)
297
+ info = _Info("local", "list")
298
+ return self.value(expression, info, out, f, node, pure)
299
+ if isinstance(node, ast.Dict):
300
+ entries = []
301
+ for key, item in zip(node.keys, node.values):
302
+ if key is None:
303
+ self.fail(f, node, "Dictionary unpacking is not supported")
304
+ k = self.data(self.expr(key, out, f, pure), f, key)
305
+ v = self.data(self.expr(item, out, f, pure), f, item)
306
+ entries.append((k, v))
307
+ return self.value(ir.DictExpr(tuple(entries)), _Info("local", "dict"), out, f, node, pure)
308
+ if isinstance(node, ast.Attribute):
309
+ receiver = self.expr(node.value, out, f, pure)
310
+ if receiver.info == _CONTEXT and node.attr == "state":
311
+ return self.value(ir.State(), _READONLY, out, f, node, pure)
312
+ response = receiver.info.response
313
+ if response is None or node.attr not in _RESPONSES[response]:
314
+ self.fail(f, node, "Only AnnoScript.state and declared inference response fields can be read")
315
+ return self.value(ir.Field(receiver.expr, node.attr), _RESPONSES[response][node.attr], out, f, node, pure)
316
+ if isinstance(node, ast.Subscript):
317
+ receiver = self.expr(node.value, out, f, pure)
318
+ index = self.expr(node.slice, out, f, pure)
319
+ info = _READONLY if receiver.info.origin == "readonly" else _UNKNOWN
320
+ if receiver.info.container == "str":
321
+ info = _STRING
322
+ if receiver.info.items is not None and isinstance(node.slice, ast.Constant) and type(node.slice.value) is int:
323
+ try:
324
+ info = receiver.info.items[node.slice.value]
325
+ except IndexError:
326
+ pass # runtime IndexError, not a compiler failure
327
+ return self.value(ir.GetItem(self.data(receiver, f, node), self.data(index, f, node)), info, out, f, node, pure)
328
+ if isinstance(node, ast.UnaryOp) and type(node.op) in (ast.Not, ast.UAdd, ast.USub):
329
+ operand = self.data(self.expr(node.operand, out, f, pure), f, node)
330
+ op = {ast.Not: "not", ast.UAdd: "positive", ast.USub: "negative"}[type(node.op)]
331
+ return self.value(ir.Unary(op, operand), _SCALAR, out, f, node, pure)
332
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
333
+ a = self.expr(node.left, out, f, pure)
334
+ b = self.expr(node.right, out, f, pure)
335
+ info = _UNKNOWN
336
+ if a.info.container == b.info.container == "list":
337
+ info = _Info("local", "list")
338
+ elif a.info.container == "str" or b.info.container == "str":
339
+ info = _STRING
340
+ elif a.info.items is not None and b.info.items is not None:
341
+ info = _Info("scalar", "tuple", a.info.items + b.info.items)
342
+ return self.value(ir.Binary("add", self.data(a, f, node), self.data(b, f, node)), info, out, f, node, pure)
343
+ if isinstance(node, ast.BoolOp):
344
+ return self.bool_expr(node, out, f, pure)
345
+ if isinstance(node, ast.Compare):
346
+ return self.compare(node, out, f, pure)
347
+ if isinstance(node, ast.JoinedStr):
348
+ parts = tuple(self.data(self.expr(v, out, f, pure), f, v) for v in node.values)
349
+ return self.value(ir.FormatString(parts), _STRING, out, f, node, pure)
350
+ if isinstance(node, ast.FormattedValue):
351
+ value = self.data(self.expr(node.value, out, f, pure), f, node)
352
+ spec = self.data(self.expr(node.format_spec, out, f, pure), f, node) if node.format_spec else None
353
+ conversion = {-1: "none", 115: "s", 114: "r", 97: "a"}[node.conversion]
354
+ return self.value(ir.FormatValue(value, conversion, spec), _STRING, out, f, node, pure)
355
+ if isinstance(node, ast.Call):
356
+ return self.call(node, out, f, pure)
357
+ self.fail(f, node, f"Unsupported expression: {type(node).__name__}")
358
+
359
+ def bool_expr(self, node, out, f, pure):
360
+ op = "and" if isinstance(node.op, ast.And) else "or"
361
+ if pure:
362
+ values = [self.expr(v, out, f, True) for v in node.values]
363
+ info = values[0].info
364
+ for v in values[1:]:
365
+ info = _merge(info, v.info)
366
+ return _Value(ir.BoolOp(op, tuple(self.data(v, f, node) for v in values)), info)
367
+ first = self.expr(node.values[0], out, f)
368
+ result = self.fresh("bool")
369
+ out.append(self.stmt(ir.Assign, f, node, result, self.data(first, f, node)))
370
+ info = first.info
371
+ for child in node.values[1:]:
372
+ body = []
373
+ value = self.expr(child, body, f)
374
+ body.append(self.stmt(ir.Assign, f, child, result, self.data(value, f, child)))
375
+ condition = ir.Var(result) if op == "and" else ir.Unary("not", ir.Var(result))
376
+ out.append(self.stmt(ir.If, f, child, condition, tuple(body)))
377
+ info = _merge(info, value.info)
378
+ return _Value(ir.Var(result), info)
379
+
380
+ def compare(self, node, out, f, pure):
381
+ for op, left, right in zip(node.ops, [node.left, *node.comparators], node.comparators):
382
+ if isinstance(op, (ast.Is, ast.IsNot)) and not any(
383
+ isinstance(v, ast.Constant) and (v.value is None or type(v.value) is bool)
384
+ for v in (left, right)
385
+ ):
386
+ self.fail(f, node, "Identity comparisons are supported only against None or booleans")
387
+ ops = tuple(_COMPARISONS[type(op)] for op in node.ops)
388
+ left = self.data(self.expr(node.left, out, f, pure), f, node)
389
+ if pure:
390
+ rights = tuple(self.data(self.expr(v, out, f, True), f, v) for v in node.comparators)
391
+ return _Value(ir.Compare(left, ops, rights), _SCALAR)
392
+ result = self.fresh("comparison")
393
+ for index, (op, child) in enumerate(zip(ops, node.comparators)):
394
+ body = out if index == 0 else []
395
+ right = self.data(self.expr(child, body, f), f, child)
396
+ body.append(self.stmt(ir.Assign, f, child, result, ir.Compare(left, (op,), (right,))))
397
+ if index:
398
+ out.append(self.stmt(ir.If, f, child, ir.Var(result), tuple(body)))
399
+ left = right
400
+ return _Value(ir.Var(result), _SCALAR)
401
+
402
+ def arguments(self, node, out, f, pure):
403
+ if any(isinstance(a, ast.Starred) for a in node.args) or any(k.arg is None for k in node.keywords):
404
+ self.fail(f, node, "Starred call arguments are not supported")
405
+ args = [self.expr(a, out, f, pure) for a in node.args]
406
+ kwargs = {}
407
+ for kw in node.keywords:
408
+ if kw.arg in kwargs:
409
+ self.fail(f, node, f"Duplicate keyword: {kw.arg}")
410
+ kwargs[kw.arg] = self.expr(kw.value, out, f, pure)
411
+ return args, kwargs
412
+
413
+ def bind(self, signature, args, kwargs, f, node):
414
+ try:
415
+ return signature.bind(*args, **kwargs)
416
+ except TypeError as exc:
417
+ self.fail(f, node, f"Invalid call arguments: {exc}")
418
+
419
+ def call(self, node, out, f, pure):
420
+ target = None
421
+ receiver = None
422
+ operation = None
423
+ intrinsic = None
424
+ if isinstance(node.func, ast.Name):
425
+ target = self.raw_name(f, node.func)
426
+ elif isinstance(node.func, ast.Attribute):
427
+ base = node.func.value
428
+ if isinstance(base, ast.Name) and base.id not in f.names and self.raw_name(f, base) is json:
429
+ if node.func.attr != "dumps":
430
+ self.fail(f, node, "Only json.dumps is supported")
431
+ target = json.dumps
432
+ else:
433
+ receiver = self.expr(base, out, f, pure)
434
+ if receiver.info == _CONTEXT and node.func.attr in _OPERATIONS:
435
+ operation = node.func.attr
436
+ elif node.func.attr == "strip" and receiver.expr is not None:
437
+ intrinsic = "str.strip"
438
+ else:
439
+ self.fail(f, node, "Unsupported method or dynamic call")
440
+ else:
441
+ self.fail(f, node, "Dynamic calls are not supported")
442
+ if target is constrain:
443
+ self.fail(f, node, "constrain must be a standalone statement")
444
+ if pure and (operation is not None or isinstance(target, FunctionType) and target is not json.dumps):
445
+ self.fail(f, node, "Constraint expressions must be pure; inference and helper calls are not allowed")
446
+ args, kwargs = self.arguments(node, out, f, pure)
447
+ if target is Example:
448
+ bound = self.bind(inspect.signature(Example), args, kwargs, f, node)
449
+ expression = ir.Intrinsic("example", tuple(
450
+ self.data(bound.arguments[name], f, node) for name in ("state", "answer")
451
+ ))
452
+ return self.value(expression, _Info("readonly", "dict", element=_STRING),
453
+ out, f, node, pure)
454
+ if operation:
455
+ self.bind(inspect.signature(getattr(Anno, operation)), [None, *args], kwargs, f, node)
456
+ expr_args = tuple(self.data(v, f, node) for v in args)
457
+ expr_kwargs = tuple((k, self.data(v, f, node)) for k, v in kwargs.items())
458
+ name = self.fresh(operation)
459
+ out.append(self.stmt(ir.Infer, f, node, name, operation, expr_args, expr_kwargs))
460
+ return _Value(ir.Var(name), _Info("readonly", response=operation))
461
+ for function, name in _INTRINSICS:
462
+ if target is function:
463
+ intrinsic = name
464
+ break
465
+ if intrinsic:
466
+ if receiver is not None:
467
+ args = [receiver, *args]
468
+ info = self.check_intrinsic(intrinsic, args, kwargs, f, node)
469
+ expression = ir.Intrinsic(intrinsic, tuple(self.data(v, f, node) for v in args),
470
+ tuple((k, self.data(v, f, node)) for k, v in kwargs.items()))
471
+ return self.value(expression, info, out, f, node, pure)
472
+ if isinstance(target, FunctionType) and target.__module__ == self.entry.__module__:
473
+ return self.inline(target, args, kwargs, out, f, node)
474
+ self.fail(f, node, "Only known intrinsics and same-module helper functions can be called")
475
+
476
+ def check_intrinsic(self, name, args, kwargs, f, node):
477
+ valid = True
478
+ if name == "len":
479
+ valid = len(args) == 1 and not kwargs
480
+ info = _SCALAR
481
+ elif name == "int":
482
+ valid = len(args) <= 2 and set(kwargs) <= {"base"} and not (len(args) == 2 and kwargs)
483
+ info = _SCALAR
484
+ elif name == "range":
485
+ valid = 1 <= len(args) <= 3 and not kwargs
486
+ info = _Info("scalar", "range", element=_SCALAR)
487
+ elif name == "enumerate":
488
+ self.bind(inspect.signature(enumerate), args, kwargs, f, node)
489
+ iterable = args[0] if args else kwargs["iterable"]
490
+ element = _Info("scalar", "tuple", (_SCALAR, self.element(iterable.info)))
491
+ info = _Info("scalar", "enumerate", element=element)
492
+ elif name == "str.strip":
493
+ valid = 1 <= len(args) <= 2 and not kwargs
494
+ info = _STRING
495
+ else:
496
+ self.bind(inspect.signature(json.dumps), args, kwargs, f, node)
497
+ valid = set(kwargs) <= {"obj", "skipkeys", "ensure_ascii", "check_circular", "allow_nan", "indent", "separators", "sort_keys"}
498
+ info = _STRING
499
+ if not valid:
500
+ self.fail(f, node, f"Unsupported arguments for intrinsic {name}")
501
+ return info
502
+
503
+ def inline(self, function, args, kwargs, out, caller, call):
504
+ if function in self.active:
505
+ self.fail(caller, call, "Recursive helper calls are not supported")
506
+ parsed = self.parse(function, caller, call)
507
+ frame = self.frame(parsed, caller.calls + (self.span(caller, call),), helper=True)
508
+ self.decorators(frame)
509
+ bound = self.bind(parsed.signature, args, kwargs, caller, call)
510
+ bound.apply_defaults()
511
+ for name, value in bound.arguments.items():
512
+ if not isinstance(value, _Value):
513
+ value = self.capture(value, caller, call)
514
+ frame.env[name] = value.info
515
+ if value.expr is not None:
516
+ out.append(self.stmt(ir.Assign, caller, call, frame.names[name], value.expr))
517
+ out.append(self.stmt(ir.Assign, caller, call, frame.result, ir.Literal(None)))
518
+ out.append(self.stmt(ir.Assign, caller, call, frame.done, ir.Literal(False)))
519
+ self.active.append(function)
520
+ try:
521
+ body, falls, _ = self.block(parsed.node.body, frame)
522
+ finally:
523
+ self.active.pop()
524
+ out.extend(body)
525
+ infos = frame.return_infos + ([_SCALAR] if falls else [])
526
+ info = infos[0] if infos else _SCALAR
527
+ for other in infos[1:]:
528
+ info = _merge(info, other)
529
+ return _Value(ir.Var(frame.result), info)
530
+
531
+ def element(self, info):
532
+ if info.element is not None:
533
+ return info.element
534
+ if info.container == "str":
535
+ return _STRING
536
+ if info.container == "dict":
537
+ return _SCALAR
538
+ if info.items:
539
+ element = info.items[0]
540
+ for item in info.items[1:]:
541
+ element = _merge(element, item)
542
+ return element
543
+ return _READONLY if info.origin == "readonly" else _UNKNOWN
544
+
545
+ def target(self, node, info, f):
546
+ if isinstance(node, ast.Name):
547
+ # A join must never turn the AnnoScript capability into ordinary data.
548
+ if node.id in f.env and (f.env[node.id] == _CONTEXT) != (info == _CONTEXT):
549
+ self.fail(f, node, "Cannot rebind a AnnoScript context to data or vice versa")
550
+ f.env[node.id] = info
551
+ return ir.NameTarget(f.names[node.id])
552
+ if isinstance(node, (ast.Tuple, ast.List)):
553
+ if info.items is not None and len(info.items) == len(node.elts):
554
+ infos = info.items
555
+ else:
556
+ infos = (self.element(info),) * len(node.elts)
557
+ return ir.TupleTarget(tuple(self.target(n, i, f) for n, i in zip(node.elts, infos)))
558
+ self.fail(f, node, "Unpacking and loop targets must contain only names")
559
+
560
+ def assign(self, target, value, out, f):
561
+ if isinstance(target, ast.Subscript):
562
+ receiver = self.expr(target.value, out, f)
563
+ index = self.expr(target.slice, out, f)
564
+ if receiver.info.origin != "local" or receiver.info.container not in ("list", "dict"):
565
+ self.fail(f, target, "Only provably local list/dict containers may be modified; inputs and captures are read-only")
566
+ out.append(self.stmt(ir.SetItem, f, target, receiver.expr, self.data(index, f, target), self.data(value, f, target)))
567
+ return
568
+ compiled = self.target(target, value.info, f)
569
+ if value.info == _CONTEXT:
570
+ if not isinstance(compiled, ir.NameTarget):
571
+ self.fail(f, target, "Cannot unpack a AnnoScript context")
572
+ return
573
+ expression = self.data(value, f, target)
574
+ if isinstance(compiled, ir.NameTarget):
575
+ out.append(self.stmt(ir.Assign, f, target, compiled.name, expression))
576
+ else:
577
+ out.append(self.stmt(ir.Unpack, f, target, compiled, expression))
578
+
579
+ def block(self, nodes, f):
580
+ out = []
581
+ returned = False
582
+ for index, node in enumerate(nodes):
583
+ falls, may_return = self.statement(node, out, f)
584
+ returned |= may_return
585
+ if not falls:
586
+ if index + 1 < len(nodes):
587
+ self.fail(f, nodes[index + 1], "Unreachable statements after an unconditional return are not supported")
588
+ return tuple(out), False, returned
589
+ if may_return and f.done is not None and index + 1 < len(nodes):
590
+ suffix, falls, suffix_returns = self.block(nodes[index + 1:], f)
591
+ out.append(self.stmt(ir.If, f, node, ir.Unary("not", ir.Var(f.done)), suffix))
592
+ return tuple(out), falls, returned or suffix_returns
593
+ return tuple(out), True, returned
594
+
595
+ def statement(self, node, out, f):
596
+ if isinstance(node, ast.Expr):
597
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
598
+ return True, False # docstring
599
+ call = node.value
600
+ if isinstance(call, ast.Call) and isinstance(call.func, ast.Name) and call.func.id not in f.names:
601
+ if self.raw_name(f, call.func) is constrain:
602
+ args, kwargs = self.arguments(call, out, f, True)
603
+ bound = self.bind(inspect.signature(constrain), args, kwargs, f, call)
604
+ condition = self.data(bound.arguments["condition"], f, call)
605
+ message = bound.arguments.get("message")
606
+ out.append(self.stmt(ir.Constrain, f, node, condition, self.data(message, f, call) if message else None))
607
+ return True, False
608
+ value = self.expr(node.value, out, f)
609
+ self.data(value, f, node)
610
+ return True, False
611
+ if isinstance(node, ast.Assign):
612
+ value = self.expr(node.value, out, f)
613
+ # Evaluate the RHS once even when a target rebinds a referenced name.
614
+ value = self.materialize(value, out, f, node)
615
+ for target in node.targets:
616
+ self.assign(target, value, out, f)
617
+ return True, False
618
+ if isinstance(node, ast.If):
619
+ condition = self.data(self.expr(node.test, out, f), f, node.test)
620
+ a, b = f.fork(), f.fork()
621
+ yes, a_falls, a_returns = self.block(node.body, a)
622
+ no, b_falls, b_returns = self.block(node.orelse, b)
623
+ if a_falls and b_falls:
624
+ # Context/data mixtures cannot be represented as ordinary values.
625
+ for name in a.env.keys() & b.env.keys():
626
+ if (a.env[name] == _CONTEXT) != (b.env[name] == _CONTEXT):
627
+ self.fail(f, node, f"Inconsistent AnnoScript context binding across branches: {name}")
628
+ f.env = _merge_env(a.env, b.env)
629
+ elif a_falls:
630
+ f.env = a.env
631
+ elif b_falls:
632
+ f.env = b.env
633
+ out.append(self.stmt(ir.If, f, node, condition, yes, no))
634
+ return a_falls or b_falls, a_returns or b_returns
635
+ if isinstance(node, ast.For):
636
+ if node.orelse:
637
+ self.fail(f, node, "for/else is not supported")
638
+ iterable = self.expr(node.iter, out, f)
639
+ self.data(iterable, f, node)
640
+ # Compute a conservative loop invariant before accepting writes. This
641
+ # catches aliases that become read-only on a later iteration.
642
+ initial = dict(f.env)
643
+ invariant = initial
644
+ while True:
645
+ body_frame = f.fork()
646
+ body_frame.env = dict(invariant)
647
+ body_frame.loop_depth += 1
648
+ target = self.target(node.target, self.element(iterable.info), body_frame)
649
+ body, _, _ = self.block(node.body, body_frame)
650
+ merged = _merge_env(initial, body_frame.env)
651
+ merged = _merge_env(invariant, merged)
652
+ if merged == invariant:
653
+ break
654
+ invariant = merged
655
+ f.env = invariant
656
+ out.append(self.stmt(ir.For, f, node, target, iterable.expr, body))
657
+ return True, False
658
+ if isinstance(node, ast.Return):
659
+ if f.loop_depth:
660
+ self.fail(f, node, "Returns inside loops are not supported")
661
+ value = self.expr(node.value, out, f) if node.value else _Value(ir.Literal(None), _SCALAR)
662
+ expression = self.data(value, f, node)
663
+ if f.result is None:
664
+ out.append(self.stmt(ir.Return, f, node, expression))
665
+ else:
666
+ out.append(self.stmt(ir.Assign, f, node, f.result, expression))
667
+ out.append(self.stmt(ir.Assign, f, node, f.done, ir.Literal(True)))
668
+ f.return_infos.append(value.info)
669
+ return False, True
670
+ if isinstance(node, ast.Pass):
671
+ return True, False
672
+ self.fail(f, node, f"Unsupported statement: {type(node).__name__}")
673
+
674
+ def compile(self):
675
+ parsed = self.parse(self.entry)
676
+ parameters = list(parsed.signature.parameters.values())
677
+ if len(parameters) != 1 or parameters[0].kind not in (
678
+ inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD
679
+ ) or parameters[0].default is not inspect.Parameter.empty:
680
+ raise CompileError("Entry must accept exactly one positional AnnoScript parameter without a default")
681
+ frame = self.frame(parsed)
682
+ frame.env[parameters[0].name] = _CONTEXT
683
+ self.decorators(frame, entry=True)
684
+ self.active.append(self.entry)
685
+ body, falls, _ = self.block(parsed.node.body, frame)
686
+ if falls:
687
+ body += (self.stmt(ir.Return, frame, parsed.node, ir.Literal(None)),)
688
+ config = get_program_config(self.entry)
689
+ program = ir.Program(
690
+ self.entry.__qualname__, ir.ProgramConfig(**config), body, self.span(frame, parsed.node)
691
+ )
692
+ program.to_dict() # ensure the emitted tree is serializable
693
+ return program
694
+
695
+
696
+ def compile_program(program: Callable[[Anno], JSONValue], *, optimize: bool = True) -> ir.Program:
697
+ """Compile one function and snapshot its reachable constants/configuration.
698
+
699
+ Importing the function is the caller's responsibility. Compilation performs
700
+ no input validation, model calls, user function execution or remote work.
701
+ Unsupported programs raise CompileError instead of falling back to Python.
702
+ Set optimize=False to inspect the original lowering before optimization.
703
+ """
704
+ result = _Compiler(program).compile()
705
+ if optimize:
706
+ from annoscript.passes import optimize_program
707
+ result = optimize_program(result)
708
+ return result