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,410 @@
1
+ """Task recipes, physical batching and speculative error isolation; no model calls."""
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+ from dataclasses import replace
6
+ import io
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import tempfile
11
+ import unittest
12
+ from unittest.mock import patch
13
+
14
+ import httpx
15
+
16
+ from annoscript._data import thaw
17
+ from annoscript._request_adapters import CONFIGURABLE_DECISION_ADAPTER, normalize_decision_options
18
+ from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError, DecisionBatchPlan
19
+ from annoscript.backends.base import normalize_call, encode_payload
20
+ from annoscript.backends.config import backends_from_env
21
+ from annoscript.client import AnnoClient
22
+ from annoscript.compiler import compile_program
23
+ from annoscript.plan_lowering import lower_to_plan
24
+ from annoscript.preview import preview_request, main
25
+ from annoscript.runtime import AnnoRuntime, RunInput
26
+ from annoscript.server.releases import Releases, manifest
27
+ from annoscript.server.worker import ObservedBackend, backends as worker_backends
28
+ from annoscript._dispatch_pool import RequestSlots
29
+ from annoscript.test_decision_wire import answers_for
30
+ from annoscript.test_request_adapters import configuration, spec
31
+ from annoscript.test_runtime import FakeBackend, mixed
32
+
33
+
34
+ def recipe(criteria='preserve', examples='preserve', mode='full', **state):
35
+ return {'criteria': criteria, 'examples': examples, 'state': {'mode': mode, **state}}
36
+
37
+
38
+ def plain(mode='full', **state):
39
+ return recipe('text', 'omit', mode, **state)
40
+
41
+
42
+ def backend(options=None, **kwargs):
43
+ return DecisionAPIBackend('http://offline.invalid/decision', 'secret', 'unchanged-model',
44
+ request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options=options, **kwargs)
45
+
46
+
47
+ def call(context, identity='a', name='tone', operation='choice', examples=True):
48
+ kwargs = {'instructions': '独立任务', 'field_name': name}
49
+ if operation == 'choice': kwargs['criteria'] = {'甲': '类别甲', '乙': '类别乙'}
50
+ if operation == 'score': kwargs['criteria'] = ['低', '高']
51
+ if examples:
52
+ kwargs['examples'] = [{'state': {'text': '示例一', 'meta': ['仅示例']},
53
+ 'answer': {'choice': '甲', 'score': 0, 'noul': False}[operation]},
54
+ {'state': {'text': '示例二'},
55
+ 'answer': {'choice': '乙', 'score': 1, 'noul': True}[operation]}]
56
+ return normalize_call(identity, operation, (), kwargs, context)
57
+
58
+
59
+ class RepresentationTests(unittest.TestCase):
60
+ def test_preserve_unwrap_and_projection_without_mutating_inputs(self):
61
+ context = InferenceContext({'text': '当前', 'meta': ['当前元数据']})
62
+ c = call(context)
63
+ before = thaw(c.arguments)
64
+ for criteria, examples in [('preserve', 'preserve'), ('preserve', 'omit'), ('text', 'omit')]:
65
+ for state in [{'mode': 'full'}, {'mode': 'fields_object', 'fields': ['text']}, {'mode': 'field_string', 'field': 'text'}]:
66
+ r = {'criteria': criteria, 'examples': examples, 'state': state}
67
+ b = backend({'tasks': {'tone': r}})
68
+ value = b.preview_batch(context, [c])['payload']
69
+ self.assertEqual(list(value['questions']['tone']['criteria']), ['甲', '乙'])
70
+ expected = {'full': thaw(context.state), 'fields_object': {'text': '当前'}, 'field_string': '当前'}[state['mode']]
71
+ self.assertEqual(value['state'], expected)
72
+ criterion = value['questions']['tone']['criteria']['甲']
73
+ if criteria == 'text': self.assertEqual(criterion, '类别甲')
74
+ else:
75
+ self.assertEqual(criterion['what'], '类别甲')
76
+ if examples == 'preserve':
77
+ projected = {'full': {'text': '示例一', 'meta': ['仅示例']}, 'fields_object': {'text': '示例一'}, 'field_string': '示例一'}[state['mode']]
78
+ self.assertEqual(criterion['examples'], [projected])
79
+ else: self.assertNotIn('examples', criterion)
80
+ self.assertEqual(thaw(c.arguments), before)
81
+ self.assertEqual(thaw(context.state), {'text': '当前', 'meta': ['当前元数据']})
82
+
83
+ def test_task_matching_is_exact_with_default_for_unnamed_tasks(self):
84
+ ctx = InferenceContext({'text': '输入'})
85
+ b = backend({'tasks': {'tone': plain()}})
86
+ calls = [call(ctx, str(i), name) for i, name in enumerate(['tone', ' tone ', 'tone_extra', None])]
87
+ payload = b.preview_batch(ctx, calls)['payload']
88
+ self.assertEqual(payload['questions']['tone']['criteria']['甲'], '类别甲')
89
+ for key in [' tone ', 'tone_extra', '3']:
90
+ self.assertIn('examples', payload['questions'][key]['criteria']['甲'])
91
+ self.assertEqual(len(b.preview_batches(ctx, calls)['requests']), 1)
92
+
93
+ def test_empty_and_missing_projection_and_example_errors_are_local(self):
94
+ for state in ['正文', [], {}, {'text': []}]:
95
+ ctx = InferenceContext(state)
96
+ b = backend({'default': plain('field_string', field='text')})
97
+ with self.subTest(state=state), self.assertRaises(InferenceError) as caught:
98
+ b.preview_batch(ctx, [call(ctx)])
99
+ self.assertEqual(caught.exception.code, 'invalid_state_projection')
100
+ self.assertEqual(caught.exception.call_id, 'a')
101
+ self.assertEqual(caught.exception.metadata['dispatch_status'], 'not_sent')
102
+ self.assertIsNone(b._client)
103
+ ctx = InferenceContext({'text': ''})
104
+ b = backend({'default': plain('field_string', field='text')})
105
+ self.assertEqual(b.preview_batch(ctx, [call(ctx)])['payload']['state'], '')
106
+ kept = backend({'default': recipe(mode='field_string', field='text')})
107
+ c = normalize_call('x', 'choice', ('任务', {'甲': '甲'}),
108
+ {'examples': [{'state': {'other': 'missing'}, 'answer': '甲'}]}, ctx)
109
+ with self.assertRaises(InferenceError): kept.preview_batch(ctx, [c])
110
+ self.assertEqual(b.preview_batch(ctx, [c])['payload']['state'], '')
111
+
112
+ def test_actual_shared_state_controls_compatibility_and_prefix_order(self):
113
+ opts = {'tasks': {'tone': plain('field_string', field='text')}}
114
+ b = backend(opts)
115
+ ctx = InferenceContext({'text': '输入', 'brand': ['甲']})
116
+ calls = [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand'), call(ctx, 'c', 'tone')]
117
+ plan = b.preview_batches(ctx, calls)
118
+ self.assertEqual([list(v['evidence']['wire_call_ids'].values()) for v in plan['requests']], [['a'], ['b'], ['c']])
119
+ self.assertEqual([v['payload']['state'] for v in plan['requests']], ['输入', thaw(ctx.state), '输入'])
120
+ with self.assertRaises(InferenceError) as caught: b.preview_batch(ctx, calls[:2])
121
+ self.assertEqual(caught.exception.code, 'incompatible_decision_batch')
122
+ # Different view definitions may share the same actual object bytes.
123
+ ctx = InferenceContext({'text': '输入'})
124
+ b = backend({'tasks': {'tone': plain('fields_object', fields=['text'])}})
125
+ self.assertEqual(len(b.plan_batch(ctx, [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand')], max_calls=32).calls), 2)
126
+ # Object key order is model-visible and must not be canonicalized away.
127
+ ctx = InferenceContext({'x': 1, 'text': '输入'})
128
+ b = backend({'tasks': {'tone': plain('fields_object', fields=['text', 'x'])}})
129
+ self.assertEqual(len(b.plan_batch(ctx, [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand')], max_calls=32).calls), 1)
130
+
131
+ def test_limits_validation_and_duplicate_names_are_not_hidden(self):
132
+ ctx = InferenceContext({'text': '输入'})
133
+ calls = [call(ctx, str(i), 'n' + str(i)) for i in range(5)]
134
+ b = backend()
135
+ self.assertEqual([len(p['payload']['questions']) for p in b.preview_batches(ctx, calls, max_calls=2)['requests']], [2, 2, 1])
136
+ for limit in [0, -1, True, 1.5, None]:
137
+ with self.assertRaises(ValueError): b.plan_batch(ctx, calls, max_calls=limit)
138
+ duplicate = [call(ctx, 'a'), call(ctx, 'b')]
139
+ self.assertIsNone(b.plan_batch(ctx, duplicate, max_calls=32).prepared)
140
+ with self.assertRaisesRegex(ValueError, 'duplicate model-visible'): b.preview_batch(ctx, duplicate)
141
+
142
+ def test_invalid_options_are_rejected_before_transport_and_detached(self):
143
+ invalid = [False, [], {'extra': {}}, {'default': {}}, {'tasks': []}, {'tasks': {'': plain()}},
144
+ {'default': recipe('text', 'preserve')}, {'default': recipe(mode='unknown')},
145
+ {'default': recipe(mode='field_string', field=' ')}, {'default': recipe(mode='full', field='x')},
146
+ {'default': recipe(mode='fields_object', fields=[])}, {'default': recipe(mode='fields_object', fields=['x', 'x'])}]
147
+ for options in invalid:
148
+ with self.subTest(options=options), patch('annoscript.backends.base.HTTPBackend.__init__', side_effect=AssertionError('transport')):
149
+ with self.assertRaises(ValueError): backend(options)
150
+ source = {'tasks': {'tone': plain()}}
151
+ b = backend(source)
152
+ source['tasks']['tone']['criteria'] = 'preserve'
153
+ self.assertEqual(b.request_adapter_options['tasks']['tone']['criteria'], 'text')
154
+ with self.assertRaises(TypeError): b.request_adapter_options['tasks']['tone']['criteria'] = 'preserve'
155
+ with self.assertRaises(AttributeError): b.request_adapter_options = {}
156
+ with self.assertRaises(ValueError): DecisionAPIBackend('http://offline.invalid', 'secret', 'm', request_adapter_options=source)
157
+
158
+
159
+ class DecisionHTTPTests(unittest.IsolatedAsyncioTestCase):
160
+ async def test_score_legends_and_noul_without_invented_descriptions(self):
161
+ ctx = InferenceContext({'text': '输入'})
162
+ sent = []
163
+ def handle(req):
164
+ body = json.loads(req.content); sent.append(body)
165
+ return httpx.Response(200, json={'answers': answers_for(body)})
166
+ b = backend({'default': plain()}, transport=httpx.MockTransport(handle))
167
+ try:
168
+ calls = [call(ctx, 's', 'score', 'score'), call(ctx, 'n', 'boolean', 'noul')]
169
+ result = await b.infer_batch(ctx, calls)
170
+ self.assertEqual(result.responses['s'].legend, {0: '低', 1: '高'})
171
+ self.assertEqual(result.responses['n'].noul, .8)
172
+ self.assertEqual(sent[0]['questions']['score']['criteria'], ['低', '高'])
173
+ self.assertNotIn('criteria', sent[0]['questions']['boolean'])
174
+ finally: await b.aclose()
175
+
176
+ async def test_prepared_requests_bound_to_options_calls_context_and_backend(self):
177
+ ctx = InferenceContext({'text': '输入'})
178
+ calls = [call(ctx)]
179
+ received = []
180
+ def handle(req):
181
+ received.append(req.content)
182
+ return httpx.Response(200, json={'answers': answers_for(json.loads(req.content))})
183
+ a = backend(transport=httpx.MockTransport(handle))
184
+ b = backend({'default': plain()}, transport=httpx.MockTransport(handle))
185
+ try:
186
+ prepared = a.plan_batch(ctx, calls, max_calls=32).prepared
187
+ self.assertIsNotNone(prepared)
188
+ await b.infer_batch(ctx, calls, prepared=prepared)
189
+ self.assertEqual(received[-1], encode_payload(b.preview_batch(ctx, calls)['payload']))
190
+ self.assertNotEqual(received[-1], prepared.body)
191
+ # Valid preparation is sent without reconstructing the body.
192
+ plan = b.plan_batch(ctx, calls, max_calls=32)
193
+ with patch.object(b, '_batch_payload', side_effect=AssertionError('rebuilt')):
194
+ await b.infer_batch(ctx, calls, prepared=plan.prepared)
195
+ self.assertEqual(received[-1], plan.prepared.body)
196
+ changed = [call(ctx, 'other', 'other')]
197
+ await b.infer_batch(ctx, changed, prepared=plan.prepared)
198
+ self.assertIn('other', json.loads(received[-1])['questions'])
199
+ other_ctx = InferenceContext({'text': '另一个输入'})
200
+ await b.infer_batch(other_ctx, calls, prepared=plan.prepared)
201
+ self.assertEqual(json.loads(received[-1])['state'], {'text': '另一个输入'})
202
+ finally:
203
+ await a.aclose(); await b.aclose()
204
+
205
+ async def test_incompatible_direct_batch_sends_nothing_and_partial_answers_keep_ids(self):
206
+ ctx = InferenceContext({'text': '输入', 'meta': []}); sent = []
207
+ calls = [call(ctx, 'a', 'tone'), call(ctx, 'b', 'other')]
208
+ def handle(req):
209
+ sent.append(req.content); body = json.loads(req.content)
210
+ answers = answers_for(body); answers.pop(next(reversed(answers)))
211
+ return httpx.Response(200, json={'answers': answers})
212
+ b = backend({'tasks': {'tone': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
213
+ try:
214
+ with self.assertRaises(InferenceError): await b.infer_batch(ctx, calls)
215
+ self.assertFalse(sent)
216
+ finally: await b.aclose()
217
+ b = backend(transport=httpx.MockTransport(handle))
218
+ try:
219
+ result = await b.infer_batch(ctx, calls, allow_partial=True)
220
+ self.assertEqual(set(result.responses) | set(result.errors), {'a', 'b'})
221
+ self.assertEqual(len(sent), 1)
222
+ self.assertTrue(all(e.call_id == k for k, e in result.errors.items()))
223
+ finally: await b.aclose()
224
+
225
+
226
+ def ordered_tasks(anno):
227
+ a = anno.choice('body first', {'yes': '成立', 'no': '不成立'}, field_name='body').choice
228
+ b = anno.noul('brand', field_name='brand').noul
229
+ c = anno.choice('body last', {'yes': '成立', 'no': '不成立'}, field_name='body').choice
230
+ return [a, b, c]
231
+
232
+
233
+ def projection_branch(anno):
234
+ a = anno.choice('gate', {'yes': '成立', 'no': '不成立'}, field_name='gate').choice
235
+ if a == 'no':
236
+ return anno.noul('bad projection', field_name='bad').noul
237
+ return anno.noul('good projection', field_name='good').noul
238
+
239
+
240
+ def selected_invalid(anno):
241
+ return anno.noul('selected invalid', field_name='bad').noul
242
+
243
+
244
+ class DecisionRuntimeTests(unittest.IsolatedAsyncioTestCase):
245
+ async def run_program(self, function, b, *, mode='speculative', **options):
246
+ async with AnnoRuntime(b, FakeBackend(), trace=True, **options) as runtime:
247
+ results = [r async for r in await runtime.submit(lower_to_plan(compile_program(function)),
248
+ [RunInput(0, {'text': '输入', 'brand': ['甲']})], mode=mode)]
249
+ return results[0], runtime.trace
250
+
251
+ async def test_ordered_split_all_modes_and_one_slot_per_physical_request(self):
252
+ for mode in ['eager', 'strict', 'speculative']:
253
+ sent, received, active, peak = [], [], 0, 0
254
+ async def handle(req):
255
+ nonlocal active, peak
256
+ body = json.loads(req.content); sent.append(body); received.append(req.content); active += 1; peak = max(peak, active)
257
+ await asyncio.sleep(.005); active -= 1
258
+ return httpx.Response(200, json={'answers': answers_for(body)})
259
+ slots = RequestSlots(decision=1, generation=1)
260
+ b = backend({'tasks': {'body': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
261
+ ctx = InferenceContext({'text': '输入', 'brand': ['甲']})
262
+ calls = [normalize_call('a', 'choice', ('body first', {'yes': '成立', 'no': '不成立'}), {'field_name': 'body'}, ctx),
263
+ normalize_call('b', 'noul', ('brand',), {'field_name': 'brand'}, ctx),
264
+ normalize_call('c', 'choice', ('body last', {'yes': '成立', 'no': '不成立'}), {'field_name': 'body'}, ctx)]
265
+ preview = b.preview_batches(ctx, calls)
266
+ observed = ObservedBackend(b, [], lambda: True)
267
+ result, trace = await self.run_program(ordered_tasks, observed, mode=mode, request_slots=slots)
268
+ self.assertEqual(result.status, 'success', result.error)
269
+ self.assertEqual(result.value, ['yes', .8, 'yes'])
270
+ self.assertEqual([list(v['questions']) for v in sent], [['body'], ['brand'], ['body']])
271
+ self.assertEqual(received, [encode_payload(v['payload']) for v in preview['requests']])
272
+ self.assertEqual(len([e for e in trace if e['event'] == 'http_submitted']), 3)
273
+ self.assertEqual(peak, 1); self.assertEqual(slots.active['decision'], 0)
274
+
275
+ async def test_projection_errors_are_isolated_and_discarded_when_not_adopted(self):
276
+ for function, expected in [(projection_branch, 'success'), (selected_invalid, 'error')]:
277
+ sent = []
278
+ def handle(req):
279
+ body = json.loads(req.content); sent.append(body)
280
+ return httpx.Response(200, json={'answers': answers_for(body)})
281
+ b = backend({'tasks': {'bad': plain('field_string', field='missing')}}, transport=httpx.MockTransport(handle))
282
+ result, trace = await self.run_program(function, b)
283
+ self.assertEqual(result.status, expected, result.error)
284
+ self.assertFalse(any('bad' in v['questions'] for v in sent))
285
+ if function is projection_branch:
286
+ self.assertEqual(result.value, .8)
287
+ self.assertTrue(any(e['event'] == 'speculation_error_deferred' for e in trace))
288
+ else: self.assertEqual(result.error.code, 'invalid_state_projection')
289
+
290
+ async def test_custom_backend_without_planning_keeps_existing_batching(self):
291
+ b = FakeBackend()
292
+ result, trace = await self.run_program(mixed, b, mode='eager', max_batch_questions=2)
293
+ self.assertEqual(result.status, 'success', result.error)
294
+ self.assertEqual([len(v[1]) for v in b.batches], [2, 1])
295
+
296
+ async def test_split_partial_response_error_is_only_observed_on_selected_path(self):
297
+ for selected in ['yes', 'no']:
298
+ sent = []
299
+ def handle(req):
300
+ body = json.loads(req.content); sent.append(body); answers = answers_for(body)
301
+ if 'gate' in answers:
302
+ answers['gate'].update(choice=selected, probabilities={'yes': float(selected == 'yes'), 'no': float(selected == 'no')})
303
+ if 'bad' in answers: answers['bad']['noul'] = 'invalid'
304
+ return httpx.Response(200, json={'answers': answers})
305
+ b = backend({'tasks': {'bad': plain('field_string', field='text'), 'good': plain('field_string', field='text')}},
306
+ transport=httpx.MockTransport(handle))
307
+ result, trace = await self.run_program(projection_branch, ObservedBackend(b, [], lambda: True))
308
+ self.assertEqual(result.status, 'success' if selected == 'yes' else 'error', result.error)
309
+ self.assertEqual(sum('bad' in v['questions'] for v in sent), 1)
310
+ if selected == 'yes': self.assertEqual(result.value, .8)
311
+ else: self.assertEqual(result.error.code, 'invalid_response')
312
+
313
+ async def test_runtime_question_limit_and_invalid_planner_release_slots(self):
314
+ for limit in [1, 2]:
315
+ sent = []
316
+ def handle(req):
317
+ body = json.loads(req.content); sent.append(body)
318
+ return httpx.Response(200, json={'answers': answers_for(body)})
319
+ b = backend(transport=httpx.MockTransport(handle))
320
+ result, trace = await self.run_program(mixed, b, mode='eager', max_batch_questions=limit)
321
+ self.assertEqual(result.status, 'success', result.error)
322
+ self.assertEqual([len(v['questions']) for v in sent], [1, 1, 1] if limit == 1 else [2, 1])
323
+ slots = RequestSlots(decision=1, generation=1)
324
+ b = backend(transport=httpx.MockTransport(lambda req: self.fail('HTTP')))
325
+ def invalid(context, calls, *, max_calls):
326
+ return DecisionBatchPlan(tuple(reversed(calls)), None)
327
+ with patch.object(b, 'plan_batch', side_effect=invalid), self.assertRaises(InferenceError) as caught:
328
+ await self.run_program(mixed, b, mode='eager', request_slots=slots)
329
+ self.assertEqual(caught.exception.code, 'runtime_failed')
330
+ self.assertEqual(slots.active['decision'], 0)
331
+
332
+ async def test_cancellation_drains_inflight_slot_without_sending_other_prefixes(self):
333
+ entered, release = asyncio.Event(), asyncio.Event(); sent = []
334
+ async def handle(req):
335
+ body = json.loads(req.content); sent.append(body); entered.set(); await release.wait()
336
+ return httpx.Response(200, json={'answers': answers_for(body)})
337
+ slots = RequestSlots(decision=1, generation=1)
338
+ b = backend({'tasks': {'body': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
339
+ runtime = AnnoRuntime(b, FakeBackend(), request_slots=slots)
340
+ await runtime.__aenter__()
341
+ try:
342
+ stream = await runtime.submit(lower_to_plan(compile_program(ordered_tasks)), [RunInput(0, {'text': '输入'})], mode='speculative')
343
+ await asyncio.wait_for(entered.wait(), 2)
344
+ await stream.aclose()
345
+ self.assertEqual(len(sent), 1)
346
+ self.assertEqual(slots.active['decision'], 1)
347
+ release.set()
348
+ finally:
349
+ release.set(); await runtime.aclose()
350
+ self.assertEqual(len(sent), 1)
351
+ self.assertEqual(slots.active['decision'], 0)
352
+
353
+
354
+ class OptionsAndPreviewTests(unittest.TestCase):
355
+ def test_release_normalization_binding_and_worker_ignores_environment(self):
356
+ cfg = configuration()
357
+ cfg['decision'].update(request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options={'tasks': {'tone': plain()}})
358
+ m = manifest(cfg)
359
+ normalized = normalize_decision_options(CONFIGURABLE_DECISION_ADAPTER, cfg['decision']['request_adapter_options'])
360
+ self.assertEqual(m['decision']['request_adapter_options'], normalized)
361
+ explicit = deepcopy(cfg); explicit['decision']['request_adapter_options'] = normalized
362
+ self.assertEqual(Releases({'x': cfg}, 'x').default, Releases({'x': explicit}, 'x').default)
363
+ changed = deepcopy(cfg); changed['decision']['request_adapter_options']['tasks']['tone'] = plain('field_string', field='text')
364
+ self.assertNotEqual(Releases({'x': cfg}, 'x').default, Releases({'x': changed}, 'x').default)
365
+ with patch.dict(os.environ, {'ANNO_DECISION_AUTH_TOKEN': 'secret', 'ANNO_GENERATE_AUTH_TOKEN': 'secret',
366
+ 'ANNO_DECISION_REQUEST_ADAPTER_OPTIONS': 'invalid'}, clear=True):
367
+ d, _ = worker_backends(m)
368
+ self.assertEqual(thaw(d.request_adapter_options), normalized)
369
+ bad = deepcopy(cfg); bad['decision']['request_adapter_options'] = None
370
+ with self.assertRaises(ValueError): manifest(bad)
371
+
372
+ def test_existing_job_retains_recipe_when_default_release_changes(self):
373
+ from annoscript.server.store import Store
374
+ from annoscript.test_jobs_server import payload
375
+ a = configuration()
376
+ a['decision'].update(request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options={'tasks': {'tone': plain()}})
377
+ b = deepcopy(a)
378
+ b['decision']['request_adapter_options']['tasks']['tone'] = plain('field_string', field='text')
379
+ old, new = Releases({'x': a}, 'x'), Releases({'x': b}, 'x')
380
+ with tempfile.TemporaryDirectory() as folder:
381
+ store = Store(Path(folder) / 'jobs.sqlite')
382
+ first = store.submit('alice', 'same', payload(), old)
383
+ repeated = store.submit('alice', 'same', payload(), new)
384
+ fresh = store.submit('alice', 'fresh', payload(), new)
385
+ self.assertEqual(first['release_id'], repeated['release_id'])
386
+ self.assertEqual(fresh['release_id'], new.default)
387
+ self.assertNotEqual(first['release_id'], fresh['release_id'])
388
+
389
+ def test_environment_json_precedence_and_client_forwarding(self):
390
+ env = {f'ANNO_{p}_{k}': v for p in ['DECISION', 'GENERATE'] for k, v in
391
+ [('BASE_URL', 'http://offline.invalid'), ('AUTH_TOKEN', 'secret'), ('MODEL', 'unchanged')]}
392
+ env['ANNO_DECISION_REQUEST_ADAPTER'] = CONFIGURABLE_DECISION_ADAPTER
393
+ opts = {'tasks': {'tone': plain()}}
394
+ with tempfile.TemporaryDirectory() as folder:
395
+ path = Path(folder) / 'env'
396
+ path.write_text("ANNO_DECISION_REQUEST_ADAPTER_OPTIONS='" + json.dumps(opts) + "'\n")
397
+ with patch.dict(os.environ, env, clear=True):
398
+ self.assertEqual(thaw(backends_from_env(env_file=path)[0].request_adapter_options)['tasks'], opts['tasks'])
399
+ env['ANNO_DECISION_REQUEST_ADAPTER_OPTIONS'] = '{"default":{},"default":{}}'
400
+ with patch.dict(os.environ, env, clear=True):
401
+ with self.assertRaises(ValueError): backends_from_env(env_file=path)
402
+ with AnnoClient.from_env(env_file=path, decision_request_adapter_options=opts) as client:
403
+ self.assertEqual(thaw(client.runtime.decision_backend.request_adapter_options)['tasks'], opts['tasks'])
404
+ for invalid in ['null', '[]', '{"x":NaN}', '']:
405
+ env['ANNO_DECISION_REQUEST_ADAPTER_OPTIONS'] = invalid
406
+ with patch.dict(os.environ, env, clear=True), self.assertRaises(ValueError): backends_from_env()
407
+
408
+
409
+ if __name__ == '__main__':
410
+ unittest.main()
@@ -0,0 +1,190 @@
1
+ """Decision request layout and public names across real HTTP and runtime paths."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ import unittest
6
+
7
+ import httpx
8
+
9
+ from annoscript._data import thaw
10
+ from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError
11
+ from annoscript.backends.base import normalize_call
12
+ from annoscript.compiler import compile_program
13
+ from annoscript.ir import Program
14
+ from annoscript.plan import ExecutionPlan
15
+ from annoscript.plan_lowering import lower_to_plan
16
+ from annoscript.runtime import AnnoRuntime, RunInput
17
+ from annoscript.test_runtime import FakeBackend
18
+
19
+
20
+ def named_decisions(anno):
21
+ first = anno.choice("选择", {"a": "甲", "b": "乙"}, field_name="category").choice
22
+ second = anno.noul("判断", field_name="is_event").noul
23
+ third = anno.score("程度", ["低", "高"], field_name="severity").score
24
+ again = anno.choice("选择", {"a": "甲", "b": "乙"}, field_name="category").choice
25
+ return [first, second, third, again]
26
+
27
+
28
+ def answers_for(body):
29
+ answers = {}
30
+ for name, q in reversed(list(body["questions"].items())):
31
+ if q["type"] == "choice":
32
+ selected = next(iter(q["criteria"]))
33
+ answer = {"choice": selected, "confidence": 1.,
34
+ "probabilities": {key: float(key == selected) for key in q["criteria"]}}
35
+ elif q["type"] == "noul":
36
+ answer = {"noul": .8}
37
+ else:
38
+ answer = {"score": 1., "confidence": 1., "probabilities": {"0": 0., "1": 1.},
39
+ "legend": {str(i): value for i, value in enumerate(q["criteria"])}}
40
+ answers[name] = {"type": q["type"], **answer}
41
+ return answers
42
+
43
+
44
+ class DecisionWireTests(unittest.IsolatedAsyncioTestCase):
45
+ def call(self, operation, identity="internal", *, context=None, **options):
46
+ args = {"choice": ("选择", {"a": "甲", "b": "乙"}),
47
+ "noul": ("判断",), "score": ("程度", ["低", "高"])}[operation]
48
+ return normalize_call(identity, operation, args, options, context or InferenceContext("当前输入"))
49
+
50
+ async def test_grouped_examples_preserve_states_order_and_original_arguments(self):
51
+ context = InferenceContext({"text": "当前事实"}, "共享规则仅供 LLM 使用")
52
+ states = [{"text": "示例甲", "nested": [1, {"answer": "数据"}]}, "示例乙", ["示例丙"]]
53
+ calls = [self.call(op, identity=op, field_name=name, requirements=["额外规则", "额外规则"],
54
+ examples=[{"state": state, "answer": answer} for state, answer in zip(states, answers)])
55
+ for op, name, answers in (("choice", "category", ["b", "a", "b"]),
56
+ ("noul", "is_event", [False, True, False]),
57
+ ("score", "severity", [1, 0, 1]))]
58
+ original = [thaw(c.arguments) for c in calls]
59
+ sent, events = [], []
60
+ def handle(request):
61
+ body = json.loads(request.content)
62
+ sent.append(body)
63
+ return httpx.Response(200, json={"answers": answers_for(body)})
64
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
65
+ transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
66
+ try:
67
+ result = await backend.infer_batch(context, calls)
68
+ finally:
69
+ await backend.aclose()
70
+ body = sent[0]
71
+ self.assertEqual(body["state"], thaw(context.state))
72
+ self.assertEqual(set(body), {"model", "state", "questions"})
73
+ self.assertEqual(list(body["questions"]), ["category", "is_event", "severity"])
74
+ for call, q in zip(calls, body["questions"].values()):
75
+ self.assertEqual(q["instructions"], call.arguments["instructions"] + "\n\n要求:\n额外规则")
76
+ self.assertEqual(set(q), {"type", "instructions", "criteria"})
77
+ self.assertEqual(body["questions"]["category"]["criteria"], {
78
+ "a": {"what": "甲", "examples": [states[1]]},
79
+ "b": {"what": "乙", "examples": [states[0], states[2]]}})
80
+ self.assertEqual(body["questions"]["is_event"]["criteria"], {
81
+ "false": {"examples": [states[0], states[2]]}, "true": {"examples": [states[1]]}})
82
+ self.assertEqual(body["questions"]["severity"]["criteria"], [
83
+ {"what": "低", "examples": [states[1]]}, {"what": "高", "examples": [states[0], states[2]]}])
84
+ self.assertEqual([thaw(c.arguments) for c in calls], original)
85
+ self.assertEqual(set(result.responses), {c.id for c in calls})
86
+ self.assertEqual(result.responses["score"].legend, {0: "低", 1: "高"})
87
+ self.assertTrue(any(e.get("wire_call_ids") == {"category": "choice", "is_event": "noul", "severity": "score"}
88
+ for e in events))
89
+
90
+ async def test_empty_examples_and_noul_descriptions(self):
91
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture")
92
+ context = InferenceContext("当前输入", "不应拼接")
93
+ try:
94
+ for op in ("choice", "noul", "score"):
95
+ plain = self.call(op)
96
+ expected = backend._batch_payload(context, [plain])
97
+ for examples in (None, []):
98
+ explicit = self.call(op, field_name=None, examples=examples)
99
+ self.assertEqual(explicit, plain)
100
+ self.assertEqual(backend._batch_payload(context, [explicit]), expected)
101
+ self.assertEqual(list(expected["questions"]), [plain.id])
102
+ self.assertEqual(expected["questions"][plain.id]["instructions"], plain.arguments["instructions"])
103
+ call = self.call("noul", criteria_false="不成立", criteria_true="成立",
104
+ examples=[{"state": "肯定示例", "answer": True}])
105
+ self.assertEqual(backend._batch_payload(context, [call])["questions"][call.id]["criteria"],
106
+ {"false": "不成立", "true": {"what": "成立", "examples": ["肯定示例"]}})
107
+ finally:
108
+ await backend.aclose()
109
+
110
+ async def test_invalid_names_and_collisions_fail_before_http(self):
111
+ sent = []
112
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
113
+ transport=httpx.MockTransport(lambda r: sent.append(r)))
114
+ try:
115
+ for op in ("choice", "noul", "score"):
116
+ for name in ("", " \t", 1, False, {}, []):
117
+ with self.subTest(op=op, name=name), self.assertRaisesRegex(ValueError, "field_name"):
118
+ self.call(op, field_name=name)
119
+ for calls in ([self.call("choice", "a", field_name="same"), self.call("score", "b", field_name="same")],
120
+ [self.call("choice", "a", field_name="b"), self.call("noul", "b")]):
121
+ with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
122
+ await backend.infer_batch(InferenceContext("输入"), calls)
123
+ self.assertEqual(sent, [])
124
+ finally:
125
+ await backend.aclose()
126
+
127
+ async def test_partial_answers_and_errors_keep_internal_ids(self):
128
+ name = ' 字段"\\\n😀 '
129
+ calls = [self.call("noul", "run-q0", field_name=name), self.call("choice", "run-q1")]
130
+ answers = {name: {"type": "noul", "noul": .8}}
131
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
132
+ transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"answers": answers})))
133
+ try:
134
+ result = await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
135
+ self.assertEqual(set(result.responses), {"run-q0"})
136
+ self.assertEqual(result.errors["run-q1"].call_id, "run-q1")
137
+ with self.assertRaises(InferenceError):
138
+ await backend.infer_batch(InferenceContext("输入"), calls)
139
+ answers[name]["noul"] = "invalid"
140
+ result = await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
141
+ self.assertEqual(set(result.errors), {"run-q0", "run-q1"})
142
+ answers["unexpected"] = {"type": "noul", "noul": .5}
143
+ with self.assertRaises(InferenceError):
144
+ await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
145
+ finally:
146
+ await backend.aclose()
147
+
148
+ async def test_score_legend_rejects_wrong_description_or_examples(self):
149
+ call = self.call("score", field_name="severity", examples=[{"state": "示例", "answer": 1}])
150
+ for legend in ({"0": "低", "1": "高"},
151
+ {"0": "低", "1": {"what": "高", "examples": ["示例"]}},
152
+ {"0": "低", "1": {"what": "高", "examples": ["错误示例"]}},
153
+ {"0": "低", "1": "错误描述"}):
154
+ def handle(request):
155
+ answers = answers_for(json.loads(request.content))
156
+ answers["severity"]["legend"] = legend
157
+ return httpx.Response(200, json={"answers": answers})
158
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
159
+ try:
160
+ if "错误" in json.dumps(legend, ensure_ascii=False):
161
+ with self.assertRaises(InferenceError):
162
+ await backend.infer_batch(InferenceContext("输入"), [call])
163
+ else:
164
+ result = await backend.infer_batch(InferenceContext("输入"), [call])
165
+ self.assertEqual(result.responses[call.id].legend, {0: "低", 1: "高"})
166
+ finally:
167
+ await backend.aclose()
168
+
169
+ async def test_named_decisions_roundtrip_and_reuse_in_all_runtime_modes(self):
170
+ for optimize in (False, True):
171
+ program = Program.from_json(compile_program(named_decisions, optimize=optimize).to_json())
172
+ plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
173
+ for mode in ("strict", "eager", "speculative"):
174
+ for limit in (1, 32):
175
+ sent = []
176
+ def handle(request):
177
+ body = json.loads(request.content)
178
+ sent.append(deepcopy(body))
179
+ return httpx.Response(200, json={"answers": answers_for(body)})
180
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
181
+ async with AnnoRuntime(backend, FakeBackend(), max_batch_questions=limit) as runtime:
182
+ result, = [r async for r in await runtime.submit(plan, [RunInput(0, "输入")], mode=mode)]
183
+ self.assertEqual(result.status, "success", result.error)
184
+ self.assertEqual(result.value, ["a", .8, 1., "a"])
185
+ names = [name for body in sent for name in body["questions"]]
186
+ self.assertCountEqual(names, ["category", "is_event", "severity"])
187
+
188
+
189
+ if __name__ == "__main__":
190
+ unittest.main()