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,261 @@
1
+ """Metric persistence, explicit migration and real HTTP Backend/Runtime tests."""
2
+
3
+ import asyncio
4
+ from contextlib import closing
5
+ import json
6
+ from pathlib import Path
7
+ import sqlite3
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import httpx
12
+
13
+ from annoscript._client_common import Result
14
+ from annoscript._test_evaluation_http import http_backends, llm_calls, llm_content
15
+ from annoscript.compiler import compile_program
16
+ from annoscript.metrics import validate_metrics
17
+ from annoscript.server.migrate import migrate
18
+ from annoscript.server.store import SCHEMA_V1, ServiceError, Store
19
+ from annoscript.server.worker import Worker
20
+ from annoscript.server.releases import Releases
21
+ from annoscript.test_jobs import generate
22
+ from annoscript.test_jobs_server import ExecutionTests, StoreTests, payload, configurations
23
+
24
+
25
+ def reused(anno):
26
+ a = anno.generate("same request", max_length=512)
27
+ b = anno.generate("same request", max_length=512)
28
+ return [a.text, b.text]
29
+
30
+
31
+ def concurrent(anno):
32
+ a = anno.generate("first", max_length=512)
33
+ b = anno.generate("second", max_length=512)
34
+ return [a.text, b.text]
35
+
36
+
37
+ class MetricStoreTests(StoreTests):
38
+ # Only new methods are discovered in this subclass (the existing suite runs
39
+ # independently); inheritance provides a small setup/helper harness.
40
+ def test_metric_fencing_retry_unknown_and_once_only(self):
41
+ job = self.submit(states=("a",))
42
+ claim = self.claim()
43
+ self.store.meter_attempt(claim, True)
44
+ self.store.request_started(claim, "r1", "generation", 1)
45
+ self.store.request_started(claim, "r1", "generation", 1)
46
+ self.store.request_finished(claim, "r1", seconds=1, http_status=200, usage={"prompt_tokens": 10, "completion_tokens": 2})
47
+ self.store.request_finished(claim, "r1", seconds=9, http_status=200, usage={"prompt_tokens": 100})
48
+ self.store.request_started(claim, "uncertain", "generation", 1)
49
+ self.clock.now += 61
50
+ replacement = self.claim()
51
+ self.assertFalse(self.store.request_finished(claim, "uncertain", seconds=1, http_status=200, usage={}))
52
+ self.store.meter_attempt(replacement, True)
53
+ self.store.request_started(replacement, "r2", "generation", 1)
54
+ self.store.request_finished(replacement, "r2", seconds=2, http_status=200, usage={"prompt_tokens": 7, "completion_tokens": 3})
55
+ self.store.finish(replacement, Result(0, "success", "a"), elapsed_seconds=3)
56
+ value = self.store.metrics("alice", job["job_id"])
57
+ validate_metrics(value, job["job_id"])
58
+ g = value["summary"]["backends"]["generation"]
59
+ self.assertEqual((g["requests"], g["unknown_requests"], g["input_tokens"]["observed"]), (3, 1, 17))
60
+ self.assertIsNone(g["input_tokens"]["total"])
61
+ self.assertIsNone(value["summary"]["attempts"]["seconds"]["total"])
62
+ self.assertEqual(value["summary"]["attempts"]["count"], 2)
63
+ self.assertEqual(Store(self.path, clock=self.clock).metrics("alice", job["job_id"]), value)
64
+
65
+ def test_cancel_and_expiration_authorization_and_queue_epoch(self):
66
+ self.store.monotonic = lambda: 10
67
+ job = self.submit(states=("a",))
68
+ self.store.monotonic = lambda: 13
69
+ claim = self.claim()
70
+ self.store.meter_attempt(claim, True)
71
+ self.store.request_started(claim, "r", "decision", 4)
72
+ self.store.cancel("alice", job["job_id"])
73
+ self.assertFalse(self.store.request_finished(claim, "r", seconds=1, http_status=200, usage={}))
74
+ value = self.store.metrics("alice", job["job_id"])
75
+ self.assertEqual(value["summary"]["queue_wait_seconds"]["total"], 3)
76
+ self.assertEqual(value["summary"]["backends"]["decision"]["unknown_requests"], 1)
77
+ with self.assertRaises(ServiceError) as error:
78
+ self.store.metrics("bob", job["job_id"])
79
+ self.assertEqual(error.exception.status, 404)
80
+ self.clock.now += 8 * 86400
81
+ with self.assertRaises(ServiceError) as error:
82
+ self.store.metrics("alice", job["job_id"])
83
+ self.assertEqual(error.exception.status, 410)
84
+ job2 = self.submit(states=("b",), key="new")
85
+ self.store.clock_epoch = "different-boot"
86
+ claim2 = self.claim()
87
+ self.store.meter_attempt(claim2, True)
88
+ self.store.finish(claim2, Result(0, "success", "b"), elapsed_seconds=1)
89
+ self.assertIsNone(self.store.metrics("alice", job2["job_id"])["summary"]["queue_wait_seconds"]["total"])
90
+
91
+ def legacy_copy(self):
92
+ path = Path(self.folder.name, "legacy.sqlite")
93
+ with closing(sqlite3.connect(path)) as old, closing(sqlite3.connect(self.path)) as new:
94
+ old.executescript(SCHEMA_V1)
95
+ for table in ("releases", "sessions", "jobs", "inputs", "attempts", "results", "resume_ops"):
96
+ names = [r[1] for r in old.execute(f"PRAGMA table_info({table})")]
97
+ rows = new.execute(f"SELECT {','.join(names)} FROM {table}").fetchall()
98
+ old.executemany(f"INSERT INTO {table} VALUES ({','.join('?' for _ in names)})", rows)
99
+ old.commit()
100
+ return path
101
+
102
+ def test_explicit_migration_preserves_binding_backup_and_history(self):
103
+ job = self.submit(states=("a",))
104
+ self.store.finish(self.claim(), Result(0, "success", "a"))
105
+ legacy = self.legacy_copy()
106
+ with self.assertRaisesRegex(RuntimeError, "schema 1"):
107
+ Store(legacy)
108
+ backup = Path(self.folder.name, "backup.sqlite")
109
+ migrate(legacy, backup)
110
+ upgraded = Store(legacy, clock=self.clock)
111
+ self.assertEqual(upgraded.lookup("alice", "job"), job | {"state": "completed", "counts": {"total": 1, "completed": 1, "success": 1, "error": 0}})
112
+ value = upgraded.metrics("alice", job["job_id"])
113
+ self.assertFalse(value["summary"]["history_complete"])
114
+ self.assertIsNone(value["summary"]["backends"]["generation"]["total_requests"])
115
+ self.assertEqual(upgraded.results("alice", job["job_id"])["results"][0]["value"], "a")
116
+ with closing(sqlite3.connect(backup)) as db:
117
+ self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 1)
118
+ self.assertEqual(db.execute("SELECT COUNT(*) FROM results").fetchone()[0], 1)
119
+
120
+ def test_migration_rolls_back_and_does_not_replace_backup(self):
121
+ legacy = self.legacy_copy()
122
+ backup = Path(self.folder.name, "backup.sqlite")
123
+ def fail(statement):
124
+ raise RuntimeError("simulated failure")
125
+ with self.assertRaisesRegex(RuntimeError, "simulated"):
126
+ migrate(legacy, backup, _after_statement=fail)
127
+ with closing(sqlite3.connect(legacy)) as db:
128
+ self.assertEqual(db.execute("PRAGMA user_version").fetchone()[0], 1)
129
+ self.assertNotIn("metrics_available", [r[1] for r in db.execute("PRAGMA table_info(jobs)")])
130
+ with self.assertRaises(FileExistsError):
131
+ migrate(legacy, backup)
132
+
133
+
134
+ class MetricExecutionTests(ExecutionTests):
135
+ def test_runtime_reuse_is_one_physical_request(self):
136
+ requests = []
137
+ client = self.client()
138
+ job = client.submit_ir(compile_program(reused, optimize=False), [{"index": 0, "state": "hello"}], idempotency_key="reuse")
139
+ self.run_claim(lambda _: http_backends(requests=requests))
140
+ self.assertEqual(len(requests), 1)
141
+ self.assertEqual(client.metrics(job)["summary"]["backends"]["generation"]["total_requests"], 1)
142
+
143
+ def test_concurrent_http_sums_are_not_attempt_wall_time(self):
144
+ from annoscript.backends import DecisionAPIBackend
145
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
146
+ started = []
147
+ async def handle(request):
148
+ started.append(request)
149
+ while len(started) < 2:
150
+ await asyncio.sleep(0)
151
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}],
152
+ "usage": {"prompt_tokens": 1, "completion_tokens": 1}})
153
+ def factory(_):
154
+ transport = httpx.MockTransport(handle)
155
+ return (DecisionAPIBackend("http://offline", "fixture", "fixture", transport=transport),
156
+ LLMAPIBackend("http://offline", "fixture", "fixture", transport=transport))
157
+ # This test needs two independent physical HTTP requests, irrespective
158
+ # of the default batching optimization covered by the car tests.
159
+ config = configurations()
160
+ config["test"]["runtime"] = {"max_llm_batch_calls": 1}
161
+ configured = Releases(config, "test")
162
+ self.releases.manifests.update(configured.manifests)
163
+ self.releases.default = configured.default
164
+ client = self.client()
165
+ job = client.submit_ir(compile_program(concurrent), [{"index": 0, "state": "hello"}], idempotency_key="concurrent")
166
+ ticks = iter((0., 0., 10., 10.))
167
+ from annoscript.server.metrics import RequestMeter
168
+ original = RequestMeter.__init__
169
+ def meter_init(instance, *args, **kwargs):
170
+ original(instance, *args, **kwargs)
171
+ instance.clock = lambda: next(ticks)
172
+ with patch.object(RequestMeter, "__init__", meter_init):
173
+ self.run_claim(factory)
174
+ summary = client.metrics(job)["summary"]
175
+ self.assertEqual(summary["backends"]["generation"]["http_seconds"]["total"], 20)
176
+ self.assertLess(summary["attempts"]["seconds"]["total"], 20)
177
+
178
+ def test_mixed_batch_and_local_repair_meter_each_http_once(self):
179
+ from annoscript.test_llm_batch import mixed_generation, answer, completion
180
+ requests = []
181
+ def fail_one(body):
182
+ content = llm_content(body, answer)
183
+ if llm_calls(body)[0]:
184
+ value = json.loads(content)
185
+ del value["answers"][next(iter(value["answers"]))]
186
+ content = json.dumps(value)
187
+ return completion(content)
188
+ client = self.client()
189
+ job = client.submit_ir(compile_program(mixed_generation), [{"index": 0, "state": "广州与北京"}],
190
+ idempotency_key="batch-repair-metrics")
191
+ self.run_claim(lambda _: http_backends(requests=requests, override=fail_one))
192
+ self.assertEqual(client.fetch_results(job)["results"][0]["status"], "success")
193
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1])
194
+ g = client.metrics(job)["summary"]["backends"]["generation"]
195
+ self.assertEqual((g["requests"], g["questions"], g["unknown_requests"]), (2, 4, 0))
196
+ self.assertEqual((g["input_tokens"]["total"], g["output_tokens"]["total"]), (26, 10))
197
+
198
+
199
+ def test_content_repair_budget_resets_only_with_new_execution_attempt(self):
200
+ from annoscript.test_llm_batch import mixed_generation, answer, completion
201
+ requests, repairs = [], []
202
+ def response(body):
203
+ content = llm_content(body, answer)
204
+ if llm_calls(body)[0]:
205
+ value = json.loads(content)
206
+ del value["answers"][next(iter(value["answers"]))]
207
+ return completion(json.dumps(value))
208
+ repairs.append(body)
209
+ if len(repairs) == 1:
210
+ return httpx.Response(429, json={"usage": {"prompt_tokens": 4, "completion_tokens": 0}},
211
+ headers={"Retry-After": "0"})
212
+ return completion(content)
213
+ client = self.client()
214
+ job = client.submit_ir(compile_program(mixed_generation), [{"index": 0, "state": "广州与北京"}],
215
+ idempotency_key="repair-per-attempt")
216
+ for _ in range(2):
217
+ self.run_claim(lambda _: http_backends(requests=requests, override=response))
218
+ self.assertEqual(client.fetch_results(job)["results"][0]["attempts"], 2)
219
+ self.assertEqual(client.fetch_results(job)["results"][0]["status"], "success")
220
+ self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1, 3, 1])
221
+ metrics = client.metrics(job)["summary"]["backends"]["generation"]
222
+ self.assertEqual((metrics["requests"], metrics["questions"], metrics["input_tokens"]["total"]), (4, 8, 43))
223
+
224
+ def test_retry_usage_survives_pause_and_resume(self):
225
+ client = self.client()
226
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key="retry-metrics")
227
+ def fail(body):
228
+ return httpx.Response(429, json={"usage": {"prompt_tokens": 4, "completion_tokens": 0}}, headers={"Retry-After": "0"})
229
+ for _ in range(3):
230
+ self.run_claim(lambda _: http_backends(override=fail))
231
+ status = client.status(job)
232
+ self.assertEqual(status["state"], "paused")
233
+ self.assertEqual(client.metrics(job)["summary"]["backends"]["generation"]["input_tokens"]["total"], 12)
234
+ client.resume(job, pause_generation=status["pause_generation"], idempotency_key="resume-metrics")
235
+ self.run_claim(lambda _: http_backends())
236
+ metrics = client.metrics(job)
237
+ self.assertEqual(metrics["summary"]["attempts"]["count"], 4)
238
+ self.assertEqual(metrics["summary"]["backends"]["generation"]["input_tokens"]["total"], 19)
239
+
240
+ def test_timeout_and_missing_usage_are_not_zero(self):
241
+ for name in ("timeout", "missing"):
242
+ client = self.client()
243
+ job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key=name)
244
+ def response(body):
245
+ if name == "timeout":
246
+ raise httpx.ReadTimeout("offline")
247
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}]})
248
+ self.run_claim(lambda _: http_backends(override=response))
249
+ g = client.metrics(job)["summary"]["backends"]["generation"]
250
+ self.assertIsNone(g["input_tokens"]["total"])
251
+ self.assertEqual(g["input_tokens"]["unknown"], 1)
252
+ self.assertEqual(g["unknown_requests"], int(name == "timeout"))
253
+ client.cancel(job)
254
+
255
+
256
+ # Reuse setup helpers, not the inherited test cases themselves.
257
+ for cls, base in ((MetricStoreTests, StoreTests), (MetricExecutionTests, ExecutionTests)):
258
+ for name in base.__dict__:
259
+ if name.startswith("test_") and name not in cls.__dict__:
260
+ setattr(cls, name, None)
261
+ del StoreTests, ExecutionTests, cls, base
@@ -0,0 +1,59 @@
1
+ """Minimal fusion regression: synthetic fixtures only; no live model calls."""
2
+ import json
3
+ import unittest
4
+ from copy import deepcopy
5
+ import httpx
6
+
7
+ from annoscript.backends import InferenceContext
8
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
9
+ from annoscript.backends.base import normalize_call
10
+ from annoscript._test_evaluation_http import llm_calls, llm_content
11
+ from annoscript.test_llm_batch import completion
12
+ from annoscript.anno import GenerateListResponse, ExtractResponse
13
+ from annoscript.server.releases import Releases
14
+ from annoscript.test_jobs_server import configurations
15
+
16
+
17
+
18
+ class LayoutBudgetTests(unittest.IsolatedAsyncioTestCase):
19
+ async def test_inline_single_batch_and_repair_preserve_sources_and_160_budget(self):
20
+ ctx=InferenceContext({'text':'广州😀é\\"\n'},'固定策略')
21
+ calls=[normalize_call('name','generate_list',('名称',),dict(min_items=0,max_items=1,min_length_per_item=1,max_length_per_item=80,max_num_tokens=160),ctx),
22
+ normalize_call('e','extract',('片段',),dict(source_field='text',max_items=2,max_length_per_item=20),ctx)]
23
+ for profile in ('legacy','qwen_vllm'):
24
+ sent=[];events=[]
25
+ def handle(req):
26
+ body=json.loads(req.content);sent.append(body)
27
+ return completion(llm_content(body,lambda c,x: GenerateListResponse(['名称']) if x.operation=='generate_list' else ExtractResponse(['广州'])))
28
+ b=LLMAPIBackend('http://offline.invalid','fixture','fixture',profile=profile,
29
+ transport=httpx.MockTransport(handle),diagnostic_observer=events.append)
30
+ try:
31
+ for c in calls: await b.infer(ctx,c)
32
+ await b.infer(ctx,calls[0],repair_feedback='invalid',max_tokens_override=160)
33
+ await b.infer(ctx,calls[1],repair_feedback='invalid')
34
+ plan=b.plan_batch(ctx,calls,max_calls=8,max_input_bytes=262144,max_output_tokens=4096,default_tokens=1024)
35
+ await b.infer_batch(ctx,plan.calls,max_tokens=plan.max_tokens,prepared=plan.prepared)
36
+ self.assertEqual(sent[0]['max_tokens'],160);self.assertEqual(sent[2]['max_tokens'],160)
37
+ self.assertEqual(plan.max_tokens,64+160+32+(2*2*20+32+8*2)+32)
38
+ for body in (sent[1],sent[3],sent[4]):
39
+ payload=json.loads(body['messages'][-1]['content'])
40
+ self.assertNotIn('source_ref',json.dumps(payload))
41
+ self.assertEqual(json.dumps(body['messages'],ensure_ascii=False).count('广州'),1)
42
+ self.assertEqual(llm_calls(body)[1][-1][1].extraction_source,ctx.state['text'])
43
+ self.assertTrue(all(e['source_layout']=='source_field_v1' for e in events if 'source_layout' in e))
44
+ finally:await b.aclose()
45
+
46
+ async def test_obsolete_layout_rejected_and_release_records_new_binding(self):
47
+ for value in ('inline','state_refs_v1',False,'bad'):
48
+ with self.assertRaisesRegex(TypeError, 'source_layout'):
49
+ LLMAPIBackend('http://offline.invalid','fixture','m',source_layout=value)
50
+ cfg=configurations()['test'];cfg['generation']['source_layout']=value
51
+ with self.assertRaisesRegex(ValueError, 'source_layout was removed'):
52
+ Releases({'test':cfg},'test')
53
+ release=Releases(configurations(),'test')
54
+ self.assertEqual(release.manifests[release.default]['engineering']['qwen_source_layout'],'source_field_v1')
55
+
56
+ if __name__=='__main__':unittest.main()
57
+
58
+ class WorkerFusionTests(unittest.TestCase):
59
+ pass
@@ -0,0 +1,169 @@
1
+ """Native nullable scalars, default compatibility and mixed runtime requests."""
2
+
3
+ import asyncio
4
+ from dataclasses import replace
5
+ import json
6
+ from pathlib import Path
7
+ import tempfile
8
+ import unittest
9
+
10
+ import httpx
11
+
12
+ from annoscript._data import thaw
13
+ from annoscript._test_evaluation_http import FixtureLLMBackend, llm_calls
14
+ from annoscript.backends import InferenceContext, InferenceError, LLMAPIBackend
15
+ from annoscript.backends.base import normalize_call, validate_response
16
+ from annoscript.compiler import compile_program
17
+ from annoscript.ir import Program
18
+ from annoscript.anno import GenerateResponse
19
+ from annoscript.plan import ExecutionPlan
20
+ from annoscript.plan_lowering import lower_to_plan
21
+ from annoscript.runtime import AnnoRuntime, RunInput
22
+ from annoscript.server.releases import Releases
23
+ from annoscript.server.store import Store
24
+ from annoscript.server.worker import Worker
25
+ from annoscript.test_jobs_server import configurations, payload
26
+ from annoscript.test_runtime import FakeBackend
27
+
28
+
29
+ def optional_calls(anno):
30
+ normal = anno.generate("same", min_length=1, max_length=24, max_num_tokens=64).text
31
+ optional = anno.generate("same", min_length=1, max_length=24, max_num_tokens=64, nullable=True).text
32
+ again = anno.generate("same", min_length=1, max_length=24, max_num_tokens=64, nullable=True).text
33
+ return [normal, optional, again]
34
+
35
+
36
+ def completion(content):
37
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}],
38
+ "usage": {"prompt_tokens": 7, "completion_tokens": 2}})
39
+
40
+
41
+ def fixture_handler(sent, repair=False):
42
+ def handle(request):
43
+ body = json.loads(request.content); sent.append(body)
44
+ batched, calls = llm_calls(body)
45
+ values = [None if call.arguments.get("nullable") else "normal" for _, call in calls]
46
+ if batched:
47
+ if repair:
48
+ values = [[] if value is None else value for value in values]
49
+ return completion(json.dumps({"answers": {f"q{i}": value for i, value in enumerate(values, 1)}}))
50
+ return completion(json.dumps(values[0]) if calls[0][1].arguments.get("nullable") else values[0])
51
+ return handle
52
+
53
+
54
+ class NullableGenerateTests(unittest.IsolatedAsyncioTestCase):
55
+ def call(self, *, identity="internal", **options):
56
+ return normalize_call(identity, "generate", ("标签",),
57
+ {"min_length": 1, "max_length": 24, "max_num_tokens": 64, **options},
58
+ InferenceContext("合成正文"))
59
+
60
+ async def test_false_is_exact_default_and_true_binds_cache_and_examples(self):
61
+ ctx = InferenceContext("合成正文")
62
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
63
+ try:
64
+ a, b, c = self.call(), self.call(nullable=False), self.call(nullable=True)
65
+ self.assertEqual(a, b)
66
+ self.assertNotIn("nullable", a.arguments)
67
+ for batch in (False, True):
68
+ pa = backend._prepare(ctx, [a], 64, batch=batch)
69
+ pb = backend._prepare(ctx, [b], 64, batch=batch)
70
+ pc = backend._prepare(ctx, [c], 64, batch=batch)
71
+ self.assertEqual((pa.binding, pa.body), (pb.binding, pb.body))
72
+ self.assertNotEqual(pa.binding, pc.binding)
73
+ for invalid in (None, 0, 1, "true", [], {}):
74
+ with self.subTest(value=invalid), self.assertRaisesRegex(ValueError, "nullable"):
75
+ self.call(nullable=invalid)
76
+ for answer in (None, "评价", "null"):
77
+ self.call(nullable=True, examples=[{"state": "示例", "answer": answer}])
78
+ with self.assertRaises(ValueError):
79
+ self.call(examples=[{"state": "示例", "answer": None}])
80
+ validate_response(c, GenerateResponse(None))
81
+ empty_allowed = self.call(nullable=True, min_length=0)
82
+ validate_response(empty_allowed, GenerateResponse(""))
83
+ validate_response(empty_allowed, GenerateResponse(None))
84
+ validate_response(c, GenerateResponse(" \t")) # Business constrain rejects whitespace separately.
85
+ with self.assertRaisesRegex(InferenceError, "generated text must be a string and satisfy nonempty requirement"):
86
+ validate_response(a, GenerateResponse(None))
87
+ finally:
88
+ await backend.aclose()
89
+
90
+ async def test_single_repair_strict_json_scalars_and_null_diagnostics(self):
91
+ ctx = InferenceContext("合成正文")
92
+ for profile in ("legacy", "qwen_vllm"):
93
+ for feedback in (None, "invalid response"):
94
+ for value in (None, "null", "评价", '甲"\\\n😀'):
95
+ sent = []
96
+ def handle(request):
97
+ sent.append(json.loads(request.content)); return completion(json.dumps(value, ensure_ascii=False))
98
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile,
99
+ transport=httpx.MockTransport(handle))
100
+ try:
101
+ out = await backend.infer(ctx, self.call(nullable=True), repair_feedback=feedback)
102
+ self.assertEqual(out.response.text, value)
103
+ observed = out.metadata["advisory_lengths"]["internal"]
104
+ self.assertEqual(observed["is_null"], value is None)
105
+ self.assertEqual(observed["actual_codepoints"], [] if value is None else [len(value)])
106
+ self.assertEqual(sent[0]["max_tokens"], 64)
107
+ self.assertIn("JSON null", sent[0]["messages"][0]["content"])
108
+ finally:
109
+ await backend.aclose()
110
+ for raw in ('[]', '["评价"]', '{}', '1', 'true', '""', '未加引号', '"截断', ''):
111
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile,
112
+ transport=httpx.MockTransport(lambda r: completion(raw)))
113
+ try:
114
+ with self.subTest(profile=profile, raw=raw), self.assertRaises(InferenceError):
115
+ await backend.infer(ctx, self.call(nullable=True))
116
+ finally:
117
+ await backend.aclose()
118
+ # Ordinary raw text "null" retains its literal value.
119
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
120
+ transport=httpx.MockTransport(lambda r: completion("null")))
121
+ try:
122
+ self.assertEqual((await backend.infer(ctx, self.call())).response.text, "null")
123
+ finally:
124
+ await backend.aclose()
125
+
126
+
127
+ async def test_ir_runtime_reuse_and_repair_in_all_modes(self):
128
+ for optimize in (False, True):
129
+ program = Program.from_json(compile_program(optional_calls, optimize=optimize).to_json())
130
+ plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
131
+ for mode in ("strict", "eager", "speculative"):
132
+ for repair in (False, True):
133
+ sent = []
134
+ backend = FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
135
+ transport=httpx.MockTransport(fixture_handler(sent, repair)))
136
+ async with AnnoRuntime(FakeBackend(), backend, trace=True) as runtime:
137
+ rows = [r async for r in await runtime.submit(plan, [RunInput(0, "正文")], mode=mode)]
138
+ self.assertEqual(rows[0].status, "success", rows[0])
139
+ self.assertEqual(rows[0].value, ["normal", None, None])
140
+ expected = 3 if repair and mode != "strict" else 2
141
+ self.assertEqual(sum(len(llm_calls(body)[1]) for body in sent), expected)
142
+ self.assertTrue(all(body["max_tokens"] == (256 if llm_calls(body)[0] else 64) for body in sent))
143
+
144
+
145
+ class DurableNullableTests(unittest.TestCase):
146
+ def test_native_worker_persists_none_and_collect_never_infers(self):
147
+ with tempfile.TemporaryDirectory() as directory:
148
+ store = Store(Path(directory) / "jobs.sqlite")
149
+ releases = Releases(configurations(), "test")
150
+ sent = []
151
+ def factory(_):
152
+ return FakeBackend(), FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
153
+ transport=httpx.MockTransport(fixture_handler(sent)))
154
+ job = store.submit("alice", "nullable", payload(("甲", "乙"), mode="speculative", program=optional_calls), releases)
155
+ worker = Worker(store, releases, backend_factory=factory)
156
+ async def run():
157
+ while (claim := store.claim("worker", releases)) is not None:
158
+ await worker.execute(claim)
159
+ await worker.aclose()
160
+ asyncio.run(run()); before = len(sent)
161
+ for _ in range(2):
162
+ rows = store.results("alice", job["job_id"])["results"]
163
+ self.assertEqual([r["value"] for r in rows], [["normal", None, None]] * 2)
164
+ self.assertIsNone(store.claim("worker", releases))
165
+ self.assertEqual(len(sent), before)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ unittest.main()