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,211 @@
|
|
|
1
|
+
"""In-memory session adapter behind AnnoClient.remote(); never imported locally."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
import weakref
|
|
9
|
+
|
|
10
|
+
from annoscript._client_common import Result, ResultError, ResultStream, prepare_inputs
|
|
11
|
+
from annoscript.compiler import compile_program
|
|
12
|
+
from annoscript.jobs import (AnnoJobClient, RemoteCancellationError, RemoteJobPausedError,
|
|
13
|
+
RemoteProtocolError, RemoteSubmissionError, RemoteTransportError)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def bounded_cleanup(operation, timeout):
|
|
17
|
+
"""Bound caller latency even when DNS/transport shutdown ignores timeouts."""
|
|
18
|
+
outcome, done = [], threading.Event()
|
|
19
|
+
def run():
|
|
20
|
+
try:
|
|
21
|
+
operation()
|
|
22
|
+
except BaseException as exc:
|
|
23
|
+
outcome.append(exc)
|
|
24
|
+
finally:
|
|
25
|
+
done.set()
|
|
26
|
+
threading.Thread(target=run, name="anno-job-cleanup", daemon=True).start()
|
|
27
|
+
if not done.wait(timeout):
|
|
28
|
+
raise RemoteCancellationError("remote cancellation confirmation timed out; session lease will expire")
|
|
29
|
+
if outcome:
|
|
30
|
+
raise RemoteCancellationError("remote cancellation could not be confirmed; session lease will expire") from outcome[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Session:
|
|
34
|
+
def __init__(self, client):
|
|
35
|
+
self.client = client
|
|
36
|
+
self.id = None
|
|
37
|
+
self.key = uuid.uuid4().hex
|
|
38
|
+
self.closed = False
|
|
39
|
+
self.stop = threading.Event()
|
|
40
|
+
self.streams = weakref.WeakSet()
|
|
41
|
+
self.failure = None
|
|
42
|
+
self.thread = None
|
|
43
|
+
|
|
44
|
+
def start(self):
|
|
45
|
+
if self.closed:
|
|
46
|
+
raise RuntimeError("client is closed")
|
|
47
|
+
if self.failure is not None:
|
|
48
|
+
raise self.failure
|
|
49
|
+
if self.id is None:
|
|
50
|
+
value = self.client._create_session(self.key)
|
|
51
|
+
self.id = value["session_id"]
|
|
52
|
+
self.thread = threading.Thread(target=self.heartbeat, args=(value["heartbeat_interval"],),
|
|
53
|
+
name="anno-job-heartbeat", daemon=True)
|
|
54
|
+
self.thread.start()
|
|
55
|
+
|
|
56
|
+
def heartbeat(self, interval):
|
|
57
|
+
# This target holds the session state, never the public AnnoClient shell.
|
|
58
|
+
while not self.stop.wait(interval):
|
|
59
|
+
try:
|
|
60
|
+
self.client._heartbeat(self.id)
|
|
61
|
+
except RemoteTransportError:
|
|
62
|
+
continue
|
|
63
|
+
except RemoteSubmissionError as exc:
|
|
64
|
+
if exc.http_status in (408, 429) or (exc.http_status is not None and exc.http_status >= 500):
|
|
65
|
+
continue
|
|
66
|
+
self.failure = exc
|
|
67
|
+
return
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
self.failure = exc
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
def close(self, timeout):
|
|
73
|
+
if self.closed:
|
|
74
|
+
return
|
|
75
|
+
self.closed = True
|
|
76
|
+
self.stop.set()
|
|
77
|
+
for stream in list(self.streams):
|
|
78
|
+
stream._closed = True
|
|
79
|
+
stream.buffer.clear()
|
|
80
|
+
stream.ready.clear()
|
|
81
|
+
def cleanup():
|
|
82
|
+
try:
|
|
83
|
+
if self.id is not None:
|
|
84
|
+
self.client._close_session(self.id, timeout)
|
|
85
|
+
finally:
|
|
86
|
+
self.client.close()
|
|
87
|
+
bounded_cleanup(cleanup, timeout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _finalize(session):
|
|
91
|
+
try:
|
|
92
|
+
session.close(1.0)
|
|
93
|
+
except BaseException:
|
|
94
|
+
# Destructors cannot report reliable cancellation. The durable session
|
|
95
|
+
# expiry is the fallback, including when the process is killed outright.
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RemoteAdapter:
|
|
100
|
+
def __init__(self, base_url, auth_token=None, *, mode="eager", optimize=True,
|
|
101
|
+
timeout=30.0, transport=None):
|
|
102
|
+
if mode not in ("eager", "strict", "speculative"):
|
|
103
|
+
raise ValueError("mode must be eager, strict or speculative")
|
|
104
|
+
if type(optimize) is not bool:
|
|
105
|
+
raise TypeError("optimize must be a boolean")
|
|
106
|
+
self.mode, self.optimize = mode, optimize
|
|
107
|
+
self.session = _Session(AnnoJobClient(base_url, auth_token, timeout=timeout, transport=transport))
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def closed(self):
|
|
111
|
+
return self.session.closed
|
|
112
|
+
|
|
113
|
+
def submit(self, program, inputs, *, ordered=False):
|
|
114
|
+
if self.closed:
|
|
115
|
+
raise RuntimeError("client is closed")
|
|
116
|
+
if type(ordered) is not bool:
|
|
117
|
+
raise TypeError("ordered must be a boolean")
|
|
118
|
+
valid, errors = prepare_inputs(program, inputs)
|
|
119
|
+
ir = compile_program(program, optimize=self.optimize).to_dict()
|
|
120
|
+
job = None
|
|
121
|
+
if valid:
|
|
122
|
+
self.session.start()
|
|
123
|
+
try:
|
|
124
|
+
job = self.session.client.submit_ir(ir, [{"index": i, "state": v} for i, v in valid],
|
|
125
|
+
idempotency_key=uuid.uuid4().hex, mode=self.mode, session_id=self.session.id)
|
|
126
|
+
except BaseException:
|
|
127
|
+
# Acceptance may have happened without its response. Closing
|
|
128
|
+
# the ownership session also reaches such otherwise unknown jobs.
|
|
129
|
+
try:
|
|
130
|
+
self.session.close(5.0)
|
|
131
|
+
except BaseException:
|
|
132
|
+
logging.getLogger(__name__).warning("Unconfirmed remote cleanup after submission failure")
|
|
133
|
+
raise
|
|
134
|
+
stream = _JobStream(self.session, job, errors, ordered)
|
|
135
|
+
self.session.streams.add(stream)
|
|
136
|
+
return stream
|
|
137
|
+
|
|
138
|
+
def close(self):
|
|
139
|
+
self.session.close(5.0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _JobStream(ResultStream):
|
|
143
|
+
def __init__(self, session, job, errors, ordered):
|
|
144
|
+
super().__init__()
|
|
145
|
+
self.session, self.job, self.ordered = session, job, ordered
|
|
146
|
+
self.ready = deque(Result(i, "error", error=error) for i, error in sorted(errors.items()))
|
|
147
|
+
self.buffer, self.index, self.seen = {}, 0, set()
|
|
148
|
+
self.cursor = None
|
|
149
|
+
self.done = job is None
|
|
150
|
+
|
|
151
|
+
def _pull(self):
|
|
152
|
+
while not self.ready:
|
|
153
|
+
if self.done:
|
|
154
|
+
raise StopIteration
|
|
155
|
+
if self.session.closed:
|
|
156
|
+
raise RemoteSubmissionError("client_closed", "client session is closed")
|
|
157
|
+
if self.session.failure is not None:
|
|
158
|
+
raise self.session.failure
|
|
159
|
+
page = self.session.client.fetch_results(self.job, cursor=self.cursor)
|
|
160
|
+
received = page["job"]
|
|
161
|
+
for field in ("job_id", "ir_sha256", "release_id", "mode", "input_indices"):
|
|
162
|
+
if received[field] != self.job[field]:
|
|
163
|
+
raise RemoteProtocolError("job binding changed while receiving results")
|
|
164
|
+
pending = []
|
|
165
|
+
for item in page["results"]:
|
|
166
|
+
if item["index"] in self.seen:
|
|
167
|
+
raise RemoteProtocolError("result index was delivered more than once")
|
|
168
|
+
pending.append(Result(item["index"], item["status"], item["value"],
|
|
169
|
+
None if item["error"] is None else ResultError(**item["error"])))
|
|
170
|
+
new_seen = self.seen | {r.index for r in pending}
|
|
171
|
+
if page["done"] and new_seen != set(self.job["input_indices"]):
|
|
172
|
+
raise RemoteProtocolError("terminal job is missing input results")
|
|
173
|
+
# Commit a fully validated page to memory as a unit, then advance.
|
|
174
|
+
self.ready.extend(pending)
|
|
175
|
+
self.seen, self.cursor, self.done = new_seen, page["next_cursor"], page["done"]
|
|
176
|
+
self.job = received
|
|
177
|
+
if not self.ready:
|
|
178
|
+
if received["state"] == "paused":
|
|
179
|
+
raise RemoteJobPausedError(received)
|
|
180
|
+
if not self.done:
|
|
181
|
+
self.session.stop.wait(0.2)
|
|
182
|
+
return self.ready.popleft()
|
|
183
|
+
|
|
184
|
+
def _next_result(self):
|
|
185
|
+
if not self.ordered:
|
|
186
|
+
return self._pull()
|
|
187
|
+
while self.index not in self.buffer:
|
|
188
|
+
item = self._pull()
|
|
189
|
+
self.buffer[item.index] = item
|
|
190
|
+
item = self.buffer.pop(self.index)
|
|
191
|
+
self.index += 1
|
|
192
|
+
return item
|
|
193
|
+
|
|
194
|
+
def _close(self):
|
|
195
|
+
deadline = time.monotonic() + 5.0
|
|
196
|
+
try:
|
|
197
|
+
if self.job is not None and not self.done and not self.session.closed:
|
|
198
|
+
try:
|
|
199
|
+
bounded_cleanup(lambda: self.session.client.cancel(self.job, attempts=1, budget=5.0), 5.0)
|
|
200
|
+
except RemoteCancellationError:
|
|
201
|
+
# Stop owning work if even cancellation cannot be delivered.
|
|
202
|
+
# Do not keep heartbeating an orphaned job indefinitely.
|
|
203
|
+
try:
|
|
204
|
+
self.session.close(max(0.0, deadline - time.monotonic()))
|
|
205
|
+
except RemoteCancellationError:
|
|
206
|
+
pass
|
|
207
|
+
raise
|
|
208
|
+
finally:
|
|
209
|
+
self.ready.clear()
|
|
210
|
+
self.buffer.clear()
|
|
211
|
+
self.session.streams.discard(self)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Versioned request recipes; independent of models, credentials and transport."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_DECISION_ADAPTER = "decision_native_v1"
|
|
4
|
+
DEFAULT_GENERATION_ADAPTER = "generation_chat_v1"
|
|
5
|
+
CONFIGURABLE_DECISION_ADAPTER = "decision_configurable_v1"
|
|
6
|
+
REQUEST_ADAPTERS = {
|
|
7
|
+
"decision": (DEFAULT_DECISION_ADAPTER, CONFIGURABLE_DECISION_ADAPTER),
|
|
8
|
+
"generation": (DEFAULT_GENERATION_ADAPTER,),
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_request_adapter(value, stage):
|
|
13
|
+
if stage not in REQUEST_ADAPTERS:
|
|
14
|
+
raise ValueError("request adapter stage must be decision or generation")
|
|
15
|
+
if type(value) is not str or value not in REQUEST_ADAPTERS[stage]:
|
|
16
|
+
raise ValueError(f"unsupported {stage} request_adapter; expected " +
|
|
17
|
+
", ".join(REQUEST_ADAPTERS[stage]))
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def native_decision_recipe():
|
|
22
|
+
return {"criteria": "preserve", "examples": "preserve", "state": {"mode": "full"}}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_decision_options(adapter, options=None):
|
|
26
|
+
"""Detach and validate JSON configuration before any transport is initialized."""
|
|
27
|
+
from annoscript._data import snapshot
|
|
28
|
+
validate_request_adapter(adapter, "decision")
|
|
29
|
+
if options is not None and type(options) is not dict:
|
|
30
|
+
raise ValueError("request_adapter_options must be an object")
|
|
31
|
+
if adapter == DEFAULT_DECISION_ADAPTER:
|
|
32
|
+
if options:
|
|
33
|
+
raise ValueError("decision_native_v1 does not accept request_adapter_options")
|
|
34
|
+
return None
|
|
35
|
+
options = snapshot(options) if options is not None else {}
|
|
36
|
+
if set(options) - {"default", "tasks"}:
|
|
37
|
+
raise ValueError("request_adapter_options only accepts default and tasks")
|
|
38
|
+
|
|
39
|
+
def recipe(value):
|
|
40
|
+
if type(value) is not dict or set(value) != {"criteria", "examples", "state"}:
|
|
41
|
+
raise ValueError("a task recipe requires criteria, examples and state")
|
|
42
|
+
if value["criteria"] not in ("preserve", "text") or type(value["criteria"]) is not str:
|
|
43
|
+
raise ValueError("criteria must be preserve or text")
|
|
44
|
+
if value["examples"] not in ("preserve", "omit") or type(value["examples"]) is not str:
|
|
45
|
+
raise ValueError("examples must be preserve or omit")
|
|
46
|
+
if value["criteria"] == "text" and value["examples"] == "preserve":
|
|
47
|
+
raise ValueError("text criteria cannot preserve nested examples")
|
|
48
|
+
state = value["state"]
|
|
49
|
+
if type(state) is not dict:
|
|
50
|
+
raise ValueError("state projection must be an object")
|
|
51
|
+
mode = state.get("mode")
|
|
52
|
+
if mode == "full" and set(state) == {"mode"}:
|
|
53
|
+
pass
|
|
54
|
+
elif mode == "fields_object" and set(state) == {"mode", "fields"}:
|
|
55
|
+
fields = state["fields"]
|
|
56
|
+
if (type(fields) is not list or not fields
|
|
57
|
+
or any(type(f) is not str or not f.strip() for f in fields)
|
|
58
|
+
or len(set(fields)) != len(fields)):
|
|
59
|
+
raise ValueError("fields_object needs distinct nonblank fields in order")
|
|
60
|
+
elif mode == "field_string" and set(state) == {"mode", "field"}:
|
|
61
|
+
if type(state["field"]) is not str or not state["field"].strip():
|
|
62
|
+
raise ValueError("field_string needs a nonblank field")
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("invalid state projection")
|
|
65
|
+
return {"criteria": value["criteria"], "examples": value["examples"], "state": state}
|
|
66
|
+
|
|
67
|
+
tasks = options.get("tasks", {})
|
|
68
|
+
if type(tasks) is not dict or any(type(k) is not str or not k.strip() for k in tasks):
|
|
69
|
+
raise ValueError("tasks must map exact nonblank field_name values to recipes")
|
|
70
|
+
return {"default": recipe(options.get("default", native_decision_recipe())),
|
|
71
|
+
"tasks": {key: recipe(tasks[key]) for key in sorted(tasks)}}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""A bounded, private argument evaluator; never commits Plan task effects.
|
|
2
|
+
|
|
3
|
+
Formal execution still owns branches, memory, checks, returns and loop progress.
|
|
4
|
+
Only normalized, reusable inference requests cross from this evaluator to the
|
|
5
|
+
transport scheduler. Values are rebuilt after formal progress or a response.
|
|
6
|
+
Each revision reaches Decision quiescence before collecting generation work;
|
|
7
|
+
generation requires confirmed control gates, even before formal branch progress.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
|
|
12
|
+
from annoscript import plan as p
|
|
13
|
+
from annoscript._expressions import expression, unpack
|
|
14
|
+
from annoscript.backends.base import normalize_call, DECISION_OPERATIONS
|
|
15
|
+
from annoscript.backends._bounded_grammar import ensure_satisfiable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Unavailable(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detached(value):
|
|
23
|
+
"""Reject consumable iterators, including those nested in containers."""
|
|
24
|
+
seen = set()
|
|
25
|
+
def check(item):
|
|
26
|
+
if id(item) in seen:
|
|
27
|
+
return
|
|
28
|
+
seen.add(id(item))
|
|
29
|
+
if type(item) in (list, tuple):
|
|
30
|
+
for child in item:
|
|
31
|
+
check(child)
|
|
32
|
+
elif type(item) is dict:
|
|
33
|
+
for child in item.values():
|
|
34
|
+
check(child)
|
|
35
|
+
elif hasattr(item, "__next__"):
|
|
36
|
+
raise Unavailable()
|
|
37
|
+
check(value)
|
|
38
|
+
return deepcopy(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Speculation:
|
|
42
|
+
def __init__(self, run):
|
|
43
|
+
self.run = run
|
|
44
|
+
self.sites = tuple(t for t in run.plan.tasks if isinstance(t, p.Infer))
|
|
45
|
+
self.requests = []
|
|
46
|
+
self.scan = None
|
|
47
|
+
self.revision = -1
|
|
48
|
+
self.ready = False
|
|
49
|
+
self.phase = "decision"
|
|
50
|
+
self.selected_requests = set()
|
|
51
|
+
|
|
52
|
+
def viable(self, task, ctx):
|
|
53
|
+
return all(self.run.port(g.task, ctx, g.port) is not False for g in task.gates)
|
|
54
|
+
|
|
55
|
+
def memory_ready(self, task, ctx):
|
|
56
|
+
return all(self.run.dependency(d, ctx) for d in self.run.incoming[task.id]
|
|
57
|
+
if d.kind == "memory")
|
|
58
|
+
|
|
59
|
+
def port(self, tid, ctx, port, visiting):
|
|
60
|
+
"""Read a control decision without launching regions or committing effects."""
|
|
61
|
+
run = self.run
|
|
62
|
+
known = run.port(tid, ctx, port)
|
|
63
|
+
if type(known) is bool:
|
|
64
|
+
return known
|
|
65
|
+
task = run.tasks[tid]
|
|
66
|
+
parent = ctx[:len(run.paths[tid])]
|
|
67
|
+
key = tid, parent, port
|
|
68
|
+
yield
|
|
69
|
+
if key in visiting or not self.memory_ready(task, parent):
|
|
70
|
+
raise Unavailable()
|
|
71
|
+
visiting = visiting | {key}
|
|
72
|
+
yield from self.selected(task, parent, visiting)
|
|
73
|
+
if isinstance(task, p.Branch) and port in ("true", "false"):
|
|
74
|
+
selected = bool((yield from self.bound(task.condition, parent, visiting)))
|
|
75
|
+
return selected if port == "true" else not selected
|
|
76
|
+
if isinstance(task, p.Loop) and port == "enter":
|
|
77
|
+
child = ctx[:len(parent) + 1]
|
|
78
|
+
if len(child) > len(parent) and ((tid, child) in self.items or (tid, child) in run.items):
|
|
79
|
+
return True
|
|
80
|
+
# Join/return completion and mutable iteration remain formally owned.
|
|
81
|
+
raise Unavailable()
|
|
82
|
+
|
|
83
|
+
def selected(self, task, ctx, visiting=frozenset()):
|
|
84
|
+
for gate in task.gates:
|
|
85
|
+
if not (yield from self.port(gate.task, ctx, gate.port, visiting)):
|
|
86
|
+
raise Unavailable()
|
|
87
|
+
|
|
88
|
+
def possible(self, task, ctx, visiting=frozenset()):
|
|
89
|
+
for gate in task.gates:
|
|
90
|
+
try:
|
|
91
|
+
known = yield from self.port(gate.task, ctx, gate.port, visiting)
|
|
92
|
+
except Unavailable:
|
|
93
|
+
continue
|
|
94
|
+
if not known:
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def lookup(self, key, call):
|
|
99
|
+
for request in self.requests:
|
|
100
|
+
if request.key == key and self.same(request.call, call):
|
|
101
|
+
return request
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def retire_mismatches(self, key, call, task, ctx):
|
|
105
|
+
for request in self.requests:
|
|
106
|
+
if request.key == key and not self.same(request.call, call):
|
|
107
|
+
origin = task.id, ctx
|
|
108
|
+
if origin in request.origins:
|
|
109
|
+
request.origins.remove(origin)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def same(left, right):
|
|
113
|
+
return (left.operation == right.operation and left.arguments == right.arguments
|
|
114
|
+
and left.extraction_source == right.extraction_source)
|
|
115
|
+
|
|
116
|
+
def request(self, task, ctx, arguments):
|
|
117
|
+
run = self.run
|
|
118
|
+
definition = run.requests[task.request]
|
|
119
|
+
if not definition.reusable:
|
|
120
|
+
raise Unavailable()
|
|
121
|
+
args, values = arguments
|
|
122
|
+
call = normalize_call(run.call_id(), definition.operation, args,
|
|
123
|
+
dict(zip(task.keywords, values)), run.context)
|
|
124
|
+
key = run.request_key(task, ctx)
|
|
125
|
+
request = run.cache.get(key)
|
|
126
|
+
if request is not None:
|
|
127
|
+
if not self.same(request.call, call):
|
|
128
|
+
raise Unavailable()
|
|
129
|
+
else:
|
|
130
|
+
request = self.lookup(key, call)
|
|
131
|
+
if request is None:
|
|
132
|
+
kind = "decision" if call.operation in DECISION_OPERATIONS else "llm"
|
|
133
|
+
if kind != self.phase:
|
|
134
|
+
raise Unavailable()
|
|
135
|
+
request = run.new_request(call, [], key=key, speculative=True)
|
|
136
|
+
self.requests.append(request)
|
|
137
|
+
if call.operation not in DECISION_OPERATIONS:
|
|
138
|
+
try:
|
|
139
|
+
ensure_satisfiable(call)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
request.error, request.status = exc, "error"
|
|
142
|
+
run.runtime._record("speculation_error_deferred", run, call=call.id,
|
|
143
|
+
code=getattr(exc, "code", "invalid_arguments"))
|
|
144
|
+
run.runtime._record("infer_speculated", run, task=task.id, path=ctx,
|
|
145
|
+
call=call.id, operation=call.operation)
|
|
146
|
+
origin = task.id, ctx
|
|
147
|
+
if origin not in request.origins:
|
|
148
|
+
request.origins.append(origin)
|
|
149
|
+
request.discarded = False
|
|
150
|
+
if self.phase == "llm" and call.operation not in DECISION_OPERATIONS:
|
|
151
|
+
self.selected_requests.add(id(request))
|
|
152
|
+
return request
|
|
153
|
+
|
|
154
|
+
def bound(self, bound, ctx, visiting):
|
|
155
|
+
env = {}
|
|
156
|
+
for binding in bound.bindings:
|
|
157
|
+
env[binding.name] = yield from self.value(binding.value, ctx, visiting)
|
|
158
|
+
# Allocations remain private and cannot be addressed by formal Store.
|
|
159
|
+
return expression(bound.expression, env, self.run.input.state, {})
|
|
160
|
+
|
|
161
|
+
def value(self, vid, ctx, visiting=frozenset()):
|
|
162
|
+
run = self.run
|
|
163
|
+
task = run.tasks[run.values[vid].producer]
|
|
164
|
+
ctx = ctx[:len(run.paths[task.id])]
|
|
165
|
+
key = vid, ctx
|
|
166
|
+
yield
|
|
167
|
+
if key in visiting:
|
|
168
|
+
raise Unavailable()
|
|
169
|
+
if key in self.memo:
|
|
170
|
+
return self.memo[key]
|
|
171
|
+
if not self.viable(task, ctx) or not self.memory_ready(task, ctx):
|
|
172
|
+
raise Unavailable()
|
|
173
|
+
if self.phase == "llm":
|
|
174
|
+
yield from self.selected(task, ctx, visiting)
|
|
175
|
+
elif not (yield from self.possible(task, ctx, visiting)):
|
|
176
|
+
raise Unavailable()
|
|
177
|
+
if key in run.outputs:
|
|
178
|
+
return detached(run.outputs[key])
|
|
179
|
+
visiting = visiting | {key}
|
|
180
|
+
if isinstance(task, p.Eval):
|
|
181
|
+
values = [(yield from self.bound(task.expression, ctx, visiting))]
|
|
182
|
+
elif isinstance(task, p.Unpack):
|
|
183
|
+
value = yield from self.bound(task.expression, ctx, visiting)
|
|
184
|
+
values = unpack(task.target, detached(value))
|
|
185
|
+
elif isinstance(task, p.Infer):
|
|
186
|
+
arguments = yield from self.value(task.arguments, ctx, visiting)
|
|
187
|
+
request = self.request(task, ctx, arguments)
|
|
188
|
+
if request.status != "done":
|
|
189
|
+
raise Unavailable()
|
|
190
|
+
values = [request.response]
|
|
191
|
+
elif isinstance(task, p.Merge):
|
|
192
|
+
selected = yield from self.port(task.branch, ctx, "true", visiting)
|
|
193
|
+
source = task.then_value if selected else task.else_value
|
|
194
|
+
if source is None:
|
|
195
|
+
raise Unavailable()
|
|
196
|
+
values = [(yield from self.value(source, ctx, visiting))]
|
|
197
|
+
elif isinstance(task, p.Carry):
|
|
198
|
+
source = task.initial if ctx[-1] == 0 else task.update
|
|
199
|
+
parent = ctx[:-1] if ctx[-1] == 0 else ctx[:-1] + (ctx[-1] - 1,)
|
|
200
|
+
values = [(yield from self.value(source, parent, visiting))]
|
|
201
|
+
elif isinstance(task, p.Iteration):
|
|
202
|
+
item_key = task.loop, ctx
|
|
203
|
+
if item_key not in self.items and item_key not in run.items:
|
|
204
|
+
raise Unavailable()
|
|
205
|
+
item = self.items[item_key] if item_key in self.items else run.items[item_key]
|
|
206
|
+
values = unpack(task.target, detached(item))
|
|
207
|
+
else:
|
|
208
|
+
raise Unavailable()
|
|
209
|
+
for output, value in zip(task.outputs, values):
|
|
210
|
+
self.memo[output, ctx] = detached(value)
|
|
211
|
+
return self.memo[key]
|
|
212
|
+
|
|
213
|
+
def contexts(self, path, ctx=()):
|
|
214
|
+
if not path:
|
|
215
|
+
yield ctx
|
|
216
|
+
return
|
|
217
|
+
run = self.run
|
|
218
|
+
loop = run.tasks[path[0]]
|
|
219
|
+
if not self.viable(loop, ctx) or not self.memory_ready(loop, ctx):
|
|
220
|
+
return
|
|
221
|
+
if self.phase == "llm":
|
|
222
|
+
try:
|
|
223
|
+
yield from self.selected(loop, ctx)
|
|
224
|
+
except Unavailable:
|
|
225
|
+
return
|
|
226
|
+
inst = run.instances.get((loop.id, ctx))
|
|
227
|
+
existing = inst.iterations if inst else []
|
|
228
|
+
start = next((i for i in existing if not run.region_done(loop.body, ctx + (i,))), len(existing))
|
|
229
|
+
window = 1 if "eager" in loop.serial_modes else run.runtime.loop_window
|
|
230
|
+
sequence = None
|
|
231
|
+
# Mutable iteration must remain owned by the formal executor.
|
|
232
|
+
if "eager" not in loop.serial_modes:
|
|
233
|
+
try:
|
|
234
|
+
sequence = yield from self.bound(loop.iterable, ctx, frozenset())
|
|
235
|
+
if type(sequence) is dict:
|
|
236
|
+
sequence = tuple(sequence)
|
|
237
|
+
if type(sequence) not in (str, list, tuple, range):
|
|
238
|
+
sequence = None
|
|
239
|
+
except Exception:
|
|
240
|
+
sequence = None
|
|
241
|
+
for index in range(start, start + window):
|
|
242
|
+
yield None # Charge loop expansion to the local scheduling budget.
|
|
243
|
+
child = ctx + (index,)
|
|
244
|
+
if (loop.id, child) in run.items:
|
|
245
|
+
item = run.items[loop.id, child]
|
|
246
|
+
elif sequence is not None and index < len(sequence):
|
|
247
|
+
item = sequence[index]
|
|
248
|
+
else:
|
|
249
|
+
break
|
|
250
|
+
try:
|
|
251
|
+
self.items[loop.id, child] = detached(item)
|
|
252
|
+
except Exception:
|
|
253
|
+
break
|
|
254
|
+
yield from self.contexts(path[1:], child)
|
|
255
|
+
|
|
256
|
+
def candidates(self):
|
|
257
|
+
for task in self.sites:
|
|
258
|
+
definition = self.run.requests[task.request]
|
|
259
|
+
kind = "decision" if definition.operation in DECISION_OPERATIONS else "llm"
|
|
260
|
+
if not definition.reusable or kind != self.phase:
|
|
261
|
+
continue
|
|
262
|
+
for ctx in self.contexts(self.run.paths[task.id]):
|
|
263
|
+
if ctx is None:
|
|
264
|
+
yield
|
|
265
|
+
continue
|
|
266
|
+
yield
|
|
267
|
+
if not self.viable(task, ctx) or not self.memory_ready(task, ctx):
|
|
268
|
+
continue
|
|
269
|
+
inst = self.run.instances.get((task.id, ctx))
|
|
270
|
+
if inst is not None and inst.status == "done":
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
if self.phase == "llm":
|
|
274
|
+
yield from self.selected(task, ctx)
|
|
275
|
+
elif not (yield from self.possible(task, ctx)):
|
|
276
|
+
continue
|
|
277
|
+
arguments = yield from self.value(task.arguments, ctx)
|
|
278
|
+
self.request(task, ctx, arguments)
|
|
279
|
+
except Exception:
|
|
280
|
+
# Argument failures on an unselected path have no effects.
|
|
281
|
+
# Formal execution re-evaluates and reports them on demand.
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
def advance(self, budget):
|
|
285
|
+
if self.revision != self.run.revision:
|
|
286
|
+
self.scan = None
|
|
287
|
+
self.ready = False
|
|
288
|
+
self.revision = self.run.revision
|
|
289
|
+
self.phase = "decision"
|
|
290
|
+
self.selected_requests.clear()
|
|
291
|
+
count = 0
|
|
292
|
+
while count < budget:
|
|
293
|
+
if self.ready:
|
|
294
|
+
self.prune()
|
|
295
|
+
if (self.phase != "decision" or not self.run.local_ready or any(
|
|
296
|
+
r.call.operation in DECISION_OPERATIONS and r.status in ("queued", "inflight")
|
|
297
|
+
and (not r.discarded or r.status == "inflight") for r in self.run.all_requests())):
|
|
298
|
+
break
|
|
299
|
+
self.phase, self.ready = "llm", False
|
|
300
|
+
count += 1
|
|
301
|
+
continue
|
|
302
|
+
if self.scan is None:
|
|
303
|
+
self.memo, self.items = {}, {}
|
|
304
|
+
self.scan = self.candidates()
|
|
305
|
+
try:
|
|
306
|
+
next(self.scan)
|
|
307
|
+
except StopIteration:
|
|
308
|
+
self.scan = None
|
|
309
|
+
self.ready = True
|
|
310
|
+
continue
|
|
311
|
+
count += 1
|
|
312
|
+
return count
|
|
313
|
+
|
|
314
|
+
def prune(self, *, final=False):
|
|
315
|
+
for request in self.requests:
|
|
316
|
+
if request.waiters or request.discarded:
|
|
317
|
+
continue
|
|
318
|
+
if final or not any(self.viable(self.run.tasks[tid], ctx) for tid, ctx in request.origins):
|
|
319
|
+
request.discarded = True
|
|
320
|
+
self.run.runtime._record("speculation_discarded", self.run, call=request.call.id,
|
|
321
|
+
status=request.status, failed=request.error is not None)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Artifact assertions: Python 3.12 is the canonical generation interpreter."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import fields, is_dataclass
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from annoscript.ir import SourceSpan
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def without_coordinates(value):
|
|
11
|
+
"""Ignore only typed source coordinates, never user fields named 'source'."""
|
|
12
|
+
if isinstance(value, SourceSpan):
|
|
13
|
+
return {"module": value.module, "function": value.function}
|
|
14
|
+
if is_dataclass(value):
|
|
15
|
+
result = {f.name: without_coordinates(getattr(value, f.name)) for f in fields(value)}
|
|
16
|
+
if hasattr(value, "kind"):
|
|
17
|
+
result["kind"] = value.kind
|
|
18
|
+
return result
|
|
19
|
+
if isinstance(value, tuple):
|
|
20
|
+
return [without_coordinates(item) for item in value]
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def assert_saved_artifact(test, path, generated):
|
|
25
|
+
text = path.read_text(encoding="utf-8")
|
|
26
|
+
restored = type(generated).from_json(text)
|
|
27
|
+
if sys.version_info[:2] == (3, 12):
|
|
28
|
+
test.assertEqual(text, generated.to_json(indent=2) + "\n")
|
|
29
|
+
else:
|
|
30
|
+
# Serialize to keep bool/int/float distinctions, as in the IR optimizer.
|
|
31
|
+
encode = lambda v: json.dumps(without_coordinates(v), ensure_ascii=False, allow_nan=False)
|
|
32
|
+
test.assertEqual(encode(restored), encode(generated))
|
|
33
|
+
test.assertEqual(generated.to_json(), type(generated).from_json(generated.to_json()).to_json())
|