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/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()