annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
annoscript/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AnnoScript's Python eDSL, compiler, execution plans and API-backed runtime."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Generation wire profiles shared by configuration readers; no backend imports."""
|
|
2
|
+
|
|
3
|
+
GENERATION_PROFILES = ("legacy", "qwen_vllm")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_extract_mode(mode, profile):
|
|
7
|
+
if type(mode) is not str or mode not in ("validated", "substring_chars"):
|
|
8
|
+
raise ValueError("extract_mode must be validated or substring_chars")
|
|
9
|
+
if mode == "substring_chars" and profile != "qwen_vllm":
|
|
10
|
+
raise ValueError("substring_chars requires qwen_vllm")
|
|
11
|
+
return mode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_generation_profile(profile):
|
|
15
|
+
if type(profile) is not str or profile not in GENERATION_PROFILES:
|
|
16
|
+
raise ValueError("generation profile must be legacy or qwen_vllm")
|
|
17
|
+
return profile
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Python 3.10+ contracts shared by local and remote clients; no execution imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import FunctionType, TracebackType
|
|
9
|
+
from typing import Generic, List, Literal, TypeAlias, TypeVar
|
|
10
|
+
from weakref import WeakKeyDictionary
|
|
11
|
+
|
|
12
|
+
from annoscript.anno import Anno
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
JSONValue: TypeAlias = (
|
|
16
|
+
str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
|
|
17
|
+
)
|
|
18
|
+
State: TypeAlias = str | list[JSONValue] | dict[str, JSONValue]
|
|
19
|
+
R = TypeVar("R", bound=JSONValue)
|
|
20
|
+
InputCheck: TypeAlias = Callable[[State], None]
|
|
21
|
+
|
|
22
|
+
# Keep local checks outside the program function's attributes and execution body.
|
|
23
|
+
_PRECONDITIONS: WeakKeyDictionary[
|
|
24
|
+
Callable[[Anno], JSONValue], tuple[InputCheck, ...]
|
|
25
|
+
] = WeakKeyDictionary()
|
|
26
|
+
# Only this registry's text values are exported as remote program configuration.
|
|
27
|
+
_SYSTEM_PROMPTS: WeakKeyDictionary[Callable[[Anno], JSONValue], str] = WeakKeyDictionary()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InputValidationError(ValueError):
|
|
31
|
+
"""An input failed a client-local precondition, before remote submission."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require(condition: bool, *, message: str) -> None:
|
|
35
|
+
"""Check an input condition locally; raise InputValidationError on failure.
|
|
36
|
+
|
|
37
|
+
This ordinary Python function remains active under optimization. It never
|
|
38
|
+
invokes inference, repairs inputs, or propagates constraints to the eDSL.
|
|
39
|
+
"""
|
|
40
|
+
if not condition:
|
|
41
|
+
raise InputValidationError(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def precondition(
|
|
45
|
+
check: InputCheck,
|
|
46
|
+
) -> Callable[[Callable[[Anno], R]], Callable[[Anno], R]]:
|
|
47
|
+
"""Register a client-local input check and return the original program.
|
|
48
|
+
|
|
49
|
+
Checks are synchronous, deterministic, read-only Python functions. Return
|
|
50
|
+
None for success and raise InputValidationError for an invalid input. Other
|
|
51
|
+
exceptions indicate a checker defect and abort submission. Checks must not
|
|
52
|
+
invoke models or make network requests.
|
|
53
|
+
|
|
54
|
+
Registration does not run checks or wrap the program. Direct program calls
|
|
55
|
+
do not run checks; AnnoClient implementations run them before any remote
|
|
56
|
+
submission. Stacked decorators are checked in source order (top to bottom),
|
|
57
|
+
stopping at the first failed check for each input.
|
|
58
|
+
|
|
59
|
+
The registry is local only. Remote program packaging must exclude these
|
|
60
|
+
decorator declarations, check functions, and their validation dependencies.
|
|
61
|
+
"""
|
|
62
|
+
if not callable(check):
|
|
63
|
+
raise TypeError("precondition requires a callable input check")
|
|
64
|
+
|
|
65
|
+
def register(program: Callable[[Anno], R]) -> Callable[[Anno], R]:
|
|
66
|
+
if not isinstance(program, FunctionType):
|
|
67
|
+
raise TypeError("precondition must decorate a Python function")
|
|
68
|
+
_PRECONDITIONS[program] = (check,) + _PRECONDITIONS.get(program, ())
|
|
69
|
+
return program
|
|
70
|
+
|
|
71
|
+
return register
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def system_prompt(
|
|
75
|
+
prompt: str,
|
|
76
|
+
) -> Callable[[Callable[[Anno], R]], Callable[[Anno], R]]:
|
|
77
|
+
"""Declare shared model instructions and return the original program.
|
|
78
|
+
|
|
79
|
+
The client exports this text as program configuration. The runtime binds it
|
|
80
|
+
to every record's injected AnnoScript and applies it to LLM operations, including
|
|
81
|
+
calls inside helper functions. Decision requests use their own instructions
|
|
82
|
+
and requirements. API guarantees and hard constraints remain independently
|
|
83
|
+
enforced. Mapping shared instructions to a model is the runtime's job.
|
|
84
|
+
|
|
85
|
+
Registration neither wraps nor executes the program and creates no context.
|
|
86
|
+
Direct Python calls do not inject the prompt. Each program may declare one
|
|
87
|
+
system prompt; any repeated declaration raises ValueError. Empty strings
|
|
88
|
+
are allowed and preserved. Prompt text is sent as configuration, without
|
|
89
|
+
executing the decorator remotely or exporting local precondition checks.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(prompt, str):
|
|
92
|
+
raise TypeError("system_prompt requires a string")
|
|
93
|
+
|
|
94
|
+
def register(program: Callable[[Anno], R]) -> Callable[[Anno], R]:
|
|
95
|
+
if not isinstance(program, FunctionType):
|
|
96
|
+
raise TypeError("system_prompt must decorate a Python function")
|
|
97
|
+
if program in _SYSTEM_PROMPTS:
|
|
98
|
+
raise ValueError("a program may declare only one system_prompt")
|
|
99
|
+
_SYSTEM_PROMPTS[program] = prompt
|
|
100
|
+
return program
|
|
101
|
+
|
|
102
|
+
return register
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class ResultError:
|
|
107
|
+
"""A per-input failure, including local validation or unsatisfied constraints."""
|
|
108
|
+
|
|
109
|
+
code: str
|
|
110
|
+
message: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Result(Generic[R]):
|
|
115
|
+
"""One terminal outcome, identified by its zero-based input index.
|
|
116
|
+
|
|
117
|
+
Success preserves the program's return value, including ``None``. An error
|
|
118
|
+
carries no value. A list-valued result stays one result, not multiple items.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
index: int
|
|
122
|
+
status: Literal["success", "error"]
|
|
123
|
+
value: R | None = None
|
|
124
|
+
error: ResultError | None = None
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
if type(self.index) is not int or self.index < 0:
|
|
128
|
+
raise ValueError("index must be a nonnegative integer")
|
|
129
|
+
if self.status == "success":
|
|
130
|
+
if self.error is not None:
|
|
131
|
+
raise ValueError("a successful result cannot contain an error")
|
|
132
|
+
elif self.status == "error":
|
|
133
|
+
if not isinstance(self.error, ResultError) or self.value is not None:
|
|
134
|
+
raise ValueError("an error result requires ResultError and no value")
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError("status must be 'success' or 'error'")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ResultStream(Iterator[Result[R]], ABC):
|
|
140
|
+
"""A single-pass result iterator with explicit resource ownership.
|
|
141
|
+
|
|
142
|
+
Use ``with`` when stopping early: breaking a plain ``for`` loop does not
|
|
143
|
+
close the stream. Exhaustion and iteration errors automatically close it.
|
|
144
|
+
Concrete transports implement ``_next_result`` and ``_close`` and call
|
|
145
|
+
``super().__init__()`` when overriding construction.
|
|
146
|
+
|
|
147
|
+
Streams are intended for consumption and closure by a single thread.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
self._closed = False
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def closed(self) -> bool:
|
|
155
|
+
return self._closed
|
|
156
|
+
|
|
157
|
+
def __iter__(self) -> ResultStream[R]:
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __next__(self) -> Result[R]:
|
|
161
|
+
if self._closed:
|
|
162
|
+
raise StopIteration
|
|
163
|
+
try:
|
|
164
|
+
return self._next_result()
|
|
165
|
+
except StopIteration:
|
|
166
|
+
self.close()
|
|
167
|
+
raise
|
|
168
|
+
except BaseException:
|
|
169
|
+
try:
|
|
170
|
+
self.close()
|
|
171
|
+
except BaseException:
|
|
172
|
+
import logging
|
|
173
|
+
logging.getLogger(__name__).warning("Result cleanup failed while handling another exception")
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
"""Stop receiving results and release resources; repeated calls are harmless.
|
|
178
|
+
|
|
179
|
+
Closing discards undelivered results. No further work may be submitted.
|
|
180
|
+
The transport should request cancellation of pending
|
|
181
|
+
work, without promising that already-running remote work can be stopped.
|
|
182
|
+
The stream stays closed even if transport cleanup raises an exception.
|
|
183
|
+
"""
|
|
184
|
+
if not self._closed:
|
|
185
|
+
self._closed = True
|
|
186
|
+
self._close()
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> ResultStream[R]:
|
|
189
|
+
if self._closed:
|
|
190
|
+
raise RuntimeError("cannot enter a closed result stream")
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(
|
|
194
|
+
self,
|
|
195
|
+
exc_type: type[BaseException] | None,
|
|
196
|
+
exc_value: BaseException | None,
|
|
197
|
+
traceback: TracebackType | None,
|
|
198
|
+
) -> Literal[False]:
|
|
199
|
+
if exc_type is None:
|
|
200
|
+
self.close()
|
|
201
|
+
else:
|
|
202
|
+
try:
|
|
203
|
+
self.close()
|
|
204
|
+
except BaseException:
|
|
205
|
+
import logging
|
|
206
|
+
logging.getLogger(__name__).warning("Result cleanup failed while handling another exception")
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
@abstractmethod
|
|
210
|
+
def _next_result(self) -> Result[R]:
|
|
211
|
+
"""Return the next result, or raise StopIteration when fully exhausted.
|
|
212
|
+
|
|
213
|
+
Return per-input failures as error results. Raise submission-level or
|
|
214
|
+
transport failures as exceptions; the stream will then close itself.
|
|
215
|
+
"""
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
|
|
218
|
+
@abstractmethod
|
|
219
|
+
def _close(self) -> None:
|
|
220
|
+
"""Release local resources and request cancellation, at most once.
|
|
221
|
+
|
|
222
|
+
This hook must not submit new work or wait for unfinished work to
|
|
223
|
+
complete. It is also called after normal exhaustion or iteration errors.
|
|
224
|
+
"""
|
|
225
|
+
raise NotImplementedError
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_program_config(program: Callable[[Anno], R]) -> dict[str, JSONValue]:
|
|
229
|
+
"""Return fresh, JSON-compatible execution settings for remote submission.
|
|
230
|
+
|
|
231
|
+
Export system-prompt text only, never local check functions or rules.
|
|
232
|
+
Programs without a declaration have no system_prompt configuration key.
|
|
233
|
+
Reading configuration does not execute the program or its preconditions.
|
|
234
|
+
"""
|
|
235
|
+
prompt = _SYSTEM_PROMPTS.get(program)
|
|
236
|
+
return {} if prompt is None else {"system_prompt": prompt}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_inputs(
|
|
240
|
+
program: Callable[[Anno], R], inputs: List[State]
|
|
241
|
+
) -> dict[int, ResultError]:
|
|
242
|
+
"""Run the whole batch's preconditions locally, without running program.
|
|
243
|
+
|
|
244
|
+
Call before any remote submission. Return invalid-input errors keyed by
|
|
245
|
+
original list index; the remaining indices passed all registered checks.
|
|
246
|
+
Only InputValidationError becomes an item error. Unexpected exceptions
|
|
247
|
+
and non-None check returns abort validation and must prevent submission.
|
|
248
|
+
|
|
249
|
+
This helper validates the outer list and program-specific preconditions.
|
|
250
|
+
Concrete clients also validate the generic JSON input contract.
|
|
251
|
+
"""
|
|
252
|
+
if not isinstance(inputs, list):
|
|
253
|
+
raise TypeError("inputs must be a list")
|
|
254
|
+
checks = _PRECONDITIONS.get(program, ())
|
|
255
|
+
errors = {}
|
|
256
|
+
for index, state in enumerate(inputs):
|
|
257
|
+
try:
|
|
258
|
+
for check in checks:
|
|
259
|
+
if check(state) is not None:
|
|
260
|
+
raise TypeError("precondition checks must return None on success")
|
|
261
|
+
except InputValidationError as error:
|
|
262
|
+
errors[index] = ResultError(code="invalid_input", message=str(error))
|
|
263
|
+
return errors
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def prepare_inputs(program, inputs):
|
|
267
|
+
"""Snapshot JSON inputs, then check all valid shapes before any submission.
|
|
268
|
+
|
|
269
|
+
Return (indexed states, local errors), retaining the original input indices.
|
|
270
|
+
Unexpected checker exceptions propagate before either client dispatches work.
|
|
271
|
+
"""
|
|
272
|
+
from annoscript._data import snapshot
|
|
273
|
+
|
|
274
|
+
if not isinstance(inputs, list):
|
|
275
|
+
raise TypeError("inputs must be a list")
|
|
276
|
+
errors, valid = {}, []
|
|
277
|
+
for index, state in enumerate(inputs):
|
|
278
|
+
try:
|
|
279
|
+
valid.append((index, snapshot(state, state=True)))
|
|
280
|
+
except (ValueError, RecursionError):
|
|
281
|
+
errors[index] = ResultError(
|
|
282
|
+
"invalid_input",
|
|
283
|
+
"input must contain finite JSON data and have string, array or object shape",
|
|
284
|
+
)
|
|
285
|
+
checked = validate_inputs(program, [state for _, state in valid])
|
|
286
|
+
errors.update({valid[i][0]: error for i, error in checked.items()})
|
|
287
|
+
return [item for i, item in enumerate(valid) if i not in checked], errors
|
annoscript/_data.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""JSON snapshots and immutable inference payloads; no application code runs here."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def snapshot(value, path="$", *, state=False):
|
|
9
|
+
if state and type(value) not in (str, list, dict):
|
|
10
|
+
raise ValueError(f"{path}: input must be a string, list or object")
|
|
11
|
+
if value is None or type(value) in (str, bool, int):
|
|
12
|
+
return value
|
|
13
|
+
if type(value) is float and math.isfinite(value):
|
|
14
|
+
return value
|
|
15
|
+
if type(value) is list:
|
|
16
|
+
return [snapshot(v, f"{path}[{i}]") for i, v in enumerate(value)]
|
|
17
|
+
if type(value) is dict:
|
|
18
|
+
if any(type(k) is not str for k in value):
|
|
19
|
+
raise ValueError(f"{path}: object keys must be strings")
|
|
20
|
+
return {k: snapshot(v, f"{path}.{k}") for k, v in value.items()}
|
|
21
|
+
raise ValueError(f"{path}: expected finite JSON data")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def freeze(value):
|
|
25
|
+
if isinstance(value, Mapping):
|
|
26
|
+
return MappingProxyType({k: freeze(v) for k, v in value.items()})
|
|
27
|
+
if isinstance(value, (list, tuple)):
|
|
28
|
+
return tuple(freeze(v) for v in value)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def thaw(value):
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
return {k: thaw(v) for k, v in value.items()}
|
|
35
|
+
if isinstance(value, tuple):
|
|
36
|
+
return [thaw(v) for v in value]
|
|
37
|
+
return value
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Event-loop-local, FIFO model slots shared by isolated input runtimes.
|
|
2
|
+
|
|
3
|
+
Waiters contain owner identities and wake callbacks, never model calls or payloads.
|
|
4
|
+
Batch selection therefore happens after admission, using the current ready set.
|
|
5
|
+
"""
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestSlots:
|
|
10
|
+
def __init__(self, *, generation, decision):
|
|
11
|
+
if any(type(v) is not int or v < 1 for v in (generation, decision)):
|
|
12
|
+
raise ValueError("shared model limits must be positive integers")
|
|
13
|
+
self.limits = {"llm": generation, "decision": decision}
|
|
14
|
+
self.active = dict.fromkeys(self.limits, 0)
|
|
15
|
+
self.peak = dict(self.active)
|
|
16
|
+
self.waiters = {k: OrderedDict() for k in self.limits}
|
|
17
|
+
|
|
18
|
+
def acquire(self, kind, owner, wake):
|
|
19
|
+
waiting = self.waiters[kind]
|
|
20
|
+
waiting.setdefault(owner, wake)
|
|
21
|
+
if self.active[kind] >= self.limits[kind] or next(iter(waiting)) != owner:
|
|
22
|
+
return None
|
|
23
|
+
waiting.pop(owner)
|
|
24
|
+
self.active[kind] += 1
|
|
25
|
+
self.peak[kind] = max(self.peak[kind], self.active[kind])
|
|
26
|
+
self._wake(kind)
|
|
27
|
+
released = False
|
|
28
|
+
|
|
29
|
+
def release():
|
|
30
|
+
nonlocal released
|
|
31
|
+
if not released:
|
|
32
|
+
released = True
|
|
33
|
+
self.active[kind] -= 1
|
|
34
|
+
self._wake(kind)
|
|
35
|
+
return release
|
|
36
|
+
|
|
37
|
+
def _wake(self, kind):
|
|
38
|
+
for wake in list(self.waiters[kind].values())[:self.limits[kind] - self.active[kind]]:
|
|
39
|
+
wake()
|
|
40
|
+
|
|
41
|
+
def cancel(self, owner):
|
|
42
|
+
for kind in self.limits:
|
|
43
|
+
if self.waiters[kind].pop(owner, None) is not None:
|
|
44
|
+
self._wake(kind)
|
|
45
|
+
|
|
46
|
+
def waiting(self, owner):
|
|
47
|
+
return any(owner in q for q in self.waiters.values())
|
annoscript/_env.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Canonical ANNO configuration with legacy LEV input compatibility."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize(values):
|
|
7
|
+
"""Normalize one configuration layer before merging higher-priority layers."""
|
|
8
|
+
result = dict(values)
|
|
9
|
+
for name, value in values.items():
|
|
10
|
+
if name.startswith("LEV_"):
|
|
11
|
+
result.setdefault("ANNO_" + name[4:], value)
|
|
12
|
+
return result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def env_value(name, default=None, *, environ=None):
|
|
16
|
+
values = os.environ if environ is None else environ
|
|
17
|
+
if name in values:
|
|
18
|
+
return values[name]
|
|
19
|
+
return values.get("LEV_" + name[5:], default)
|
annoscript/_examples.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Portable example data; this module has no runtime/backend dependencies."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from annoscript._data import freeze, snapshot, thaw
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def example_data(state, answer):
|
|
9
|
+
"""The closed ``example`` intrinsic: return detached, finite JSON data."""
|
|
10
|
+
return {"state": snapshot(state, state=True), "answer": snapshot(answer)}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Example:
|
|
15
|
+
"""An input and its task answer, using the enclosing operation's parameters.
|
|
16
|
+
|
|
17
|
+
Answers contain only the business value, never confidence or probabilities.
|
|
18
|
+
Nested values are immutable snapshots. Compiled examples carry ordinary JSON,
|
|
19
|
+
not Python objects. Task/criteria overrides are intentionally unsupported.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
state: object
|
|
23
|
+
answer: object
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
data = example_data(self.state, self.answer)
|
|
27
|
+
object.__setattr__(self, "state", freeze(data["state"]))
|
|
28
|
+
object.__setattr__(self, "answer", freeze(data["answer"]))
|
|
29
|
+
|
|
30
|
+
def to_dict(self):
|
|
31
|
+
return {"state": thaw(self.state), "answer": thaw(self.answer)}
|
|
32
|
+
|
|
33
|
+
def __deepcopy__(self, memo):
|
|
34
|
+
return self
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Closed expression interpreter for the runtime's value bindings."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import operator
|
|
5
|
+
|
|
6
|
+
from annoscript import ir
|
|
7
|
+
from annoscript._examples import example_data
|
|
8
|
+
from annoscript.backends.base import RESPONSE_TYPES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_COMPARE = {
|
|
12
|
+
"eq": operator.eq, "ne": operator.ne, "lt": operator.lt, "le": operator.le,
|
|
13
|
+
"gt": operator.gt, "ge": operator.ge, "in": lambda a, b: a in b,
|
|
14
|
+
"not_in": lambda a, b: a not in b, "is": operator.is_, "is_not": operator.is_not,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def expression(node, env, state, allocations=None):
|
|
19
|
+
ev = lambda e: expression(e, env, state, allocations)
|
|
20
|
+
def allocated(value):
|
|
21
|
+
if allocations is not None and type(value) in (list, dict):
|
|
22
|
+
allocations[id(value)] = value
|
|
23
|
+
return value
|
|
24
|
+
if isinstance(node, ir.Literal):
|
|
25
|
+
return node.value
|
|
26
|
+
if isinstance(node, ir.Var):
|
|
27
|
+
return env[node.name]
|
|
28
|
+
if isinstance(node, ir.State):
|
|
29
|
+
return state
|
|
30
|
+
if isinstance(node, ir.GetItem):
|
|
31
|
+
return ev(node.value)[ev(node.index)]
|
|
32
|
+
if isinstance(node, ir.Field):
|
|
33
|
+
value = ev(node.value)
|
|
34
|
+
if type(value) not in RESPONSE_TYPES.values() or node.name not in value.__dataclass_fields__:
|
|
35
|
+
raise ValueError("field access requires a response field")
|
|
36
|
+
return getattr(value, node.name)
|
|
37
|
+
if isinstance(node, ir.ListExpr):
|
|
38
|
+
return allocated([ev(x) for x in node.items])
|
|
39
|
+
if isinstance(node, ir.TupleExpr):
|
|
40
|
+
return tuple(ev(x) for x in node.items)
|
|
41
|
+
if isinstance(node, ir.DictExpr):
|
|
42
|
+
return allocated({ev(k): ev(v) for k, v in node.entries})
|
|
43
|
+
if isinstance(node, ir.Unary):
|
|
44
|
+
return {"not": operator.not_, "negative": operator.neg, "positive": operator.pos}[node.op](ev(node.value))
|
|
45
|
+
if isinstance(node, ir.Binary):
|
|
46
|
+
return allocated(ev(node.left) + ev(node.right))
|
|
47
|
+
if isinstance(node, ir.Compare):
|
|
48
|
+
left = ev(node.left)
|
|
49
|
+
for op, other in zip(node.ops, node.comparators):
|
|
50
|
+
right = ev(other)
|
|
51
|
+
if not _COMPARE[op](left, right):
|
|
52
|
+
return False
|
|
53
|
+
left = right
|
|
54
|
+
return True
|
|
55
|
+
if isinstance(node, ir.BoolOp):
|
|
56
|
+
value = ev(node.values[0])
|
|
57
|
+
for other in node.values[1:]:
|
|
58
|
+
if (node.op == "and" and not value) or (node.op == "or" and value):
|
|
59
|
+
break
|
|
60
|
+
value = ev(other)
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(node, ir.Intrinsic):
|
|
63
|
+
fn = {"len": len, "int": int, "range": range, "enumerate": enumerate,
|
|
64
|
+
"str.strip": str.strip, "json.dumps": json.dumps,
|
|
65
|
+
"example": example_data}[node.name]
|
|
66
|
+
return fn(*[ev(x) for x in node.args], **{k: ev(v) for k, v in node.kwargs})
|
|
67
|
+
if isinstance(node, ir.FormatValue):
|
|
68
|
+
value = ev(node.value)
|
|
69
|
+
spec = ev(node.spec) if node.spec is not None else ""
|
|
70
|
+
if node.conversion != "none":
|
|
71
|
+
value = {"s": str, "r": repr, "a": ascii}[node.conversion](value)
|
|
72
|
+
return format(value, spec)
|
|
73
|
+
if isinstance(node, ir.FormatString):
|
|
74
|
+
return "".join(ev(x) for x in node.parts)
|
|
75
|
+
raise ValueError("unsupported expression")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def unpack(target, value):
|
|
79
|
+
if isinstance(target, ir.NameTarget):
|
|
80
|
+
return [value]
|
|
81
|
+
# Consume at most one extra item, just like exact Python unpacking.
|
|
82
|
+
it = iter(value)
|
|
83
|
+
values = []
|
|
84
|
+
for _ in target.items:
|
|
85
|
+
try:
|
|
86
|
+
values.append(next(it))
|
|
87
|
+
except StopIteration:
|
|
88
|
+
raise ValueError("not enough values to unpack") from None
|
|
89
|
+
try:
|
|
90
|
+
next(it)
|
|
91
|
+
except StopIteration:
|
|
92
|
+
return [v for t, item in zip(target.items, values) for v in unpack(t, item)]
|
|
93
|
+
raise ValueError("too many values to unpack")
|