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,428 @@
1
+ """Offline wire, scheduling, bounded repair and car acceptance for LLM batches."""
2
+
3
+ import asyncio
4
+ import json
5
+ import unittest
6
+
7
+ import httpx
8
+
9
+ from annoscript._test_evaluation_http import llm_calls, llm_content, llm_wire_id
10
+ from annoscript.backends import InferenceContext, InferenceError, BackendFatalError
11
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
12
+ from annoscript.backends.base import normalize_call, encode_payload
13
+ from annoscript.backends._bounded_grammar import grammar
14
+ def batch_grammar(calls): return grammar(calls, batch=True)
15
+ from annoscript.compiler import compile_program
16
+ from annoscript.anno import GenerateResponse, GenerateListResponse, ExtractResponse
17
+ from annoscript.plan_lowering import lower_to_plan
18
+ from annoscript.runtime import AnnoRuntime, RunInput
19
+ from annoscript.test_runtime import FakeBackend
20
+
21
+
22
+ def mixed_generation(anno):
23
+ title = anno.generate("标题", max_length=8, max_num_tokens=16).text
24
+ labels = anno.generate_list("维度", max_items=2, max_num_tokens=32, max_length_per_item=58).items
25
+ places = anno.extract("地点", min_items=1, max_items=8, max_length_per_item=58).items
26
+ return [title, labels, places]
27
+
28
+
29
+ def independent_generation(anno):
30
+ a = anno.generate("first", max_length=512).text
31
+ b = anno.generate("second", max_length=512).text
32
+ c = anno.generate("third", max_length=512).text
33
+ return [a, b, c]
34
+
35
+
36
+ def dependent_generation(anno):
37
+ a = anno.generate("first", max_length=512).text
38
+ b = anno.generate("second", max_length=512).text
39
+ return [a, b, anno.generate("dependent:" + a, max_length=512).text]
40
+
41
+
42
+ def conditional_generation(anno):
43
+ gate = anno.generate("no", max_length=512).text
44
+ if gate == "yes":
45
+ return anno.generate("inactive", max_length=512).text
46
+ return gate
47
+
48
+
49
+ def generation_loop(anno):
50
+ result = []
51
+ for item in anno.state:
52
+ result = result + [anno.generate(item, max_length=512).text]
53
+ return result
54
+
55
+
56
+ def reused_generation(anno):
57
+ a = anno.generate("same", max_length=512).text
58
+ b = anno.generate("same", max_length=512).text
59
+ return [a, b, anno.generate("different", max_length=512).text]
60
+
61
+
62
+ def answer(context, call):
63
+ if call.operation == "generate":
64
+ return GenerateResponse(call.arguments["instructions"])
65
+ if call.operation == "generate_list":
66
+ return GenerateListResponse(["条目"])
67
+ return ExtractResponse(["广州"])
68
+
69
+
70
+ def completion(content, *, finish="stop", refusal=None):
71
+ return httpx.Response(200, json={"choices": [{"finish_reason": finish,
72
+ "message": {"content": content, "refusal": refusal}}],
73
+ "usage": {"prompt_tokens": 13, "completion_tokens": 5}})
74
+
75
+
76
+ def wire(handler=None, *, profile="legacy"):
77
+ requests, sizes = [], []
78
+ async def handle(request):
79
+ body = json.loads(request.content)
80
+ requests.append(body)
81
+ sizes.append(len(request.content))
82
+ if handler is not None:
83
+ response = handler(body)
84
+ if hasattr(response, "__await__"):
85
+ response = await response
86
+ if response is not None:
87
+ return response
88
+ return completion(llm_content(body, answer))
89
+ backend = LLMAPIBackend("https://offline.invalid/v1", "fixture", "fixture", profile=profile,
90
+ transport=httpx.MockTransport(handle))
91
+ backend.test_request_sizes = sizes
92
+ return backend, requests
93
+
94
+
95
+ async def execute(function=independent_generation, state="state", *, handler=None, profile="legacy",
96
+ mode="eager", inputs=None, **options):
97
+ backend, requests = wire(handler, profile=profile)
98
+ async with AnnoRuntime(FakeBackend(), backend, trace=True, **options) as runtime:
99
+ plan = lower_to_plan(compile_program(function))
100
+ stream = await runtime.submit(plan, inputs or [RunInput(0, state)], mode=mode)
101
+ results = [r async for r in stream]
102
+ return results, requests, runtime.trace
103
+
104
+
105
+ class BatchBackendTests(unittest.IsolatedAsyncioTestCase):
106
+ def calls(self):
107
+ context = InferenceContext({"text": "背景", "places": "广州与北京"}, "共同规则")
108
+ calls = [normalize_call("a", "generate", ("标题",), {"max_num_tokens": 16, 'max_length': 512}, context),
109
+ normalize_call("b", "generate_list", ("维度",), {"max_num_tokens": 32, 'max_items': 8, 'max_length_per_item': 58}, context),
110
+ normalize_call("c", "extract", ("地点",), {"source_field": "places", 'max_items': 8, 'max_length_per_item': 58}, context),
111
+ normalize_call("d", "extract", ("城市",), {"source_field": "places", 'max_items': 8, 'max_length_per_item': 58}, context)]
112
+ return context, calls
113
+
114
+ async def test_mixed_profiles_source_dedup_reordering_and_exact_bytes(self):
115
+ context, calls = self.calls()
116
+ for profile in ("legacy", "qwen_vllm"):
117
+ def reverse(body):
118
+ value = json.loads(llm_content(body, answer))
119
+ value["answers"] = dict(reversed(list(value["answers"].items())))
120
+ return completion(json.dumps(value, ensure_ascii=False))
121
+ backend, requests = wire(reverse, profile=profile)
122
+ try:
123
+ plan = backend.plan_batch(context, calls, max_calls=8, max_input_bytes=262144,
124
+ max_output_tokens=16384, default_tokens=1024)
125
+ self.assertEqual(plan.max_tokens, 64 + 16 + 32 + 1024 * 2 + 32 * 4)
126
+ self.assertEqual(requests, [])
127
+ result = await backend.infer_batch(context, plan.calls, max_tokens=plan.max_tokens)
128
+ self.assertEqual(result.responses["a"].text, "标题")
129
+ self.assertEqual(result.responses["c"].items, ["广州"])
130
+ self.assertEqual(result.errors, {})
131
+ body = requests[0]
132
+ self.assertEqual(len(encode_payload(body)), plan.input_bytes)
133
+ self.assertEqual(backend.test_request_sizes, [plan.input_bytes])
134
+ payload = json.loads(body["messages"][-1]["content"])
135
+ self.assertEqual(payload, {"text": "背景", "places": "广州与北京"})
136
+ self.assertNotIn("sources", payload)
137
+ self.assertIn('输入的顶层字段 "places"', body["messages"][0]["content"])
138
+ if profile == "qwen_vllm":
139
+ self.assertEqual([m["role"] for m in body["messages"]], ["system", "user"])
140
+ self.assertTrue(body["messages"][0]["content"].startswith("共同规则\n"))
141
+ self.assertEqual(body["structured_outputs"], {"grammar": batch_grammar(calls)})
142
+ self.assertEqual(body["chat_template_kwargs"], {"enable_thinking": False})
143
+ else:
144
+ self.assertTrue(body["messages"][0]["content"].startswith("共同规则\n"))
145
+ self.assertEqual(body["response_format"], {"type": "json_object"})
146
+ self.assertEqual(body["thinking"], {"type": "disabled"})
147
+ finally:
148
+ await backend.aclose()
149
+
150
+ async def test_planning_boundaries_do_not_truncate_or_expand_individual_calls(self):
151
+ context, calls = self.calls()
152
+ backend, requests = wire()
153
+ limits = dict(max_calls=8, max_input_bytes=262144, max_output_tokens=16384, default_tokens=1024)
154
+ two = backend.plan_batch(context, calls[:2], **limits)
155
+ for change in ({"max_calls": 2}, {"max_output_tokens": two.max_tokens}, {"max_input_bytes": two.input_bytes}):
156
+ plan = backend.plan_batch(context, calls, **(limits | change))
157
+ self.assertEqual(plan.calls, tuple(calls[:2]))
158
+ for change in ({"max_calls": 1}, {"max_output_tokens": two.max_tokens - 1}, {"max_input_bytes": two.input_bytes - 1}):
159
+ self.assertIsNone(backend.plan_batch(context, calls[:2], **(limits | change)))
160
+ self.assertEqual(requests, [])
161
+ await backend.aclose()
162
+
163
+ async def test_batch_thinking_override_can_be_omitted(self):
164
+ context, calls = self.calls()
165
+ for profile in ("legacy", "qwen_vllm"):
166
+ backend, requests = wire(profile=profile)
167
+ backend.disable_thinking = False
168
+ try:
169
+ await backend.infer_batch(context, calls, max_tokens=4096)
170
+ self.assertNotIn("thinking", requests[0])
171
+ self.assertNotIn("chat_template_kwargs", requests[0])
172
+ finally:
173
+ await backend.aclose()
174
+
175
+ async def test_per_item_errors_preserve_good_results(self):
176
+ context, calls = self.calls()
177
+ def invalid(body):
178
+ value = json.loads(llm_content(body, answer))
179
+ del value["answers"]["q2"]
180
+ value["answers"]["q3"] = ["深圳"]
181
+ value["answers"]["q4"] = "wrong value type"
182
+ return completion(json.dumps(value))
183
+ backend, _ = wire(invalid)
184
+ try:
185
+ result = await backend.infer_batch(context, calls, max_tokens=2048)
186
+ self.assertEqual(set(result.responses), {"a"})
187
+ self.assertEqual(set(result.errors), {"b", "c", "d"})
188
+ self.assertTrue(all(e.call_id == key for key, e in result.errors.items()))
189
+ finally:
190
+ await backend.aclose()
191
+
192
+ async def test_unreliable_mapping_truncation_refusal_and_http_classification(self):
193
+ context, calls = self.calls()
194
+ cases = [
195
+ (completion('{"answers":{'), "invalid_batch_output"),
196
+ (completion('{"answers":{"q1":[],"q1":[]}}'), "invalid_batch_output"),
197
+ (completion('{"answers":{"unknown":{}}}'), "invalid_batch_output"),
198
+ (completion('{"answers":{}}', finish="length"), "incomplete_batch_generation"),
199
+ (completion(None, refusal="no"), "model_refusal"),
200
+ (completion(None, finish="content_filter"), "model_refusal"),
201
+ (httpx.Response(200, json={"choices": [{"finish_reason": "content_filter"}]}), "model_refusal"),
202
+ (httpx.Response(429, json={}), "model_http_error"),
203
+ (httpx.Response(200, text="not JSON"), "invalid_response"),
204
+ ]
205
+ for response, code in cases:
206
+ backend, requests = wire(lambda _: response)
207
+ try:
208
+ with self.subTest(code=code), self.assertRaises(InferenceError) as caught:
209
+ await backend.infer_batch(context, calls, max_tokens=1024)
210
+ self.assertEqual(caught.exception.code, code)
211
+ self.assertEqual(len(requests), 1)
212
+ finally:
213
+ await backend.aclose()
214
+
215
+ async def test_unicode_and_all_per_item_contracts(self):
216
+ source = ' 中文😀 "引号" \\路径\n换行\t '
217
+ context = InferenceContext(source)
218
+ calls = [normalize_call("a", "generate", ("text",), {"min_length": 1, "max_length": len(source)}, context),
219
+ normalize_call("b", "generate_list", ("list",), {"min_items": 1, "max_items": 1, "max_length_per_item": len(source)}, context),
220
+ normalize_call("c", "extract", ("source",), {"min_items": 1, "max_length_per_item": len(source), 'max_items': 8}, context)]
221
+ valid = {"q1": source, "q2": [source], "q3": [source]}
222
+ for ascii_only in (False, True):
223
+ backend, _ = wire(lambda _: completion(json.dumps({"answers": valid}, ensure_ascii=ascii_only)))
224
+ try:
225
+ result = await backend.infer_batch(context, calls, max_tokens=1024)
226
+ self.assertEqual(result.responses["a"].text, source)
227
+ self.assertEqual(result.responses["c"].items, [source])
228
+ finally:
229
+ await backend.aclose()
230
+ for key, identity, value in (("q1", "a", None), ("q1", "a", ""),
231
+ ("q2", "b", []), ("q2", "b", [source, source]),
232
+ ("q3", "c", [""])):
233
+ invalid = json.loads(json.dumps(valid))
234
+ invalid[key] = value
235
+ backend, _ = wire(lambda _: completion(json.dumps({"answers": invalid})))
236
+ try:
237
+ result = await backend.infer_batch(context, calls, max_tokens=1024)
238
+ self.assertEqual(set(result.errors), {identity})
239
+ finally:
240
+ await backend.aclose()
241
+
242
+
243
+ class BatchRuntimeTests(unittest.IsolatedAsyncioTestCase):
244
+ async def test_default_mixed_batch_and_disable_restores_original_wire(self):
245
+ for profile in ("legacy", "qwen_vllm"):
246
+ results, requests, trace = await execute(mixed_generation, state="广州与北京", profile=profile)
247
+ self.assertEqual(results[0].value, ["标题", ["条目"], ["广州"]])
248
+ self.assertEqual(len(requests), 1)
249
+ self.assertEqual(requests[0]["max_tokens"], 64 + 16 + 32 + 1024 + 32 * 3)
250
+ self.assertEqual(sum(e.get("usage", {}).get("prompt_tokens", 0) for e in trace), 13)
251
+ results, requests, _ = await execute(mixed_generation, state="广州与北京", profile=profile, max_llm_batch_calls=1)
252
+ self.assertEqual(len(requests), 3)
253
+ self.assertEqual([r.get("max_tokens") for r in requests], [16, 32, 1024])
254
+ self.assertTrue(all(not llm_calls(r)[0] for r in requests))
255
+
256
+ async def test_count_byte_budget_and_oversized_single_fallback(self):
257
+ _, reference, _ = await execute(max_llm_batch_calls=2)
258
+ two_call_bytes = len(encode_payload(reference[0]))
259
+ for options in ({"max_llm_batch_calls": 2}, {"max_llm_batch_output_tokens": 64 + 2 * (1024 + 32)},
260
+ {"max_llm_batch_input_bytes": two_call_bytes}):
261
+ results, requests, _ = await execute(**options)
262
+ self.assertEqual(results[0].value, ["first", "second", "third"])
263
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [2, 1])
264
+ for options in ({"max_llm_batch_input_bytes": 1}, {"max_llm_batch_output_tokens": 1}):
265
+ results, requests, _ = await execute(**options)
266
+ self.assertEqual(results[0].status, "success")
267
+ self.assertEqual(len(requests), 3)
268
+ self.assertTrue(all(r["max_tokens"] == 1024 for r in requests))
269
+
270
+ async def test_dependencies_strict_activation_reuse_and_cross_input_isolation(self):
271
+ _, requests, _ = await execute(dependent_generation)
272
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [2, 1])
273
+ self.assertEqual(llm_calls(requests[-1])[1][0][1].arguments["instructions"], "dependent:first")
274
+ _, requests, _ = await execute(mode="strict")
275
+ self.assertEqual([llm_calls(r)[1][0][1].arguments["instructions"] for r in requests], ["first", "second", "third"])
276
+ _, requests, _ = await execute(conditional_generation)
277
+ self.assertEqual(len(requests), 1)
278
+ results, requests, _ = await execute(reused_generation)
279
+ self.assertEqual(results[0].value, ["same", "same", "different"])
280
+ self.assertEqual(len(llm_calls(requests[0])[1]), 2)
281
+ results, requests, _ = await execute(inputs=[RunInput(0, "same"), RunInput(1, "same")])
282
+ self.assertEqual(len(results), 2)
283
+ self.assertEqual(len(requests), 2)
284
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 3])
285
+
286
+ async def test_loops_local_quantum_and_physical_concurrency_bound(self):
287
+ active = peak = 0
288
+ async def handle(body):
289
+ nonlocal active, peak
290
+ active += 1
291
+ peak = max(peak, active)
292
+ await asyncio.sleep(0)
293
+ active -= 1
294
+ results, requests, _ = await execute(generation_loop, [str(i) for i in range(19)], handler=handle,
295
+ max_llm_batch_calls=3, loop_window=5, local_task_budget=20, max_llm_inflight=2)
296
+ self.assertEqual(results[0].value, [str(i) for i in range(19)])
297
+ self.assertLessEqual(peak, 2)
298
+ sizes = [len(llm_calls(r)[1]) for r in requests]
299
+ self.assertEqual(sum(sizes), 19)
300
+ self.assertLessEqual(max(sizes), 3)
301
+ self.assertGreater(max(sizes), 1)
302
+
303
+ async def test_partial_repair_keeps_good_answers_and_records_usage_once(self):
304
+ def fail_one(body):
305
+ if llm_calls(body)[0]:
306
+ value = json.loads(llm_content(body, answer))
307
+ del value["answers"][next(iter(value["answers"]))]
308
+ return completion(json.dumps(value))
309
+ results, requests, trace = await execute(handler=fail_one)
310
+ self.assertEqual(results[0].value, ["first", "second", "third"])
311
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1])
312
+ self.assertEqual(llm_calls(requests[1])[1][0][1].arguments["instructions"], "first")
313
+ self.assertEqual(requests[1]["max_tokens"], 1024)
314
+ self.assertIn("未通过格式或约束检查", requests[1]["messages"][0]["content"])
315
+ submitted = [e for e in trace if e["event"] == "http_submitted"]
316
+ self.assertEqual(submitted[1]["repair_parent"], submitted[0]["batch"])
317
+ self.assertEqual(submitted[1]["repair_attempt"], 1)
318
+ self.assertEqual(sum(e.get("usage", {}).get("prompt_tokens", 0) for e in trace), 26)
319
+
320
+ async def test_unreliable_batch_repairs_every_item_once_and_failure_is_terminal(self):
321
+ for bad in (completion("{"), completion('{"answers":{}}', finish="length")):
322
+ def handle(body):
323
+ return bad if llm_calls(body)[0] else None
324
+ results, requests, _ = await execute(handler=handle)
325
+ self.assertEqual(results[0].status, "success")
326
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1, 1, 1])
327
+ def fail_again(body):
328
+ return completion("{") if llm_calls(body)[0] else completion("", finish="length")
329
+ results, requests, _ = await execute(handler=fail_again, max_llm_inflight=1)
330
+ self.assertEqual(results[0].status, "error")
331
+ self.assertEqual(len(requests), 2)
332
+ results, requests, _ = await execute(handler=lambda _: completion("{"), llm_batch_repair_attempts=0)
333
+ self.assertEqual(results[0].status, "error")
334
+ self.assertEqual(len(requests), 1)
335
+
336
+ async def test_no_content_repair_on_network_errors_or_refusal(self):
337
+ for response in (httpx.Response(429, json={}), completion(None, refusal="no"),
338
+ httpx.Response(200, json={"choices": [{"finish_reason": "content_filter"}]})):
339
+ results, requests, _ = await execute(handler=lambda _: response)
340
+ self.assertEqual(results[0].status, "error")
341
+ self.assertEqual(len(requests), 1)
342
+ for error in (httpx.ReadTimeout("offline"), httpx.ReadError("offline")):
343
+ def fail(body):
344
+ raise error
345
+ results, requests, _ = await execute(handler=fail)
346
+ self.assertEqual(results[0].status, "error")
347
+ self.assertEqual(len(requests), 1)
348
+ def disconnected(body):
349
+ raise httpx.ConnectError("offline")
350
+ backend, requests = wire(disconnected)
351
+ async with AnnoRuntime(FakeBackend(), backend) as runtime:
352
+ stream = await runtime.submit(lower_to_plan(compile_program(independent_generation)), [RunInput(0, "state")])
353
+ with self.assertRaises(BackendFatalError):
354
+ [r async for r in stream]
355
+ self.assertEqual(len(requests), 1)
356
+
357
+ async def test_repair_preserves_explicit_budget_task_state_and_source(self):
358
+ for operation in ("generate", "generate_list", "extract"):
359
+ def invalid(body):
360
+ batched, calls = llm_calls(body)
361
+ if batched:
362
+ value = json.loads(llm_content(body, answer))
363
+ call = next(c for _, c in calls if c.operation == operation)
364
+ value["answers"][llm_wire_id(body, call.id)] = {"type": "wrong"}
365
+ return completion(json.dumps(value))
366
+ results, requests, trace = await execute(mixed_generation, state="广州与北京", handler=invalid, max_llm_inflight=1)
367
+ self.assertEqual(results[0].status, "success")
368
+ self.assertEqual(len(requests), 2)
369
+ context, repair = llm_calls(requests[1])[1][0]
370
+ original = next(c for _, c in llm_calls(requests[0])[1] if c.operation == operation)
371
+ self.assertEqual(repair.arguments, original.arguments)
372
+ self.assertEqual(context.state, "广州与北京")
373
+ self.assertEqual(requests[1]["max_tokens"], {"generate": 16, "generate_list": 32, "extract": 1024}[operation])
374
+ self.assertNotIn("content", str(trace))
375
+
376
+ async def test_strict_failure_order_and_fairness_across_runs(self):
377
+ results, requests, _ = await execute(mode="strict", handler=lambda _: completion("", finish="length"))
378
+ self.assertEqual(results[0].status, "error")
379
+ self.assertEqual(len(requests), 1)
380
+ _, requests, _ = await execute(inputs=[RunInput(0, "A"), RunInput(1, "B")],
381
+ max_llm_batch_calls=2, max_llm_inflight=1)
382
+ self.assertEqual([llm_calls(r)[1][0][0].state for r in requests], ["A", "B", "A", "B"])
383
+
384
+ async def test_repairs_precede_ready_calls_and_other_runs(self):
385
+ def invalid(body):
386
+ batched, calls = llm_calls(body)
387
+ if batched and calls[0][0].state == "A":
388
+ value = json.loads(llm_content(body, answer))
389
+ del value["answers"][llm_wire_id(body, calls[1][1].id)]
390
+ return completion(json.dumps(value))
391
+ results, requests, _ = await execute(dependent_generation, state="A", handler=invalid, max_llm_inflight=1)
392
+ self.assertEqual(results[0].status, "success")
393
+ self.assertEqual([llm_calls(r)[1][-1][1].arguments["instructions"] for r in requests],
394
+ ["second", "second", "dependent:first"])
395
+ results, requests, _ = await execute(inputs=[RunInput(0, "A"), RunInput(1, "B")],
396
+ handler=invalid, max_llm_inflight=1)
397
+ self.assertTrue(all(r.status == "success" for r in results))
398
+ self.assertEqual([llm_calls(r)[1][0][0].state for r in requests], ["A", "A", "B"])
399
+
400
+
401
+ async def test_cancellation_during_repair_stops_further_dispatch(self):
402
+ started, cancelled = asyncio.Event(), asyncio.Event()
403
+ async def handle(body):
404
+ if llm_calls(body)[0]:
405
+ return completion("{")
406
+ started.set()
407
+ try:
408
+ await asyncio.Event().wait()
409
+ finally:
410
+ cancelled.set()
411
+ backend, requests = wire(handle)
412
+ async with AnnoRuntime(FakeBackend(), backend, max_llm_inflight=1) as runtime:
413
+ stream = await runtime.submit(lower_to_plan(compile_program(independent_generation)), [RunInput(0, "state")])
414
+ await asyncio.wait_for(started.wait(), 2)
415
+ await stream.aclose()
416
+ await asyncio.wait_for(cancelled.wait(), 2)
417
+ self.assertEqual(len(requests), 2)
418
+
419
+ async def test_configuration_validation(self):
420
+ for kwargs in ({"max_llm_batch_calls": 0}, {"max_llm_batch_input_bytes": True},
421
+ {"max_llm_batch_output_tokens": -1}, {"llm_batch_default_tokens": 0},
422
+ {"llm_batch_repair_attempts": 2}, {"llm_batch_repair_attempts": False}):
423
+ with self.assertRaises(ValueError):
424
+ AnnoRuntime(FakeBackend(), FakeBackend(), **kwargs)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ unittest.main()
@@ -0,0 +1,59 @@
1
+ """Client-only metric wire/usage contracts; no Runtime or server imports."""
2
+
3
+ from copy import deepcopy
4
+ import unittest
5
+
6
+ import httpx
7
+
8
+ from annoscript.jobs import AnnoJobClient
9
+ from annoscript.metrics import aggregate, normalize_usage, quantile, validate_metrics
10
+ from annoscript.remote import RemoteProtocolError
11
+
12
+
13
+ def fixture():
14
+ job = {"job_id": "job", "ir_sha256": "a" * 64, "release_id": "b" * 64, "mode": "eager", "input_indices": [3],
15
+ "state": "completed", "counts": {"total": 1, "completed": 1, "success": 1, "error": 0},
16
+ "pause_generation": 0, "pause_reason": None, "created_at": 0, "updated_at": 1}
17
+ attempt = {"metered": 1, "status": "finished", "duration_seconds": 1}
18
+ request = {"backend": "generation", "questions": 1, "http_status": 200, "seconds": .5,
19
+ "usage": normalize_usage({"prompt_tokens": 10, "completion_tokens": 2})}
20
+ summary = aggregate([attempt], [request], history=True, waits=[.1])
21
+ return {"protocol_version": 1, "metrics_version": 1, "job": job, "provisional": False,
22
+ "summary": summary, "inputs": [{"index": 3, "metrics": deepcopy(summary)}]}
23
+
24
+
25
+ class MetricContracts(unittest.TestCase):
26
+ def test_usage_aliases_details_missing_invalid_conflicts(self):
27
+ for usage, value, reason in (({}, None, "missing"), ({"input_tokens": True}, None, "invalid"),
28
+ ({"input_tokens": -1}, None, "invalid"), ({"input_tokens": 1.5}, None, "invalid"),
29
+ ({"input_tokens": "1"}, None, "invalid"), ({"input_tokens": 1, "prompt_tokens": 2}, None, "conflicting"),
30
+ ({"input_tokens": 2, "prompt_tokens": 2}, 2, None), ({"prompt_tokens": 0}, 0, None)):
31
+ item = normalize_usage(usage)["input_tokens"]
32
+ self.assertEqual((item["value"], item["reason"]), (value, reason))
33
+ result = normalize_usage({"completion_tokens": 5, "total_tokens": 999, "completion_tokens_details": {"reasoning_tokens": 4}})
34
+ self.assertEqual(result["output_tokens"]["value"], 5)
35
+
36
+ def test_client_binding_and_invalid_responses(self):
37
+ payload = fixture()
38
+ with AnnoJobClient("http://offline", transport=httpx.MockTransport(lambda _: httpx.Response(200, json=payload))) as client:
39
+ self.assertEqual(client.metrics(payload["job"]), payload)
40
+ changed = {**payload["job"], "release_id": "c" * 64}
41
+ with self.assertRaises(RemoteProtocolError):
42
+ client.metrics(changed)
43
+ mutations = [lambda p: p.update(metrics_version=True), lambda p: p.update(provisional=True),
44
+ lambda p: p["job"].update(state=[]), lambda p: p.update(inputs=[None]),
45
+ lambda p: p["inputs"][0].update(index=4), lambda p: p["summary"]["backends"]["generation"].update(requests=2),
46
+ lambda p: p["summary"]["backends"]["generation"]["input_tokens"].update(total=0),
47
+ lambda p: p["summary"]["attempts"]["seconds"].update(p95=-1)]
48
+ for mutation in mutations:
49
+ bad = fixture(); mutation(bad)
50
+ with self.assertRaises(RemoteProtocolError):
51
+ validate_metrics(bad, "job")
52
+
53
+ def test_unknown_history_never_becomes_zero(self):
54
+ metric = aggregate([], [], history=False, waits=[None])
55
+ self.assertIsNone(metric["backends"]["generation"]["input_tokens"]["total"])
56
+ self.assertIsNone(metric["backends"]["generation"]["total_requests"])
57
+ self.assertIsNone(metric["attempts"]["seconds"]["total"])
58
+ self.assertEqual(quantile([1, 2, 3, 4], .95), 4)
59
+ self.assertIsNone(quantile([], .5))