annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
|
@@ -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()
|