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