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
annoscript/anno.py ADDED
@@ -0,0 +1,270 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, List, Dict
3
+ from dataclasses import dataclass
4
+
5
+ from annoscript._examples import Example
6
+
7
+
8
+ def constrain(condition: bool, *, message: str | None = None) -> None:
9
+ """Declare a hard constraint in a compiled AnnoScript eDSL program.
10
+
11
+ The compiler captures the pure ``condition`` expression and its dependencies
12
+ before Python evaluates the arguments. It may propagate the constraint to
13
+ related inference operations whose results are still adjustable within the
14
+ current solving scope. A constraint inside a branch applies only on that
15
+ execution path.
16
+
17
+ Successful execution past this call requires the condition to hold, as
18
+ established by proof or runtime validation. If a satisfying result cannot
19
+ be obtained, execution must report failure; retry and repair budgets are
20
+ execution-policy concerns. ``message`` optionally describes the constraint
21
+ for failure diagnostics. Constraints remain active in optimized execution.
22
+
23
+ This function is an eDSL intrinsic, not a standalone solver or validator.
24
+ Direct Python calls always raise ``RuntimeError``, even for a true condition:
25
+ the evaluated argument no longer carries the expression needed to propagate
26
+ constraints. The eDSL compiler must lower this call to a constraint operation.
27
+ """
28
+ raise RuntimeError(
29
+ "constrain() requires a compiled AnnoScript eDSL program; "
30
+ "direct Python execution cannot propagate constraints."
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class NoulResponse:
36
+ noul: float
37
+
38
+
39
+ @dataclass
40
+ class ChoiceResponse:
41
+ """Choice result; the provider guarantees finite confidence in [0, 1]."""
42
+
43
+ choice: str
44
+ confidence: float
45
+ probabilities: Dict[str, float]
46
+
47
+
48
+ @dataclass
49
+ class ScoreResponse:
50
+ probabilities: Dict[int, float]
51
+ score: float
52
+ legend: Dict[int, str]
53
+ confidence: float
54
+
55
+
56
+ @dataclass
57
+ class ExtractResponse:
58
+ items: List[str]
59
+
60
+
61
+ @dataclass
62
+ class GenerateResponse:
63
+ text: str | None
64
+
65
+
66
+ @dataclass
67
+ class GenerateListResponse:
68
+ items: List[str]
69
+
70
+
71
+ class Anno(ABC):
72
+ """Decision, extraction, and generation operations sharing execution context.
73
+
74
+ The runtime supplies the program's system_prompt when constructing the
75
+ per-record instance. The LLM backend applies it alongside per-call task
76
+ instructions, including calls from helpers. Decision requests use only their
77
+ task instructions and requirements. Neither overrides API guarantees or hard
78
+ constraints.
79
+
80
+ All operations accept ``examples``: a list of Example values (or their
81
+ serialized state/answer objects). None and [] mean no examples. Examples
82
+ inherit this call's instructions, criteria and constraints, and are checked
83
+ before dispatch. They teach task semantics, not facts about the current state.
84
+
85
+ All operations accept ``requirements``: a list of nonblank strings describing
86
+ additional task instructions. None and [] mean no extra requirements. LLM
87
+ requests group identical strings and name only the tasks they apply to;
88
+ Decision questions include their own requirements in the task instructions.
89
+ Requirements do not replace API guarantees or hard constraints.
90
+
91
+ All operations accept ``field_name``: a nonblank string used as the
92
+ public task name, including question and batch answer keys. None preserves automatic
93
+ names. Names must be distinct within a physical request, including automatic
94
+ aliases; internal call IDs and response types remain unchanged.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ state: str | Dict[str, Any] | List[Any],
100
+ model: str,
101
+ *,
102
+ system_prompt: str | None = None,
103
+ ):
104
+ self.state = state
105
+ self.model = model
106
+ self.system_prompt = system_prompt
107
+
108
+ @abstractmethod
109
+ def noul(
110
+ self,
111
+ instructions: str,
112
+ criteria_false: str | None = None,
113
+ criteria_true: str | None = None,
114
+ *,
115
+ examples: list[Example] | None = None,
116
+ requirements: list[str] | None = None,
117
+ field_name: str | None = None,
118
+ ) -> NoulResponse:
119
+ """Evaluate a yes/no question using this instance's state and model."""
120
+ raise NotImplementedError
121
+
122
+ @abstractmethod
123
+ def choice(self, instructions: str, criteria: Dict[str, str], *,
124
+ examples: list[Example] | None = None,
125
+ requirements: list[str] | None = None,
126
+ field_name: str | None = None) -> ChoiceResponse:
127
+ """Select an option using this instance's state and model.
128
+
129
+ A successful result must have ``result.choice in criteria`` and a finite
130
+ ``result.confidence`` in the inclusive range [0, 1], excluding NaN and
131
+ infinities. Implementations must enforce these guarantees and report
132
+ failure if a valid result cannot be produced.
133
+ """
134
+ raise NotImplementedError
135
+
136
+ @abstractmethod
137
+ def score(self, instructions: str, criteria: List[str], *,
138
+ examples: list[Example] | None = None,
139
+ requirements: list[str] | None = None,
140
+ field_name: str | None = None) -> ScoreResponse:
141
+ """Score the state against ordered criteria using this instance's model."""
142
+ raise NotImplementedError
143
+
144
+ @abstractmethod
145
+ def extract(
146
+ self,
147
+ instructions: str,
148
+ *,
149
+ source_field: str | None = None,
150
+ min_items: int = 0,
151
+ max_items: int,
152
+ max_length_per_item: int,
153
+ max_num_tokens: int | None = None,
154
+ examples: list[Example] | None = None,
155
+ field_name: str | None = None,
156
+ requirements: list[str] | None = None,
157
+ ) -> ExtractResponse:
158
+ """Extract verbatim passages using this instance's state and model.
159
+
160
+ ``source_field`` names a top-level string field in an object state,
161
+ such as ``source_field="text"``. It is a literal key, not a nested path.
162
+ String states require ``source_field=None`` and are extracted directly.
163
+ Object states require an explicit field; list states are unsupported.
164
+ Other state fields may provide context, but are not extraction sources.
165
+
166
+ Each item must be a nonempty, contiguous substring of the source, without
167
+ rewriting, normalization, or concatenation of separate passages. The
168
+ number of items must satisfy the inclusive ``min_items`` / ``max_items``
169
+ bounds. Required ``max_length_per_item`` is a suggested Unicode code-point
170
+ length, measured by Python's ``len``, and contributes to the token budget
171
+ unless a positive ``max_num_tokens`` overrides it (including format overhead).
172
+ In a batch this budget contributes to the shared limit.
173
+ Exceeding the suggestion does not invalidate an otherwise valid item.
174
+ Return an empty list when nothing matches and ``min_items`` is zero.
175
+
176
+ Implementations must raise ``ValueError`` for a missing, invalid or
177
+ non-string field, negative item bounds, an inverted item range, or a
178
+ nonpositive ``max_length_per_item``. If extraction cannot satisfy the
179
+ constraints, report failure rather than inventing items or returning a
180
+ successful result that violates the bounds. Matching the source does
181
+ not by itself establish that an item semantically supports a claim.
182
+ """
183
+ raise NotImplementedError
184
+
185
+ @abstractmethod
186
+ def generate(
187
+ self,
188
+ instructions: str,
189
+ *,
190
+ min_length: int = 0,
191
+ max_length: int,
192
+ max_num_tokens: int | None = None,
193
+ examples: list[Example] | None = None,
194
+ field_name: str | None = None,
195
+ nullable: bool = False,
196
+ requirements: list[str] | None = None,
197
+ ) -> GenerateResponse:
198
+ """Generate text using this instance's state and model.
199
+
200
+ The result is a string by default. ``nullable=True`` additionally allows
201
+ ``None`` (JSON null), without converting the literal string "null".
202
+ Nullable standalone calls and repairs return a JSON string or null scalar;
203
+ ordinary standalone calls remain raw text. Batches retain their answers
204
+ wrapper. The result may paraphrase or synthesize the state as directed by
205
+ ``instructions``. ``min_length`` and
206
+ required ``max_length`` are suggested Unicode code-point lengths, measured by
207
+ Python's ``len``. A positive ``min_length`` requires nonempty non-null text,
208
+ but other deviations from the suggested range are not failures.
209
+
210
+ ``max_num_tokens`` is an independent output limit for a standalone
211
+ generation. When Runtime batches ready calls, it contributes to a shared
212
+ budget and is not an enforced per-answer token limit. ``None`` uses the
213
+ client's assumed_avg_num_tokens_per_char (default 2) times max_length,
214
+ rounded up with a minimum of one token. Suggested lengths are not hard bounds.
215
+ Exhausting the budget must not silently turn an incomplete generation
216
+ into a successful response.
217
+
218
+ Implementations must raise ``ValueError`` for negative length bounds,
219
+ an inverted length range, a nonpositive ``max_num_tokens``, or a
220
+ non-boolean ``nullable``. If valid
221
+ output cannot be produced, report failure rather than returning a
222
+ successful result that violates the constraints. These constraints do
223
+ not guarantee factual accuracy. Branching and recovery remain the
224
+ caller's responsibility.
225
+ """
226
+ raise NotImplementedError
227
+
228
+ @abstractmethod
229
+ def generate_list(
230
+ self,
231
+ instructions: str,
232
+ *,
233
+ min_items: int = 0,
234
+ max_items: int,
235
+ min_length_per_item: int = 0,
236
+ max_length_per_item: int,
237
+ max_num_tokens: int | None = None,
238
+ examples: list[Example] | None = None,
239
+ field_name: str | None = None,
240
+ requirements: list[str] | None = None,
241
+ ) -> GenerateListResponse:
242
+ """Generate a list of texts using this instance's state and model.
243
+
244
+ The result is always a list of strings, with no ``None`` items. Each
245
+ item may paraphrase or synthesize the state as directed by
246
+ ``instructions``. The item count must satisfy the inclusive
247
+ ``min_items`` / ``max_items`` bounds; an empty list is allowed when
248
+ ``min_items=0``.
249
+
250
+ ``min_length_per_item`` and required ``max_length_per_item`` are suggested
251
+ Unicode code-point lengths for each string, measured by Python's ``len``.
252
+ A positive minimum still requires nonempty items. Required ``max_items``
253
+ is a hard count limit. Neither required maximum accepts None.
254
+
255
+ ``max_num_tokens`` is a generation budget for the entire list,
256
+ including formatting, in the model's tokens. In a Runtime batch it
257
+ contributes to a shared budget, without an independent per-list token
258
+ limit. ``None`` estimates ceil(assumed_avg_num_tokens_per_char * max_items *
259
+ max_length_per_item) + 32 + 8 * max_items. Extraction uses the same
260
+ formula. Item count and nonempty requirements remain strict. Exhausting the budget must not silently
261
+ turn an incomplete list or item into a successful response.
262
+
263
+ Implementations must raise ``ValueError`` for negative item or length
264
+ bounds, inverted ranges, or a nonpositive ``max_num_tokens``. If valid
265
+ output cannot be produced, report failure rather than returning a
266
+ successful result that violates the constraints. These constraints do
267
+ not guarantee factual accuracy. Branching and recovery remain the
268
+ caller's responsibility.
269
+ """
270
+ raise NotImplementedError
@@ -0,0 +1,13 @@
1
+ """API adapters. Batching and dependency scheduling belong to AnnoRuntime."""
2
+
3
+ from .base import (
4
+ BatchResult, LLMBatchPlan, DecisionBatchPlan, InferenceCall, InferenceContext, InferenceOutput,
5
+ InferenceError, BackendFatalError,
6
+ )
7
+ from .decision import DecisionAPIBackend
8
+ from .llm import LLMAPIBackend
9
+
10
+ __all__ = [
11
+ "BatchResult", "LLMBatchPlan", "DecisionBatchPlan", "InferenceCall", "InferenceContext", "InferenceOutput",
12
+ "InferenceError", "BackendFatalError", "DecisionAPIBackend", "LLMAPIBackend",
13
+ ]
@@ -0,0 +1,26 @@
1
+ """Request-local names for model APIs and compact LLM batch responses."""
2
+
3
+ FORMAT = "short_ids_values_v1"
4
+
5
+
6
+ def wire_name(call, fallback):
7
+ """Resolve an optional public name without replacing runtime identity."""
8
+ name = call.arguments.get("field_name")
9
+ if name is None:
10
+ return fallback
11
+ if type(name) is not str or not name.strip():
12
+ raise ValueError("field_name must be a nonblank string or None")
13
+ return name
14
+
15
+
16
+ def batch_calls(calls):
17
+ """Map wire IDs to unchanged calls; aliases never enter runtime identity."""
18
+ if len({call.id for call in calls}) != len(calls):
19
+ raise ValueError("a batch requires distinct calls")
20
+ identified = {}
21
+ for index, call in enumerate(calls, 1):
22
+ name = wire_name(call, f"q{index}")
23
+ if name in identified:
24
+ raise ValueError(f"duplicate model-visible field name: {name!r}")
25
+ identified[name] = call
26
+ return identified
@@ -0,0 +1,97 @@
1
+ """Hard structure/count/nonempty constraints; advisory code-point lengths, with source-isolated Substring rules."""
2
+
3
+ import json
4
+
5
+ from .base import InferenceError
6
+ from ._batch_wire import batch_calls
7
+
8
+
9
+ # Keep a complete JSON code point in one lexical FSM: a repeated independent
10
+ # json-char rule makes every text token context-sensitive in XGrammar.
11
+ JSON_ATOM = r'(?:[^"\\\x00-\x1f]|\\["\\/bfnrt]|\\u(?:[0-9a-cA-Ce-fE-F][0-9a-fA-F]{3}|[dD][0-7][0-9a-fA-F]{2})|\\u[dD][89aAbB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2})'
12
+
13
+
14
+ def json_body(nonempty):
15
+ return "Regex(" + json.dumps(JSON_ATOM + ("+" if nonempty else "*")) + ")"
16
+
17
+
18
+ def literal(value):
19
+ return json.dumps(value, ensure_ascii=False)
20
+
21
+
22
+ def repeat(rule, minimum, maximum):
23
+ if maximum == 0:
24
+ return '""'
25
+ return rule + ('{' + str(minimum) + ',' + ('' if maximum is None else str(maximum)) + '}')
26
+
27
+
28
+ def ensure_satisfiable(call):
29
+ a = call.arguments
30
+ if call.operation == "extract" and not call.extraction_source and a.get("min_items", 0) > 0:
31
+ raise InferenceError("empty extraction source cannot satisfy min_items",
32
+ code="unsatisfiable_constraints", call_id=call.id,
33
+ metadata={"dispatch_status": "not_sent"})
34
+
35
+
36
+ def grammar(calls, *, batch=False, extract_mode="validated"):
37
+ definitions = []
38
+ cache = {}
39
+
40
+ def value_rule(call):
41
+ ensure_satisfiable(call)
42
+ a = call.arguments
43
+ key = (call.operation, call.extraction_source, a.get("min_items", 0), a.get("max_items"),
44
+ bool(a.get("min_length", 0)), bool(a.get("min_length_per_item", 0)),
45
+ bool(a.get("nullable", False)))
46
+ if key in cache:
47
+ return cache[key]
48
+ prefix = 'value-' + str(len(cache))
49
+ cache[key] = prefix
50
+ if call.operation == "generate":
51
+ body = json_body(bool(a.get('min_length', 0)))
52
+ string = '"\\\"" ' + body + ' "\\\""'
53
+ value = '(' + string + ' | "null")' if a.get("nullable", False) else string
54
+ definitions.append(prefix + ' ::= ' + value)
55
+ return prefix
56
+ if call.operation == "extract" and not call.extraction_source:
57
+ definitions.append(prefix + ' ::= "[]"')
58
+ return prefix
59
+ item = prefix + '-string'
60
+ if call.operation == "extract" and extract_mode == "substring_chars":
61
+ chunks = [literal(json.dumps(c, ensure_ascii=False)[1:-1]) for c in call.extraction_source]
62
+ options = ', allow_empty=false'
63
+ body = prefix + '-body'
64
+ definitions.append(body + ' ::= Substring(' + ', '.join(chunks) + options + ')')
65
+ else:
66
+ minimum = 1 if call.operation == "extract" else a.get('min_length_per_item', 0)
67
+ body = json_body(bool(minimum))
68
+ definitions.append(item + ' ::= "\\\"" ' + body + ' "\\\""')
69
+ low, high = a.get('min_items', 0), a.get('max_items')
70
+ if high == 0:
71
+ interior = '""'
72
+ else:
73
+ tail = repeat('(\",\" ' + item + ')', max(0, low - 1), None if high is None else high - 1)
74
+ interior = item + ' ' + tail
75
+ if low == 0:
76
+ interior = '(' + interior + ')?'
77
+ definitions.append(prefix + ' ::= "[" ' + interior + ' "]"')
78
+ return prefix
79
+
80
+ if batch:
81
+ parts = [literal('{"answers":{')]
82
+ for i, (identity, call) in enumerate(batch_calls(calls).items()):
83
+ prefix = (',' if i else '') + json.dumps(identity) + ':'
84
+ parts.extend([literal(prefix), value_rule(call)])
85
+ parts.append(literal('}}'))
86
+ root = ' '.join(parts)
87
+ else:
88
+ call, = calls
89
+ ensure_satisfiable(call)
90
+ if call.operation == 'generate' and not call.arguments.get("nullable", False):
91
+ # Standalone generate remains raw text, with no JSON wrapper.
92
+ root = 'Regex(' + json.dumps(r'[\s\S]' + ('+' if call.arguments.get('min_length', 0) else '*')) + ')'
93
+ elif call.operation == 'generate':
94
+ root = value_rule(call)
95
+ else:
96
+ root = literal('{"items":') + ' ' + value_rule(call) + ' ' + literal('}')
97
+ return 'root ::= ' + root + '\n' + '\n'.join(definitions)
@@ -0,0 +1,23 @@
1
+ """Request-local example sharing; never modifies the current inference state."""
2
+
3
+ import json
4
+
5
+
6
+ EXAMPLE_CONTRACT = (
7
+ "examples 是任务的输入与正确答案示范,只用于理解规则,不是当前 state 的事实。"
8
+ "answer 是该算子的值,不包含置信度;当前输出仍遵守指定格式。"
9
+ )
10
+
11
+
12
+ def group_examples(tasks):
13
+ """Group inputs by first occurrence; retain every answer, including repeats."""
14
+ groups = {}
15
+ for identity, task in tasks.items():
16
+ for example in task["task"].get("examples", []):
17
+ state = example["state"]
18
+ key = json.dumps(state, ensure_ascii=False, allow_nan=False,
19
+ sort_keys=True, separators=(",", ":"))
20
+ if key not in groups:
21
+ groups[key] = {"state": state, "answers": []}
22
+ groups[key]["answers"].append((identity, example["answer"]))
23
+ return list(groups.values())
@@ -0,0 +1,116 @@
1
+ """Opt-in, release-bound Qwen request limits. No network or credential access."""
2
+
3
+ from functools import lru_cache
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import os
8
+ from annoscript._env import env_value
9
+ from pathlib import Path
10
+
11
+ from .base import BackendFatalError
12
+ from annoscript._data import thaw
13
+
14
+
15
+ SAMPLING = {"temperature": 0, "top_p": 1, "top_k": -1, "min_p": 0,
16
+ "repetition_penalty": 1, "presence_penalty": 0, "frequency_penalty": 0}
17
+
18
+
19
+ def validate_policy(value):
20
+ if value is None:
21
+ return None
22
+ keys = {"sampling", "default_max_tokens", "context_window", "tokenizer", "bounded_outputs"}
23
+ if type(value) is not dict or set(value) != keys:
24
+ raise ValueError("invalid generation request_policy")
25
+ sampling = value["sampling"]
26
+ if (type(sampling) is not dict or set(sampling) != set(SAMPLING)
27
+ or any(type(v) not in (int, float) or not math.isfinite(v) for v in sampling.values())
28
+ or sampling != SAMPLING):
29
+ raise ValueError("P0 requires explicit greedy sampling without additional penalties")
30
+ for name in ("default_max_tokens", "context_window"):
31
+ if type(value[name]) is not int or value[name] < 1:
32
+ raise ValueError("generation limits must be positive integers")
33
+ if value["default_max_tokens"] > value["context_window"] or value["bounded_outputs"] is not True:
34
+ raise ValueError("invalid bounded generation policy")
35
+ binding = value["tokenizer"]
36
+ if (type(binding) is not dict or set(binding) != {"revision", "files"}
37
+ or type(binding["revision"]) is not str or not binding["revision"]
38
+ or type(binding["files"]) is not dict
39
+ or not {"tokenizer.json", "tokenizer_config.json", "chat_template.jinja"} <= set(binding["files"])):
40
+ raise ValueError("tokenizer needs an immutable revision and file digests")
41
+ for name, digest in binding["files"].items():
42
+ if (type(name) is not str or Path(name).name != name or name in (".", "..")
43
+ or type(digest) is not str or len(digest) != 64
44
+ or any(c not in "0123456789abcdef" for c in digest)):
45
+ raise ValueError("invalid tokenizer file binding")
46
+ return json.loads(json.dumps(value))
47
+
48
+
49
+ @lru_cache(maxsize=4)
50
+ def _load_tokenizer(directory, serialized_binding):
51
+ binding = json.loads(serialized_binding)
52
+ root = Path(directory)
53
+ for name, digest in binding["files"].items():
54
+ if hashlib.sha256((root / name).read_bytes()).hexdigest() != digest:
55
+ raise ValueError("tokenizer artifact digest mismatch")
56
+ from transformers import AutoTokenizer
57
+ tokenizer = AutoTokenizer.from_pretrained(root, local_files_only=True, trust_remote_code=False)
58
+ if tokenizer.chat_template != (root / "chat_template.jinja").read_text():
59
+ raise ValueError("loaded chat template differs from bound artifact")
60
+ # Validate the template engine at startup, before any task can send HTTP.
61
+ tokenizer.apply_chat_template([{"role": "system", "content": ""},
62
+ {"role": "user", "content": "startup check"}],
63
+ tokenize=True, return_dict=False, add_generation_prompt=True, enable_thinking=False)
64
+ return tokenizer
65
+
66
+
67
+ class PinnedTokenCounter:
68
+ def __init__(self, binding):
69
+ try:
70
+ directory = env_value("ANNO_GENERATE_TOKENIZER_DIR")
71
+ self.tokenizer = _load_tokenizer(directory, json.dumps(binding, sort_keys=True))
72
+ except Exception:
73
+ raise BackendFatalError("pinned tokenizer is unavailable or mismatched",
74
+ code="configuration_failed") from None
75
+
76
+ def __call__(self, payload):
77
+ try:
78
+ ids = self.tokenizer.apply_chat_template(
79
+ payload["messages"], tokenize=True, return_dict=False, add_generation_prompt=True,
80
+ **payload.get("chat_template_kwargs", {}))
81
+ if type(ids) is not list or any(type(token) is not int for token in ids):
82
+ raise ValueError("template must return one token ID sequence")
83
+ return len(ids)
84
+ except Exception:
85
+ raise BackendFatalError("bound chat template could not tokenize request",
86
+ code="configuration_failed") from None
87
+
88
+
89
+ def validate_token_ratio(value):
90
+ if type(value) not in (int, float) or not math.isfinite(value) or value <= 0:
91
+ raise ValueError("assumed_avg_num_tokens_per_char must be finite and positive")
92
+ return value
93
+
94
+
95
+ def effective_budget(call, assumed_avg_num_tokens_per_char=2, override=None):
96
+ """One rule for direct, runtime, batch and repair budgets; no hidden cap."""
97
+ ratio = validate_token_ratio(assumed_avg_num_tokens_per_char)
98
+ a = call.arguments
99
+ # Direct backend callers also pass through the public argument validator.
100
+ from .base import normalize_arguments
101
+ normalize_arguments(call.operation, (), thaw(a))
102
+ if call.operation == "extract" and type(call.extraction_source) is not str:
103
+ raise ValueError("extract call requires a normalized source_field binding")
104
+ explicit = a.get("max_num_tokens")
105
+ if explicit is not None:
106
+ budget = explicit
107
+ elif call.operation == "generate":
108
+ budget = max(1, math.ceil(ratio * a["max_length"]))
109
+ else:
110
+ count = a["max_items"]
111
+ budget = math.ceil(ratio * count * a["max_length_per_item"]) + 32 + 8 * count
112
+ if override is not None:
113
+ if type(override) is not int or override < 1:
114
+ raise ValueError("invalid token override")
115
+ budget = min(budget, override)
116
+ return budget
@@ -0,0 +1,83 @@
1
+ """Compact JSON structure for list operations, independent of business contracts.
2
+
3
+ JSON syntax and batch ID/value type mapping are constrained here. Runtime validates
4
+ decoded strings, item counts, nonempty requirements and verbatim extraction.
5
+ The grammar is source, not generated from the car example or a third-party repo.
6
+ """
7
+
8
+ import json
9
+ from ._batch_wire import batch_calls
10
+
11
+
12
+ # Keep the string continuation in the same right-recursive rule. Repeating a
13
+ # separate `character` rule is language-equivalent but makes xgrammar 0.2.3
14
+ # classify tokens against the surrounding parser stack at every decode step
15
+ # with the Qwen3.5 vocabulary. It can dominate decoding and cause HTTP timeouts.
16
+ # This spelling preserves JSON escapes, Unicode and empty strings; it neither
17
+ # narrows extraction semantics nor changes any business/token budget.
18
+ STRING_RULES = r'''
19
+ string ::= "\"" string-body
20
+ string-body ::= "\"" | [^"\\\x00-\x1f] string-body | "\\" escape string-body
21
+ escape ::= ["\\/bfnrt] | "u" hex hex hex hex
22
+ hex ::= [0-9a-fA-F]
23
+ '''.strip()
24
+
25
+ ITEMS_GRAMMAR = r'''root ::= "{\"items\":[" (string ("," string)*)? "]}"''' + "\n" + STRING_RULES
26
+
27
+
28
+ BATCH_GRAMMAR = r'''
29
+ root ::= "{\"answers\":{" (answer ("," answer)*)? "}}"
30
+ answer ::= string ":" (string | items)
31
+ items ::= "[" (string ("," string)*)? "]"
32
+ '''.strip() + "\n" + STRING_RULES
33
+
34
+
35
+ def _extract_rules(source, prefix):
36
+ """Atomic JSON encodings preserve code-point boundaries after JSON decoding.
37
+
38
+ Requires the pinned XGrammar allow_empty extension. No Unicode normalization
39
+ or union of sources is permitted. Runtime still checks counts and lengths.
40
+ """
41
+ items, body = prefix + "-items", prefix + "-body"
42
+ if not source:
43
+ return items, [items + ' ::= "[]"']
44
+ chunks = [json.dumps(json.dumps(char, ensure_ascii=False)[1:-1], ensure_ascii=False)
45
+ for char in source]
46
+ string = prefix + "-string"
47
+ return items, [
48
+ body + " ::= Substring(" + ", ".join(chunks) + ", allow_empty=false)",
49
+ string + ' ::= "\\\"" ' + body + ' "\\\""',
50
+ items + ' ::= "[" (' + string + ' ("," ' + string + ')*)? "]"',
51
+ ]
52
+
53
+
54
+ def extract_grammar(source):
55
+ items, rules = _extract_rules(source, "extract")
56
+ return 'root ::= "{\\\"items\\\":" ' + items + ' "}"\n' + "\n".join(rules)
57
+
58
+
59
+ def batch_grammar(calls, *, extract_mode="validated"):
60
+ """Bind every requested ID to its operation, once, in request order.
61
+
62
+ A generic union allows an extract ID to produce a string, even though
63
+ that answer is valid JSON. The response parser still validates arbitrary
64
+ object ordering and all constraints when the provider ignores the grammar.
65
+ """
66
+ parts = [json.dumps('{"answers":{')]
67
+ sources, rules = {}, []
68
+ for index, (identity, call) in enumerate(batch_calls(calls).items()):
69
+ prefix = ("," if index else "") + json.dumps(identity) + ':'
70
+ value_rule = "string" if call.operation == "generate" else "items"
71
+ if call.operation == "generate" and call.arguments.get("nullable", False):
72
+ value_rule = '(string | "null")'
73
+ if call.operation == "extract" and extract_mode == "substring_chars":
74
+ source = call.extraction_source
75
+ if source not in sources:
76
+ rule, definitions = _extract_rules(source, f"source-{len(sources)}")
77
+ sources[source] = rule
78
+ rules.extend(definitions)
79
+ value_rule = sources[source]
80
+ parts.extend((json.dumps(prefix, ensure_ascii=False), value_rule))
81
+ parts.append(json.dumps("}}"))
82
+ grammar = "root ::= " + " ".join(parts) + '\nitems ::= "[" (string ("," string)*)? "]"\n' + STRING_RULES
83
+ return grammar + ("\n" + "\n".join(rules) if rules else "")
@@ -0,0 +1,13 @@
1
+ """Detached inspection of the exact HTTP body; never includes transport headers."""
2
+
3
+ import hashlib
4
+
5
+ from .base import encode_payload
6
+
7
+
8
+ def request_preview(payload, *, stage, adapter, url, evidence=None):
9
+ body = encode_payload(payload)
10
+ return {"format": "lev-request-preview", "version": 1, "stage": stage,
11
+ "request_adapter": adapter, "method": "POST", "url": url,
12
+ "body_sha256": hashlib.sha256(body).hexdigest(), "body_bytes": len(body),
13
+ "payload": payload, "evidence": evidence or {}}