annoscript 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
@@ -0,0 +1,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())