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.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
annoscript/ir.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Version 1 of AnnoScript's structured, fully inlined intermediate language.
|
|
2
|
+
|
|
3
|
+
Blocks execute in order. Expressions are pure and evaluate left to right;
|
|
4
|
+
BoolOp and Compare short-circuit. Assignments bind values (including container
|
|
5
|
+
references), not copies. For evaluates its finite iterable once. Infer invokes
|
|
6
|
+
exactly one operation on the program's injected AnnoScript and binds its response.
|
|
7
|
+
Field reads address response fields, never arbitrary Python attributes.
|
|
8
|
+
|
|
9
|
+
Constrain retains an expression over the bindings at its execution point. A
|
|
10
|
+
runtime must establish it before proceeding, or fail; validation alone is not
|
|
11
|
+
a repair solver. Branch decisions already consumed by control flow stay fixed.
|
|
12
|
+
Inlining introduces no solving scope. Retrying an inference requires invalidating
|
|
13
|
+
its dependent computations. Return delivers one JSON value after active
|
|
14
|
+
constraints have been satisfied. Tuples are permitted as intermediate values.
|
|
15
|
+
|
|
16
|
+
Statement IDs identify static sites; a runtime must additionally distinguish loop
|
|
17
|
+
iterations and repeated evaluations. Source metadata is diagnostic only.
|
|
18
|
+
Intrinsic names have fixed semantics: len, int, range, enumerate, str.strip and
|
|
19
|
+
json.dumps follow their Python counterparts on supported data values. example
|
|
20
|
+
snapshots state and answer into a plain JSON object, without constructing Python
|
|
21
|
+
application objects. strip
|
|
22
|
+
requires a string receiver. These names never resolve to user-provided code.
|
|
23
|
+
|
|
24
|
+
This module defines data and serialization, not an executor or solver. All node
|
|
25
|
+
collections are tuples. The wire representation uses explicit snake-case tags,
|
|
26
|
+
preserves dictionary entry order, and contains no Python objects or source code.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import MISSING, dataclass, fields, is_dataclass
|
|
32
|
+
from functools import lru_cache
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
from types import UnionType
|
|
36
|
+
from typing import ClassVar, Literal as TypingLiteral, Union, get_args, get_origin, get_type_hints
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IRDecodeError(ValueError):
|
|
40
|
+
"""Malformed or unsupported serialized IR, with a path into the document."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, path: str = "$"):
|
|
43
|
+
self.message = message
|
|
44
|
+
self.path = path
|
|
45
|
+
super().__init__(f"{path}: {message}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _encode(value):
|
|
49
|
+
if isinstance(value, Node):
|
|
50
|
+
return {"kind": value.kind, **{f.name: _encode(getattr(value, f.name)) for f in fields(value)}}
|
|
51
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
52
|
+
return {f.name: _encode(getattr(value, f.name)) for f in fields(value)}
|
|
53
|
+
if isinstance(value, tuple):
|
|
54
|
+
return [_encode(item) for item in value]
|
|
55
|
+
if value is None or type(value) in (str, bool, int):
|
|
56
|
+
return value
|
|
57
|
+
if type(value) is float and math.isfinite(value):
|
|
58
|
+
return value
|
|
59
|
+
raise ValueError(f"IR requires immutable JSON-compatible data, got {type(value).__name__}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Node:
|
|
64
|
+
kind: ClassVar[str]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Expr(Node):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Literal(Expr):
|
|
74
|
+
kind: ClassVar[str] = "literal"
|
|
75
|
+
value: str | int | float | bool | None
|
|
76
|
+
|
|
77
|
+
def __post_init__(self):
|
|
78
|
+
if self.value is not None and type(self.value) not in (str, int, float, bool):
|
|
79
|
+
raise ValueError("Literal values must be JSON scalars")
|
|
80
|
+
_encode(self.value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Var(Expr):
|
|
85
|
+
kind: ClassVar[str] = "var"
|
|
86
|
+
name: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class State(Expr):
|
|
91
|
+
kind: ClassVar[str] = "state"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class GetItem(Expr):
|
|
96
|
+
kind: ClassVar[str] = "get_item"
|
|
97
|
+
value: Expr
|
|
98
|
+
index: Expr
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Field(Expr):
|
|
103
|
+
kind: ClassVar[str] = "field"
|
|
104
|
+
value: Expr
|
|
105
|
+
name: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ListExpr(Expr):
|
|
110
|
+
kind: ClassVar[str] = "list"
|
|
111
|
+
items: tuple[Expr, ...]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class TupleExpr(Expr):
|
|
116
|
+
kind: ClassVar[str] = "tuple"
|
|
117
|
+
items: tuple[Expr, ...]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class DictExpr(Expr):
|
|
122
|
+
kind: ClassVar[str] = "dict"
|
|
123
|
+
entries: tuple[tuple[Expr, Expr], ...]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Unary(Expr):
|
|
128
|
+
kind: ClassVar[str] = "unary"
|
|
129
|
+
op: TypingLiteral["not", "negative", "positive"]
|
|
130
|
+
value: Expr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Binary(Expr):
|
|
135
|
+
kind: ClassVar[str] = "binary"
|
|
136
|
+
op: TypingLiteral["add"]
|
|
137
|
+
left: Expr
|
|
138
|
+
right: Expr
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Compare(Expr):
|
|
143
|
+
kind: ClassVar[str] = "compare"
|
|
144
|
+
left: Expr
|
|
145
|
+
ops: tuple[str, ...]
|
|
146
|
+
comparators: tuple[Expr, ...]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class BoolOp(Expr):
|
|
151
|
+
kind: ClassVar[str] = "bool_op"
|
|
152
|
+
op: TypingLiteral["and", "or"]
|
|
153
|
+
values: tuple[Expr, ...]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class Intrinsic(Expr):
|
|
158
|
+
kind: ClassVar[str] = "intrinsic"
|
|
159
|
+
name: str
|
|
160
|
+
args: tuple[Expr, ...]
|
|
161
|
+
kwargs: tuple[tuple[str, Expr], ...] = ()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class FormatValue(Expr):
|
|
166
|
+
kind: ClassVar[str] = "format_value"
|
|
167
|
+
value: Expr
|
|
168
|
+
conversion: TypingLiteral["none", "s", "r", "a"] = "none"
|
|
169
|
+
spec: Expr | None = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class FormatString(Expr):
|
|
174
|
+
kind: ClassVar[str] = "format_string"
|
|
175
|
+
parts: tuple[Expr, ...]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class Target(Node):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class NameTarget(Target):
|
|
185
|
+
kind: ClassVar[str] = "name_target"
|
|
186
|
+
name: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class TupleTarget(Target):
|
|
191
|
+
kind: ClassVar[str] = "tuple_target"
|
|
192
|
+
items: tuple[Target, ...]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class SourceSpan:
|
|
197
|
+
module: str
|
|
198
|
+
function: str
|
|
199
|
+
line: int
|
|
200
|
+
column: int # one-based Unicode character column
|
|
201
|
+
end_line: int
|
|
202
|
+
end_column: int # exclusive, one-based
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class Source:
|
|
207
|
+
span: SourceSpan
|
|
208
|
+
calls: tuple[SourceSpan, ...] = () # outermost to innermost call site
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, kw_only=True)
|
|
212
|
+
class Stmt(Node):
|
|
213
|
+
id: str
|
|
214
|
+
source: Source
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Assign(Stmt):
|
|
219
|
+
kind: ClassVar[str] = "assign"
|
|
220
|
+
target: str
|
|
221
|
+
value: Expr
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class Unpack(Stmt):
|
|
226
|
+
kind: ClassVar[str] = "unpack"
|
|
227
|
+
target: TupleTarget
|
|
228
|
+
value: Expr
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class SetItem(Stmt):
|
|
233
|
+
kind: ClassVar[str] = "set_item"
|
|
234
|
+
value: Expr
|
|
235
|
+
index: Expr
|
|
236
|
+
item: Expr
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class If(Stmt):
|
|
241
|
+
kind: ClassVar[str] = "if"
|
|
242
|
+
condition: Expr
|
|
243
|
+
then_body: tuple[Stmt, ...]
|
|
244
|
+
else_body: tuple[Stmt, ...] = ()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class For(Stmt):
|
|
249
|
+
kind: ClassVar[str] = "for"
|
|
250
|
+
target: Target
|
|
251
|
+
iterable: Expr
|
|
252
|
+
body: tuple[Stmt, ...]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class Infer(Stmt):
|
|
257
|
+
kind: ClassVar[str] = "infer"
|
|
258
|
+
target: str
|
|
259
|
+
operation: TypingLiteral["noul", "choice", "score", "extract", "generate", "generate_list"]
|
|
260
|
+
args: tuple[Expr, ...]
|
|
261
|
+
kwargs: tuple[tuple[str, Expr], ...]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class Constrain(Stmt):
|
|
266
|
+
kind: ClassVar[str] = "constrain"
|
|
267
|
+
condition: Expr
|
|
268
|
+
message: Expr | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class Return(Stmt):
|
|
273
|
+
kind: ClassVar[str] = "return"
|
|
274
|
+
value: Expr
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class ProgramConfig:
|
|
279
|
+
system_prompt: str | None = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class Program:
|
|
284
|
+
entry: str
|
|
285
|
+
config: ProgramConfig
|
|
286
|
+
body: tuple[Stmt, ...]
|
|
287
|
+
source: SourceSpan
|
|
288
|
+
format: str = "lev-ir"
|
|
289
|
+
version: int = 1
|
|
290
|
+
|
|
291
|
+
def to_dict(self) -> dict:
|
|
292
|
+
"""Return detached JSON data; absence of a prompt remains distinguishable."""
|
|
293
|
+
data = _encode(self)
|
|
294
|
+
if self.config.system_prompt is None:
|
|
295
|
+
data["config"] = {}
|
|
296
|
+
return data
|
|
297
|
+
|
|
298
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
299
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, allow_nan=False, indent=indent)
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def from_dict(cls, data: dict) -> Program:
|
|
303
|
+
"""Restore version-1 IR from JSON data, without executing any code.
|
|
304
|
+
|
|
305
|
+
Checks node categories, field types, operators, source ranges and unique
|
|
306
|
+
statement IDs. This is structural validation, not type/ownership analysis
|
|
307
|
+
or proof that the program's constraints can be satisfied. Unknown fields
|
|
308
|
+
and versions are rejected. Collections are detached immutable tuples.
|
|
309
|
+
"""
|
|
310
|
+
if type(data) is not dict:
|
|
311
|
+
raise IRDecodeError("expected a program object")
|
|
312
|
+
if data.get("format") != "lev-ir":
|
|
313
|
+
raise IRDecodeError("expected format 'lev-ir'", "$.format")
|
|
314
|
+
if type(data.get("version")) is not int or data["version"] != 1:
|
|
315
|
+
raise IRDecodeError("unsupported IR version; expected 1", "$.version")
|
|
316
|
+
try:
|
|
317
|
+
return _Decoder().decode(data, Program, "$")
|
|
318
|
+
except RecursionError as exc:
|
|
319
|
+
raise IRDecodeError("IR nesting is too deep") from exc
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def from_json(cls, text: str) -> Program:
|
|
323
|
+
"""Read JSON text; reject duplicate object keys and non-finite numbers."""
|
|
324
|
+
def object_pairs(pairs):
|
|
325
|
+
result = {}
|
|
326
|
+
for key, value in pairs:
|
|
327
|
+
if key in result:
|
|
328
|
+
raise IRDecodeError(f"duplicate JSON object key {key!r}")
|
|
329
|
+
result[key] = value
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def constant(value):
|
|
333
|
+
raise IRDecodeError(f"non-finite JSON number {value!r}")
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
data = json.loads(text, object_pairs_hook=object_pairs, parse_constant=constant)
|
|
337
|
+
except json.JSONDecodeError as exc:
|
|
338
|
+
raise IRDecodeError(f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
|
|
339
|
+
except (RecursionError, UnicodeError) as exc:
|
|
340
|
+
raise IRDecodeError(f"invalid JSON: {exc}") from exc
|
|
341
|
+
return cls.from_dict(data)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# A closed registry: a JSON tag can never import a class or resolve Python code.
|
|
345
|
+
_NODE_TYPES = {
|
|
346
|
+
node.kind: node for node in (
|
|
347
|
+
Literal, Var, State, GetItem, Field, ListExpr, TupleExpr, DictExpr,
|
|
348
|
+
Unary, Binary, Compare, BoolOp, Intrinsic, FormatValue, FormatString,
|
|
349
|
+
NameTarget, TupleTarget, Assign, Unpack, SetItem, If, For, Infer,
|
|
350
|
+
Constrain, Return,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
_COMPARISON_OPS = frozenset(("eq", "ne", "lt", "le", "gt", "ge", "in", "not_in", "is", "is_not"))
|
|
354
|
+
_INTRINSIC_NAMES = frozenset(("len", "int", "range", "enumerate", "str.strip", "json.dumps", "example"))
|
|
355
|
+
_RESPONSE_FIELDS = frozenset(("noul", "choice", "confidence", "probabilities", "score", "legend", "items", "text"))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@lru_cache(maxsize=None)
|
|
359
|
+
def _field_types(cls):
|
|
360
|
+
# Only our trusted dataclasses reach this function, never document contents.
|
|
361
|
+
return get_type_hints(cls)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class _Decoder:
|
|
365
|
+
def __init__(self):
|
|
366
|
+
self.statement_ids = set()
|
|
367
|
+
|
|
368
|
+
def decode(self, value, expected, path):
|
|
369
|
+
origin = get_origin(expected)
|
|
370
|
+
args = get_args(expected)
|
|
371
|
+
if origin in (Union, UnionType):
|
|
372
|
+
if value is None and type(None) in args:
|
|
373
|
+
return None
|
|
374
|
+
options = tuple(t for t in args if t is not type(None))
|
|
375
|
+
if len(options) == 1:
|
|
376
|
+
return self.decode(value, options[0], path)
|
|
377
|
+
# The only non-optional union in the schema is Literal.value.
|
|
378
|
+
if type(value) in options:
|
|
379
|
+
return self.decode(value, type(value), path)
|
|
380
|
+
raise IRDecodeError("expected a JSON scalar", path)
|
|
381
|
+
if origin is TypingLiteral:
|
|
382
|
+
if not any(type(value) is type(option) and value == option for option in args):
|
|
383
|
+
raise IRDecodeError(f"expected one of {args!r}", path)
|
|
384
|
+
return value
|
|
385
|
+
if origin is tuple:
|
|
386
|
+
if type(value) is not list:
|
|
387
|
+
raise IRDecodeError("expected a JSON array", path)
|
|
388
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
389
|
+
return tuple(self.decode(v, args[0], f"{path}[{i}]") for i, v in enumerate(value))
|
|
390
|
+
if len(value) != len(args):
|
|
391
|
+
raise IRDecodeError(f"expected an array of length {len(args)}", path)
|
|
392
|
+
return tuple(self.decode(v, t, f"{path}[{i}]") for i, (v, t) in enumerate(zip(value, args)))
|
|
393
|
+
if expected in (str, bool, int, float, type(None)):
|
|
394
|
+
if type(value) is not expected:
|
|
395
|
+
raise IRDecodeError(f"expected {expected.__name__}", path)
|
|
396
|
+
if expected is float and not math.isfinite(value):
|
|
397
|
+
raise IRDecodeError("numbers must be finite", path)
|
|
398
|
+
return value
|
|
399
|
+
if not isinstance(expected, type) or not is_dataclass(expected):
|
|
400
|
+
raise TypeError(f"Unsupported internal IR schema type: {expected!r}")
|
|
401
|
+
if type(value) is not dict:
|
|
402
|
+
raise IRDecodeError("expected an object", path)
|
|
403
|
+
|
|
404
|
+
cls = expected
|
|
405
|
+
tagged = issubclass(expected, Node)
|
|
406
|
+
if tagged:
|
|
407
|
+
kind = value.get("kind")
|
|
408
|
+
if type(kind) is not str or kind not in _NODE_TYPES:
|
|
409
|
+
raise IRDecodeError(f"unknown node kind {kind!r}", f"{path}.kind")
|
|
410
|
+
cls = _NODE_TYPES[kind]
|
|
411
|
+
if not issubclass(cls, expected):
|
|
412
|
+
raise IRDecodeError(f"{kind!r} is not a {expected.__name__} node", path)
|
|
413
|
+
|
|
414
|
+
definitions = fields(cls)
|
|
415
|
+
allowed = {f.name for f in definitions} | ({"kind"} if tagged else set())
|
|
416
|
+
unknown = value.keys() - allowed
|
|
417
|
+
if unknown:
|
|
418
|
+
raise IRDecodeError(f"unknown fields: {', '.join(sorted(map(repr, unknown)))}", path)
|
|
419
|
+
values = {}
|
|
420
|
+
types = _field_types(cls)
|
|
421
|
+
for f in definitions:
|
|
422
|
+
if f.name in value:
|
|
423
|
+
values[f.name] = self.decode(value[f.name], types[f.name], f"{path}.{f.name}")
|
|
424
|
+
elif f.default is MISSING and f.default_factory is MISSING:
|
|
425
|
+
raise IRDecodeError("missing required field", f"{path}.{f.name}")
|
|
426
|
+
result = cls(**values)
|
|
427
|
+
self.validate(result, path)
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
def validate(self, node, path):
|
|
431
|
+
if isinstance(node, Stmt):
|
|
432
|
+
if not node.id or node.id in self.statement_ids:
|
|
433
|
+
raise IRDecodeError("statement IDs must be nonempty and unique", f"{path}.id")
|
|
434
|
+
self.statement_ids.add(node.id)
|
|
435
|
+
if isinstance(node, SourceSpan):
|
|
436
|
+
if min(node.line, node.column, node.end_line, node.end_column) < 1:
|
|
437
|
+
raise IRDecodeError("source coordinates must be positive", path)
|
|
438
|
+
if (node.end_line, node.end_column) < (node.line, node.column):
|
|
439
|
+
raise IRDecodeError("source end precedes its start", path)
|
|
440
|
+
if isinstance(node, (Var, NameTarget)) and not node.name:
|
|
441
|
+
raise IRDecodeError("variable name must not be empty", f"{path}.name")
|
|
442
|
+
if isinstance(node, (Assign, Infer)) and not node.target:
|
|
443
|
+
raise IRDecodeError("target must not be empty", f"{path}.target")
|
|
444
|
+
if isinstance(node, Compare):
|
|
445
|
+
if not node.ops or len(node.ops) != len(node.comparators):
|
|
446
|
+
raise IRDecodeError("comparison requires matching nonempty operators and operands", path)
|
|
447
|
+
if any(op not in _COMPARISON_OPS for op in node.ops):
|
|
448
|
+
raise IRDecodeError("unknown comparison operator", f"{path}.ops")
|
|
449
|
+
if isinstance(node, BoolOp) and len(node.values) < 2:
|
|
450
|
+
raise IRDecodeError("boolean operation requires at least two operands", f"{path}.values")
|
|
451
|
+
if isinstance(node, Intrinsic) and node.name not in _INTRINSIC_NAMES:
|
|
452
|
+
raise IRDecodeError(f"unknown intrinsic {node.name!r}", f"{path}.name")
|
|
453
|
+
if isinstance(node, Field) and node.name not in _RESPONSE_FIELDS:
|
|
454
|
+
raise IRDecodeError(f"unknown response field {node.name!r}", f"{path}.name")
|
|
455
|
+
if isinstance(node, (Infer, Intrinsic)):
|
|
456
|
+
names = [name for name, _ in node.kwargs]
|
|
457
|
+
if any(not name for name in names) or len(names) != len(set(names)):
|
|
458
|
+
raise IRDecodeError("keyword argument names must be nonempty and unique", f"{path}.kwargs")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Mandatory Python 3.10 -> Python 3.12 API + worker durable-job acceptance."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from annoscript.cross_version_tests import interpreter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
17
|
+
parser.add_argument("--client-python", required=True)
|
|
18
|
+
parser.add_argument("--server-python", required=True)
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
interpreter(args.client_python, "3.10")
|
|
21
|
+
interpreter(args.server_python, "3.12")
|
|
22
|
+
root = Path(__file__).resolve().parents[1]
|
|
23
|
+
processes = []
|
|
24
|
+
|
|
25
|
+
def stop(process, *, kill=False):
|
|
26
|
+
if process.poll() is None:
|
|
27
|
+
process.kill() if kill else process.terminate()
|
|
28
|
+
try:
|
|
29
|
+
process.wait(10)
|
|
30
|
+
except subprocess.TimeoutExpired:
|
|
31
|
+
process.kill()
|
|
32
|
+
process.wait(5)
|
|
33
|
+
|
|
34
|
+
with tempfile.TemporaryDirectory(prefix="anno-jobs-cross-") as folder:
|
|
35
|
+
clock = Path(folder, "clock")
|
|
36
|
+
clock.write_text("1000")
|
|
37
|
+
|
|
38
|
+
def set_clock(value):
|
|
39
|
+
temporary = clock.with_suffix(".next")
|
|
40
|
+
temporary.write_text(str(value))
|
|
41
|
+
temporary.replace(clock)
|
|
42
|
+
|
|
43
|
+
def start(python, kind, *extra):
|
|
44
|
+
process = subprocess.Popen([python, "-m", "annoscript._test_job_process", kind, folder, *extra],
|
|
45
|
+
cwd=root, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
46
|
+
processes.append(process)
|
|
47
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
48
|
+
future = pool.submit(process.stdout.readline)
|
|
49
|
+
try:
|
|
50
|
+
line = future.result(timeout=30)
|
|
51
|
+
if not line:
|
|
52
|
+
raise RuntimeError(process.stderr.read())
|
|
53
|
+
return process, json.loads(line)
|
|
54
|
+
except BaseException:
|
|
55
|
+
stop(process)
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def client(url, action):
|
|
59
|
+
result = subprocess.run([args.client_python, "-m", "annoscript._test_job_process", "client", folder,
|
|
60
|
+
"--url", url, "--action", action], cwd=root, capture_output=True, text=True, timeout=180)
|
|
61
|
+
if result.returncode:
|
|
62
|
+
raise RuntimeError(result.stderr + result.stdout)
|
|
63
|
+
return json.loads(result.stdout)
|
|
64
|
+
|
|
65
|
+
def wait_for(predicate, seconds=20):
|
|
66
|
+
deadline = time.monotonic() + seconds
|
|
67
|
+
while time.monotonic() < deadline:
|
|
68
|
+
value = predicate()
|
|
69
|
+
if value:
|
|
70
|
+
return value
|
|
71
|
+
time.sleep(0.02)
|
|
72
|
+
raise AssertionError("cross-process condition timed out")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
subprocess.run([args.server_python, "-m", "annoscript._test_job_process", "fixtures", folder], cwd=root, check=True, timeout=30)
|
|
76
|
+
api, ready = start(args.server_python, "api")
|
|
77
|
+
worker, _ = start(args.server_python, "worker")
|
|
78
|
+
url = ready["url"]
|
|
79
|
+
assert client(url, "matrix")["matrix"] == "12 combinations passed"
|
|
80
|
+
|
|
81
|
+
# Real independent API and worker restarts, retaining the same DB.
|
|
82
|
+
from annoscript.jobs import AnnoJobClient
|
|
83
|
+
with AnnoJobClient(url, "offline-token") as observer:
|
|
84
|
+
durable = client(url, "submit-durable")
|
|
85
|
+
wait_for(lambda: Path(folder, "model-started").exists())
|
|
86
|
+
stop(worker, kill=True)
|
|
87
|
+
stop(api)
|
|
88
|
+
set_clock(1001) # Expire the abandoned 0.4s worker lease.
|
|
89
|
+
api, restarted = start(args.server_python, "api", "--port", str(urlsplit(url).port))
|
|
90
|
+
assert restarted["url"] == url
|
|
91
|
+
Path(folder, "release-model").touch()
|
|
92
|
+
worker, _ = start(args.server_python, "worker")
|
|
93
|
+
wait_for(lambda: observer.status(durable)["state"] == "completed")
|
|
94
|
+
assert client(url, "read-durable")["recovery"] == "passed"
|
|
95
|
+
|
|
96
|
+
# A killed simplified client cannot finalize; the durable owner
|
|
97
|
+
# lease must cancel its outstanding job after virtual time moves.
|
|
98
|
+
Path(folder, "release-model").unlink()
|
|
99
|
+
Path(folder, "model-started").unlink(missing_ok=True)
|
|
100
|
+
owner, owned = start(args.client_python, "client", "--url", url, "--action", "hold-session")
|
|
101
|
+
wait_for(lambda: Path(folder, "model-started").exists())
|
|
102
|
+
stop(owner, kill=True)
|
|
103
|
+
set_clock(1302)
|
|
104
|
+
status = observer.status(owned["job_id"])
|
|
105
|
+
assert status["state"] == "cancelled", status
|
|
106
|
+
page = observer.fetch_results(owned["job_id"])
|
|
107
|
+
assert page["results"][0]["error"]["code"] == "session_expired"
|
|
108
|
+
finally:
|
|
109
|
+
for process in reversed(processes):
|
|
110
|
+
stop(process)
|
|
111
|
+
for pipe in (process.stdin, process.stdout, process.stderr):
|
|
112
|
+
if pipe is not None:
|
|
113
|
+
pipe.close()
|
|
114
|
+
print("Python 3.10 durable client -> Python 3.12 API/worker: OK (12 combinations, lost responses, restarts, killed owner)")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|