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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Fuse a single-use assignment into the next whole-expression consumer.
|
|
2
|
+
|
|
3
|
+
No computation crosses another statement or enters a conditional subexpression.
|
|
4
|
+
Global static use/definition counts deliberately reject ambiguous bindings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
from annoscript import ir
|
|
11
|
+
from ._analysis import definitions, direct_reads, walk_statements
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def merge_temporaries(program: ir.Program) -> ir.Program:
|
|
15
|
+
uses, writes = Counter(), Counter()
|
|
16
|
+
for statement in walk_statements(program.body):
|
|
17
|
+
uses.update(direct_reads(statement))
|
|
18
|
+
writes.update(definitions(statement))
|
|
19
|
+
|
|
20
|
+
def block(body):
|
|
21
|
+
output = []
|
|
22
|
+
for statement in body:
|
|
23
|
+
if isinstance(statement, ir.If):
|
|
24
|
+
statement = replace(statement, then_body=block(statement.then_body), else_body=block(statement.else_body))
|
|
25
|
+
elif isinstance(statement, ir.For):
|
|
26
|
+
statement = replace(statement, body=block(statement.body))
|
|
27
|
+
field = None
|
|
28
|
+
if isinstance(statement, (ir.Assign, ir.Return)):
|
|
29
|
+
field = "value"
|
|
30
|
+
elif isinstance(statement, ir.If):
|
|
31
|
+
field = "condition"
|
|
32
|
+
elif isinstance(statement, ir.For):
|
|
33
|
+
field = "iterable"
|
|
34
|
+
while field and output and isinstance(output[-1], ir.Assign):
|
|
35
|
+
previous = output[-1]
|
|
36
|
+
value = getattr(statement, field)
|
|
37
|
+
if not (isinstance(value, ir.Var) and value.name == previous.target
|
|
38
|
+
and uses[value.name] == writes[value.name] == 1):
|
|
39
|
+
break
|
|
40
|
+
statement = replace(statement, **{field: previous.value})
|
|
41
|
+
output.pop()
|
|
42
|
+
output.append(statement)
|
|
43
|
+
return tuple(output)
|
|
44
|
+
|
|
45
|
+
return replace(program, body=block(program.body))
|
annoscript/plan.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Serializable dependency templates for a future dynamic AnnoScript scheduler.
|
|
2
|
+
|
|
3
|
+
Tasks are instantiated per request and loop coordinate. An edge's common loop
|
|
4
|
+
coordinates match through its carried loop (or the entire common prefix when
|
|
5
|
+
there is no carried loop), with the specified positive distance subtracted.
|
|
6
|
+
Deeper source coordinates are gathered and deeper destination coordinates are
|
|
7
|
+
broadcast, including nested loops whose trip counts vary between outer rounds.
|
|
8
|
+
Inactive control paths satisfy completion edges, but never produce values.
|
|
9
|
+
|
|
10
|
+
Infer sites demand a shared request, *after* their own dependencies are ready.
|
|
11
|
+
Sharing is local to a request, reuse scope, and dependency/repair generation.
|
|
12
|
+
It is not eager hoisting, global memoization, retrying, or a batch assignment.
|
|
13
|
+
Response values are read-only. Identity-observing programs disable sharing.
|
|
14
|
+
|
|
15
|
+
Eager execution omits failure_order and explicitly strict_only ordering edges,
|
|
16
|
+
allowing independent work to pass pending checks. Strict execution includes them,
|
|
17
|
+
preserving the sequential failure prefix (with equivalent requests shared).
|
|
18
|
+
Both report the first observed error, stop dispatch on failure, best-effort cancel
|
|
19
|
+
in-flight work, and await all active work, including checks, before success.
|
|
20
|
+
Resource exhaustion is outside the static expression safety model.
|
|
21
|
+
|
|
22
|
+
Speculative execution uses the same formal dependencies and serial loop rules
|
|
23
|
+
as eager. A separate bounded evaluator first speculates Decision requests across
|
|
24
|
+
unknown control gates, then batches generation requests on confirmed paths.
|
|
25
|
+
It never commits local effects or checks. Results and request-local errors are
|
|
26
|
+
adopted only on demand with matching arguments.
|
|
27
|
+
|
|
28
|
+
BoundExpr captures value bindings, not copies of their containers. State and
|
|
29
|
+
inference responses are read-only; Store addresses local storage. Branch.done
|
|
30
|
+
means its predicate is known; Join.done means its selected region has completed,
|
|
31
|
+
and Join.continue is false if that path returned (including nested returns).
|
|
32
|
+
Loop.enter activates iteration regions; Loop.done gathers all active iterations
|
|
33
|
+
and publishes exports. serial_modes additionally orders iterator advancement
|
|
34
|
+
after the preceding region finishes, preserving iterator faults in strict mode
|
|
35
|
+
and necessary mutable-iterator ordering in both modes.
|
|
36
|
+
Carry uses its initial value in iteration zero and update from the prior round.
|
|
37
|
+
Merge reads only the selected arm. Return supplies a candidate result: success
|
|
38
|
+
also requires completion of all activated required work in the root region.
|
|
39
|
+
|
|
40
|
+
Checks validate captured expressions on their original paths. Data, control and
|
|
41
|
+
memory dependencies apply in both modes; source-order check barriers apply only
|
|
42
|
+
in strict mode. A check alone does not serialize eager loop iterations. Checks
|
|
43
|
+
do not introduce solving scopes or authorize changing branch choices.
|
|
44
|
+
Any future repair must invalidate downstream values and shared request caches
|
|
45
|
+
for the affected dependency generation; this module implements no repair.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from dataclasses import MISSING, dataclass, fields, is_dataclass
|
|
51
|
+
import json
|
|
52
|
+
from types import UnionType
|
|
53
|
+
from typing import ClassVar, Literal, Union, get_args, get_origin, get_type_hints
|
|
54
|
+
|
|
55
|
+
from annoscript import ir
|
|
56
|
+
|
|
57
|
+
FailureMode = Literal["eager", "strict", "speculative"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PlanDecodeError(ValueError):
|
|
61
|
+
def __init__(self, message, path="$"):
|
|
62
|
+
self.path = path
|
|
63
|
+
super().__init__(f"{path}: {message}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Binding:
|
|
68
|
+
name: str
|
|
69
|
+
value: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class BoundExpr:
|
|
74
|
+
expression: ir.Expr
|
|
75
|
+
bindings: tuple[Binding, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Gate:
|
|
80
|
+
task: str
|
|
81
|
+
port: Literal["true", "false", "continue", "enter"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Region:
|
|
86
|
+
id: str
|
|
87
|
+
parent: str | None
|
|
88
|
+
kind: Literal["root", "then", "else", "loop"]
|
|
89
|
+
owner: str | None
|
|
90
|
+
tasks: tuple[str, ...]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Value:
|
|
95
|
+
id: str
|
|
96
|
+
producer: str
|
|
97
|
+
name: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class InstanceMap:
|
|
102
|
+
source_loops: tuple[str, ...] = ()
|
|
103
|
+
target_loops: tuple[str, ...] = ()
|
|
104
|
+
carried_loop: str | None = None
|
|
105
|
+
distance: int = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Dependency:
|
|
110
|
+
source: str
|
|
111
|
+
target: str
|
|
112
|
+
kind: Literal["data", "control", "memory", "constraint", "failure_order"]
|
|
113
|
+
mapping: InstanceMap
|
|
114
|
+
port: Literal["done", "enter", "true", "false", "continue"] = "done"
|
|
115
|
+
value: str | None = None
|
|
116
|
+
hazard: Literal["RAW", "WAR", "WAW"] | None = None
|
|
117
|
+
certainty: Literal["proven", "conservative"] = "proven"
|
|
118
|
+
condition: Gate | None = None
|
|
119
|
+
seed: str | None = None
|
|
120
|
+
# Constraint ordering can be relaxed without dropping check input/memory
|
|
121
|
+
# dependencies. Absent flags retain the stronger semantics of older plans.
|
|
122
|
+
# failure_order edges are always strict-only, including legacy plans.
|
|
123
|
+
strict_only: bool = False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class InferRequest:
|
|
128
|
+
id: str
|
|
129
|
+
operation: Literal["noul", "choice", "score", "extract", "generate", "generate_list"]
|
|
130
|
+
scope: tuple[str, ...]
|
|
131
|
+
sites: tuple[str, ...]
|
|
132
|
+
reusable: bool
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, kw_only=True)
|
|
136
|
+
class Task(ir.Node):
|
|
137
|
+
id: str
|
|
138
|
+
region: str
|
|
139
|
+
origin: str
|
|
140
|
+
source: ir.Source
|
|
141
|
+
outputs: tuple[str, ...] = ()
|
|
142
|
+
gates: tuple[Gate, ...] = ()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Eval(Task):
|
|
147
|
+
kind: ClassVar[str] = "eval"
|
|
148
|
+
expression: BoundExpr
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Unpack(Task):
|
|
153
|
+
kind: ClassVar[str] = "unpack"
|
|
154
|
+
expression: BoundExpr
|
|
155
|
+
target: ir.TupleTarget
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Store(Task):
|
|
160
|
+
kind: ClassVar[str] = "store"
|
|
161
|
+
container: BoundExpr
|
|
162
|
+
index: BoundExpr
|
|
163
|
+
item: BoundExpr
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class Infer(Task):
|
|
168
|
+
kind: ClassVar[str] = "infer_site"
|
|
169
|
+
request: str
|
|
170
|
+
arguments: str # Eval output: (positional tuple, keyword-value tuple)
|
|
171
|
+
keywords: tuple[str, ...]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class Branch(Task):
|
|
176
|
+
kind: ClassVar[str] = "branch"
|
|
177
|
+
condition: BoundExpr
|
|
178
|
+
then_region: str
|
|
179
|
+
else_region: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Join(Task):
|
|
184
|
+
kind: ClassVar[str] = "join"
|
|
185
|
+
branch: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Merge(Task):
|
|
190
|
+
kind: ClassVar[str] = "merge"
|
|
191
|
+
branch: str
|
|
192
|
+
then_value: str | None
|
|
193
|
+
else_value: str | None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class Carry(Task):
|
|
198
|
+
kind: ClassVar[str] = "carry"
|
|
199
|
+
loop: str
|
|
200
|
+
initial: str
|
|
201
|
+
update: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class Iteration(Task):
|
|
206
|
+
kind: ClassVar[str] = "iteration"
|
|
207
|
+
loop: str
|
|
208
|
+
target: ir.Target
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class LoopExport:
|
|
213
|
+
output: str
|
|
214
|
+
initial: str
|
|
215
|
+
update: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class Loop(Task):
|
|
220
|
+
kind: ClassVar[str] = "loop"
|
|
221
|
+
iterable: BoundExpr
|
|
222
|
+
body: str
|
|
223
|
+
exports: tuple[LoopExport, ...]
|
|
224
|
+
# Iterator advancement waits for the preceding body in these modes.
|
|
225
|
+
# This is independent of the data-carry edges between individual tasks.
|
|
226
|
+
serial_modes: tuple[FailureMode, ...] = ("strict",)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class Check(Task):
|
|
231
|
+
kind: ClassVar[str] = "check"
|
|
232
|
+
condition: BoundExpr
|
|
233
|
+
message: BoundExpr | None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class Return(Task):
|
|
238
|
+
kind: ClassVar[str] = "return"
|
|
239
|
+
expression: BoundExpr
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_TASKS = {t.kind: t for t in (Eval, Unpack, Store, Infer, Branch, Join, Merge,
|
|
243
|
+
Carry, Iteration, Loop, Check, Return)}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class ExecutionPlan:
|
|
248
|
+
entry: str
|
|
249
|
+
config: ir.ProgramConfig
|
|
250
|
+
source: ir.SourceSpan
|
|
251
|
+
regions: tuple[Region, ...]
|
|
252
|
+
tasks: tuple[Task, ...]
|
|
253
|
+
values: tuple[Value, ...]
|
|
254
|
+
dependencies: tuple[Dependency, ...]
|
|
255
|
+
requests: tuple[InferRequest, ...]
|
|
256
|
+
format: str = "lev-execution-plan"
|
|
257
|
+
version: int = 1
|
|
258
|
+
|
|
259
|
+
def dependencies_for(self, mode: FailureMode = "eager") -> tuple[Dependency, ...]:
|
|
260
|
+
"""Select edges; region, loop, demand and root-completion rules still apply."""
|
|
261
|
+
if mode not in ("eager", "strict", "speculative"):
|
|
262
|
+
raise ValueError(f"unknown failure mode {mode!r}")
|
|
263
|
+
return tuple(d for d in self.dependencies
|
|
264
|
+
if mode == "strict" or (d.kind != "failure_order" and not d.strict_only))
|
|
265
|
+
|
|
266
|
+
def to_dict(self) -> dict:
|
|
267
|
+
return ir._encode(self)
|
|
268
|
+
|
|
269
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
270
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, allow_nan=False, indent=indent)
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_dict(cls, data: dict) -> ExecutionPlan:
|
|
274
|
+
try:
|
|
275
|
+
plan = _decode(data, cls, "$")
|
|
276
|
+
plan.validate()
|
|
277
|
+
return plan
|
|
278
|
+
except (ir.IRDecodeError, RecursionError) as exc:
|
|
279
|
+
raise PlanDecodeError(str(exc)) from exc
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def from_json(cls, text: str) -> ExecutionPlan:
|
|
283
|
+
def pairs(items):
|
|
284
|
+
result = {}
|
|
285
|
+
for key, value in items:
|
|
286
|
+
if key in result:
|
|
287
|
+
raise PlanDecodeError(f"duplicate JSON key {key!r}")
|
|
288
|
+
result[key] = value
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
def constant(value):
|
|
292
|
+
raise PlanDecodeError(f"non-finite number {value}")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
return cls.from_dict(json.loads(text, object_pairs_hook=pairs, parse_constant=constant))
|
|
296
|
+
except (json.JSONDecodeError, RecursionError, UnicodeError) as exc:
|
|
297
|
+
raise PlanDecodeError(f"invalid JSON: {exc}") from exc
|
|
298
|
+
|
|
299
|
+
def validate(self) -> None:
|
|
300
|
+
"""Check references, ownership, availability, seeded carries and DAGs."""
|
|
301
|
+
_validate(self)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _decode(value, expected, path):
|
|
305
|
+
origin, args = get_origin(expected), get_args(expected)
|
|
306
|
+
if origin in (Union, UnionType):
|
|
307
|
+
if value is None and type(None) in args:
|
|
308
|
+
return None
|
|
309
|
+
return _decode(value, next(t for t in args if t is not type(None)), path)
|
|
310
|
+
if origin is Literal:
|
|
311
|
+
if not any(type(value) is type(a) and value == a for a in args):
|
|
312
|
+
raise PlanDecodeError(f"expected one of {args}", path)
|
|
313
|
+
return value
|
|
314
|
+
if origin is tuple:
|
|
315
|
+
if type(value) is not list:
|
|
316
|
+
raise PlanDecodeError("expected array", path)
|
|
317
|
+
return tuple(_decode(v, args[0], f"{path}[{i}]") for i, v in enumerate(value))
|
|
318
|
+
if expected in (str, int, bool, type(None)):
|
|
319
|
+
if type(value) is not expected:
|
|
320
|
+
raise PlanDecodeError(f"expected {expected.__name__}", path)
|
|
321
|
+
return value
|
|
322
|
+
if isinstance(expected, type) and expected.__module__ == ir.__name__:
|
|
323
|
+
return ir._Decoder().decode(value, expected, path)
|
|
324
|
+
if type(value) is not dict:
|
|
325
|
+
raise PlanDecodeError("expected object", path)
|
|
326
|
+
cls = expected
|
|
327
|
+
tagged = isinstance(expected, type) and issubclass(expected, Task)
|
|
328
|
+
if tagged:
|
|
329
|
+
kind = value.get("kind")
|
|
330
|
+
cls = _TASKS.get(kind) if type(kind) is str else None
|
|
331
|
+
if cls is None or not issubclass(cls, expected):
|
|
332
|
+
raise PlanDecodeError("unknown or incorrect task kind", path)
|
|
333
|
+
if not is_dataclass(cls):
|
|
334
|
+
raise TypeError(f"unknown plan schema type {cls}")
|
|
335
|
+
definitions = fields(cls)
|
|
336
|
+
unknown = value.keys() - ({f.name for f in definitions} | ({"kind"} if tagged else set()))
|
|
337
|
+
if unknown:
|
|
338
|
+
raise PlanDecodeError(f"unknown fields {sorted(unknown)}", path)
|
|
339
|
+
types, kwargs = get_type_hints(cls), {}
|
|
340
|
+
for f in definitions:
|
|
341
|
+
if f.name in value:
|
|
342
|
+
kwargs[f.name] = _decode(value[f.name], types[f.name], f"{path}.{f.name}")
|
|
343
|
+
elif f.default is MISSING and f.default_factory is MISSING:
|
|
344
|
+
raise PlanDecodeError("missing required field", f"{path}.{f.name}")
|
|
345
|
+
return cls(**kwargs)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def expressions(task):
|
|
349
|
+
return tuple(getattr(task, f.name) for f in fields(task)
|
|
350
|
+
if isinstance(getattr(task, f.name), BoundExpr))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def loop_path(region, regions):
|
|
354
|
+
result = []
|
|
355
|
+
while region is not None:
|
|
356
|
+
r = regions[region]
|
|
357
|
+
if r.kind == "loop":
|
|
358
|
+
result.append(r.owner)
|
|
359
|
+
region = r.parent
|
|
360
|
+
return tuple(reversed(result))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _validate(plan):
|
|
364
|
+
from annoscript.passes._analysis import expression_reads
|
|
365
|
+
|
|
366
|
+
def require(condition, message, path="$"):
|
|
367
|
+
if not condition:
|
|
368
|
+
raise PlanDecodeError(message, path)
|
|
369
|
+
|
|
370
|
+
def index(items, name):
|
|
371
|
+
result = {x.id: x for x in items}
|
|
372
|
+
require(len(result) == len(items) and all(result), "IDs must be unique and nonempty", name)
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
require(plan.format == "lev-execution-plan" and type(plan.version) is int and plan.version == 1,
|
|
376
|
+
"unsupported execution plan format/version")
|
|
377
|
+
ts, rs, vs, qs = (index(getattr(plan, name), f"$.{name}")
|
|
378
|
+
for name in ("tasks", "regions", "values", "requests"))
|
|
379
|
+
require(len(set(ts) | set(rs) | set(vs) | set(qs)) == len(ts) + len(rs) + len(vs) + len(qs),
|
|
380
|
+
"identifiers must be unique across the plan")
|
|
381
|
+
require(sum(r.kind == "root" for r in rs.values()) == 1, "expected one root region")
|
|
382
|
+
for r in rs.values():
|
|
383
|
+
require((r.parent is None) == (r.kind == "root"), "incorrect region parent", r.id)
|
|
384
|
+
require(r.kind != "root" or r.owner is None, "root region cannot have an owner", r.id)
|
|
385
|
+
require(r.parent is None or r.parent in rs, "unknown parent region", r.id)
|
|
386
|
+
seen, cur = set(), r
|
|
387
|
+
while cur.parent is not None:
|
|
388
|
+
require(cur.id not in seen, "cyclic region hierarchy", r.id)
|
|
389
|
+
seen.add(cur.id)
|
|
390
|
+
require(cur.parent in rs, "unknown ancestor region", r.id)
|
|
391
|
+
cur = rs[cur.parent]
|
|
392
|
+
require(len(set(r.tasks)) == len(r.tasks), "duplicate region task", r.id)
|
|
393
|
+
require(all(t in ts and ts[t].region == r.id for t in r.tasks), "incorrect task ownership", r.id)
|
|
394
|
+
if r.parent is not None:
|
|
395
|
+
require(r.owner in ts and ts[r.owner].region == r.parent, "incorrect region owner", r.id)
|
|
396
|
+
owner = ts[r.owner]
|
|
397
|
+
require((isinstance(owner, Loop) and r.kind == "loop" and owner.body == r.id) or
|
|
398
|
+
(isinstance(owner, Branch) and ((r.kind == "then" and owner.then_region == r.id) or
|
|
399
|
+
(r.kind == "else" and owner.else_region == r.id))),
|
|
400
|
+
"region is not selected by its owner", r.id)
|
|
401
|
+
for v in vs.values():
|
|
402
|
+
require(v.producer in ts and v.id in ts[v.producer].outputs, "invalid value producer", v.id)
|
|
403
|
+
|
|
404
|
+
def ref(v, where):
|
|
405
|
+
require(v in vs, f"unknown value {v!r}", where)
|
|
406
|
+
|
|
407
|
+
def ancestors(region):
|
|
408
|
+
result = set()
|
|
409
|
+
while region is not None:
|
|
410
|
+
result.add(region)
|
|
411
|
+
region = rs[region].parent
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
def available(v, region, where):
|
|
415
|
+
ref(v, where)
|
|
416
|
+
require(ts[vs[v].producer].region in ancestors(region), "value is unavailable in this region", where)
|
|
417
|
+
|
|
418
|
+
def data_edge(v, target, condition=None):
|
|
419
|
+
require(any(d.target == target and d.value == v and d.kind == "data" and d.condition == condition
|
|
420
|
+
for d in plan.dependencies), "missing value dependency", target)
|
|
421
|
+
|
|
422
|
+
for t in ts.values():
|
|
423
|
+
require(t.region in rs and t.id in rs[t.region].tasks, "unlisted task", t.id)
|
|
424
|
+
require(bool(t.origin), "missing source statement identity", t.id)
|
|
425
|
+
require(len(set(t.outputs)) == len(t.outputs), "duplicate output", t.id)
|
|
426
|
+
if isinstance(t, (Eval, Infer, Merge, Carry)):
|
|
427
|
+
require(len(t.outputs) == 1, "task requires one output", t.id)
|
|
428
|
+
elif isinstance(t, (Store, Branch, Join, Check, Return)):
|
|
429
|
+
require(not t.outputs, "task cannot define outputs", t.id)
|
|
430
|
+
elif isinstance(t, (Unpack, Iteration)):
|
|
431
|
+
def count(target):
|
|
432
|
+
return 1 if isinstance(target, ir.NameTarget) else sum(count(x) for x in target.items)
|
|
433
|
+
require(count(t.target) == len(t.outputs), "unpack target/output mismatch", t.id)
|
|
434
|
+
for v in t.outputs:
|
|
435
|
+
ref(v, t.id)
|
|
436
|
+
require(vs[v].producer == t.id, "output owned by another task", t.id)
|
|
437
|
+
for b in expressions(t):
|
|
438
|
+
names = [v.name for v in b.bindings]
|
|
439
|
+
require(len(names) == len(set(names)) and set(names) == set(expression_reads(b.expression)),
|
|
440
|
+
"expression bindings do not match its variables", t.id)
|
|
441
|
+
for binding in b.bindings:
|
|
442
|
+
available(binding.value, t.region, t.id)
|
|
443
|
+
data_edge(binding.value, t.id)
|
|
444
|
+
for g in t.gates:
|
|
445
|
+
require(g.task in ts, "unknown gate task", t.id)
|
|
446
|
+
owner = ts[g.task]
|
|
447
|
+
require((g.port in ("true", "false") and isinstance(owner, Branch)) or
|
|
448
|
+
(g.port == "continue" and isinstance(owner, Join)) or
|
|
449
|
+
(g.port == "enter" and isinstance(owner, Loop)), "incorrect gate port", t.id)
|
|
450
|
+
require(any(d.source == g.task and d.target == t.id and d.port == g.port and d.kind == "control"
|
|
451
|
+
for d in plan.dependencies), "missing control dependency", t.id)
|
|
452
|
+
if isinstance(t, Branch):
|
|
453
|
+
for region, kind in ((t.then_region, "then"), (t.else_region, "else")):
|
|
454
|
+
require(region in rs and rs[region].kind == kind and rs[region].owner == t.id,
|
|
455
|
+
"invalid branch region", t.id)
|
|
456
|
+
if isinstance(t, (Join, Merge)):
|
|
457
|
+
require(isinstance(ts.get(t.branch), Branch), "unknown branch", t.id)
|
|
458
|
+
require(ts[t.branch].region == t.region, "branch and merge/join must share a region", t.id)
|
|
459
|
+
require(any(d.source == t.branch and d.target == t.id and d.kind == "control" and d.port == "done"
|
|
460
|
+
for d in plan.dependencies), "missing branch dependency", t.id)
|
|
461
|
+
if isinstance(t, Merge):
|
|
462
|
+
branch = ts[t.branch]
|
|
463
|
+
for v, arm, port in ((t.then_value, branch.then_region, "true"), (t.else_value, branch.else_region, "false")):
|
|
464
|
+
if v is not None:
|
|
465
|
+
ref(v, t.id)
|
|
466
|
+
producer_region = ts[vs[v].producer].region
|
|
467
|
+
eligible, r = {arm}, rs[t.region]
|
|
468
|
+
while True:
|
|
469
|
+
eligible.add(r.id)
|
|
470
|
+
if r.parent is None:
|
|
471
|
+
break
|
|
472
|
+
r = rs[r.parent]
|
|
473
|
+
require(producer_region in eligible, "merge value belongs to the wrong arm", t.id)
|
|
474
|
+
data_edge(v, t.id, Gate(t.branch, port))
|
|
475
|
+
require(t.then_value is not None or t.else_value is not None, "merge has no incoming values", t.id)
|
|
476
|
+
if isinstance(t, Loop):
|
|
477
|
+
require(t.body in rs and rs[t.body].owner == t.id and rs[t.body].kind == "loop", "invalid loop body", t.id)
|
|
478
|
+
require(set(t.serial_modes) <= {"strict", "eager"} and "strict" in t.serial_modes,
|
|
479
|
+
"strict iteration sequencing is required", t.id)
|
|
480
|
+
require(tuple(e.output for e in t.exports) == t.outputs, "loop exports do not match outputs", t.id)
|
|
481
|
+
for e in t.exports:
|
|
482
|
+
available(e.initial, t.region, t.id)
|
|
483
|
+
data_edge(e.initial, t.id)
|
|
484
|
+
ref(e.update, t.id)
|
|
485
|
+
require(ts[vs[e.update].producer].region == t.body, "loop update must be in its body", t.id)
|
|
486
|
+
if isinstance(t, (Carry, Iteration)):
|
|
487
|
+
require(isinstance(ts.get(t.loop), Loop) and ts[t.loop].body == t.region, "incorrect iteration owner", t.id)
|
|
488
|
+
if isinstance(t, Carry):
|
|
489
|
+
available(t.initial, rs[t.region].parent, t.id)
|
|
490
|
+
data_edge(t.initial, t.loop)
|
|
491
|
+
ref(t.update, t.id)
|
|
492
|
+
require(ts[vs[t.update].producer].region == t.region, "invalid carry update", t.id)
|
|
493
|
+
require(t.loop not in loop_path(ts[vs[t.initial].producer].region, rs), "carry seed must precede loop", t.id)
|
|
494
|
+
if isinstance(t, Infer):
|
|
495
|
+
available(t.arguments, t.region, t.id)
|
|
496
|
+
data_edge(t.arguments, t.id)
|
|
497
|
+
require(len(t.keywords) == len(set(t.keywords)) and all(t.keywords), "invalid keyword names", t.id)
|
|
498
|
+
require(t.request in qs and t.id in qs[t.request].sites, "invalid shared request", t.id)
|
|
499
|
+
for q in qs.values():
|
|
500
|
+
require(bool(q.sites) and len(q.sites) == len(set(q.sites)), "invalid request sites", q.id)
|
|
501
|
+
for site in q.sites:
|
|
502
|
+
require(isinstance(ts.get(site), Infer) and ts[site].request == q.id, "request/site mismatch", q.id)
|
|
503
|
+
path = loop_path(ts[site].region, rs)
|
|
504
|
+
require(path[:len(q.scope)] == q.scope, "request scope is not an enclosing scope", q.id)
|
|
505
|
+
|
|
506
|
+
graph = {t: set() for t in ts}
|
|
507
|
+
for d in plan.dependencies:
|
|
508
|
+
require(d.source in ts and d.target in ts, "unknown dependency endpoint")
|
|
509
|
+
require(type(d.strict_only) is bool, "strict_only must be a boolean", d.target)
|
|
510
|
+
require(not d.strict_only or d.kind in ("constraint", "failure_order"),
|
|
511
|
+
"data, control and memory dependencies cannot be strict-only", d.target)
|
|
512
|
+
m = d.mapping
|
|
513
|
+
require(m.source_loops == loop_path(ts[d.source].region, rs)
|
|
514
|
+
and m.target_loops == loop_path(ts[d.target].region, rs), "incorrect instance mapping", d.target)
|
|
515
|
+
require((m.carried_loop is None and m.distance == 0) or
|
|
516
|
+
(m.carried_loop in m.source_loops and m.carried_loop in m.target_loops and m.distance > 0),
|
|
517
|
+
"invalid carried distance", d.target)
|
|
518
|
+
if d.value is not None:
|
|
519
|
+
ref(d.value, d.target)
|
|
520
|
+
require(vs[d.value].producer == d.source, "dependency has wrong value producer", d.target)
|
|
521
|
+
if d.seed is not None:
|
|
522
|
+
ref(d.seed, d.target)
|
|
523
|
+
if d.condition:
|
|
524
|
+
require(isinstance(ts.get(d.condition.task), Branch), "invalid dependency condition", d.target)
|
|
525
|
+
require(d.condition.port in ("true", "false"), "invalid branch condition port", d.target)
|
|
526
|
+
if d.kind == "memory":
|
|
527
|
+
require(d.hazard is not None, "memory dependency requires a hazard", d.target)
|
|
528
|
+
else:
|
|
529
|
+
require(d.hazard is None, "non-memory dependency cannot carry a memory hazard", d.target)
|
|
530
|
+
if not m.distance and d.port == "done":
|
|
531
|
+
graph[d.target].add(d.source)
|
|
532
|
+
# Structural loop/join completion is not a normal same-instance graph edge.
|
|
533
|
+
remaining = {k: set(v) for k, v in graph.items()}
|
|
534
|
+
while remaining:
|
|
535
|
+
ready = {k for k, v in remaining.items() if not v}
|
|
536
|
+
require(bool(ready), "unseeded zero-distance dependency cycle")
|
|
537
|
+
remaining = {k: v - ready for k, v in remaining.items() if k not in ready}
|
|
538
|
+
for t in ts.values():
|
|
539
|
+
if isinstance(t, Carry):
|
|
540
|
+
require(any(d.target == t.id and d.value == t.update and d.seed == t.initial
|
|
541
|
+
and d.mapping.carried_loop == t.loop and d.mapping.distance == 1
|
|
542
|
+
for d in plan.dependencies), "carry requires a seeded previous-iteration edge", t.id)
|