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
@@ -0,0 +1,371 @@
1
+ """Client-only durable job tests: deterministic HTTP, no execution imports."""
2
+
3
+ from copy import deepcopy
4
+ import gc
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import unittest
10
+ from unittest.mock import patch
11
+ import weakref
12
+
13
+ import httpx
14
+
15
+ from annoscript.client import AnnoClient, precondition, require
16
+ from annoscript.compiler import compile_program
17
+ from annoscript.job_protocol import cursor_for, cursor_seq, request_sha256, validate_page
18
+ from annoscript.jobs import (AnnoJobClient, RemoteCancellationError, RemoteJobPausedError,
19
+ RemoteProtocolError, RemoteSubmissionError, RemoteTransportError)
20
+
21
+
22
+ def generate(anno):
23
+ return anno.generate(anno.state, max_length=512).text
24
+
25
+
26
+ def check(state):
27
+ require(state != "invalid", message="invalid record")
28
+
29
+
30
+ @precondition(check)
31
+ def checked(anno):
32
+ return anno.generate(anno.state, max_length=512).text
33
+
34
+
35
+ class Clock:
36
+ def __init__(self):
37
+ self.now, self.sleeps = 1000.0, []
38
+
39
+ def __call__(self):
40
+ return self.now
41
+
42
+ def sleep(self, seconds):
43
+ self.sleeps.append(seconds)
44
+ self.now += seconds
45
+
46
+
47
+ class Wire:
48
+ def __init__(self, *, values=None, reverse=False):
49
+ self.requests, self.jobs, self.keys, self.sessions = [], {}, {}, {}
50
+ self.values, self.reverse = values, reverse
51
+ self.failures, self.page_transform = [], None
52
+ self.lose_accept = False
53
+ self.paused = False
54
+ self.job_counter = 0
55
+
56
+ def response(self, value, status=200, headers=None):
57
+ return httpx.Response(status, json=value, headers=headers)
58
+
59
+ def session(self, sid):
60
+ return {"protocol_version": 1, "session_id": sid, "state": self.sessions[sid],
61
+ "expires_at": 1300.0, "heartbeat_interval": 30.0}
62
+
63
+ def __call__(self, request):
64
+ self.requests.append(request)
65
+ if self.failures:
66
+ failure = self.failures.pop(0)
67
+ if isinstance(failure, BaseException):
68
+ raise failure
69
+ if failure is not None:
70
+ return failure
71
+ path = request.url.path
72
+ if path == "/v1/sessions":
73
+ sid = request.headers["Idempotency-Key"]
74
+ self.sessions.setdefault(sid, "active")
75
+ return self.response(self.session(sid), 201)
76
+ if path.startswith("/v1/sessions/"):
77
+ sid, op = path.rsplit("/", 1)[1].split(":")
78
+ if op == "close":
79
+ self.sessions[sid] = "closed"
80
+ for jid in self.jobs:
81
+ self.jobs[jid]["job"]["state"] = "cancelled"
82
+ return self.response(self.session(sid))
83
+ if path == "/v1/jobs" and request.method == "POST":
84
+ payload = json.loads(request.content)
85
+ key = request.headers["Idempotency-Key"]
86
+ if key not in self.keys:
87
+ self.job_counter += 1
88
+ jid = str(self.job_counter)
89
+ items = payload["inputs"]
90
+ values = self.values if self.values is not None else [v["state"] for v in items]
91
+ rows = [{"index": item["index"], "status": "success", "value": value,
92
+ "error": None, "attempts": 1} for item, value in zip(items, values)]
93
+ if self.reverse:
94
+ rows.reverse()
95
+ for n, row in enumerate(rows, 1):
96
+ row["seq"] = n
97
+ job = {"job_id": jid, "ir_sha256": payload["ir_sha256"], "release_id": "fixed-release",
98
+ "mode": payload["mode"], "input_indices": [i["index"] for i in items],
99
+ "state": "paused" if self.paused else "completed",
100
+ "counts": {"total": len(items), "completed": 0 if self.paused else len(rows),
101
+ "success": 0 if self.paused else len(rows), "error": 0},
102
+ "pause_generation": 1 if self.paused else 0,
103
+ "pause_reason": {"code": "model_timeout", "message": "budget exhausted"} if self.paused else None,
104
+ "created_at": 1000.0, "updated_at": 1000.0}
105
+ self.keys[key] = jid
106
+ self.jobs[jid] = {"job": job, "rows": [] if self.paused else rows, "request": payload}
107
+ job = self.jobs[self.keys[key]]["job"]
108
+ if self.lose_accept:
109
+ self.lose_accept = False
110
+ raise httpx.ReadError("lost acceptance")
111
+ return self.response({"protocol_version": 1, "job": job}, 202)
112
+ if path == "/v1/jobs:lookup":
113
+ job = self.jobs[self.keys[request.headers["Idempotency-Key"]]]["job"]
114
+ return self.response({"protocol_version": 1, "job": job})
115
+ jid = path.split("/")[3].split(":")[0]
116
+ job = self.jobs[jid]["job"]
117
+ if path.endswith(":cancel"):
118
+ job["state"], job["pause_reason"] = "cancelled", None
119
+ job["counts"].update(completed=job["counts"]["total"], error=job["counts"]["total"], success=0)
120
+ return self.response({"protocol_version": 1, "job": job})
121
+ if path.endswith("/results"):
122
+ after = cursor_seq(request.url.params.get("cursor"), jid)
123
+ # Small pages exercise cursors and ordered buffers across pages.
124
+ rows = self.jobs[jid]["rows"][after:after + 1]
125
+ last = after + len(rows)
126
+ more = last < job["counts"]["completed"]
127
+ page = {"protocol_version": 1, "job": deepcopy(job), "results": deepcopy(rows),
128
+ "next_cursor": cursor_for(jid, last), "has_more": more,
129
+ "done": job["state"] in ("completed", "cancelled", "failed") and not more}
130
+ if self.page_transform:
131
+ self.page_transform(page)
132
+ return self.response(page)
133
+ return self.response({"protocol_version": 1, "job": job})
134
+
135
+
136
+ class ClientTests(unittest.TestCase):
137
+ def client(self, wire=None, **kwargs):
138
+ wire = wire or Wire()
139
+ client = AnnoClient.remote("http://test", transport=httpx.MockTransport(wire), **kwargs)
140
+ clock = Clock()
141
+ low = client._remote.session.client
142
+ low.clock, low.wall_clock, low.sleep, low.random = clock, clock, clock.sleep, lambda: 1.0
143
+ self.addCleanup(client.close)
144
+ return client, wire, clock
145
+
146
+ def test_empty_all_invalid_and_defects_do_not_contact_server(self):
147
+ client, wire, _ = self.client()
148
+ self.assertEqual(list(client.submit(checked, [])), [])
149
+ result = list(client.submit(checked, ["invalid", float("nan")]))
150
+ self.assertEqual([r.error.code for r in result], ["invalid_input"] * 2)
151
+ with patch("annoscript._client_common._PRECONDITIONS", {checked: (lambda state: 1 / 0,)}):
152
+ with self.assertRaises(ZeroDivisionError):
153
+ client.submit(checked, ["a"])
154
+ with patch("annoscript._remote_jobs.compile_program", side_effect=ValueError("compile")):
155
+ with self.assertRaisesRegex(ValueError, "compile"):
156
+ client.submit(checked, ["a"])
157
+ self.assertEqual(wire.requests, [])
158
+
159
+ def test_results_indices_order_and_json_values(self):
160
+ for ordered in (False, True):
161
+ client, wire, _ = self.client(Wire(values=[None, [1, None], {"中文": True}], reverse=True))
162
+ result = list(client.submit(checked, ["a", "invalid", "b", "c"], ordered=ordered))
163
+ self.assertEqual([r.index for r in result], [0, 1, 2, 3] if ordered else [1, 3, 2, 0])
164
+ values = {r.index: r.value for r in result}
165
+ self.assertIsNone(values[0])
166
+ self.assertEqual(values[2], [1, None])
167
+ self.assertEqual(values[3], {"中文": True})
168
+ self.assertEqual([v["index"] for v in wire.jobs["1"]["request"]["inputs"]], [0, 2, 3])
169
+
170
+ def test_lost_acceptance_uses_identical_request_and_one_compile(self):
171
+ client, wire, clock = self.client()
172
+ wire.lose_accept = True
173
+ with patch("annoscript._remote_jobs.compile_program", wraps=compile_program) as compile:
174
+ self.assertEqual(list(client.submit(generate, ["a"]))[0].value, "a")
175
+ self.assertEqual(compile.call_count, 1)
176
+ posts = [r for r in wire.requests if r.url.path == "/v1/jobs"]
177
+ self.assertEqual(len(posts), 2)
178
+ self.assertEqual(posts[0].content, posts[1].content)
179
+ self.assertEqual(posts[0].headers["Idempotency-Key"], posts[1].headers["Idempotency-Key"])
180
+ self.assertEqual(wire.job_counter, 1)
181
+ self.assertTrue(clock.sleeps)
182
+ list(client.submit(generate, ["a"]))
183
+ self.assertEqual(wire.job_counter, 2)
184
+
185
+ def test_result_disconnect_does_not_duplicate_delivery(self):
186
+ client, wire, _ = self.client(Wire(reverse=True))
187
+ stream = client.submit(generate, ["a", "b", "c"])
188
+ first = next(stream)
189
+ wire.failures = [httpx.ReadError("disconnected")]
190
+ result = [first, *list(stream)]
191
+ self.assertEqual([r.index for r in result], [2, 1, 0])
192
+ gets = [r for r in wire.requests if r.url.path.endswith("/results")]
193
+ self.assertEqual(gets[1].url, gets[2].url)
194
+
195
+ def test_auth_and_protocol_errors_do_not_retry(self):
196
+ for response, exception in ((httpx.Response(401, json={"error": {"code": "auth", "message": "denied"}}), RemoteSubmissionError),
197
+ (httpx.Response(200, content=b"{bad", headers={"Content-Type": "application/json"}), RemoteProtocolError)):
198
+ wire = Wire()
199
+ wire.failures = [response]
200
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
201
+ with self.assertRaises(exception):
202
+ client.status("anything")
203
+ self.assertEqual(len(wire.requests), 1)
204
+
205
+ def test_rate_limit_retry_after_and_exhaustion(self):
206
+ wire, clock = Wire(), Clock()
207
+ wire.failures = [httpx.Response(429, json={"error": {"code": "limited", "message": "later"}}, headers={"Retry-After": "3"})] * 5
208
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(wire), _clock=clock,
209
+ _wall_clock=clock, _sleep=clock.sleep, _random=lambda: 0) as client:
210
+ with self.assertRaises(RemoteSubmissionError) as caught:
211
+ client.status("anything")
212
+ self.assertEqual(caught.exception.http_status, 429)
213
+ self.assertEqual(caught.exception.retry_after, "3")
214
+ self.assertEqual(clock.sleeps, [3] * 4)
215
+ self.assertEqual(len(wire.requests), 5)
216
+
217
+ def test_timeout_retries_are_bounded(self):
218
+ wire, clock = Wire(), Clock()
219
+ wire.failures = [httpx.ReadTimeout("timeout")] * 5
220
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(wire), _clock=clock,
221
+ _sleep=clock.sleep, _random=lambda: 0) as client:
222
+ with self.assertRaises(RemoteTransportError):
223
+ client.status("anything")
224
+ self.assertEqual(len(wire.requests), 5)
225
+
226
+ def test_total_deadline_bounds_a_transport_ignoring_phase_timeout(self):
227
+ started, release, finished = threading.Event(), threading.Event(), threading.Event()
228
+ def slow(request):
229
+ started.set()
230
+ release.wait(2)
231
+ finished.set()
232
+ raise httpx.ReadTimeout("late transport")
233
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(slow)) as client:
234
+ try:
235
+ with self.assertRaisesRegex(RemoteTransportError, "deadline"):
236
+ client._request("GET", "/v1/jobs/unknown", budget=0.03, attempts=1)
237
+ self.assertTrue(started.is_set())
238
+ self.assertFalse(finished.is_set())
239
+ finally:
240
+ release.set()
241
+ self.assertTrue(finished.wait(1))
242
+
243
+ def test_duplicate_index_across_pages_fails_after_partial_delivery(self):
244
+ client, wire, _ = self.client()
245
+ stream = client.submit(generate, ["a", "b"])
246
+ self.assertEqual(next(stream).index, 0)
247
+ wire.page_transform = lambda page: page["results"][0].update(index=0)
248
+ with self.assertRaises(RemoteProtocolError):
249
+ next(stream)
250
+
251
+ def test_lost_acceptance_exhaustion_cancels_unknown_accepted_job(self):
252
+ wire = Wire()
253
+ def disconnect(request):
254
+ response = wire(request)
255
+ if request.url.path == "/v1/jobs":
256
+ raise httpx.ReadError("response always lost")
257
+ return response
258
+ client = AnnoClient.remote("http://test", transport=httpx.MockTransport(disconnect))
259
+ client._remote.session.client.sleep = lambda _: None
260
+ with self.assertRaises(RemoteTransportError):
261
+ client.submit(generate, ["a"])
262
+ self.assertEqual(wire.job_counter, 1)
263
+ self.assertTrue(client.closed)
264
+ self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
265
+ client.close()
266
+
267
+ def test_duplicate_unknown_missing_and_changed_bindings_fail(self):
268
+ transforms = [lambda p: p["results"][0].update(index=99),
269
+ lambda p: p.update(next_cursor=cursor_for(p["job"]["job_id"], 9)),
270
+ lambda p: p["job"].update(release_id="different"),
271
+ lambda p: p["job"].update(state="running"),
272
+ lambda p: p.update(done=True),
273
+ lambda p: p["results"].append(deepcopy(p["results"][0]))]
274
+ for transform in transforms:
275
+ client, wire, _ = self.client()
276
+ stream = client.submit(generate, ["a", "b"])
277
+ wire.page_transform = transform
278
+ with self.assertRaises(RemoteProtocolError):
279
+ list(stream)
280
+ self.assertTrue(stream.closed)
281
+
282
+ def test_pause_is_explicit_and_closes_submission(self):
283
+ wire = Wire()
284
+ wire.paused = True
285
+ client, _, _ = self.client(wire)
286
+ stream = client.submit(generate, ["a"])
287
+ with self.assertRaises(RemoteJobPausedError):
288
+ list(stream)
289
+ self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
290
+
291
+ def test_one_stream_close_then_client_close(self):
292
+ client, wire, _ = self.client()
293
+ first, second = client.submit(generate, ["a"]), client.submit(generate, ["b"])
294
+ first.close()
295
+ self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
296
+ self.assertNotEqual(wire.jobs["2"]["job"]["state"], "cancelled")
297
+ client.close()
298
+ self.assertTrue(second.closed)
299
+ self.assertEqual(set(wire.sessions.values()), {"closed"})
300
+ client.close()
301
+
302
+ def test_destructor_collects_shell_even_with_live_stream_and_heartbeat(self):
303
+ wire = Wire()
304
+ client = AnnoClient.remote("http://test", transport=httpx.MockTransport(wire))
305
+ stream = client.submit(generate, ["a"])
306
+ session, ref = client._remote.session, weakref.ref(client)
307
+ del client
308
+ gc.collect()
309
+ self.assertIsNone(ref())
310
+ self.assertTrue(session.closed)
311
+ self.assertTrue(stream.closed)
312
+ self.assertEqual(set(wire.sessions.values()), {"closed"})
313
+ session.thread.join(1)
314
+ self.assertFalse(session.thread.is_alive())
315
+
316
+ def test_explicit_close_timeout_is_reported_and_business_exception_survives(self):
317
+ client, wire, _ = self.client()
318
+ stream = client.submit(generate, ["a"])
319
+ with patch.object(client._remote.session.client, "_close_session", side_effect=httpx.ReadTimeout("timeout")):
320
+ with self.assertRaises(RemoteCancellationError):
321
+ client.close()
322
+ self.assertTrue(stream.closed)
323
+ client, wire, _ = self.client()
324
+ with patch.object(client._remote, "close", side_effect=RemoteCancellationError("cleanup")):
325
+ with self.assertLogs("annoscript.client", level="WARNING"):
326
+ with self.assertRaisesRegex(ValueError, "business"):
327
+ with client:
328
+ raise ValueError("business")
329
+ # The patched close deliberately bypassed real resource cleanup.
330
+ client._remote.session.close(1)
331
+
332
+ def test_close_low_level_client_does_not_cancel_job(self):
333
+ wire = Wire()
334
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
335
+ ir = compile_program(generate)
336
+ job = client.submit_ir(ir, [{"index": 2, "state": "a"}], idempotency_key="stable")
337
+ self.assertFalse(any(r.url.path.endswith(":cancel") for r in wire.requests))
338
+ with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
339
+ self.assertEqual(client.lookup("stable"), job)
340
+
341
+ def test_input_object_order_is_part_of_request_identity(self):
342
+ payload = {"protocol_version": 1, "ir_sha256": "hash", "mode": "eager",
343
+ "inputs": [{"index": 0, "state": {"a": 1, "b": 2}}]}
344
+ other = deepcopy(payload)
345
+ other["inputs"][0]["state"] = {"b": 2, "a": 1}
346
+ self.assertNotEqual(request_sha256(payload), request_sha256(other))
347
+
348
+ def test_fresh_process_remote_import_isolation_and_reconnect(self):
349
+ code = '''
350
+ import importlib.abc, sys
351
+ class Block(importlib.abc.MetaPathFinder):
352
+ def find_spec(self, name, *args):
353
+ if name.startswith(("annoscript.runtime", "annoscript.backends", "annoscript.plan", "annoscript.server", "fastapi", "uvicorn", "xgrammar", "vllm")):
354
+ raise AssertionError("forbidden import: " + name)
355
+ sys.meta_path.insert(0, Block())
356
+ from annoscript.test_jobs import Wire, checked, Clock
357
+ from annoscript.client import AnnoClient
358
+ import httpx
359
+ w = Wire(); w.lose_accept = True
360
+ with AnnoClient.remote("http://test", transport=httpx.MockTransport(w)) as c:
361
+ c._remote.session.client.sleep = lambda _: None
362
+ r = list(c.submit(checked, ["a", "invalid"], ordered=True))
363
+ assert [v.index for v in r] == [0, 1]
364
+ assert r[0].value == "a" and r[1].error.code == "invalid_input"
365
+ assert w.job_counter == 1
366
+ '''
367
+ subprocess.run([sys.executable, "-c", code], check=True, timeout=15)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ unittest.main()