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,458 @@
1
+ """Decision-first speculation with selected-path generation and bounded work."""
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+ from dataclasses import asdict, replace
6
+ import json
7
+ import unittest
8
+
9
+ import httpx
10
+
11
+
12
+ from annoscript import test_plan as shapes
13
+ from annoscript import test_plan_constraints as checks
14
+ from annoscript import plan as p
15
+ from annoscript._test_evaluation_http import FixtureLLMBackend, http_backends, llm_calls
16
+ from annoscript.backends import DecisionAPIBackend
17
+ from annoscript.compiler import compile_program
18
+ from annoscript.plan_lowering import lower_to_plan
19
+ from annoscript.plan_formatter import analyze_plan, format_plan_html, format_plan
20
+ from annoscript.runtime import AnnoRuntime, RunInput
21
+ from annoscript.test_runtime import execute, changed_criteria
22
+ from annoscript.test_generation_policy import policy
23
+ from annoscript._dispatch_pool import RequestSlots
24
+ from annoscript.anno import constrain
25
+
26
+
27
+ def arms(anno):
28
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
29
+ if selected == "yes":
30
+ return anno.generate("wanted", min_length=1, max_length=20, max_num_tokens=64).text
31
+ else:
32
+ return anno.generate("unused", min_length=1, max_length=20, max_num_tokens=64).text
33
+
34
+
35
+ def batch_arms(anno):
36
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
37
+ if selected == "yes":
38
+ first = anno.generate("wanted", min_length=1, max_length=20, max_num_tokens=64).text
39
+ second = anno.generate("companion", min_length=1, max_length=20, max_num_tokens=64).text
40
+ return [first, second]
41
+ return anno.generate("unused", max_length=20).text
42
+
43
+
44
+ def alternating(anno):
45
+ outer = anno.choice("outer", {"yes": "yes", "no": "no"}).choice
46
+ first = anno.generate(outer, max_length=20).text
47
+ inner = anno.choice(first, {"yes": "yes", "no": "no"}).choice
48
+ if inner == "yes":
49
+ return anno.generate("wanted", max_length=20).text
50
+ return anno.generate("unused", max_length=20).text
51
+
52
+
53
+ def qwen_gate(anno):
54
+ first = anno.generate("predicate", max_length=20).text
55
+ if first == "yes":
56
+ return anno.generate("wanted", max_length=20).text
57
+ return anno.generate("unused", max_length=20).text
58
+
59
+
60
+ def delayed_branch(anno):
61
+ first = anno.generate("independent", max_length=20).text
62
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
63
+ if selected == "yes":
64
+ instructions = "wanted"
65
+ else:
66
+ instructions = "unused"
67
+ return [first, anno.generate(instructions, max_length=20).text]
68
+
69
+
70
+ def unused_decision(anno):
71
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
72
+ if selected == "no":
73
+ return anno.noul("invalid").noul
74
+ return anno.generate("wanted", max_length=20).text
75
+
76
+
77
+ def unused_arguments(anno):
78
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
79
+ if selected == "no":
80
+ return anno.generate(anno.state["missing"], max_length=-1).text
81
+ return anno.generate("wanted", max_length=20).text
82
+
83
+
84
+ def unused_transport(anno):
85
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
86
+ if selected == "no":
87
+ return anno.generate("unused", max_length=20).text
88
+ return "done"
89
+
90
+
91
+ def nested_and_chain(anno):
92
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
93
+ if selected == "yes":
94
+ if anno.noul("inside").noul > 0.5:
95
+ first = anno.generate("first", max_length=20).text
96
+ return anno.generate(first + " next", max_length=20).text
97
+ return "none"
98
+
99
+
100
+ def gated_loop(anno):
101
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
102
+ result = []
103
+ if selected == "yes":
104
+ for item in anno.state:
105
+ result = result + [anno.generate(item, max_length=20).text]
106
+ return result
107
+
108
+
109
+ def unused_check_and_store(anno):
110
+ data = {"value": "original"}
111
+ selected = anno.choice("gate", {"yes": "yes", "no": "no"}).choice
112
+ if selected == "no":
113
+ data["value"] = "changed"
114
+ constrain(False)
115
+ anno.generate("unused", max_length=20)
116
+ return data
117
+
118
+
119
+ async def run(function, state, decision, generation, *, optimize=True, **options):
120
+ async with AnnoRuntime(decision, generation, trace=True, **options) as runtime:
121
+ plan = lower_to_plan(compile_program(function, optimize=optimize))
122
+ result, = await asyncio.wait_for(collect(runtime, plan, state), 15)
123
+ return result, runtime.trace
124
+
125
+
126
+ async def collect(runtime, plan, state):
127
+ return [r async for r in await runtime.submit(plan, [RunInput(0, state)], mode="speculative")]
128
+
129
+
130
+ def wire_backends(handler):
131
+ transport = httpx.MockTransport(handler)
132
+ return (DecisionAPIBackend("http://offline.invalid/decision", "fixture", "fixture", transport=transport),
133
+ FixtureLLMBackend("http://offline.invalid/v1", "fixture", "fixture",
134
+ profile="qwen_vllm", transport=transport))
135
+
136
+
137
+ def decision_answer(body, *, selected="yes", invalid=False):
138
+ answers = {}
139
+ for identity, question in body["questions"].items():
140
+ if question["type"] == "choice":
141
+ value = selected
142
+ answers[identity] = {"type": "choice", "choice": value, "confidence": 1,
143
+ "probabilities": {k: float(k == value) for k in question["criteria"]}}
144
+ else:
145
+ answers[identity] = {"type": "noul", "noul": "bad" if invalid else 0.8}
146
+ return httpx.Response(200, json={"answers": answers})
147
+
148
+
149
+ def completion(body, answer):
150
+ batched, calls = llm_calls(body)
151
+ answers = {call.arguments.get("field_name", f"q{index}"): answer(call)
152
+ for index, (_, call) in enumerate(calls, 1)}
153
+ content = json.dumps({"answers": answers}) if batched else next(iter(answers.values()))
154
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}]})
155
+
156
+
157
+ class SpeculationTests(unittest.IsolatedAsyncioTestCase):
158
+
159
+
160
+ async def test_existing_memory_loop_and_return_semantics(self):
161
+ cases = [
162
+ (shapes.independent, "s"), (shapes.duplicates, "s"),
163
+ (shapes.branch_duplicates, []), (shapes.branch_duplicates, [1]),
164
+ (shapes.loop_invariant, [True, False, True]),
165
+ (shapes.independent_loop, ["a", "b", "c"]),
166
+ (shapes.last_value, []), (shapes.last_value, ["a", "b"]),
167
+ (shapes.conditional_last, ["a", "", "b"]),
168
+ (shapes.nested_loop, [["a", "b"], [], ["c"]]),
169
+ (shapes.local_container, ["a", "b"]), (shapes.memory_snapshots, "s"),
170
+ (shapes.iteration_alias, ["a", "b", "c"]),
171
+ (shapes.nested_memory, [["a", "b"], [], ["c"]]),
172
+ (shapes.iterator_twice, ["a", "b"]),
173
+ (shapes.early_return, []), (shapes.early_return, [True]),
174
+ (shapes.nested_return, [False]), (shapes.nested_return, [True]),
175
+ (shapes.partial_nested_return, []), (shapes.partial_nested_return, [False]),
176
+ (checks.checked_memory, "s"), (checks.checked_rebinding, "s"),
177
+ (checks.checked_shared, "same"), (checks.checked_memory_loop, ["a", "b"]),
178
+ (changed_criteria, "s"),
179
+ ]
180
+ for function, state in cases:
181
+ with self.subTest(function=function.__name__, state=state):
182
+ expected, _, _ = await execute(function, state, mode="eager", loop_window=2)
183
+ result, _, _ = await asyncio.wait_for(
184
+ execute(function, state, mode="speculative", loop_window=2), 5)
185
+ self.assertEqual(asdict(result), asdict(expected))
186
+
187
+ async def test_only_demanded_invalid_answers_are_repaired(self):
188
+ for fail_wanted in (False, True):
189
+ requests, occurrences = [], {}
190
+
191
+ def handler(request):
192
+ body = json.loads(request.content)
193
+ requests.append(body)
194
+ if "questions" in body:
195
+ return decision_answer(body)
196
+ def answer(call):
197
+ name = call.arguments["instructions"]
198
+ occurrences[name] = occurrences.get(name, 0) + 1
199
+ return "" if name == "unused" or fail_wanted and name == "wanted" and occurrences[name] == 1 else name
200
+ return completion(body, answer)
201
+
202
+ result, trace = await run(batch_arms, "text", *wire_backends(handler))
203
+ self.assertEqual(result.value, ["wanted", "companion"], result.error)
204
+ repairs = [e for e in trace if e.get("repair_attempt")]
205
+ self.assertEqual(len(repairs), int(fail_wanted))
206
+ self.assertNotIn("unused", occurrences)
207
+ self.assertEqual(occurrences["companion"], 1)
208
+
209
+ async def test_jev_partial_errors_and_argument_errors(self):
210
+ for function, state in ((unused_decision, "text"), (unused_arguments, {})):
211
+ def handler(request):
212
+ body = json.loads(request.content)
213
+ if "questions" in body:
214
+ return decision_answer(body, invalid=True)
215
+ return completion(body, lambda c: c.arguments["instructions"])
216
+ result, trace = await run(function, state, *wire_backends(handler))
217
+ self.assertEqual(result.value, "wanted", result.error)
218
+ self.assertFalse(any(e.get("repair_attempt") for e in trace))
219
+ if function is unused_decision:
220
+ self.assertTrue(any(e["event"] == "speculation_error_deferred" for e in trace))
221
+
222
+ async def test_checks_and_stores_commit_only_on_the_selected_path(self):
223
+ def handler(request):
224
+ body = json.loads(request.content)
225
+ if "questions" in body:
226
+ return decision_answer(body)
227
+ return completion(body, lambda _: "unused")
228
+ result, _ = await run(unused_check_and_store, "text", *wire_backends(handler))
229
+ self.assertEqual(result.value, {"value": "original"}, result.error)
230
+
231
+ async def test_cancelled_selected_work_keeps_shared_slot_until_completion(self):
232
+ started, release = asyncio.Event(), asyncio.Event()
233
+ pool = RequestSlots(generation=1, decision=1)
234
+ async def handler(request):
235
+ body = json.loads(request.content)
236
+ if "questions" in body:
237
+ return decision_answer(body, selected="no")
238
+ started.set()
239
+ await release.wait()
240
+ return completion(body, lambda _: "unused")
241
+ decision, generation = wire_backends(handler)
242
+ runtime = AnnoRuntime(decision, generation, request_slots=pool, trace=True)
243
+ try:
244
+ stream = await runtime.submit(lower_to_plan(compile_program(unused_transport)), [RunInput(0, "text")], mode="speculative")
245
+ await asyncio.wait_for(started.wait(), 5)
246
+ await stream.aclose()
247
+ self.assertEqual(pool.active["llm"], 1)
248
+ closing = asyncio.create_task(runtime.aclose())
249
+ await asyncio.sleep(0)
250
+ self.assertFalse(closing.done())
251
+ self.assertEqual(pool.active["llm"], 1)
252
+ release.set()
253
+ await asyncio.wait_for(closing, 5)
254
+ self.assertEqual(pool.active, {"decision": 0, "llm": 0})
255
+ finally:
256
+ release.set()
257
+ await runtime.aclose()
258
+
259
+ async def test_unselected_transport_is_never_called(self):
260
+ for selected in ("yes", "no"):
261
+ generated = []
262
+ async def handler(request):
263
+ body = json.loads(request.content)
264
+ if "questions" in body:
265
+ return decision_answer(body, selected=selected)
266
+ generated.append(body)
267
+ return httpx.Response(503)
268
+ result, _ = await run(unused_transport, "text", *wire_backends(handler))
269
+ self.assertEqual(result.status, "success" if selected == "yes" else "error")
270
+ if selected == "yes":
271
+ self.assertEqual(result.value, "done")
272
+ self.assertEqual(len(generated), int(selected == "no"))
273
+
274
+ async def test_context_limits_split_or_reject_using_actual_payload(self):
275
+ for input_tokens, count, status in ((800, 2, "success"), (961, 0, "error")):
276
+ requests = []
277
+ def handler(request):
278
+ body = json.loads(request.content)
279
+ if "questions" in body:
280
+ return decision_answer(body)
281
+ requests.append(body)
282
+ return completion(body, lambda c: c.arguments["instructions"])
283
+ transport = httpx.MockTransport(handler)
284
+ decision = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=transport)
285
+ generation = FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
286
+ request_policy=policy(1024), _token_counter=lambda _: input_tokens, transport=transport)
287
+ result, _ = await run(batch_arms, "input", decision, generation)
288
+ self.assertEqual(result.status, status, result.error)
289
+ self.assertEqual(len(requests), count)
290
+ if status == "error":
291
+ self.assertEqual(result.error.code, "context_length_exceeded")
292
+ for body in requests:
293
+ self.assertEqual(body["messages"][1]["content"], "input")
294
+ self.assertEqual(body["max_tokens"], 64)
295
+
296
+ async def test_fatal_service_error_is_not_suppressed(self):
297
+ async def handler(request):
298
+ body = json.loads(request.content)
299
+ if "questions" in body:
300
+ return decision_answer(body, selected="no")
301
+ return httpx.Response(401)
302
+ from annoscript.backends import BackendFatalError
303
+ with self.assertRaises(BackendFatalError):
304
+ await run(unused_transport, "text", *wire_backends(handler))
305
+
306
+ async def test_real_data_dependency_waits_for_response(self):
307
+ generated = []
308
+ def handler(request):
309
+ body = json.loads(request.content)
310
+ if "questions" in body:
311
+ return decision_answer(body)
312
+ def answer(call):
313
+ name = call.arguments["instructions"]
314
+ generated.append(name)
315
+ return "response" if name == "first" else name
316
+ return completion(body, answer)
317
+ result, _ = await run(nested_and_chain, "text", *wire_backends(handler))
318
+ self.assertEqual(result.value, "response next", result.error)
319
+ self.assertEqual(generated, ["first", "response next"])
320
+
321
+ async def test_decision_phase_waits_for_every_inflight_batch(self):
322
+ entered, release = asyncio.Event(), asyncio.Event()
323
+ decisions, generated = [], []
324
+ async def handler(request):
325
+ body = json.loads(request.content)
326
+ if "questions" in body:
327
+ self.assertFalse(generated)
328
+ decisions.append(body)
329
+ if len(decisions) == 2:
330
+ entered.set()
331
+ await release.wait()
332
+ return decision_answer(body)
333
+ self.assertTrue(release.is_set())
334
+ generated.append(body)
335
+ return completion(body, lambda c: "response" if c.arguments["instructions"] == "first" else c.arguments["instructions"])
336
+ decision, generation = wire_backends(handler)
337
+ task = asyncio.create_task(run(nested_and_chain, "text", decision, generation,
338
+ max_batch_questions=1, max_decision_inflight=2))
339
+ try:
340
+ await asyncio.wait_for(entered.wait(), 5)
341
+ self.assertFalse(generated)
342
+ finally:
343
+ release.set()
344
+ result, trace = await asyncio.wait_for(task, 5)
345
+ self.assertEqual(result.value, "response next", result.error)
346
+ self.assertEqual(len(decisions), 2)
347
+ submitted = [e for e in trace if e["event"] == "http_submitted" and e["backend"] == "decision"]
348
+ self.assertEqual([len(e["speculative_calls"]) for e in submitted], [0, 1])
349
+
350
+ async def test_alternating_dependencies_repeat_decision_then_generation(self):
351
+ order = []
352
+ def handler(request):
353
+ body = json.loads(request.content)
354
+ if "questions" in body:
355
+ order.append("decision")
356
+ return decision_answer(body)
357
+ order.append("llm")
358
+ return completion(body, lambda c: c.arguments["instructions"])
359
+ result, _ = await run(alternating, "text", *wire_backends(handler), local_task_budget=1)
360
+ self.assertEqual(result.value, "wanted", result.error)
361
+ self.assertEqual(order, ["decision", "llm", "decision", "llm"])
362
+
363
+ async def test_generation_waits_for_unknown_qwen_gate(self):
364
+ for selected in ("yes", "no"):
365
+ generated = []
366
+ def handler(request):
367
+ body = json.loads(request.content)
368
+ self.assertNotIn("questions", body)
369
+ self.assertEqual(len(llm_calls(body)[1]), 1)
370
+ def answer(call):
371
+ name = call.arguments["instructions"]
372
+ generated.append(name)
373
+ return selected if name == "predicate" else name
374
+ return completion(body, answer)
375
+ result, _ = await run(qwen_gate, "text", *wire_backends(handler), local_task_budget=1)
376
+ expected = "wanted" if selected == "yes" else "unused"
377
+ self.assertEqual(result.value, expected, result.error)
378
+ self.assertEqual(generated, ["predicate", expected])
379
+
380
+ async def test_private_branch_and_merge_do_not_wait_for_formal_branch(self):
381
+ for selected in ("yes", "no"):
382
+ batches = []
383
+ def handler(request):
384
+ body = json.loads(request.content)
385
+ if "questions" in body:
386
+ return decision_answer(body, selected=selected)
387
+ batches.append([c.arguments["instructions"] for _, c in llm_calls(body)[1]])
388
+ return completion(body, lambda c: c.arguments["instructions"])
389
+ plan = lower_to_plan(compile_program(delayed_branch))
390
+ first = next(t for t in plan.tasks if isinstance(t, p.Infer))
391
+ branch = next(t for t in plan.tasks if isinstance(t, p.Branch))
392
+ # A conservative formal control edge delays Branch and Merge until
393
+ # the first Qwen response; its pure predicate is already knowable.
394
+ plan = replace(plan, dependencies=plan.dependencies + (
395
+ p.Dependency(first.id, branch.id, "control", p.InstanceMap()),))
396
+ async with AnnoRuntime(*wire_backends(handler), trace=True, local_task_budget=1) as runtime:
397
+ result, = await asyncio.wait_for(collect(runtime, plan, "text"), 5)
398
+ expected = ["independent", "wanted" if selected == "yes" else "unused"]
399
+ self.assertEqual(result.value, expected, result.error)
400
+ self.assertEqual(batches, [expected])
401
+
402
+ async def test_slow_input_decisions_do_not_block_other_input_generation(self):
403
+ slow_started, fast_generated = asyncio.Event(), asyncio.Event()
404
+ order = []
405
+ async def handler(request):
406
+ body = json.loads(request.content)
407
+ if "questions" in body:
408
+ if body["state"] == "slow":
409
+ slow_started.set()
410
+ await fast_generated.wait()
411
+ else:
412
+ await slow_started.wait()
413
+ order.append((body["state"], "decision"))
414
+ return decision_answer(body)
415
+ state = llm_calls(body)[1][0][0].state
416
+ order.append((state, "llm"))
417
+ if state == "fast":
418
+ fast_generated.set()
419
+ return completion(body, lambda c: c.arguments["instructions"])
420
+ async with AnnoRuntime(*wire_backends(handler), max_active_runs=2) as runtime:
421
+ stream = await runtime.submit(lower_to_plan(compile_program(arms)),
422
+ [RunInput(0, "slow"), RunInput(1, "fast")], mode="speculative")
423
+ async def consume():
424
+ return [r async for r in stream]
425
+ results = await asyncio.wait_for(consume(), 5)
426
+ self.assertEqual([r.value for r in results], ["wanted", "wanted"])
427
+ self.assertLess(order.index(("fast", "llm")), order.index(("slow", "decision")))
428
+
429
+ async def test_gated_loop_is_selected_and_bounded(self):
430
+ for selected in ("yes", "no"):
431
+ generated, sizes = [], []
432
+ def handler(request):
433
+ body = json.loads(request.content)
434
+ if "questions" in body:
435
+ return decision_answer(body, selected=selected)
436
+ sizes.append(len(llm_calls(body)[1]))
437
+ def answer(call):
438
+ name = call.arguments["instructions"]
439
+ generated.append(name)
440
+ return name
441
+ return completion(body, answer)
442
+ state = ["a", "b", "c", "d", "e"]
443
+ result, _ = await run(gated_loop, state, *wire_backends(handler), loop_window=2, local_task_budget=1)
444
+ self.assertEqual(result.value, state if selected == "yes" else [], result.error)
445
+ self.assertEqual(generated, result.value)
446
+ self.assertTrue(all(n <= 2 for n in sizes))
447
+
448
+
449
+ def test_viewer_retains_formal_edges_and_lists_candidates(self):
450
+ plan = lower_to_plan(compile_program(arms))
451
+ before = plan.to_dict()
452
+ eager = analyze_plan(plan)
453
+ report = analyze_plan(plan, mode="speculative")
454
+ self.assertEqual(report["edges"], eager["edges"])
455
+ self.assertEqual(len(report["speculative_candidates"]), 3)
456
+ self.assertIn("推测候选", format_plan(plan, mode="speculative"))
457
+ self.assertIn('value="speculative"', format_plan_html(plan, mode="speculative"))
458
+ self.assertEqual(plan.to_dict(), before)
@@ -0,0 +1,81 @@
1
+ """Wire compatibility and source binding without model calls."""
2
+ import json
3
+ import unittest
4
+ import httpx
5
+ from annoscript.backends import InferenceContext, LLMAPIBackend, InferenceError
6
+ from annoscript.backends.base import normalize_call
7
+ from annoscript.backends._grammar import ITEMS_GRAMMAR, extract_grammar, batch_grammar
8
+ from annoscript.backends._bounded_grammar import grammar
9
+ from annoscript.server.releases import manifest
10
+
11
+
12
+ class SubstringBackendTests(unittest.IsolatedAsyncioTestCase):
13
+ async def test_single_repair_and_default_wire_equivalence(self):
14
+ ctx = InferenceContext('甲"乙\\丙\n😀', '共享规则')
15
+ call = normalize_call('x', 'extract', ('抽取',), {'max_items': 8, 'max_length_per_item': 58}, ctx)
16
+ for repair in (None, 'invalid item'):
17
+ bodies = []
18
+ def handle(request):
19
+ bodies.append(json.loads(request.content))
20
+ return httpx.Response(200, json={'choices': [{'finish_reason': 'stop',
21
+ 'message': {'content': json.dumps({'items': ['甲"乙']}, ensure_ascii=False)}}]})
22
+ for mode in ('validated', 'substring_chars'):
23
+ b = LLMAPIBackend('https://test.invalid/v1', 'fixture', 'qwen', profile='qwen_vllm',
24
+ extract_mode=mode, transport=httpx.MockTransport(handle))
25
+ try:
26
+ result = await b.infer(ctx, call, repair_feedback=repair, max_tokens_override=100)
27
+ self.assertEqual(result.response.items, ['甲"乙'])
28
+ finally:
29
+ await b.aclose()
30
+ self.assertEqual(bodies[0].pop('structured_outputs'), {'grammar': grammar([call])})
31
+ self.assertEqual(bodies[1].pop('structured_outputs'), {'grammar': grammar([call],extract_mode='substring_chars')})
32
+ self.assertEqual(bodies[0], bodies[1])
33
+
34
+ async def test_provider_ignores_grammar_runtime_still_rejects(self):
35
+ ctx = InferenceContext('甲乙')
36
+ call = normalize_call('x', 'extract', ('抽取',), {'max_items': 8, 'max_length_per_item': 58}, ctx)
37
+ for text in ('', '丙', '乙甲'):
38
+ calls = []
39
+ def handle(request):
40
+ calls.append(request)
41
+ return httpx.Response(200, json={'choices': [{'finish_reason':'stop',
42
+ 'message': {'content': json.dumps({'items':[text]})}}]})
43
+ b = LLMAPIBackend('https://test.invalid', 'fixture', 'qwen', profile='qwen_vllm',
44
+ extract_mode='substring_chars', transport=httpx.MockTransport(handle))
45
+ try:
46
+ with self.assertRaises(InferenceError): await b.infer(ctx, call)
47
+ self.assertEqual(len(calls), 1)
48
+ finally: await b.aclose()
49
+
50
+ async def test_configuration(self):
51
+ for profile, mode in [('legacy','substring_chars'), ('qwen_vllm','unknown')]:
52
+ with self.assertRaises(ValueError):
53
+ LLMAPIBackend('https://test.invalid','fixture','qwen',profile=profile,extract_mode=mode)
54
+ cfg={'decision':{'base_url':'https://d.invalid','model':'d','revision':'d1'},
55
+ 'generation':{'base_url':'https://g.invalid','model':'g','revision':'g1','profile':'qwen_vllm'}}
56
+ old=manifest(cfg)
57
+ self.assertNotIn('extract_mode', old['generation'])
58
+ cfg['generation']['extract_mode']='substring_chars'
59
+ with self.assertRaises(ValueError): manifest(cfg)
60
+ cfg['generation']['structured_output_revision']='pinned-build'
61
+ self.assertEqual(manifest(cfg)['generation']['extract_mode'],'substring_chars')
62
+
63
+ async def test_mixed_batch_payload_and_source_deduplication(self):
64
+ ctx=InferenceContext({'unrelated':'不能抽取','甲乙':'甲乙','丙丁':'丙丁'})
65
+ calls=[normalize_call(str(i),op,('任务',),({'source_field':src,'max_items':8,'max_length_per_item':58} if op=='extract' else {'max_length':512} if op=='generate' else {'max_items':8,'max_length_per_item':58}),ctx)
66
+ for i,(op,src) in enumerate([('extract','甲乙'),('extract','丙丁'),
67
+ ('extract','甲乙'),('generate',None),('generate_list',None)])]
68
+ a=LLMAPIBackend('https://test.invalid','fixture','qwen',profile='qwen_vllm')
69
+ b=LLMAPIBackend('https://test.invalid','fixture','qwen',profile='qwen_vllm',extract_mode='substring_chars')
70
+ try:
71
+ pa,pb=a._batch_payload(ctx,calls,4096),b._batch_payload(ctx,calls,4096)
72
+ ga=pa.pop('structured_outputs')['grammar'];gb=pb.pop('structured_outputs')['grammar']
73
+ self.assertEqual(pa,pb)
74
+ self.assertEqual(ga,grammar(calls,batch=True))
75
+ self.assertEqual(gb.count(' ::= Substring('),2)
76
+ self.assertNotIn('不能抽取',gb)
77
+ self.assertEqual(gb.count('allow_empty=false'),2)
78
+ finally: await a.aclose(); await b.aclose()
79
+
80
+
81
+ if __name__ == '__main__': unittest.main()
@@ -0,0 +1,61 @@
1
+ """Synthetic end-to-end checks; no external services or application fixtures."""
2
+ import asyncio
3
+ from collections import Counter
4
+ import json
5
+ from pathlib import Path
6
+ import unittest
7
+
8
+ from annoscript._test_artifacts import assert_saved_artifact
9
+ from annoscript._test_evaluation_http import http_backends
10
+ from annoscript._test_synthetic import CannedBackend, cases
11
+ from annoscript.client import AnnoClient
12
+ from annoscript.compiler import compile_program
13
+ from annoscript.plan_lowering import lower_to_plan
14
+ from annoscript.runtime import AnnoRuntime, RunInput
15
+ from annoscript_examples.request import annotate
16
+
17
+
18
+ class SyntheticExampleTests(unittest.TestCase):
19
+ def test_saved_ir_and_plan(self):
20
+ directory = Path(__file__).parents[1] / "annoscript_examples"
21
+ program = compile_program(annotate)
22
+ assert_saved_artifact(self, directory / "request.json", program)
23
+ assert_saved_artifact(self, directory / "request.execution_plan.json", lower_to_plan(program))
24
+
25
+ def test_all_modes_backends_and_branch_isolation(self):
26
+ scenarios, inputs, expected, failures = cases()
27
+ for mode in ("strict", "eager", "speculative"):
28
+ with self.subTest(mode=mode):
29
+ backend = CannedBackend(scenarios, failures, speculative=True)
30
+ with AnnoClient(AnnoRuntime(backend, backend), mode=mode) as client:
31
+ results = list(client.submit(annotate, inputs, ordered=True))
32
+ self.assertEqual([r.index for r in results], list(range(len(inputs))))
33
+ for result, want in zip(results, expected):
34
+ self.assertEqual(result.status, want["status"], result.error)
35
+ if result.status == "success":
36
+ self.assertEqual(result.value, want["value"])
37
+ else:
38
+ self.assertEqual(result.error.code, want["code"])
39
+
40
+ def test_real_wire_adapters_and_output_mapping(self):
41
+ async def run(mode, profile):
42
+ scenarios, _, _, _ = cases()
43
+ requests = []
44
+ decision, generation = http_backends(scenarios, requests=requests, profile=profile)
45
+ async with AnnoRuntime(decision, generation) as runtime:
46
+ stream = await runtime.submit(lower_to_plan(compile_program(annotate)),
47
+ [RunInput(i, state) for i, (state, _) in enumerate(scenarios[:3])],
48
+ mode=mode)
49
+ rows = [r async for r in stream]
50
+ self.assertEqual({r.index: r.value for r in rows}, {i: v for i, (_, v) in enumerate(scenarios[:3])})
51
+ self.assertTrue(all(r.status == "success" for r in rows))
52
+ self.assertTrue(any("questions" in r for r in requests))
53
+ self.assertTrue(any("messages" in r for r in requests))
54
+ for mode in ("strict", "eager", "speculative"):
55
+ for profile in ("legacy", "qwen_vllm"):
56
+ with self.subTest(mode=mode, profile=profile):
57
+ asyncio.run(run(mode, profile))
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()