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,228 @@
1
+ """Additional requirements through real normalization, prompts and offline HTTP."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import replace
5
+ import inspect
6
+ import json
7
+ import unittest
8
+
9
+ import httpx
10
+
11
+
12
+ from annoscript._data import thaw
13
+ from annoscript._test_evaluation_http import FixtureLLMBackend, llm_calls
14
+ from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError, LLMAPIBackend
15
+ from annoscript.backends.base import normalize_call
16
+ from annoscript.compiler import compile_program
17
+ from annoscript.ir import Program
18
+ from annoscript.anno import Anno
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.test_runtime import FakeBackend
23
+
24
+
25
+ FIRST = "仅依据正文。"
26
+ SECOND = '保留主体 "甲" 与数值😀。'
27
+
28
+
29
+ def requirements_reuse(anno):
30
+ first = anno.generate("标签", max_length=24, max_num_tokens=64, requirements=[FIRST]).text
31
+ second = anno.generate("标签", max_length=24, max_num_tokens=64, requirements=[SECOND]).text
32
+ again = anno.generate("标签", max_length=24, max_num_tokens=64, requirements=[FIRST]).text
33
+ return [first, second, again]
34
+
35
+
36
+ def make_call(operation="generate", *, identity="internal-a", context=None, **options):
37
+ context = context or InferenceContext({"text": "广州"})
38
+ args = {
39
+ "noul": ("判断",), "choice": ("选择", {"yes": "是"}), "score": ("评分", ["低", "高"]),
40
+ "generate": ("标签",), "generate_list": ("维度",), "extract": ("地点",),
41
+ }[operation]
42
+ limits = ({"max_length": 24} if operation == "generate" else
43
+ {"max_items": 2, "max_length_per_item": 24} if operation in ("extract", "generate_list") else {})
44
+ if operation == "extract":
45
+ limits["source_field"] = "text"
46
+ return normalize_call(identity, operation, args, {**limits, **options}, context)
47
+
48
+
49
+ def completion(content):
50
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}]})
51
+
52
+
53
+ class RequirementsTests(unittest.IsolatedAsyncioTestCase):
54
+ async def test_defaults_and_validation_for_all_operations(self):
55
+ context = InferenceContext({"text": "广州"}, "共用规则")
56
+ sent = []
57
+ decision = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(
58
+ lambda r: sent.append(json.loads(r.content)) or httpx.Response(200, json={"answers": {}})))
59
+ llm = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
60
+ try:
61
+ for op in ("noul", "choice", "score", "extract", "generate", "generate_list"):
62
+ self.assertEqual(inspect.signature(getattr(Anno, op)).parameters["requirements"].kind,
63
+ inspect.Parameter.KEYWORD_ONLY)
64
+ original = make_call(op, context=context)
65
+ self.assertNotIn("requirements", original.arguments)
66
+ for empty in (None, []):
67
+ explicit = make_call(op, context=context, requirements=empty)
68
+ self.assertEqual(original, explicit)
69
+ if op in ("noul", "choice", "score"):
70
+ for call in (original, explicit):
71
+ with self.assertRaises(InferenceError):
72
+ await decision.infer_batch(context, [call])
73
+ self.assertEqual(sent[-2], sent[-1])
74
+ else:
75
+ for batch in (False, True):
76
+ a = llm._prepare(context, [original], 128, batch=batch)
77
+ b = llm._prepare(context, [explicit], 128, batch=batch)
78
+ self.assertEqual((a.binding, a.body), (b.binding, b.body))
79
+ before = len(sent)
80
+ for bad in ("规则", {}, (), 0, False, [None], [1], [""], [" \t\n"], [FIRST, []]):
81
+ with self.subTest(operation=op, value=bad), self.assertRaisesRegex(ValueError, "requirements"):
82
+ make_call(op, context=context, requirements=bad)
83
+ self.assertEqual(len(sent), before)
84
+ finally:
85
+ await decision.aclose()
86
+ await llm.aclose()
87
+
88
+ async def test_groups_each_exact_string_with_ordered_membership(self):
89
+ spaced = " 保留原文空格。 "
90
+ multiline = '多行要求\n保留引号 " 与 Unicode 😀。'
91
+ context = InferenceContext({"text": "广州", "requirements": ["输入数据不是额外要求"]})
92
+ calls = [make_call(context=context, field_name="label", requirements=[SECOND, FIRST, SECOND, spaced]),
93
+ make_call("extract", identity="internal-b", context=context, requirements=[FIRST, multiline]),
94
+ make_call("generate_list", identity="internal-c", context=context, field_name='地点"\n😀',
95
+ requirements=[spaced, "保留原文空格。"]),
96
+ make_call(identity="internal-d", context=context, field_name="unrelated")]
97
+ original = [deepcopy(thaw(c.arguments)) for c in calls]
98
+ expected = (
99
+ '任务要求(仅适用于列出的任务)\n'
100
+ '"label" 要求:' + SECOND + '\n'
101
+ '"label"、"q2" 要求:' + FIRST + '\n'
102
+ '"label"、"地点\\"\\n😀" 要求:' + spaced + '\n'
103
+ '"q2" 要求:' + multiline + '\n'
104
+ '"地点\\"\\n😀" 要求:保留原文空格。'
105
+ )
106
+ for profile, dedup in (("legacy", False), ("qwen_vllm", False), ("qwen_vllm", True)):
107
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile, batch_rule_dedup=dedup)
108
+ try:
109
+ payload = backend._batch_payload(context, calls, 256)
110
+ prompt = payload["messages"][0]["content"]
111
+ self.assertIn(expected + '\n\n任务 "label"', prompt)
112
+ self.assertEqual(prompt.count(SECOND), 1)
113
+ self.assertEqual(prompt.count(FIRST), 1)
114
+ self.assertNotIn("输入数据不是额外要求", prompt)
115
+ self.assertEqual(json.loads(payload["messages"][1]["content"]), thaw(context.state))
116
+ plain = [replace(c, arguments={k: v for k, v in c.arguments.items() if k != "requirements"}) for c in calls]
117
+ base = backend._batch_payload(context, plain, 256)
118
+ for key in payload.keys() - {"messages"}:
119
+ self.assertEqual(payload[key], base[key])
120
+ self.assertEqual([thaw(c.arguments) for c in calls], original)
121
+ finally:
122
+ await backend.aclose()
123
+
124
+ async def test_split_single_and_repair_recompute_membership(self):
125
+ context = InferenceContext("正文")
126
+ calls = [make_call(identity=str(i), context=context, requirements=[FIRST],
127
+ **({"field_name": name} if name else {}))
128
+ for i, name in enumerate(("first", None, "third"))]
129
+ for profile in ("legacy", "qwen_vllm"):
130
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile)
131
+ try:
132
+ for subset, expected, absent in ((calls, '"first"、"q2"', '"third" 要求:'),
133
+ (calls[1:], '"q1"、"third"', '"first"')):
134
+ plan = backend.plan_batch(context, subset, max_calls=2, max_input_bytes=100000,
135
+ max_output_tokens=4096, default_tokens=1024)
136
+ self.assertEqual(plan.calls, tuple(subset[:2]))
137
+ prompt = plan.prepared.payload["messages"][0]["content"]
138
+ self.assertIn(expected + " 要求:" + FIRST, prompt)
139
+ self.assertNotIn(absent, prompt)
140
+ self.assertEqual(plan.input_bytes, len(plan.prepared.body))
141
+ for feedback in (None, "格式错误"):
142
+ prompt = backend._single_payload(context, calls[1], 64, feedback)["messages"][0]["content"]
143
+ self.assertIn('"1" 要求:' + FIRST, prompt)
144
+ self.assertNotIn('"first"', prompt)
145
+ self.assertNotIn('"third"', prompt)
146
+ self.assertEqual(prompt.count(FIRST), 1)
147
+ finally:
148
+ await backend.aclose()
149
+
150
+ async def test_jev_requirements_with_shared_prompt_and_examples(self):
151
+ sent = []
152
+ def handle(req):
153
+ body = json.loads(req.content)
154
+ sent.append(body)
155
+ values = {"noul": {"noul": .8},
156
+ "choice": {"choice": "yes", "confidence": 1., "probabilities": {"yes": 1.}},
157
+ "score": {"score": 1., "confidence": 1., "probabilities": {"0": 0., "1": 1.},
158
+ "legend": {"0": "低", "1": "高"}}}
159
+ return httpx.Response(200, json={"answers": {
160
+ key: {"type": q["type"], **values[q["type"]]} for key, q in body["questions"].items()}})
161
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
162
+ try:
163
+ for prompt in (None, "共用规则"):
164
+ for examples in (False, True):
165
+ context = InferenceContext({"text": "广州"}, prompt)
166
+ calls = [make_call(op, identity=op, context=context, requirements=[FIRST, SECOND, FIRST],
167
+ **({"examples": [{"state": "示例", "answer": answer}]} if examples else {}))
168
+ for op, answer in (("noul", True), ("choice", "yes"), ("score", 1))]
169
+ original = [thaw(c.arguments) for c in calls]
170
+ result = await backend.infer_batch(context, calls)
171
+ self.assertEqual(set(result.responses), {c.id for c in calls})
172
+ for c in calls:
173
+ rendered = sent[-1]["questions"][c.id]["instructions"]
174
+ task = c.arguments["instructions"] + "\n\n要求:\n" + FIRST + "\n" + SECOND
175
+ self.assertEqual(rendered, task)
176
+ self.assertEqual([thaw(c.arguments) for c in calls], original)
177
+ self.assertEqual(sent[-1]["state"], thaw(context.state))
178
+ finally:
179
+ await backend.aclose()
180
+
181
+ async def test_requirements_bind_preparation_budget_and_runtime_reuse(self):
182
+ context = InferenceContext("正文")
183
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
184
+ try:
185
+ source = [FIRST]
186
+ call = make_call(context=context, requirements=source)
187
+ source.append(SECOND)
188
+ self.assertEqual(thaw(call.arguments)["requirements"], [FIRST])
189
+ changed = make_call(context=context, requirements=[SECOND])
190
+ first = backend._prepare(context, [call], 128)
191
+ second = backend._prepare(context, [changed], 128)
192
+ self.assertNotEqual(first.binding, second.binding)
193
+ self.assertNotEqual(first.body, second.body)
194
+ self.assertEqual(first.payload["max_tokens"], second.payload["max_tokens"])
195
+ finally:
196
+ await backend.aclose()
197
+ for optimize in (False, True):
198
+ program = Program.from_json(compile_program(requirements_reuse, optimize=optimize).to_json())
199
+ plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
200
+ for mode in ("strict", "eager", "speculative"):
201
+ for repair in (False, True):
202
+ sent = []
203
+ def handle(req):
204
+ body = json.loads(req.content)
205
+ sent.append(body)
206
+ batch, captured = llm_calls(body)
207
+ if batch:
208
+ answers = {f"q{i}": (None if repair and c.arguments["requirements"][0] == SECOND
209
+ else c.arguments["requirements"][0])
210
+ for i, (_, c) in enumerate(captured, 1)}
211
+ return completion(json.dumps({"answers": answers}, ensure_ascii=False))
212
+ return completion(captured[0][1].arguments["requirements"][0])
213
+ llm = FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
214
+ transport=httpx.MockTransport(handle))
215
+ async with AnnoRuntime(FakeBackend(), llm, max_llm_batch_calls=8) as runtime:
216
+ result, = [r async for r in await runtime.submit(plan, [RunInput(0, "正文")], mode=mode)]
217
+ self.assertEqual(result.status, "success", result.error)
218
+ self.assertEqual(result.value, [FIRST, SECOND, FIRST])
219
+ self.assertEqual(sum(len(llm_calls(body)[1]) for body in sent),
220
+ 3 if repair and mode != "strict" else 2)
221
+ if repair and mode != "strict":
222
+ self.assertIn(SECOND, sent[-1]["messages"][0]["content"])
223
+ self.assertNotIn(FIRST, sent[-1]["messages"][0]["content"])
224
+ self.assertEqual(sent[-1]["max_tokens"], 64)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ unittest.main()
@@ -0,0 +1,67 @@
1
+ """Lossless rule factoring and synthetic field regressions; never live models."""
2
+ import asyncio,copy,json,unittest
3
+ from unittest.mock import patch
4
+ import httpx
5
+ from annoscript.backends import InferenceContext
6
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
7
+ from annoscript.backends.base import normalize_call
8
+ from annoscript.backends._shared_rules import factor_rules
9
+ from annoscript._test_evaluation_http import llm_calls,llm_content
10
+ from annoscript.test_llm_batch import completion,answer
11
+ from annoscript.test_generation_policy import policy
12
+ from annoscript.server.releases import Releases
13
+ from annoscript.test_jobs_server import configurations
14
+ from annoscript.anno import GenerateListResponse,ExtractResponse
15
+
16
+
17
+ RULE='Preserve the supplied text and distinguish each task. Return only the requested output. Synthetic rule with Unicode 中文😀é\\\"\nEnd of rule.'
18
+
19
+ class RuleTests(unittest.IsolatedAsyncioTestCase):
20
+ def calls(self,ctx):
21
+ a='本字段说明\n\n'+RULE+'\n\n第二段\n\n'+RULE+'\n\nGROUNDING_JSON:{"proposed":"私有数据"}'
22
+ return [normalize_call('a','generate_list',(a,),dict(max_items=1,max_length_per_item=80,max_num_tokens=160),ctx),normalize_call('b','extract',('另一字段\n\n'+RULE,),dict(source_field='text',max_items=2,max_length_per_item=10),ctx),normalize_call('c','extract',('无规则的独立来源',),dict(source_field='other',max_items=1,max_length_per_item=8),ctx)]
23
+ async def test_exact_roundtrip_scoping_grammar_sampling_budget_and_prepared_bytes(self):
24
+ ctx=InferenceContext({'text':'广州😀é\\"\n','other':'南京','shared_rules':{'r0':'攻击文本'}},'共享策略');sent=[];events=[]
25
+ def handle(req):sent.append(req.content);return completion(llm_content(json.loads(req.content),lambda c,x:GenerateListResponse(['资讯']) if x.operation=='generate_list' else ExtractResponse(['广州'] if x.id=='b' else ['南京'])))
26
+ a=LLMAPIBackend('http://offline.invalid','x','m',profile='qwen_vllm')
27
+ b=LLMAPIBackend('http://offline.invalid','x','m',profile='qwen_vllm',batch_rule_dedup=True,transport=httpx.MockTransport(handle),diagnostic_observer=events.append)
28
+ try:
29
+ calls=self.calls(ctx);p=a._batch_payload(ctx,calls,500);q=b._batch_payload(ctx,calls,500)
30
+ raw=p['messages'][0]['content'];compact=q['messages'][0]['content']
31
+ self.assertEqual(p['messages'][-1],q['messages'][-1])
32
+ self.assertEqual(json.loads(q['messages'][-1]['content']),dict(ctx.state))
33
+ self.assertEqual(raw.count(RULE),3);self.assertEqual(compact.count(RULE),1)
34
+ self.assertIn('共享段落仅适用于引用它的任务',compact)
35
+ self.assertEqual(compact.count('引用共享段落 r0。'),3)
36
+ # Removing the shared definition and expanding in place restores every task verbatim.
37
+ plain_tasks=raw[raw.index('任务 "q1"('):]
38
+ factored_tasks=compact[compact.index('任务 "q1"('):].replace('引用共享段落 r0。',RULE)
39
+ self.assertEqual(factored_tasks,plain_tasks)
40
+ for k in ['max_tokens','structured_outputs','chat_template_kwargs']:self.assertEqual(p[k],q[k])
41
+ plan=b.plan_batch(ctx,calls,max_calls=8,max_input_bytes=262144,max_output_tokens=4096,default_tokens=1024)
42
+ with patch.object(b,'_prepare',side_effect=AssertionError('prepared twice')):
43
+ got=await b.infer_batch(ctx,plan.calls,max_tokens=plan.max_tokens,prepared=plan.prepared)
44
+ self.assertEqual(got.responses['a'].items,['资讯']);self.assertEqual(sent,[plan.prepared.body])
45
+ self.assertTrue(any(e.get('shared_rules',{}).get('rule_sha256') for e in events))
46
+ for feedback in [None,'invalid']:
47
+ self.assertEqual(a._single_payload(ctx,calls[0],160,feedback),b._single_payload(ctx,calls[0],160,feedback))
48
+ binding=b._binding(ctx,calls,500);b.batch_rule_dedup=False;self.assertNotEqual(binding,b._binding(ctx,calls,500))
49
+ finally:await a.aclose();await b.aclose()
50
+ async def test_no_substring_or_diagnostic_or_cross_request_factoring(self):
51
+ for instructions in [('short','short'),(RULE+'suffix',RULE+'other'),('GROUNDING_JSON:'+RULE,'GROUNDING_JSON:'+RULE)]:
52
+ tasks={str(i):{'task':{'instructions':s}} for i,s in enumerate(instructions)};old=copy.deepcopy(tasks);rules,_=factor_rules(tasks);self.assertEqual(rules,{});self.assertEqual(tasks,old)
53
+ tasks={'a':{'task':{'instructions':RULE}}};self.assertEqual(factor_rules(tasks)[0],{})
54
+ for bad in [1,None,'yes']:
55
+ with self.assertRaises(ValueError):LLMAPIBackend('http://offline.invalid','x','m',profile='qwen_vllm',batch_rule_dedup=bad)
56
+ with self.assertRaises(ValueError):LLMAPIBackend('http://offline.invalid','x','m',batch_rule_dedup=True)
57
+ async def test_release_and_context_use_actual_compact_payload(self):
58
+ cfg=configurations()['test'];cfg['generation']['profile']='qwen_vllm';a=Releases({'t':cfg},'t')
59
+ cfg['generation']['batch_rule_dedup']=True;b=Releases({'t':cfg},'t');self.assertNotEqual(a.default,b.default)
60
+ ctx=InferenceContext({'text':'广州','other':'南京'});calls=self.calls(ctx);sent=[]
61
+ counter=lambda p:sum(len(m['content']) for m in p['messages'])
62
+ plain=LLMAPIBackend('http://offline.invalid','x','m',profile='qwen_vllm',request_policy=policy(),_token_counter=counter)
63
+ dedup=LLMAPIBackend('http://offline.invalid','x','m',profile='qwen_vllm',batch_rule_dedup=True,request_policy=policy(),_token_counter=counter)
64
+ try:
65
+ p=plain._prepare(ctx,calls,500,batch=True);q=dedup._prepare(ctx,calls,500,batch=True)
66
+ self.assertEqual(q.evidence['input_tokens'],counter(q.payload));self.assertLess(q.evidence['input_tokens'],p.evidence['input_tokens'])
67
+ finally:await plain.aclose();await dedup.aclose()
@@ -0,0 +1,314 @@
1
+ """Independent runtime semantics, bounded scheduling and per-state batching."""
2
+
3
+ import asyncio
4
+ from collections import Counter
5
+ from contextlib import ExitStack
6
+ from copy import deepcopy
7
+ from dataclasses import replace
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ from annoscript._data import thaw
12
+ from annoscript.backends import BatchResult, InferenceOutput, BackendFatalError
13
+ from annoscript.compiler import compile_program
14
+ from annoscript.plan import ExecutionPlan
15
+ from annoscript import ir, plan as p
16
+ from annoscript.plan_lowering import lower_to_plan
17
+ from annoscript.runtime import AnnoRuntime, RunInput
18
+ from annoscript.anno import NoulResponse, ChoiceResponse, ScoreResponse, GenerateResponse, constrain
19
+ from annoscript import test_plan as fixtures
20
+ from annoscript import test_plan_constraints as checks
21
+ from annoscript._test_plan_runtime import evaluate
22
+
23
+
24
+ def mixed(anno):
25
+ a = anno.noul("yes?").noul
26
+ b = anno.choice("which?", {"a": "A", "b": "B"}).choice
27
+ c = anno.score("score?", ["bad", "good"]).score
28
+ return [a, b, c]
29
+
30
+
31
+ def decision_chain(anno):
32
+ a = anno.choice("first", {"a": "A", "b": "B"}).choice
33
+ b = anno.noul("independent").noul
34
+ return anno.noul(a).noul + b
35
+
36
+
37
+ def decision_loop(anno):
38
+ result = []
39
+ for item in anno.state:
40
+ result = result + [anno.noul(item).noul]
41
+ return result
42
+
43
+
44
+ def changed_criteria(anno):
45
+ criteria = {"a": "before", "b": "other"}
46
+ alias = criteria
47
+ first = anno.choice("choose", criteria).choice
48
+ alias["a"] = "after"
49
+ second = anno.choice("choose", criteria).choice
50
+ return [first, second]
51
+
52
+
53
+ class FakeBackend:
54
+ def __init__(self, table=None, fail=()):
55
+ self.table, self.fail = table, fail
56
+ self.calls, self.batches = [], []
57
+ self.closed = False
58
+
59
+ def answer(self, call):
60
+ a = thaw(call.arguments)
61
+ self.calls.append(fixtures._key(call.operation, (), a))
62
+ if self.table is not None:
63
+ result = self.table[self.calls[-1]]
64
+ if isinstance(result, Exception):
65
+ raise type(result)(str(result))
66
+ return deepcopy(result)
67
+ if a["instructions"] in self.fail:
68
+ raise ValueError(a["instructions"])
69
+ if call.operation == "generate":
70
+ return GenerateResponse(a["instructions"])
71
+ if call.operation == "noul":
72
+ return NoulResponse(0.8)
73
+ if call.operation == "choice":
74
+ return ChoiceResponse(next(iter(a["criteria"])), 0.9, {k: float(i == 0) for i, k in enumerate(a["criteria"])})
75
+ if call.operation == "score":
76
+ return ScoreResponse({i: float(i == 0) for i in range(len(a["criteria"]))}, 0, dict(enumerate(a["criteria"])), 0.9)
77
+ raise AssertionError(call.operation)
78
+
79
+ async def infer(self, context, call):
80
+ return InferenceOutput(self.answer(call))
81
+
82
+ async def infer_batch(self, context, calls):
83
+ self.batches.append((thaw(context.state), [c.operation for c in calls], [c.arguments["instructions"] for c in calls]))
84
+ return BatchResult({c.id: self.answer(c) for c in calls}, {"usage": {"input_tokens": 100}})
85
+
86
+ async def aclose(self):
87
+ self.closed = True
88
+
89
+
90
+ async def execute(function, state, *, backend=None, mode="eager", **options):
91
+ backend = backend or FakeBackend()
92
+ async with AnnoRuntime(backend, backend, trace=True, **options) as runtime:
93
+ plan = ExecutionPlan.from_json(lower_to_plan(compile_program(function)).to_json())
94
+ before = plan.to_dict()
95
+ stream = await runtime.submit(plan, [RunInput(0, state)], mode=mode)
96
+ result = [r async for r in stream]
97
+ assert before == plan.to_dict()
98
+ return result[0], backend, runtime.trace
99
+
100
+
101
+ class RuntimeTests(unittest.IsolatedAsyncioTestCase):
102
+ async def test_all_plan_shapes_against_independent_interpreter(self):
103
+ cases = [
104
+ (fixtures.independent, "state"), (fixtures.duplicates, "state"),
105
+ (fixtures.branch_duplicates, []), (fixtures.branch_duplicates, [1]),
106
+ (fixtures.unused_branch, []), (fixtures.loop_invariant, [True, False, True]),
107
+ (fixtures.independent_loop, [str(i) for i in range(40)]),
108
+ (fixtures.last_value, []), (fixtures.last_value, ["a", "b"]),
109
+ (fixtures.conditional_last, ["a", "", "b"]),
110
+ (fixtures.nested_loop, [["a", "b"], [], ["c"]]),
111
+ (fixtures.local_container, ["a", "b"]), (fixtures.memory_snapshots, "x"),
112
+ (fixtures.iteration_alias, ["a", "b", "c"]),
113
+ (fixtures.nested_memory, [["a", "b"], [], ["c"]]),
114
+ (fixtures.iterator_twice, ["a", "b"]),
115
+ (fixtures.early_return, []), (fixtures.early_return, [True]),
116
+ (fixtures.nested_return, [False]), (fixtures.nested_return, [True]),
117
+ (fixtures.partial_nested_return, []), (fixtures.partial_nested_return, [False]),
118
+ (checks.checked_memory, "x"), (checks.checked_rebinding, "x"),
119
+ (checks.checked_shared, "same"), (checks.checked_memory_loop, ["a", "b"]),
120
+ ]
121
+ for fn, state in cases:
122
+ for mode in ("eager", "strict"):
123
+ with self.subTest(function=fn.__name__, mode=mode, state=state):
124
+ plan = lower_to_plan(compile_program(fn))
125
+ reference = fixtures.PureAnnoScript(deepcopy(state))
126
+ expected = await asyncio.to_thread(evaluate, plan.to_dict(), reference, mode=mode)
127
+ result, backend, _ = await execute(fn, state, mode=mode, loop_window=2)
128
+ self.assertEqual(result.status, "success", result.error)
129
+ self.assertEqual(result.value, expected)
130
+ expected_calls = [fixtures._key("generate", (x,), opts) for x, opts in reference.calls]
131
+ self.assertEqual(Counter(backend.calls), Counter(expected_calls))
132
+
133
+ async def test_mixed_batch_and_usage_once(self):
134
+ result, backend, trace = await execute(mixed, "shared")
135
+ self.assertEqual(result.value, [0.8, "a", 0])
136
+ self.assertEqual(backend.batches[0][1], ["noul", "choice", "score"])
137
+ self.assertEqual(len(backend.batches), 1)
138
+ self.assertEqual(sum(e.get("usage", {}).get("input_tokens", 0) for e in trace), 100)
139
+ _, backend, _ = await execute(mixed, "shared", mode="strict")
140
+ self.assertEqual([len(b[1]) for b in backend.batches], [1, 1, 1])
141
+
142
+ async def test_dependency_forms_later_batch_and_loop_capacity(self):
143
+ result, backend, _ = await execute(decision_chain, "x")
144
+ self.assertEqual(result.value, 1.6)
145
+ self.assertEqual([b[2] for b in backend.batches], [["first", "independent"], ["a"]])
146
+ result, backend, _ = await execute(decision_loop, [str(i) for i in range(9)], max_batch_questions=3, loop_window=4)
147
+ self.assertEqual(result.value, [0.8] * 9)
148
+ self.assertTrue(all(len(b[1]) <= 3 for b in backend.batches))
149
+ self.assertGreater(max(len(b[1]) for b in backend.batches), 1)
150
+
151
+ async def test_mutated_criteria_preserves_call_snapshots(self):
152
+ for mode in ("eager", "strict"):
153
+ result, backend, _ = await execute(changed_criteria, "x", mode=mode)
154
+ self.assertEqual(result.value, ["a", "a"])
155
+ self.assertEqual(backend.calls, [fixtures._key("choice", ("choose", {"a": value, "b": "other"}), {}) for value in ("before", "after")])
156
+
157
+ async def test_inputs_never_share_batches_and_are_snapshotted(self):
158
+ backend = FakeBackend()
159
+ async with AnnoRuntime(backend, backend) as runtime:
160
+ source = {"text": "original"}
161
+ stream = await runtime.submit(lower_to_plan(compile_program(mixed)), [RunInput(3, source), RunInput(9, source)])
162
+ source["text"] = "changed"
163
+ result = [r async for r in stream]
164
+ self.assertEqual({r.index for r in result}, {3, 9})
165
+ self.assertEqual(len(backend.batches), 2)
166
+ self.assertTrue(all(b[0] == {"text": "original"} for b in backend.batches))
167
+
168
+ async def test_failure_prefix_and_constraint_paths(self):
169
+ for fn, state, fail, prefix in [
170
+ (fixtures.independent, "x", ("a",), ["a"]),
171
+ (fixtures.expression_failure, "x", (), ["first"]),
172
+ (fixtures.unpack_failure, [1], (), []),
173
+ (fixtures.unused_failure, "x", ("bad",), ["bad"]),
174
+ (fixtures.independent_loop, ["bad", "later"], ("bad",), ["bad"]),
175
+ (checks.checked_chain, "x", (), ["check"]),
176
+ (checks.checked_container_truth, "x", (), []),
177
+ ]:
178
+ with self.subTest(function=fn.__name__):
179
+ result, backend, _ = await execute(fn, state, backend=FakeBackend(fail=fail), mode="strict")
180
+ self.assertEqual(result.status, "error")
181
+ expected = [fixtures._key("generate", (x,), {}) for x in prefix]
182
+ self.assertEqual(backend.calls, expected)
183
+ self.assertIn("task=", result.error.message)
184
+
185
+ async def test_eager_failure_cancels_pending_and_no_new_dispatch(self):
186
+ class Controlled(FakeBackend):
187
+ async def infer(self, context, call):
188
+ name = call.arguments["instructions"]
189
+ self.started.append(name)
190
+ if name == "check":
191
+ await self.other_started.wait()
192
+ return InferenceOutput(GenerateResponse("bad"))
193
+ if name == "independent":
194
+ self.other_started.set()
195
+ try:
196
+ await asyncio.Event().wait()
197
+ finally:
198
+ self.cancelled.set()
199
+ self.fail_unexpected = True
200
+ backend = Controlled()
201
+ backend.started, backend.other_started, backend.cancelled = [], asyncio.Event(), asyncio.Event()
202
+ result, _, _ = await execute(checks.checked_chain, "x", backend=backend)
203
+ self.assertEqual(result.error.code, "constraint_unsatisfied")
204
+ self.assertCountEqual(backend.started, ["check", "independent"])
205
+ self.assertTrue(backend.cancelled.is_set())
206
+
207
+ async def test_concurrency_limit_close_and_submission_isolation(self):
208
+ class Controlled(FakeBackend):
209
+ async def infer(self, context, call):
210
+ self.active += 1
211
+ self.peak = max(self.active, self.peak)
212
+ self.started.set()
213
+ try:
214
+ await self.release.wait()
215
+ return InferenceOutput(GenerateResponse(call.arguments["instructions"]))
216
+ finally:
217
+ self.active -= 1
218
+ backend = Controlled()
219
+ backend.active = backend.peak = 0
220
+ backend.started, backend.release = asyncio.Event(), asyncio.Event()
221
+ async with AnnoRuntime(backend, backend, max_llm_inflight=1) as runtime:
222
+ plan = lower_to_plan(compile_program(fixtures.independent))
223
+ first = await runtime.submit(plan, [RunInput(0, "x")])
224
+ second = await runtime.submit(plan, [RunInput(1, "y")])
225
+ await asyncio.wait_for(backend.started.wait(), 2)
226
+ await first.aclose()
227
+ backend.release.set()
228
+ result = [r async for r in second]
229
+ self.assertEqual(result[0].value, ["a", "b"])
230
+ self.assertEqual(backend.peak, 1)
231
+ self.assertFalse(backend.closed)
232
+
233
+ async def test_active_run_bound_and_local_work_quantum(self):
234
+ result, backend, trace = await execute(decision_loop, [str(i) for i in range(20)], local_task_budget=3, loop_window=2)
235
+ self.assertEqual(result.value, [0.8] * 20)
236
+ self.assertEqual(sum(len(b[1]) for b in backend.batches), 20)
237
+
238
+ async def test_success_waits_for_unused_calls_and_checks(self):
239
+ for function, state in ((checks.checked_constant_return, "x"), (checks.checked_nested_return, [True]),
240
+ (checks.checked_unused_loop, ["a", "b"])):
241
+ class Delayed(FakeBackend):
242
+ async def infer(self, context, call):
243
+ self.started.set()
244
+ await self.release.wait()
245
+ return InferenceOutput(GenerateResponse("bad"))
246
+ backend = Delayed()
247
+ backend.started, backend.release = asyncio.Event(), asyncio.Event()
248
+ async with AnnoRuntime(backend, backend) as runtime:
249
+ stream = await runtime.submit(lower_to_plan(compile_program(function)), [RunInput(0, state)])
250
+ next_result = asyncio.create_task(stream.__anext__())
251
+ await asyncio.wait_for(backend.started.wait(), 2)
252
+ self.assertFalse(next_result.done())
253
+ backend.release.set()
254
+ result = await asyncio.wait_for(next_result, 2)
255
+ self.assertEqual(result.error.code, "constraint_unsatisfied")
256
+
257
+ async def test_first_observed_error_beats_source_order(self):
258
+ class Failing(FakeBackend):
259
+ async def infer(self, context, call):
260
+ if call.arguments["instructions"] == "a":
261
+ self.first_started.set()
262
+ await asyncio.Event().wait()
263
+ await self.first_started.wait()
264
+ raise ValueError("b observed first")
265
+ backend = Failing()
266
+ backend.first_started = asyncio.Event()
267
+ result, _, _ = await execute(fixtures.independent, "x", backend=backend)
268
+ self.assertIn("b observed first", result.error.message)
269
+
270
+ async def test_active_runs_and_loop_window_before_completion(self):
271
+ class Delayed(FakeBackend):
272
+ async def infer(self, context, call):
273
+ self.started.set()
274
+ await self.release.wait()
275
+ return InferenceOutput(GenerateResponse(call.arguments["instructions"]))
276
+ backend = Delayed()
277
+ backend.started, backend.release = asyncio.Event(), asyncio.Event()
278
+ async with AnnoRuntime(backend, backend, max_active_runs=1, loop_window=2, trace=True) as runtime:
279
+ stream = await runtime.submit(lower_to_plan(compile_program(fixtures.independent_loop)),
280
+ [RunInput(i, ["a", "b", "c", "d"]) for i in range(3)])
281
+ await asyncio.wait_for(backend.started.wait(), 2)
282
+ self.assertEqual(sum(e["event"] == "run_started" for e in runtime.trace), 1)
283
+ self.assertEqual(sum(e["event"] == "infer_ready" for e in runtime.trace), 2)
284
+ backend.release.set()
285
+ self.assertEqual(len([r async for r in stream]), 3)
286
+
287
+ async def test_deserialized_plan_cannot_write_input_state(self):
288
+ plan = lower_to_plan(compile_program(fixtures.memory_snapshots))
289
+ plan = replace(plan, tasks=tuple(replace(t, container=p.BoundExpr(ir.State())) if isinstance(t, p.Store) else t for t in plan.tasks))
290
+ plan = ExecutionPlan.from_json(plan.to_json())
291
+ state = {"x": "input"}
292
+ backend = FakeBackend()
293
+ async with AnnoRuntime(backend, backend) as runtime:
294
+ stream = await runtime.submit(plan, [RunInput(0, state)], mode="strict")
295
+ result = [r async for r in stream]
296
+ self.assertEqual(result[0].status, "error")
297
+ self.assertIn("locally constructed", result[0].error.message)
298
+ self.assertEqual(state, {"x": "input"})
299
+
300
+ async def test_invalid_plan_and_empty_submission(self):
301
+ backend = FakeBackend()
302
+ async with AnnoRuntime(backend, backend) as runtime:
303
+ plan = lower_to_plan(compile_program(mixed))
304
+ stream = await runtime.submit(plan, [])
305
+ self.assertEqual([r async for r in stream], [])
306
+ with self.assertRaises(ValueError):
307
+ await runtime.submit(plan, [RunInput(0, "x"), RunInput(0, "y")])
308
+ with self.assertRaises(ValueError):
309
+ await runtime.submit(plan, [RunInput(0, "x")], mode="invalid")
310
+ self.assertEqual(backend.calls, [])
311
+
312
+
313
+ if __name__ == "__main__":
314
+ unittest.main()