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,149 @@
1
+ """Client-side example snapshots and compilation; no backend dependencies."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ import unittest
6
+ from unittest.mock import patch
7
+
8
+
9
+ from annoscript_examples.fixtures import REVIEW_INPUT as REVIEW, LAUNCH_INPUT as LAUNCH
10
+
11
+
12
+ from annoscript.compiler import compile_program
13
+ from annoscript.ir import Program
14
+ from annoscript.anno import Example, ExtractResponse, GenerateResponse, GenerateListResponse
15
+ from annoscript._test_ir_runtime import evaluate
16
+
17
+
18
+ LOCATION = Example(LAUNCH, ["广州"])
19
+
20
+
21
+ def captured(anno):
22
+ return anno.extract("地点", source_field="text", max_items=2,
23
+ max_length_per_item=80, examples=[LOCATION]).items
24
+
25
+
26
+ def constructed(anno):
27
+ state = {"text": "示例在广州发布"}
28
+ answer = ["广州"]
29
+ example = Example(state=state, answer=answer)
30
+ state["text"] = "示例条目"
31
+ answer[0] = "条目"
32
+ return anno.extract("地点", source_field="text", max_items=2,
33
+ max_length_per_item=80, examples=[example]).items
34
+
35
+
36
+ def different_examples(anno):
37
+ # Deliberately vary only the answer to check identity, not business accuracy.
38
+ first = anno.choice("情感", {"正面": "积极", "中性": "中立"},
39
+ examples=[Example(REVIEW, "正面")]).choice
40
+ second = anno.choice("情感", {"正面": "积极", "中性": "中立"},
41
+ examples=[Example(REVIEW, "中性")]).choice
42
+ third = anno.choice("情感", {"正面": "积极", "中性": "中立"},
43
+ examples=[Example(REVIEW, "正面")]).choice
44
+ return [first, second, third]
45
+
46
+
47
+ def named_generations(anno):
48
+ title = anno.generate("标题", max_length=60, field_name="event_title").text
49
+ dimensions = anno.generate_list("维度", max_items=8, max_length_per_item=24,
50
+ field_name="tags").items
51
+ locations = anno.extract("地点", source_field="text", max_items=8,
52
+ max_length_per_item=64, field_name="locations").items
53
+ return [title, dimensions, locations]
54
+
55
+
56
+ def nullable_example(anno):
57
+ value = anno.generate("缺依据为 null", nullable=True, min_length=1, max_length=24,
58
+ examples=[Example(state="示例正文", answer=None)]).text
59
+ if value is not None:
60
+ return value.strip()
61
+ return value
62
+
63
+
64
+ def requirements_for_all_operations(anno):
65
+ requirements = ["仅依据正文。", "保留原意。"]
66
+ a = anno.noul("判断", requirements=requirements).noul
67
+ b = anno.choice("选择", {"yes": "是"}, requirements=requirements).choice
68
+ c = anno.score("评分", ["低", "高"], requirements=requirements).score
69
+ d = anno.generate("标签", max_length=24, requirements=requirements).text
70
+ e = anno.generate_list("维度", max_items=2, max_length_per_item=24,
71
+ requirements=requirements).items
72
+ f = anno.extract("地点", source_field="text", max_items=2, max_length_per_item=24,
73
+ requirements=requirements).items
74
+ return [a, b, c, d, e, f]
75
+
76
+
77
+ class ExampleClientTests(unittest.TestCase):
78
+ def test_requirements_survive_client_ir_for_all_operations(self):
79
+ from annoscript.anno import NoulResponse, ChoiceResponse, ScoreResponse
80
+
81
+ class Probe:
82
+ def __init__(self):
83
+ self.calls = []
84
+
85
+ def record(self, operation, options, response):
86
+ self.calls.append(operation)
87
+ assert options["requirements"] == ["仅依据正文。", "保留原意。"]
88
+ return response
89
+
90
+ def noul(self, instructions, **options):
91
+ return self.record("noul", options, NoulResponse(.8))
92
+
93
+ def choice(self, instructions, criteria, **options):
94
+ return self.record("choice", options, ChoiceResponse("yes", .9, {"yes": 1.}))
95
+
96
+ def score(self, instructions, criteria, **options):
97
+ return self.record("score", options, ScoreResponse({0: 0., 1: 1.}, 1., dict(enumerate(criteria)), .9))
98
+
99
+ def generate(self, instructions, **options):
100
+ return self.record("generate", options, GenerateResponse("标签"))
101
+
102
+ def generate_list(self, instructions, **options):
103
+ return self.record("generate_list", options, GenerateListResponse(["维度"]))
104
+
105
+ def extract(self, instructions, **options):
106
+ return self.record("extract", options, ExtractResponse(["广州"]))
107
+
108
+ for optimize in (False, True):
109
+ probe = Probe()
110
+ program = Program.from_json(compile_program(requirements_for_all_operations, optimize=optimize).to_json())
111
+ self.assertEqual(evaluate(program.to_dict(), probe), [.8, "yes", 1., "标签", ["维度"], ["广州"]])
112
+ self.assertEqual(probe.calls, ["noul", "choice", "score", "generate", "generate_list", "extract"])
113
+
114
+ def test_nullable_example_and_none_survive_client_ir(self):
115
+ class Probe:
116
+ state = "正文"
117
+
118
+ def generate(self, instructions, **options):
119
+ assert options["nullable"] is True
120
+ example = options["examples"][0]
121
+ example = example.to_dict() if type(example) is Example else example
122
+ assert example["answer"] is None
123
+ return GenerateResponse(None)
124
+
125
+ for optimize in (False, True):
126
+ program = Program.from_json(compile_program(nullable_example, optimize=optimize).to_json())
127
+ self.assertIsNone(evaluate(program.to_dict(), Probe()))
128
+
129
+ def test_field_names_survive_client_ir_roundtrip(self):
130
+ class Probe:
131
+ state = {"text": "广州"}
132
+
133
+ def generate(self, instructions, **options):
134
+ return GenerateResponse(options["field_name"])
135
+
136
+ def generate_list(self, instructions, **options):
137
+ return GenerateListResponse([options["field_name"]])
138
+
139
+ def extract(self, instructions, **options):
140
+ return ExtractResponse([options["field_name"]])
141
+
142
+ for optimize in (False, True):
143
+ program = Program.from_json(compile_program(named_generations, optimize=optimize).to_json())
144
+ self.assertEqual(evaluate(program.to_dict(), Probe()),
145
+ ["event_title", ["tags"], ["locations"]])
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -0,0 +1,221 @@
1
+ """Public task names through the real wire, planner, runtime and durable jobs."""
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.backends import InferenceContext, LLMAPIBackend, InferenceError
14
+ from annoscript.backends.base import normalize_call
15
+ from annoscript.compiler import compile_program
16
+ from annoscript.ir import Program
17
+ from annoscript.plan import ExecutionPlan
18
+ from annoscript.plan_lowering import lower_to_plan
19
+ from annoscript.runtime import AnnoRuntime, RunInput
20
+ from annoscript.server.releases import Releases
21
+ from annoscript.server.store import Store
22
+ from annoscript.server.worker import Worker
23
+ from annoscript.test_jobs_server import configurations, payload
24
+ from annoscript.test_runtime import FakeBackend
25
+ from annoscript._test_evaluation_http import FixtureLLMBackend, llm_calls
26
+
27
+
28
+ def named_pair(anno):
29
+ first = anno.generate("same", max_length=10, max_num_tokens=32, field_name="first").text
30
+ second = anno.generate("same", max_length=10, max_num_tokens=32, field_name="second").text
31
+ again = anno.generate("same", max_length=10, max_num_tokens=32, field_name="first").text
32
+ return [first, second, again]
33
+
34
+
35
+ def completion(content):
36
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}],
37
+ "usage": {"prompt_tokens": 7, "completion_tokens": 2}})
38
+
39
+
40
+ def named_handler(sent, *, repair=False):
41
+ def handle(request):
42
+ body = json.loads(request.content)
43
+ sent.append(body)
44
+ batched, calls = llm_calls(body)
45
+ if batched:
46
+ answer = {call.arguments["field_name"]: call.arguments["field_name"] for _, call in calls}
47
+ if repair:
48
+ answer.pop("second")
49
+ return completion(json.dumps({"answers": answer}))
50
+ return completion(calls[0][1].arguments["field_name"])
51
+ return handle
52
+
53
+
54
+ class FieldNameTests(unittest.IsolatedAsyncioTestCase):
55
+ def make_call(self, operation="generate", name=None, identity="run1-q1", context=None, **options):
56
+ context = context or InferenceContext({"text": 'q1 q10 甲"\\\n😀'})
57
+ defaults = {"max_length": 10} if operation == "generate" else {"max_items": 2, "max_length_per_item": 10}
58
+ if operation == "extract":
59
+ defaults["source_field"] = "text"
60
+ if name is not None:
61
+ defaults["field_name"] = name
62
+ return normalize_call(identity, operation, ("保留正文中的 q1 与 q10",), {**defaults, **options}, context)
63
+
64
+ async def test_none_and_omitted_preserve_exact_payload_and_binding(self):
65
+ ctx = InferenceContext({"text": "甲"})
66
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
67
+ try:
68
+ for op in ("generate", "generate_list", "extract"):
69
+ absent = self.make_call(op, context=ctx)
70
+ explicit = self.make_call(op, context=ctx, field_name=None)
71
+ self.assertEqual(absent, explicit)
72
+ self.assertNotIn("field_name", absent.arguments)
73
+ a, b = backend._prepare(ctx, [absent], 128), backend._prepare(ctx, [explicit], 128)
74
+ self.assertEqual((a.binding, a.body, a.payload), (b.binding, b.body, b.payload))
75
+ other = self.make_call(op, identity="run1-q2", context=ctx)
76
+ self.assertEqual(backend._batch_payload(ctx, [absent, other], 256),
77
+ backend._batch_payload(ctx, [explicit, other], 256))
78
+ finally:
79
+ await backend.aclose()
80
+
81
+ async def test_validation_and_collisions_never_send_http(self):
82
+ sent = []
83
+ ctx = InferenceContext({"text": "甲"})
84
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
85
+ transport=httpx.MockTransport(lambda r: sent.append(r)))
86
+ try:
87
+ for op in ("generate", "generate_list", "extract"):
88
+ for value in ("", " \t\n", False, 1, [], {}):
89
+ with self.subTest(op=op, value=value), self.assertRaisesRegex(ValueError, "field_name"):
90
+ self.make_call(op, context=ctx, field_name=value)
91
+ for names in (("same", "same"), ("q2", None)):
92
+ calls = [self.make_call(name=n, identity=str(i), context=ctx) for i, n in enumerate(names)]
93
+ with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
94
+ await backend.infer_batch(ctx, calls, max_tokens=128)
95
+ with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
96
+ backend.plan_batch(ctx, calls, max_calls=8, max_input_bytes=100000,
97
+ max_output_tokens=4096, default_tokens=1024)
98
+ self.assertEqual(sent, [])
99
+ finally:
100
+ await backend.aclose()
101
+
102
+ async def test_mixed_names_examples_grammar_and_reordered_answers(self):
103
+ ctx = InferenceContext({"text": 'q1 q10 甲"\\\n😀'})
104
+ strange = ' 地点"\\\n😀 '
105
+ calls = [self.make_call(name="q10", context=ctx),
106
+ self.make_call("extract", name=strange, identity="run1-q2", context=ctx,
107
+ examples=[{"state": {"text": "广州"}, "answer": ["广州"]}]),
108
+ self.make_call("generate_list", identity="run1-q3", context=ctx)]
109
+ body = {"answers": {"q3": [], strange: ["甲"], "q10": "标题"}}
110
+ for profile in ("legacy", "qwen_vllm"):
111
+ sent, events = [], []
112
+ def handle(request):
113
+ sent.append(json.loads(request.content))
114
+ return completion(json.dumps(body, ensure_ascii=False))
115
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile,
116
+ transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
117
+ try:
118
+ result = await backend.infer_batch(ctx, calls, max_tokens=256)
119
+ self.assertEqual(result.errors, {})
120
+ self.assertEqual(result.responses[calls[0].id].text, "标题")
121
+ self.assertEqual(result.responses[calls[1].id].items, ["甲"])
122
+ self.assertEqual(result.responses[calls[2].id].items, [])
123
+ prompt = sent[0]["messages"][0]["content"]
124
+ self.assertIn('保留正文中的 q1 与 q10', prompt)
125
+ self.assertIn('任务 ' + json.dumps(strange, ensure_ascii=False), prompt)
126
+ self.assertIn('示例输出:' + json.dumps({"answers": {strange: ["广州"]}}, ensure_ascii=False, separators=(",", ":")), prompt)
127
+ self.assertEqual(json.loads(sent[0]["messages"][1]["content"]), thaw(ctx.state))
128
+ self.assertTrue(any(e.get("wire_call_ids") == {"q10": calls[0].id, strange: calls[1].id, "q3": calls[2].id}
129
+ for e in events))
130
+ if profile == "qwen_vllm":
131
+ root = sent[0]["structured_outputs"]["grammar"].splitlines()[0]
132
+ self.assertIn("q10", root)
133
+ self.assertNotIn("run1", root)
134
+ finally:
135
+ await backend.aclose()
136
+
137
+ async def test_single_and_repair_only_rename_task_identity(self):
138
+ ctx = InferenceContext({"text": "甲"})
139
+ for op, text in (("generate", "标题"), ("extract", '{"items":["甲"]}'),
140
+ ("generate_list", '{"items":[]}')):
141
+ sent, events = [], []
142
+ def handle(request):
143
+ sent.append(json.loads(request.content))
144
+ return completion(text)
145
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
146
+ transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
147
+ try:
148
+ plain = self.make_call(op, context=ctx, max_num_tokens=64)
149
+ named = self.make_call(op, name="summary", context=ctx, max_num_tokens=64)
150
+ for feedback in (None, "invalid answer"):
151
+ expected = backend._single_payload(ctx, plain, 64, feedback)
152
+ await backend.infer(ctx, named, repair_feedback=feedback)
153
+ actual = sent[-1]
154
+ actual["messages"][0]["content"] = actual["messages"][0]["content"].replace('任务 "summary"', '任务 "run1-q1"')
155
+ self.assertEqual(actual, expected)
156
+ self.assertTrue(any(e.get("wire_call_ids") == {"summary": named.id} for e in events))
157
+ finally:
158
+ await backend.aclose()
159
+
160
+ async def test_names_bind_preparation_and_remain_stable_in_split_subsets(self):
161
+ ctx = InferenceContext({"text": "甲"})
162
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
163
+ try:
164
+ calls = [self.make_call(name=n, identity=str(i), context=ctx) for i, n in enumerate(("first", "second", "third"))]
165
+ first = backend._prepare(ctx, calls[:2], 128, batch=True)
166
+ second = backend._prepare(ctx, calls[1:], 128, batch=True)
167
+ self.assertEqual(dict(first.evidence["wire_call_ids"]), {"first": "0", "second": "1"})
168
+ self.assertEqual(dict(second.evidence["wire_call_ids"]), {"second": "1", "third": "2"})
169
+ changed = replace(calls[0], arguments={**calls[0].arguments, "field_name": "different"})
170
+ self.assertNotEqual(first.binding, backend._prepare(ctx, [changed, calls[1]], 128, batch=True).binding)
171
+ finally:
172
+ await backend.aclose()
173
+
174
+ async def test_ir_runtime_reuse_and_repair_in_all_modes(self):
175
+ for optimize in (False, True):
176
+ program = Program.from_json(compile_program(named_pair, optimize=optimize).to_json())
177
+ plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
178
+ for mode in ("strict", "eager", "speculative"):
179
+ for repair in (False, True):
180
+ sent = []
181
+ backend = FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
182
+ transport=httpx.MockTransport(named_handler(sent, repair=repair)))
183
+ async with AnnoRuntime(FakeBackend(), backend, trace=True, max_llm_batch_calls=16,
184
+ max_llm_batch_output_tokens=4096) as runtime:
185
+ results = [r async for r in await runtime.submit(plan, [RunInput(0, "甲")], mode=mode)]
186
+ self.assertEqual(results[0].status, "success", results[0])
187
+ self.assertEqual(results[0].value, ["first", "second", "first"])
188
+ self.assertEqual(sum(len(llm_calls(b)[1]) for b in sent), 3 if repair and mode != "strict" else 2)
189
+ self.assertTrue(all(b["max_tokens"] == (192 if llm_calls(b)[0] else 32) for b in sent))
190
+ if repair and mode != "strict":
191
+ self.assertEqual(len(sent), 2)
192
+ self.assertIn('任务 "second"', sent[-1]["messages"][0]["content"])
193
+
194
+
195
+ class DurableFieldNameTests(unittest.TestCase):
196
+ def test_names_roundtrip_through_worker_and_collect_does_not_infer(self):
197
+ with tempfile.TemporaryDirectory() as directory:
198
+ store = Store(Path(directory) / "jobs.sqlite")
199
+ releases = Releases(configurations(), "test")
200
+ sent = []
201
+ def factory(_):
202
+ return FakeBackend(), FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
203
+ transport=httpx.MockTransport(named_handler(sent)))
204
+ request = payload(("甲", "乙"), mode="speculative", program=named_pair)
205
+ job = store.submit("alice", "named", request, releases)
206
+ worker = Worker(store, releases, backend_factory=factory)
207
+ async def run():
208
+ while (claim := store.claim("worker", releases)) is not None:
209
+ await worker.execute(claim)
210
+ asyncio.run(run())
211
+ before = len(sent)
212
+ for _ in range(2):
213
+ page = store.results("alice", job["job_id"])
214
+ self.assertEqual([r["value"] for r in page["results"]], [["first", "second", "first"]] * 2)
215
+ self.assertEqual(store.submit("alice", "named", request, releases)["job_id"], job["job_id"])
216
+ self.assertIsNone(store.claim("worker", releases))
217
+ self.assertEqual(len(sent), before)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ unittest.main()
@@ -0,0 +1,153 @@
1
+ from dataclasses import replace
2
+ import io
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ from annoscript import ir
12
+ from annoscript.compiler import compile_program
13
+ from annoscript.formatter import format_ir
14
+ from annoscript.inspect import main
15
+
16
+
17
+ _ROOT = Path(__file__).resolve().parents[1]
18
+
19
+
20
+ def example_program():
21
+ span = ir.SourceSpan("missing.source.module", "annotate", 1, 1, 12, 1)
22
+ source = ir.Source(span, (ir.SourceSpan("caller", "entry", 5, 1, 5, 20),))
23
+ result = ir.Var("decision")
24
+ body = (
25
+ ir.Assign("text", ir.GetItem(ir.State(), ir.Literal("text")), id="s1", source=source),
26
+ ir.Infer("decision", "choice", (ir.Literal("判断事件"), ir.DictExpr((
27
+ (ir.Literal("yes"), ir.Literal("事件")), (ir.Literal("no"), ir.Literal("非事件")),
28
+ ))), (), id="s2", source=source),
29
+ ir.If(ir.Compare(ir.Field(result, "choice"), ("eq",), (ir.Literal("yes"),)), (
30
+ ir.Constrain(ir.Compare(ir.Intrinsic("len", (ir.Var("text"),)), ("gt",), (ir.Literal(0),)),
31
+ ir.Literal("必须有正文"), id="s3", source=source),
32
+ ir.Return(ir.Var("text"), id="s4", source=source),
33
+ ), (
34
+ ir.Return(ir.Literal(None), id="s5", source=source),
35
+ ), id="s6", source=source),
36
+ )
37
+ return ir.Program("demo", ir.ProgramConfig("公共规则"), body, span)
38
+
39
+
40
+ class FormatterTests(unittest.TestCase):
41
+ def test_readable_model_call_branch_and_constraint(self):
42
+ program = example_program()
43
+ self.assertEqual(format_ir(program), "\n".join((
44
+ "# AnnoScript IR v1 (display notation)",
45
+ "system_prompt '公共规则'",
46
+ "program 'demo':",
47
+ " text = anno.state['text']",
48
+ " decision = anno.choice('判断事件', {'yes': '事件', 'no': '非事件'})",
49
+ " if (decision.choice == 'yes'):",
50
+ " constrain((len(text) > 0), message='必须有正文')",
51
+ " return text",
52
+ " else:",
53
+ " return None",
54
+ "",
55
+ )))
56
+ self.assertEqual(format_ir(ir.Program.from_json(program.to_json())), format_ir(program))
57
+
58
+ def test_source_comments_are_optional(self):
59
+ program = example_program()
60
+ self.assertNotIn("missing.source.module", format_ir(program))
61
+ text = format_ir(program, show_source=True)
62
+ self.assertIn("# [s2] missing.source.module:1:1 (annotate)", text)
63
+ self.assertIn("inlined from caller:5:1 (entry)", text)
64
+ self.assertIn("decision = anno.choice", text)
65
+
66
+ def test_all_compiled_constructs_render_without_executing_models(self):
67
+ from annoscript import test_compiler as examples
68
+ for function in (
69
+ examples.loop_helpers, examples.expression_order, examples.short_circuit,
70
+ examples.constrained, examples.all_operations, examples.formatting,
71
+ ):
72
+ with self.subTest(function=function.__name__):
73
+ program = compile_program(function)
74
+ before = program.to_dict()
75
+ text = format_ir(program)
76
+ self.assertTrue(text.endswith("\n"))
77
+ self.assertEqual(before, program.to_dict())
78
+ self.assertEqual(text, format_ir(ir.Program.from_json(program.to_json())))
79
+ loops = format_ir(compile_program(examples.loop_helpers))
80
+ self.assertIn("for ", loops)
81
+ formatting = format_ir(compile_program(examples.formatting))
82
+ self.assertIn("format_string(", formatting)
83
+ self.assertIn("format_value(", formatting)
84
+ self.assertIn("conversion='r'", formatting)
85
+ self.assertIn("json.dumps(", formatting)
86
+ calls = format_ir(compile_program(examples.all_operations))
87
+ for operation in ("noul", "choice", "score", "extract", "generate", "generate_list"):
88
+ self.assertIn(f"anno.{operation}(", calls)
89
+
90
+ def test_empty_blocks_literals_and_unusual_names(self):
91
+ program = example_program()
92
+ statement = ir.Return(ir.TupleExpr((ir.Literal("中文\n'{}\\"),)), id="s1", source=ir.Source(program.source))
93
+ program = replace(program, config=ir.ProgramConfig(), body=(statement,))
94
+ text = format_ir(program)
95
+ self.assertIn(repr("中文\n'{}\\"), text)
96
+ self.assertEqual(len(text.splitlines()), 3)
97
+ self.assertTrue(text.rstrip().endswith(",)"))
98
+ self.assertNotIn("system_prompt", text)
99
+ self.assertEqual(format_ir(replace(program, body=())).splitlines()[-1], " pass")
100
+ statement = replace(statement, value=ir.Var("has space"))
101
+ self.assertIn("var['has space']", format_ir(replace(program, body=(statement,))))
102
+
103
+
104
+ class InspectCommandTests(unittest.TestCase):
105
+ def run_command(self, *args, stdin=None):
106
+ return subprocess.run(
107
+ [sys.executable, "-m", "annoscript.inspect", *args], input=stdin,
108
+ text=True, encoding="utf-8", capture_output=True, cwd=_ROOT,
109
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, timeout=10,
110
+ )
111
+
112
+ def test_arbitrary_path_and_source_flag(self):
113
+ program = example_program()
114
+ with tempfile.TemporaryDirectory() as directory:
115
+ path = Path(directory) / "任意 program.json"
116
+ path.write_text(program.to_json(), encoding="utf-8")
117
+ result = self.run_command(str(path), "--show-source")
118
+ self.assertEqual(result.returncode, 0, result.stderr)
119
+ self.assertEqual(result.stderr, "")
120
+ self.assertEqual(result.stdout, format_ir(program, show_source=True))
121
+
122
+ def test_stdin_and_help(self):
123
+ program = example_program()
124
+ result = self.run_command("-", stdin=program.to_json())
125
+ self.assertEqual(result.returncode, 0, result.stderr)
126
+ self.assertEqual(result.stdout, format_ir(program))
127
+ result = self.run_command("--help")
128
+ self.assertEqual(result.returncode, 0)
129
+ self.assertIn("--show-source", result.stdout)
130
+
131
+ def test_invalid_files_report_errors_without_tracebacks(self):
132
+ for text in ("not JSON", '{"format":"lev-ir","version":99}'):
133
+ with self.subTest(text=text):
134
+ result = self.run_command("-", stdin=text)
135
+ self.assertEqual(result.returncode, 2)
136
+ self.assertEqual(result.stdout, "")
137
+ self.assertIn("annoscript.inspect:", result.stderr)
138
+ self.assertNotIn("Traceback", result.stderr)
139
+ with tempfile.TemporaryDirectory() as directory:
140
+ result = self.run_command(str(Path(directory) / "missing.json"))
141
+ self.assertEqual(result.returncode, 2)
142
+ self.assertIn("annoscript.inspect:", result.stderr)
143
+
144
+ def test_main_can_be_embedded_without_exiting_process(self):
145
+ output = io.StringIO()
146
+ with patch("sys.stdin", io.StringIO(example_program().to_json())), patch("sys.stdout", output):
147
+ status = main(["-"])
148
+ self.assertEqual(status, 0)
149
+ self.assertEqual(output.getvalue(), format_ir(example_program()))
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()
@@ -0,0 +1,109 @@
1
+ """Shared admission and nullable semantics; synthetic/local HTTP only."""
2
+ import asyncio
3
+ import json
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from annoscript._dispatch_pool import RequestSlots
10
+ from annoscript.backends import BatchResult, InferenceOutput, DecisionAPIBackend, LLMAPIBackend
11
+ from annoscript.compiler import compile_program
12
+ from annoscript.anno import ChoiceResponse, GenerateResponse
13
+ from annoscript.plan_lowering import lower_to_plan
14
+ from annoscript.runtime import AnnoRuntime, RunInput
15
+ from annoscript.server.worker import Worker
16
+ from annoscript.server.store import Store
17
+ from annoscript.server.releases import Releases
18
+ from annoscript.test_jobs_server import configurations
19
+ from annoscript.remote import ir_sha256
20
+
21
+
22
+ def join_ready(anno):
23
+ first = anno.choice("ready immediately", {"yes": "yes", "no": "no"}).choice
24
+ text = anno.generate("synthetic", max_length=10).text
25
+ second = anno.choice(text, {"yes": "yes", "no": "no"}).choice
26
+ return [first, second]
27
+
28
+
29
+ class SlotTests(unittest.IsolatedAsyncioTestCase):
30
+ async def test_fifo_cancel_duplicate_wait_and_idempotent_release(self):
31
+ p = RequestSlots(generation=1, decision=1)
32
+ woke = []
33
+ r = p.acquire("llm", "a", lambda: woke.append("a"))
34
+ self.assertIsNone(p.acquire("llm", "b", lambda: woke.append("b")))
35
+ self.assertIsNone(p.acquire("llm", "c", lambda: woke.append("c")))
36
+ self.assertIsNone(p.acquire("llm", "b", lambda: woke.append("duplicate")))
37
+ r(); r()
38
+ self.assertEqual(woke, ["b"])
39
+ self.assertIsNone(p.acquire("llm", "c", lambda: None))
40
+ p.cancel("b")
41
+ c = p.acquire("llm", "c", lambda: None); c()
42
+ self.assertEqual(p.active, {"llm": 0, "decision": 0})
43
+ self.assertFalse(p.waiting("b"))
44
+
45
+ async def test_waiting_batches_are_selected_at_admission(self):
46
+ pool = RequestSlots(generation=8, decision=1)
47
+ release = pool.acquire("decision", "external", lambda: None)
48
+ batches = []
49
+ generated = asyncio.Event()
50
+ class Backend:
51
+ async def infer(self, ctx, call):
52
+ generated.set()
53
+ return InferenceOutput(GenerateResponse("newly ready"))
54
+ async def infer_batch(self, ctx, calls):
55
+ batches.append([c.arguments["instructions"] for c in calls])
56
+ return BatchResult({c.id: ChoiceResponse("yes", 1., {"yes": 1., "no": 0.}) for c in calls})
57
+ async def aclose(self): pass
58
+ b = Backend()
59
+ async with AnnoRuntime(b, b, request_slots=pool, trace=True, max_decision_inflight=1) as runtime:
60
+ stream = await runtime.submit(lower_to_plan(compile_program(join_ready)), [RunInput(0, "fixture")])
61
+ await generated.wait()
62
+ for _ in range(8): await asyncio.sleep(0)
63
+ self.assertFalse(batches)
64
+ release()
65
+ result = [r async for r in stream]
66
+ self.assertEqual(result[0].value, ["yes", "yes"])
67
+ self.assertEqual(batches, [["ready immediately", "newly ready"]])
68
+ self.assertTrue(any(e['event'] == 'slot_wait' for e in runtime.trace))
69
+ self.assertEqual(sum(pool.active.values()), 0)
70
+
71
+ async def test_cancel_waiter_does_not_send_or_leak(self):
72
+ pool = RequestSlots(generation=1, decision=1)
73
+ a = pool.acquire('decision', 'held', lambda: None)
74
+ b = pool.acquire('llm', 'held', lambda: None)
75
+ class Never:
76
+ async def infer(self, *a, **k): raise AssertionError('sent')
77
+ async def infer_batch(self, *a, **k): raise AssertionError('sent')
78
+ async def aclose(self): pass
79
+ async with AnnoRuntime(Never(), Never(), request_slots=pool) as rt:
80
+ stream = await rt.submit(lower_to_plan(compile_program(join_ready)), [RunInput(0, 'fixture')])
81
+ for _ in range(8): await asyncio.sleep(0)
82
+ await stream.aclose()
83
+ a(); b()
84
+ self.assertFalse(any(pool.waiters.values()))
85
+ self.assertEqual(sum(pool.active.values()), 0)
86
+
87
+
88
+ class SharedWorkerTests(unittest.IsolatedAsyncioTestCase):
89
+ pass
90
+
91
+
92
+ from annoscript.test_generation_policy import P0ProtocolWorkerTests, P0DiagnosticWorkerTests
93
+
94
+ class SharedProtocolTests(P0ProtocolWorkerTests):
95
+ def run_http(self, handler, *, observer=None):
96
+ w = super().run_http(handler, observer=observer)
97
+ w.concurrency = 8
98
+ w.request_slots = RequestSlots(generation=8, decision=4)
99
+ return w
100
+
101
+ class SharedDiagnosticTests(P0DiagnosticWorkerTests):
102
+ def worker(self, handler, **kw):
103
+ w = super().worker(handler, **kw)
104
+ w.concurrency = 8
105
+ w.request_slots = RequestSlots(generation=8, decision=4)
106
+ return w
107
+
108
+
109
+ if __name__ == '__main__': unittest.main()