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,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()