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,290 @@
|
|
|
1
|
+
"""Lease-owning worker with isolated Runtime instances for individual attempts."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from annoscript._env import env_value
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from annoscript.backends.base import HTTPBackend
|
|
11
|
+
from annoscript.server.metrics import RequestMeter
|
|
12
|
+
from annoscript.server._plan_cache import PlanCache
|
|
13
|
+
from annoscript._dispatch_pool import RequestSlots
|
|
14
|
+
|
|
15
|
+
from annoscript._client_common import Result, ResultError
|
|
16
|
+
from annoscript.backends import BackendFatalError
|
|
17
|
+
from annoscript.ir import Program
|
|
18
|
+
from annoscript.job_protocol import encode, retry_after_seconds
|
|
19
|
+
from annoscript.passes import optimize_program
|
|
20
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
21
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def backends(manifest):
|
|
25
|
+
from annoscript.backends.decision import DecisionAPIBackend
|
|
26
|
+
from annoscript.backends.llm import LLMAPIBackend
|
|
27
|
+
from annoscript._request_adapters import DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER
|
|
28
|
+
try:
|
|
29
|
+
decision_token = env_value("ANNO_DECISION_AUTH_TOKEN")
|
|
30
|
+
generation_token = env_value("ANNO_GENERATE_AUTH_TOKEN")
|
|
31
|
+
if decision_token is None:
|
|
32
|
+
raise KeyError("ANNO_DECISION_AUTH_TOKEN")
|
|
33
|
+
if generation_token is None:
|
|
34
|
+
raise KeyError("ANNO_GENERATE_AUTH_TOKEN")
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise BackendFatalError("model credentials are unavailable", code="configuration_failed") from None
|
|
37
|
+
timeout = manifest["policy"]["model_timeout"]
|
|
38
|
+
d, g = manifest["decision"], manifest["generation"]
|
|
39
|
+
return (DecisionAPIBackend(d["base_url"], decision_token, d["model"], timeout=timeout,
|
|
40
|
+
request_adapter=d.get("request_adapter", DEFAULT_DECISION_ADAPTER),
|
|
41
|
+
request_adapter_options=d.get("request_adapter_options")),
|
|
42
|
+
LLMAPIBackend(g["base_url"], generation_token, g["model"], timeout=timeout,
|
|
43
|
+
request_adapter=g.get("request_adapter", DEFAULT_GENERATION_ADAPTER),
|
|
44
|
+
profile=g["profile"], extract_mode=g.get("extract_mode", "validated"),
|
|
45
|
+
batch_rule_dedup=g.get("batch_rule_dedup", False),
|
|
46
|
+
disable_thinking=manifest["disable_thinking"], request_policy=g.get("request_policy"),
|
|
47
|
+
assumed_avg_num_tokens_per_char=g.get("assumed_avg_num_tokens_per_char", 2)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ObservedBackend:
|
|
51
|
+
def __init__(self, backend, failures, owned, *, dispatch_allowed=None, on_failure=None):
|
|
52
|
+
self.backend, self.failures, self.owned = backend, failures, owned
|
|
53
|
+
self.dispatch_allowed = dispatch_allowed
|
|
54
|
+
self.on_failure = on_failure
|
|
55
|
+
if isinstance(backend, HTTPBackend):
|
|
56
|
+
backend._dispatch_guard = self._check_dispatch
|
|
57
|
+
|
|
58
|
+
def _check_dispatch(self):
|
|
59
|
+
if not self.owned():
|
|
60
|
+
raise asyncio.CancelledError()
|
|
61
|
+
if self.dispatch_allowed is not None and not self.dispatch_allowed():
|
|
62
|
+
raise BackendFatalError("job is paused; no new model request was sent", code="job_paused")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def supports_llm_batch(self):
|
|
66
|
+
return getattr(self.backend, "supports_llm_batch", False) is True
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def supports_decision_batch_planning(self):
|
|
70
|
+
return getattr(self.backend, "supports_decision_batch_planning", False) is True
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def supports_partial_answers(self):
|
|
74
|
+
return getattr(self.backend, "supports_partial_answers", False) is True
|
|
75
|
+
|
|
76
|
+
def plan_batch(self, *args, **kwargs):
|
|
77
|
+
return self.backend.plan_batch(*args, **kwargs)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def supports_prepared_batch(self):
|
|
81
|
+
return getattr(self.backend, "supports_prepared_batch", False) is True
|
|
82
|
+
|
|
83
|
+
def resolve_token_budget(self, call, fallback):
|
|
84
|
+
resolve = getattr(self.backend, "resolve_token_budget", None)
|
|
85
|
+
return resolve(call, fallback) if resolve else (call.arguments.get("max_num_tokens") or fallback)
|
|
86
|
+
|
|
87
|
+
async def infer(self, *args, **kwargs):
|
|
88
|
+
self._check_dispatch()
|
|
89
|
+
try:
|
|
90
|
+
return await self.backend.infer(*args, **kwargs)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
self.failures.append(exc)
|
|
93
|
+
if self.on_failure is not None:
|
|
94
|
+
self.on_failure(exc)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
async def infer_batch(self, *args, **kwargs):
|
|
98
|
+
self._check_dispatch()
|
|
99
|
+
try:
|
|
100
|
+
return await self.backend.infer_batch(*args, **kwargs)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
self.failures.append(exc)
|
|
103
|
+
if self.on_failure is not None:
|
|
104
|
+
self.on_failure(exc)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
async def aclose(self):
|
|
108
|
+
await self.backend.aclose()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def classification(code, metadata, *, retry_unknown_outcomes=True):
|
|
112
|
+
status = metadata.get("http_status")
|
|
113
|
+
if code in ("authentication_failed", "configuration_failed", "backend_protocol_error",
|
|
114
|
+
"job_paused", "metrics_failed", "diagnostics_failed") or status in (401, 403):
|
|
115
|
+
return "pause"
|
|
116
|
+
if not retry_unknown_outcomes and status is None and code in ("model_timeout", "transport_error"):
|
|
117
|
+
return "pause"
|
|
118
|
+
if (code in ("backend_unavailable", "model_timeout", "transport_error")
|
|
119
|
+
or status in (408, 429) or (type(status) is int and 500 <= status <= 599)):
|
|
120
|
+
return "retry"
|
|
121
|
+
if code == "runtime_failed":
|
|
122
|
+
return "fatal"
|
|
123
|
+
return "result"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Worker:
|
|
127
|
+
def __init__(self, store, releases, *, backend_factory=backends, concurrency=8,
|
|
128
|
+
poll_interval=0.2, random_source=random.random, request_observer=None,
|
|
129
|
+
diagnostic_observer=None, model_request_limits=None):
|
|
130
|
+
if type(concurrency) is not int or concurrency < 1:
|
|
131
|
+
raise ValueError("worker concurrency must be positive")
|
|
132
|
+
self.store, self.releases, self.backend_factory = store, releases, backend_factory
|
|
133
|
+
self.concurrency, self.poll_interval, self.random = concurrency, poll_interval, random_source
|
|
134
|
+
self.request_observer = request_observer
|
|
135
|
+
self.diagnostic_observer = diagnostic_observer
|
|
136
|
+
self.id = uuid.uuid4().hex
|
|
137
|
+
self.plan_cache = PlanCache()
|
|
138
|
+
if model_request_limits is not None and (type(model_request_limits) is not dict
|
|
139
|
+
or set(model_request_limits) != {"generation", "decision"}):
|
|
140
|
+
raise ValueError("model_request_limits requires generation and decision")
|
|
141
|
+
self.request_slots = RequestSlots(**model_request_limits) if model_request_limits is not None else None
|
|
142
|
+
|
|
143
|
+
async def execute(self, claim):
|
|
144
|
+
manifest = self.releases.manifests[claim["release_id"]]
|
|
145
|
+
failures = []
|
|
146
|
+
started = time.monotonic()
|
|
147
|
+
|
|
148
|
+
async def run():
|
|
149
|
+
def diagnose(stage, event):
|
|
150
|
+
if self.diagnostic_observer is not None:
|
|
151
|
+
try:
|
|
152
|
+
self.diagnostic_observer({"job_id": claim["job_id"], "index": claim["index"],
|
|
153
|
+
"attempt": claim["attempt"], "stage": stage, **event})
|
|
154
|
+
except Exception:
|
|
155
|
+
raise BackendFatalError("diagnostic persistence failed", code="diagnostics_failed") from None
|
|
156
|
+
def failed(exc):
|
|
157
|
+
code, metadata = getattr(exc, "code", "execution_failed"), getattr(exc, "metadata", {})
|
|
158
|
+
if classification(code, metadata, retry_unknown_outcomes=manifest["policy"].get("retry_unknown_outcomes", True)) == "pause":
|
|
159
|
+
self.store.pause_dispatch(claim, {"code": code, "message": str(exc)})
|
|
160
|
+
if self.request_slots is not None:
|
|
161
|
+
diagnose("scheduling", {"event": "resource_limits", "active_inputs": self.concurrency,
|
|
162
|
+
"limits": self.request_slots.limits, "algorithm": "ready_at_admission_fifo_v1"})
|
|
163
|
+
def compile_plan(encoded):
|
|
164
|
+
program = Program.from_json(encoded.decode())
|
|
165
|
+
if manifest["server_optimize"]:
|
|
166
|
+
program = optimize_program(program)
|
|
167
|
+
return lower_to_plan(program)
|
|
168
|
+
plan, preparation = await self.plan_cache.get(claim["release_id"], claim["ir"], compile_plan)
|
|
169
|
+
diagnose("preparation", preparation)
|
|
170
|
+
try:
|
|
171
|
+
d, g = self.backend_factory(manifest)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise BackendFatalError("invalid backend configuration", code="configuration_failed") from None
|
|
174
|
+
supported = isinstance(d, HTTPBackend) and isinstance(g, HTTPBackend) and d is not g
|
|
175
|
+
if not self.store.meter_attempt(claim, supported):
|
|
176
|
+
raise asyncio.CancelledError()
|
|
177
|
+
if isinstance(d, HTTPBackend):
|
|
178
|
+
d._request_meter = RequestMeter(self.store, claim, "decision", observer=self.request_observer)
|
|
179
|
+
if self.diagnostic_observer is not None:
|
|
180
|
+
d.diagnostic_observer = lambda event: diagnose("decision", event)
|
|
181
|
+
if isinstance(g, HTTPBackend) and d is not g:
|
|
182
|
+
g._request_meter = RequestMeter(self.store, claim, "generation", observer=self.request_observer)
|
|
183
|
+
if self.diagnostic_observer is not None:
|
|
184
|
+
g.diagnostic_observer = lambda event: diagnose("generation", event)
|
|
185
|
+
decision = ObservedBackend(d, failures, lambda: self.store.renew(claim),
|
|
186
|
+
dispatch_allowed=lambda: self.store.can_dispatch(claim),
|
|
187
|
+
on_failure=failed if self.request_slots is not None else None)
|
|
188
|
+
generation = decision if d is g else ObservedBackend(g, failures, lambda: self.store.renew(claim),
|
|
189
|
+
dispatch_allowed=lambda: self.store.can_dispatch(claim),
|
|
190
|
+
on_failure=failed if self.request_slots is not None else None)
|
|
191
|
+
runtime = AnnoRuntime(decision, generation, trace=self.diagnostic_observer is not None,
|
|
192
|
+
request_slots=self.request_slots, **manifest["runtime"])
|
|
193
|
+
try:
|
|
194
|
+
async with runtime:
|
|
195
|
+
async with await runtime.submit(plan, [RunInput(claim["index"], claim["state"])], mode=claim["mode"]) as stream:
|
|
196
|
+
result = await anext(stream)
|
|
197
|
+
# Verify one and only one outcome for this isolated input.
|
|
198
|
+
try:
|
|
199
|
+
await anext(stream)
|
|
200
|
+
except StopAsyncIteration:
|
|
201
|
+
return result
|
|
202
|
+
raise RuntimeError("multiple results for an input attempt")
|
|
203
|
+
finally:
|
|
204
|
+
if self.diagnostic_observer is not None:
|
|
205
|
+
diagnose("runtime", {"event": "trace", "trace": runtime.trace,
|
|
206
|
+
"plan": plan.to_dict()})
|
|
207
|
+
|
|
208
|
+
task = asyncio.create_task(run())
|
|
209
|
+
|
|
210
|
+
async def renew():
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
await asyncio.sleep(self.store.lease_seconds / 4)
|
|
214
|
+
if not self.store.renew(claim):
|
|
215
|
+
task.cancel()
|
|
216
|
+
return
|
|
217
|
+
except Exception:
|
|
218
|
+
task.cancel()
|
|
219
|
+
|
|
220
|
+
heartbeat = asyncio.create_task(renew())
|
|
221
|
+
try:
|
|
222
|
+
try:
|
|
223
|
+
async with asyncio.timeout(manifest["policy"]["input_timeout"]):
|
|
224
|
+
result = await task
|
|
225
|
+
if result.status == "success":
|
|
226
|
+
self.store.finish(claim, result, elapsed_seconds=time.monotonic() - started)
|
|
227
|
+
return
|
|
228
|
+
code, message = result.error.code, result.error.message
|
|
229
|
+
original = next((e for e in failures if getattr(e, "code", None) == code), None)
|
|
230
|
+
metadata = getattr(original, "metadata", {}) if original is not None else {}
|
|
231
|
+
except TimeoutError:
|
|
232
|
+
self.store.finish(claim, Result(claim["index"], "error", error=ResultError("execution_timeout", "input execution time limit exceeded")), elapsed_seconds=time.monotonic() - started)
|
|
233
|
+
return
|
|
234
|
+
except BackendFatalError as exc:
|
|
235
|
+
code, message, metadata = exc.code, str(exc), exc.metadata
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
# Cancelled/expired ownership cannot commit anything. Process
|
|
238
|
+
# shutdown leaves a lease that another worker can reclaim.
|
|
239
|
+
raise
|
|
240
|
+
except Exception:
|
|
241
|
+
self.store.fail_job(claim, {"code": "execution_failed", "message": "job lowering or runtime failed"}, elapsed_seconds=time.monotonic() - started)
|
|
242
|
+
return
|
|
243
|
+
error = {"code": code, "message": message}
|
|
244
|
+
kind = classification(code, metadata,
|
|
245
|
+
retry_unknown_outcomes=manifest["policy"].get("retry_unknown_outcomes", True))
|
|
246
|
+
if kind in ("pause", "retry"):
|
|
247
|
+
delay = max(self.random() * min(30.0, 2 ** (claim["round_attempt"] - 1)),
|
|
248
|
+
retry_after_seconds(metadata.get("retry_after"), self.store.clock()))
|
|
249
|
+
self.store.attempt_failed(claim, error, pause=kind == "pause", delay=delay, metadata=metadata, elapsed_seconds=time.monotonic() - started)
|
|
250
|
+
elif kind == "fatal":
|
|
251
|
+
self.store.fail_job(claim, error, elapsed_seconds=time.monotonic() - started)
|
|
252
|
+
else:
|
|
253
|
+
self.store.finish(claim, Result(claim["index"], "error", error=ResultError(**error)), elapsed_seconds=time.monotonic() - started)
|
|
254
|
+
finally:
|
|
255
|
+
heartbeat.cancel()
|
|
256
|
+
task.cancel()
|
|
257
|
+
await asyncio.gather(heartbeat, task, return_exceptions=True)
|
|
258
|
+
|
|
259
|
+
async def run(self, *, stop=None):
|
|
260
|
+
active = set()
|
|
261
|
+
stop_waiter = asyncio.create_task(stop.wait()) if stop is not None else None
|
|
262
|
+
try:
|
|
263
|
+
while stop is None or not stop.is_set():
|
|
264
|
+
self.store.maintain()
|
|
265
|
+
for task in list(active):
|
|
266
|
+
if task.done():
|
|
267
|
+
active.remove(task)
|
|
268
|
+
if not task.cancelled():
|
|
269
|
+
task.result()
|
|
270
|
+
while len(active) < self.concurrency:
|
|
271
|
+
claim = self.store.claim(self.id, self.releases)
|
|
272
|
+
if claim is None:
|
|
273
|
+
break
|
|
274
|
+
active.add(asyncio.create_task(self.execute(claim)))
|
|
275
|
+
waiting = active | ({stop_waiter} if stop_waiter is not None else set())
|
|
276
|
+
if waiting:
|
|
277
|
+
await asyncio.wait(waiting, timeout=self.poll_interval, return_when=asyncio.FIRST_COMPLETED)
|
|
278
|
+
else:
|
|
279
|
+
await asyncio.sleep(self.poll_interval)
|
|
280
|
+
finally:
|
|
281
|
+
for task in active:
|
|
282
|
+
task.cancel()
|
|
283
|
+
await asyncio.gather(*active, return_exceptions=True)
|
|
284
|
+
if stop_waiter is not None:
|
|
285
|
+
stop_waiter.cancel()
|
|
286
|
+
await asyncio.gather(stop_waiter, return_exceptions=True)
|
|
287
|
+
await self.aclose()
|
|
288
|
+
|
|
289
|
+
async def aclose(self):
|
|
290
|
+
await self.plan_cache.aclose()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Acceptance against pinned XGrammar with the nonempty extension."""
|
|
2
|
+
import itertools
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import unittest
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
import xgrammar as xgr
|
|
9
|
+
from annoscript.backends._grammar import extract_grammar, batch_grammar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compiler():
|
|
13
|
+
return xgr.GrammarCompiler(xgr.TokenizerInfo([]), cache_enabled=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def accepts(compiled, text):
|
|
17
|
+
m=xgr.GrammarMatcher(compiled, terminate_without_stop_token=True)
|
|
18
|
+
return m.accept_string(text) and m.is_terminated()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def wire(items):
|
|
22
|
+
return json.dumps({'items':items},ensure_ascii=False,separators=(',',':'))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SubstringGrammarTests(unittest.TestCase):
|
|
26
|
+
@classmethod
|
|
27
|
+
def setUpClass(cls):
|
|
28
|
+
assert version('xgrammar') in ('0.2.5+lev.substring1','0.2.5+lev.substring2','0.2.5+lev.substring2.1','0.2.5+lev.substring2.2','0.2.5+lev.substring2.3')
|
|
29
|
+
|
|
30
|
+
def test_nonempty_macro_and_serialization(self):
|
|
31
|
+
for chunks in [('a','b'),('a','','b'),('','',''),()]:
|
|
32
|
+
for empty in (True,False):
|
|
33
|
+
args=', '.join(json.dumps(c) for c in chunks)
|
|
34
|
+
g=xgr.Grammar.from_ebnf('root ::= Substring('+args+(', ' if args else '')+
|
|
35
|
+
'allow_empty='+str(empty).lower()+')')
|
|
36
|
+
for copy in [g,xgr.Grammar.from_ebnf(str(g)),xgr.Grammar.deserialize_json(g.serialize_json())]:
|
|
37
|
+
c=compiler().compile_grammar(copy)
|
|
38
|
+
self.assertEqual(accepts(c,''),empty)
|
|
39
|
+
for v in ['a','b','ab','ba','x']:
|
|
40
|
+
self.assertEqual(accepts(c,v), v in {''.join(chunks[i:j]) for i in range(len(chunks)) for j in range(i+1,len(chunks)+1)} and bool(v))
|
|
41
|
+
|
|
42
|
+
def test_json_escaping_exhaustive_substrings(self):
|
|
43
|
+
for source in ['甲"乙\\丙\n😀','\x00\x1f\b\f\r\t','e\u0301/@ @','\\u4e2d','aaaa','']:
|
|
44
|
+
c=compiler().compile_grammar(extract_grammar(source))
|
|
45
|
+
self.assertTrue(accepts(c,wire([])))
|
|
46
|
+
self.assertFalse(accepts(c,wire([''])))
|
|
47
|
+
for i in range(len(source)):
|
|
48
|
+
for j in range(i+1,len(source)+1):
|
|
49
|
+
self.assertTrue(accepts(c,wire([source[i:j]])),repr((source,i,j)))
|
|
50
|
+
for bad in ['不存在',source+'不存在',source[::2]]:
|
|
51
|
+
if bad and bad not in source:self.assertFalse(accepts(c,wire([bad])))
|
|
52
|
+
for bad in ['{"items":["\\"]}',r'{"items":["\u00"]}','{"items":["\n"]}']:
|
|
53
|
+
self.assertFalse(accepts(c,bad))
|
|
54
|
+
|
|
55
|
+
def test_random_short_language(self):
|
|
56
|
+
rng=random.Random(20261016)
|
|
57
|
+
alphabet=['甲','乙','"','\\','\n','😀']
|
|
58
|
+
for _ in range(30):
|
|
59
|
+
source=''.join(rng.choices(alphabet,k=rng.randrange(1,7)))
|
|
60
|
+
c=compiler().compile_grammar(extract_grammar(source))
|
|
61
|
+
for n in range(3):
|
|
62
|
+
for chars in itertools.product(alphabet,repeat=n):
|
|
63
|
+
value=''.join(chars)
|
|
64
|
+
self.assertEqual(accepts(c,wire([value])), bool(value) and value in source)
|
|
65
|
+
|
|
66
|
+
def test_batch_isolation_and_other_operations(self):
|
|
67
|
+
calls=[SimpleNamespace(id=i,operation=op,arguments={},extraction_source=source) for i,op,source in
|
|
68
|
+
[('a','extract','甲乙'),('b','extract','丙丁'),('c','extract','甲乙'),
|
|
69
|
+
('d','generate',''),('e','generate_list','')]]
|
|
70
|
+
c=compiler().compile_grammar(batch_grammar(calls,extract_mode='substring_chars'))
|
|
71
|
+
answers={'q1':['甲'],'q2':['丁'],'q3':['乙'],'q4':'任意改写','q5':['','任意改写']}
|
|
72
|
+
def encode():return json.dumps({'answers':answers},ensure_ascii=False,separators=(',',':'))
|
|
73
|
+
self.assertTrue(accepts(c,encode()))
|
|
74
|
+
for bad in ['丁','甲丁','']:
|
|
75
|
+
answers['q1']=[bad];self.assertFalse(accepts(c,encode()))
|
|
76
|
+
answers['q1']='甲';self.assertFalse(accepts(c,encode()))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__=='__main__':unittest.main()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Mandatory suggestions, hard contracts, budget sharing and inference-free rejection."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import unittest
|
|
7
|
+
import httpx
|
|
8
|
+
from annoscript.backends import InferenceContext, InferenceError
|
|
9
|
+
from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
|
|
10
|
+
from annoscript._test_evaluation_http import llm_calls
|
|
11
|
+
from annoscript.backends.base import normalize_call, validate_response
|
|
12
|
+
from annoscript.backends._generation_policy import effective_budget
|
|
13
|
+
from annoscript.backends._bounded_grammar import grammar
|
|
14
|
+
from annoscript.compiler import compile_program, CompileError
|
|
15
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
16
|
+
from annoscript.anno import GenerateResponse, GenerateListResponse, ExtractResponse
|
|
17
|
+
from annoscript.test_generation_policy import policy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def missing_generate(anno):
|
|
21
|
+
return anno.generate('x').text
|
|
22
|
+
|
|
23
|
+
def missing_list(anno):
|
|
24
|
+
return anno.generate_list('x', max_items=1).items
|
|
25
|
+
|
|
26
|
+
def missing_extract(anno):
|
|
27
|
+
return anno.extract('x', max_length_per_item=1).items
|
|
28
|
+
|
|
29
|
+
def oversized(anno):
|
|
30
|
+
a = anno.generate('a', min_length=4, max_length=5).text
|
|
31
|
+
b = anno.generate_list('b', max_items=1, min_length_per_item=2, max_length_per_item=3).items
|
|
32
|
+
return [a,b]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def call(op, **kw):
|
|
36
|
+
return normalize_call('x',op,('task',),kw,InferenceContext('甲乙丙丁'))
|
|
37
|
+
|
|
38
|
+
class AdvisoryTests(unittest.TestCase):
|
|
39
|
+
def test_mandatory_at_compile_and_dynamic_normalization(self):
|
|
40
|
+
for f in (missing_generate,missing_list,missing_extract):
|
|
41
|
+
with self.assertRaises(CompileError):compile_program(f)
|
|
42
|
+
for op, valid in [('generate',{'max_length':5}),('generate_list',{'max_items':2,'max_length_per_item':5}),('extract',{'max_items':2,'max_length_per_item':5})]:
|
|
43
|
+
for field in valid:
|
|
44
|
+
for value in (None,False,-1,1.2,float('inf')):
|
|
45
|
+
with self.subTest(op=op,field=field,value=value),self.assertRaises((ValueError,TypeError)):
|
|
46
|
+
call(op,**{**valid,field:value})
|
|
47
|
+
opts=dict(valid);del opts[field]
|
|
48
|
+
with self.assertRaises(TypeError):call(op,**opts)
|
|
49
|
+
with self.assertRaises(ValueError):call('generate',min_length=6,max_length=5)
|
|
50
|
+
|
|
51
|
+
def test_budgets_ratio_explicit_priority_zero_and_repair(self):
|
|
52
|
+
for bad in (0,-1,True,float('inf'),float('nan'),'2',None):
|
|
53
|
+
with self.assertRaises(ValueError):effective_budget(call('generate',max_length=10),bad)
|
|
54
|
+
self.assertEqual(effective_budget(call('generate',max_length=3),1.5),5)
|
|
55
|
+
self.assertEqual(effective_budget(call('generate',max_length=0)),1)
|
|
56
|
+
self.assertEqual(effective_budget(call('extract',max_items=0,max_length_per_item=1)),32)
|
|
57
|
+
self.assertEqual(effective_budget(call('extract',max_items=16,max_length_per_item=300)),9760)
|
|
58
|
+
for op, kw in [('generate',{'max_length':80}),('generate_list',{'max_items':16,'max_length_per_item':16})]:
|
|
59
|
+
c=call(op,**kw,max_num_tokens=7)
|
|
60
|
+
self.assertEqual(effective_budget(c,100),7)
|
|
61
|
+
self.assertEqual(effective_budget(c,100,999),7)
|
|
62
|
+
self.assertEqual(effective_budget(c,100,3),3)
|
|
63
|
+
self.assertEqual(effective_budget(call('generate_list',max_items=16,max_length_per_item=16)),672)
|
|
64
|
+
self.assertEqual(effective_budget(call('extract',max_items=4,max_length_per_item=120)),1024)
|
|
65
|
+
|
|
66
|
+
def test_suggestions_and_hard_guarantees(self):
|
|
67
|
+
for c,r in [(call('generate',min_length=3,max_length=4),GenerateResponse('甲')),
|
|
68
|
+
(call('generate',min_length=3,max_length=4),GenerateResponse('甲'*100)),
|
|
69
|
+
(call('generate_list',min_length_per_item=3,max_length_per_item=4,max_items=1),GenerateListResponse(['甲'*100])),
|
|
70
|
+
(call('extract',max_items=1,max_length_per_item=1),ExtractResponse(['甲乙丙丁']))]:
|
|
71
|
+
validate_response(c,r)
|
|
72
|
+
for c,r in [(call('generate',min_length=3,max_length=4),GenerateResponse('')),
|
|
73
|
+
(call('generate_list',min_length_per_item=3,max_length_per_item=4,max_items=1),GenerateListResponse([''])),
|
|
74
|
+
(call('generate_list',max_length_per_item=4,max_items=1),GenerateListResponse(['甲','乙'])),
|
|
75
|
+
(call('extract',max_items=1,max_length_per_item=1),ExtractResponse(['甲丙']))]:
|
|
76
|
+
with self.assertRaises(InferenceError):validate_response(c,r)
|
|
77
|
+
|
|
78
|
+
def test_grammar_ignores_suggestions_keeps_source_and_count(self):
|
|
79
|
+
c=call('extract',max_items=1,max_length_per_item=1)
|
|
80
|
+
g=grammar([c],extract_mode='substring_chars')
|
|
81
|
+
self.assertNotIn('max_chunks',g);self.assertIn('allow_empty=false',g)
|
|
82
|
+
self.assertEqual(g,grammar([call('extract',max_items=1,max_length_per_item=100)],extract_mode='substring_chars'))
|
|
83
|
+
self.assertIn('Regex(',grammar([call('generate_list',max_items=2,max_length_per_item=3)]))
|
|
84
|
+
self.assertNotIn('json-char',grammar([call('generate_list',max_items=2,max_length_per_item=3)]))
|
|
85
|
+
|
|
86
|
+
class AdvisoryWireTests(unittest.IsolatedAsyncioTestCase):
|
|
87
|
+
async def test_over_suggestion_no_repair_runtime_and_metadata(self):
|
|
88
|
+
from annoscript.runtime import AnnoRuntime,RunInput
|
|
89
|
+
from annoscript.test_runtime import FakeBackend
|
|
90
|
+
sent=[]
|
|
91
|
+
def handler(req):
|
|
92
|
+
body=json.loads(req.content);sent.append(body)
|
|
93
|
+
tasks=llm_calls(body)[1]
|
|
94
|
+
answers={f'q{index}':('甲'*100 if call.operation=='generate' else ['乙'*100]) for index,(_,call) in enumerate(tasks,1)}
|
|
95
|
+
return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':json.dumps({'answers':answers})}}]})
|
|
96
|
+
b=LLMAPIBackend('https://offline.invalid','fixture','q',profile='qwen_vllm',request_policy=policy(),_token_counter=lambda p:1,transport=httpx.MockTransport(handler))
|
|
97
|
+
rt=AnnoRuntime(FakeBackend(),b)
|
|
98
|
+
try:
|
|
99
|
+
out=[r async for r in (await rt.submit(lower_to_plan(compile_program(oversized)),[RunInput(0,'source')]))]
|
|
100
|
+
self.assertEqual(out[0].status,'success');self.assertEqual(len(sent),1)
|
|
101
|
+
self.assertEqual(sent[0]['max_tokens'],64+10+32+(6+32+8)+32)
|
|
102
|
+
self.assertIn('字符长度为建议',sent[0]['messages'][0]['content'])
|
|
103
|
+
finally:await rt.aclose()
|
|
104
|
+
|
|
105
|
+
async def test_direct_invalid_args_and_context_no_http(self):
|
|
106
|
+
sent=[];events=[]
|
|
107
|
+
b=LLMAPIBackend('https://offline.invalid','fixture','q',profile='qwen_vllm',request_policy=policy(),_token_counter=lambda p:8100,transport=httpx.MockTransport(lambda r:sent.append(r)),diagnostic_observer=events.append)
|
|
108
|
+
try:
|
|
109
|
+
with self.assertRaises(InferenceError):await b.infer(InferenceContext('甲'),call('generate',max_length=80))
|
|
110
|
+
self.assertEqual(events[0]['output_budget'],160)
|
|
111
|
+
self.assertEqual(events[0]['assumed_avg_num_tokens_per_char'],2)
|
|
112
|
+
self.assertEqual(sent,[])
|
|
113
|
+
finally:await b.aclose()
|
|
114
|
+
|
|
115
|
+
if __name__=='__main__':unittest.main()
|