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,180 @@
1
+ """Python 3.10 job wire contracts. No transport or execution dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import math
9
+ from email.utils import parsedate_to_datetime
10
+
11
+ from annoscript._data import snapshot
12
+ from annoscript.remote import RemoteProtocolError, _json, ir_sha256
13
+
14
+ PROTOCOL_VERSION = 1
15
+ TERMINAL = frozenset(("completed", "cancelled", "failed"))
16
+ STATES = TERMINAL | {"queued", "running", "paused"}
17
+
18
+
19
+ def encode(value, *, canonical=False):
20
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=canonical,
21
+ separators=(",", ":")).encode("utf-8")
22
+
23
+
24
+ def decode(value):
25
+ if isinstance(value, (bytes, bytearray)):
26
+ try:
27
+ value = value.decode("utf-8")
28
+ except UnicodeError:
29
+ raise RemoteProtocolError("JSON must be UTF-8") from None
30
+ return _json(value)
31
+
32
+
33
+ def nonempty(value):
34
+ return type(value) is str and bool(value.strip())
35
+
36
+
37
+ def integer(value, minimum=0):
38
+ return type(value) is int and value >= minimum
39
+
40
+
41
+ def validate_request(value):
42
+ value = snapshot(value)
43
+ required = {"protocol_version", "ir", "ir_sha256", "mode", "inputs"}
44
+ optional = {"release_id", "application_revision", "session_id"}
45
+ if type(value) is not dict or not required <= value.keys() or value.keys() - required - optional:
46
+ raise ValueError("invalid job request fields")
47
+ if type(value["protocol_version"]) is not int or value["protocol_version"] != 1:
48
+ raise ValueError("unsupported job protocol version")
49
+ if type(value["ir"]) is not dict or ir_sha256(value["ir"]) != value["ir_sha256"]:
50
+ raise ValueError("IR digest mismatch")
51
+ if value["mode"] not in ("eager", "strict", "speculative"):
52
+ raise ValueError("invalid execution mode")
53
+ if type(value["inputs"]) is not list or not value["inputs"]:
54
+ raise ValueError("a job needs at least one input")
55
+ indices = set()
56
+ for item in value["inputs"]:
57
+ if (type(item) is not dict or set(item) != {"index", "state"}
58
+ or not integer(item["index"]) or item["index"] in indices):
59
+ raise ValueError("inputs require unique nonnegative indices and states")
60
+ snapshot(item["state"], state=True)
61
+ indices.add(item["index"])
62
+ for name in optional & value.keys():
63
+ if not nonempty(value[name]):
64
+ raise ValueError(f"{name} must be a nonempty string")
65
+ return value
66
+
67
+
68
+ def request_sha256(request):
69
+ # Normalize protocol field order and IR encoding, but NEVER sort state keys:
70
+ # an IR program can iterate over the keys of an input object.
71
+ value = {key: request[key] for key in ("protocol_version", "ir_sha256", "mode")}
72
+ value["inputs"] = [{"index": item["index"], "state": item["state"]}
73
+ for item in request["inputs"]]
74
+ for key in ("release_id", "application_revision", "session_id"):
75
+ if key in request:
76
+ value[key] = request[key]
77
+ return hashlib.sha256(encode(value)).hexdigest()
78
+
79
+
80
+ def cursor_for(job_id, seq):
81
+ return base64.urlsafe_b64encode(encode([1, job_id, seq])).decode("ascii").rstrip("=")
82
+
83
+
84
+ def cursor_seq(cursor, job_id):
85
+ if cursor is None:
86
+ return 0
87
+ try:
88
+ raw = base64.b64decode(cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True)
89
+ value = decode(raw)
90
+ if (type(value) is not list or len(value) != 3 or type(value[0]) is not int
91
+ or value[0] != 1 or value[1] != job_id or not integer(value[2])
92
+ or cursor_for(job_id, value[2]) != cursor):
93
+ raise ValueError()
94
+ return value[2]
95
+ except (ValueError, TypeError, UnicodeError, RemoteProtocolError):
96
+ raise ValueError("invalid cursor for this job") from None
97
+
98
+
99
+ def retry_after_seconds(value, now):
100
+ if value is None:
101
+ return 0.0
102
+ try:
103
+ seconds = float(value)
104
+ if math.isfinite(seconds):
105
+ return max(0.0, seconds)
106
+ except (ValueError, TypeError):
107
+ pass
108
+ try:
109
+ return max(0.0, parsedate_to_datetime(value).timestamp() - now)
110
+ except (ValueError, TypeError, OverflowError):
111
+ return 0.0
112
+
113
+
114
+ def validate_status(job):
115
+ from annoscript.remote import _error
116
+ fields = {"job_id", "ir_sha256", "release_id", "mode", "input_indices", "state", "counts",
117
+ "pause_generation", "pause_reason", "created_at", "updated_at"}
118
+ if type(job) is not dict or set(job) != fields:
119
+ raise RemoteProtocolError("invalid job status fields")
120
+ if (not all(nonempty(job[k]) for k in ("job_id", "ir_sha256", "release_id"))
121
+ or len(job["ir_sha256"]) != 64 or any(c not in "0123456789abcdef" for c in job["ir_sha256"])
122
+ or job["mode"] not in ("strict", "eager", "speculative")
123
+ or job["state"] not in STATES or not integer(job["pause_generation"])):
124
+ raise RemoteProtocolError("invalid job status")
125
+ indices, counts = job["input_indices"], job["counts"]
126
+ if (type(indices) is not list or not indices or any(not integer(i) for i in indices)
127
+ or len(set(indices)) != len(indices) or type(counts) is not dict
128
+ or set(counts) != {"total", "completed", "success", "error"}
129
+ or any(not integer(n) for n in counts.values())
130
+ or counts["total"] != len(indices)
131
+ or counts["completed"] != counts["success"] + counts["error"]
132
+ or counts["completed"] > counts["total"]
133
+ or ((job["state"] in TERMINAL) != (counts["completed"] == counts["total"]))):
134
+ raise RemoteProtocolError("invalid job indices or counts")
135
+ if job["pause_reason"] is not None:
136
+ _error(job["pause_reason"])
137
+ if (job["state"] == "paused") != (job["pause_reason"] is not None):
138
+ raise RemoteProtocolError("inconsistent paused job")
139
+ if any(type(job[k]) not in (int, float) or not math.isfinite(job[k])
140
+ for k in ("created_at", "updated_at")):
141
+ raise RemoteProtocolError("invalid job timestamps")
142
+ return job
143
+
144
+
145
+ def validate_page(page, job_id, cursor=None):
146
+ from annoscript._client_common import Result
147
+ from annoscript.remote import _error
148
+ if (type(page) is not dict
149
+ or set(page) != {"protocol_version", "job", "results", "next_cursor", "has_more", "done"}
150
+ or type(page["protocol_version"]) is not int or page["protocol_version"] != 1
151
+ or type(page["results"]) is not list
152
+ or type(page["has_more"]) is not bool or type(page["done"]) is not bool):
153
+ raise RemoteProtocolError("invalid result page")
154
+ job = validate_status(page["job"])
155
+ if job["job_id"] != job_id:
156
+ raise RemoteProtocolError("page belongs to another job")
157
+ try:
158
+ last = cursor_seq(cursor, job_id)
159
+ next_seq = cursor_seq(page["next_cursor"], job_id)
160
+ except ValueError as exc:
161
+ raise RemoteProtocolError(str(exc)) from None
162
+ seen, expected = set(), set(job["input_indices"])
163
+ for item in page["results"]:
164
+ if (type(item) is not dict or set(item) != {"seq", "index", "status", "value", "error", "attempts"}
165
+ or not integer(item["seq"], 1) or item["seq"] != last + 1
166
+ or not integer(item["index"]) or item["index"] not in expected or item["index"] in seen
167
+ or not integer(item["attempts"])):
168
+ raise RemoteProtocolError("invalid, missing or duplicate page result")
169
+ try:
170
+ Result(item["index"], item["status"], item["value"],
171
+ None if item["error"] is None else _error(item["error"]))
172
+ except ValueError as exc:
173
+ raise RemoteProtocolError(str(exc)) from None
174
+ last = item["seq"]
175
+ seen.add(item["index"])
176
+ if (next_seq != last or last > job["counts"]["completed"]
177
+ or page["has_more"] != (last < job["counts"]["completed"])
178
+ or page["done"] != (job["state"] in TERMINAL and not page["has_more"])):
179
+ raise RemoteProtocolError("inconsistent result cursor or completion")
180
+ return page
annoscript/jobs.py ADDED
@@ -0,0 +1,255 @@
1
+ """Python 3.10+ durable job HTTP client. Closing it does not cancel durable jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import hashlib
7
+ import random
8
+ import threading
9
+ import time
10
+ from urllib.parse import quote
11
+
12
+ from annoscript.job_protocol import (decode, encode, nonempty, retry_after_seconds,
13
+ validate_page, validate_request, validate_status)
14
+ from annoscript.remote import (RemoteProtocolError, RemoteSubmissionError,
15
+ RemoteTransportError, ir_sha256, _error)
16
+
17
+
18
+ class RemoteJobPausedError(RemoteSubmissionError):
19
+ def __init__(self, job):
20
+ reason = job["pause_reason"]
21
+ super().__init__(reason["code"], reason["message"])
22
+ self.job = job
23
+
24
+
25
+ class RemoteCancellationError(RemoteTransportError):
26
+ """Cancellation was requested but could not be confirmed before closing."""
27
+
28
+
29
+ def job_id(job):
30
+ value = job.get("job_id") if type(job) is dict else job
31
+ if not nonempty(value):
32
+ raise ValueError("job must be a job status or nonempty job ID")
33
+ return value
34
+
35
+
36
+ class AnnoJobClient:
37
+ def __init__(self, base_url, auth_token=None, *, timeout=30.0, transport=None,
38
+ _clock=time.monotonic, _wall_clock=time.time, _sleep=time.sleep,
39
+ _random=random.random):
40
+ import httpx
41
+ url = httpx.URL(base_url)
42
+ if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
43
+ raise ValueError("base_url must be an HTTP(S) URL without credentials, query or fragment")
44
+ if auth_token is not None and (not nonempty(auth_token) or "\r" in auth_token or "\n" in auth_token):
45
+ raise ValueError("auth_token must be a nonempty single-line string")
46
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
47
+ raise ValueError("timeout must be finite and positive")
48
+ self.base_url, self.timeout = str(url).rstrip("/"), timeout
49
+ self.clock, self.wall_clock, self.sleep, self.random = _clock, _wall_clock, _sleep, _random
50
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
51
+ if auth_token is not None:
52
+ headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else "Bearer " + auth_token
53
+ self._http = httpx.Client(headers=headers, timeout=timeout, follow_redirects=False,
54
+ transport=transport if transport is not None else httpx.HTTPTransport(retries=0))
55
+ self.closed = False
56
+
57
+ def __enter__(self):
58
+ if self.closed:
59
+ raise RuntimeError("client is closed")
60
+ return self
61
+
62
+ def __exit__(self, *args):
63
+ self.close()
64
+
65
+ def close(self):
66
+ if not self.closed:
67
+ self.closed = True
68
+ self._http.close()
69
+
70
+ def _request(self, method, path, *, payload=None, key=None, params=None,
71
+ budget=120.0, attempts=5):
72
+ # httpx timeouts are per network phase; DNS or a slowly progressing
73
+ # response must not turn a bounded reconnect budget into an endless wait.
74
+ # A late request can still reach the server, so all mutations are either
75
+ # idempotent or guarded by a closed ownership session.
76
+ outcome, done = [], threading.Event()
77
+ def run():
78
+ try:
79
+ outcome.append((True, self._request_with_retries(method, path, payload=payload,
80
+ key=key, params=params, budget=budget, attempts=attempts)))
81
+ except BaseException as exc:
82
+ outcome.append((False, exc))
83
+ finally:
84
+ done.set()
85
+ threading.Thread(target=run, name="anno-job-http", daemon=True).start()
86
+ if not done.wait(max(0.0, budget)):
87
+ raise RemoteTransportError("remote reconnect deadline exceeded")
88
+ success, value = outcome[0]
89
+ if not success:
90
+ raise value
91
+ return value
92
+
93
+ def _request_with_retries(self, method, path, *, payload=None, key=None, params=None,
94
+ budget=120.0, attempts=5):
95
+ import httpx
96
+ if self.closed:
97
+ raise RuntimeError("client is closed")
98
+ if key is not None and (not nonempty(key) or len(key) > 256 or "\r" in key or "\n" in key):
99
+ raise ValueError("invalid idempotency key")
100
+ content = encode(payload) if payload is not None else None
101
+ deadline = self.clock() + budget
102
+ for attempt in range(attempts):
103
+ remaining = deadline - self.clock()
104
+ if remaining <= 0:
105
+ raise RemoteTransportError("remote reconnect budget exhausted")
106
+ retry_after = None
107
+ try:
108
+ with self._http.stream(method, self.base_url + path, content=content, params=params,
109
+ headers={"Idempotency-Key": key} if key is not None else None,
110
+ timeout=min(self.timeout, remaining)) as response:
111
+ chunks = []
112
+ for chunk in response.iter_bytes():
113
+ if self.clock() > deadline:
114
+ raise httpx.ReadTimeout("request deadline exceeded")
115
+ chunks.append(chunk)
116
+ raw = b"".join(chunks)
117
+ if not 200 <= response.status_code < 300:
118
+ code, message = "http_error", f"remote HTTP error {response.status_code}"
119
+ try:
120
+ error = _error(decode(raw)["error"])
121
+ code, message = error.code, error.message
122
+ except (RemoteProtocolError, KeyError, TypeError):
123
+ pass
124
+ error = RemoteSubmissionError(code, message, http_status=response.status_code,
125
+ retry_after=response.headers.get("Retry-After"))
126
+ if response.status_code not in (408, 429) and not 500 <= response.status_code <= 599:
127
+ raise error
128
+ failure, retry_after = error, error.retry_after
129
+ else:
130
+ if response.headers.get("Content-Type", "").split(";", 1)[0].lower() != "application/json":
131
+ raise RemoteProtocolError("expected an application/json response")
132
+ value = decode(raw)
133
+ if type(value) is not dict or type(value.get("protocol_version")) is not int or value["protocol_version"] != 1:
134
+ raise RemoteProtocolError("unsupported job protocol version")
135
+ return value
136
+ except httpx.RequestError as exc:
137
+ failure = RemoteTransportError("remote HTTP request interrupted")
138
+ failure.__cause__ = exc
139
+ if attempt + 1 == attempts:
140
+ raise failure
141
+ delay = max(self.random() * min(10.0, 0.5 * 2 ** attempt),
142
+ retry_after_seconds(retry_after, self.wall_clock()))
143
+ if self.clock() + delay >= deadline:
144
+ raise failure
145
+ self.sleep(delay)
146
+ raise RemoteTransportError("remote reconnect budget exhausted")
147
+
148
+ @staticmethod
149
+ def _status(response, expected_id=None):
150
+ if set(response) != {"protocol_version", "job"}:
151
+ raise RemoteProtocolError("invalid job envelope")
152
+ job = validate_status(response["job"])
153
+ if expected_id is not None and job["job_id"] != expected_id:
154
+ raise RemoteProtocolError("response belongs to another job")
155
+ return job
156
+
157
+ @staticmethod
158
+ def _binding(expected, actual):
159
+ if type(expected) is dict:
160
+ for field in ("job_id", "ir_sha256", "release_id", "mode", "input_indices"):
161
+ if expected[field] != actual[field]:
162
+ raise RemoteProtocolError("immutable job binding changed")
163
+ return actual
164
+
165
+ def submit_ir(self, ir, inputs, *, idempotency_key, mode="eager", release_id=None,
166
+ application_revision=None, session_id=None):
167
+ ir = ir.to_dict() if hasattr(ir, "to_dict") else ir
168
+ payload = {"protocol_version": 1, "ir": ir, "ir_sha256": ir_sha256(ir), "mode": mode, "inputs": inputs}
169
+ for name, value in (("release_id", release_id), ("application_revision", application_revision), ("session_id", session_id)):
170
+ if value is not None:
171
+ payload[name] = value
172
+ payload = validate_request(payload)
173
+ job = self._status(self._request("POST", "/v1/jobs", payload=payload, key=idempotency_key))
174
+ if (job["ir_sha256"] != payload["ir_sha256"] or job["mode"] != mode
175
+ or job["input_indices"] != [item["index"] for item in payload["inputs"]]
176
+ or (release_id is not None and job["release_id"] != release_id)):
177
+ raise RemoteProtocolError("accepted job does not match submitted IR, inputs or release")
178
+ return job
179
+
180
+ def lookup(self, idempotency_key, **options):
181
+ return self._status(self._request("GET", "/v1/jobs:lookup", key=idempotency_key, **options))
182
+
183
+ def release(self, release_id):
184
+ """Read an immutable release without importing server implementation."""
185
+ if not nonempty(release_id):
186
+ raise ValueError("release_id must be nonempty")
187
+ response = self._request("GET", f"/v1/releases/{quote(release_id, safe='')}")
188
+ if (set(response) != {"protocol_version", "release_id", "manifest", "available"}
189
+ or response["release_id"] != release_id or type(response["available"]) is not bool
190
+ or type(response["manifest"]) is not dict
191
+ or hashlib.sha256(encode(response["manifest"], canonical=True)).hexdigest() != release_id):
192
+ raise RemoteProtocolError("invalid release manifest or digest")
193
+ return response
194
+
195
+ def status(self, job):
196
+ jid = job_id(job)
197
+ return self._binding(job, self._status(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}"), jid))
198
+
199
+ def fetch_results(self, job, *, cursor=None, limit=100):
200
+ jid = job_id(job)
201
+ params = {"limit": limit}
202
+ if cursor is not None:
203
+ params["cursor"] = cursor
204
+ page = validate_page(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}/results", params=params), jid, cursor)
205
+ self._binding(job, page["job"])
206
+ return page
207
+
208
+ def metrics(self, job):
209
+ """Read a consistent metric snapshot without loading server modules."""
210
+ from annoscript.metrics import validate_metrics
211
+ jid = job_id(job)
212
+ value = validate_metrics(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}/metrics"), jid)
213
+ self._binding(job, value["job"])
214
+ return value
215
+
216
+ def resume(self, job, *, pause_generation, idempotency_key):
217
+ jid = job_id(job)
218
+ return self._binding(job, self._status(self._request("POST", f"/v1/jobs/{quote(jid, safe='')}:resume",
219
+ payload={"pause_generation": pause_generation}, key=idempotency_key), jid))
220
+
221
+ def cancel(self, job, **options):
222
+ jid = job_id(job)
223
+ result = self._status(self._request("POST", f"/v1/jobs/{quote(jid, safe='')}:cancel", payload={}, **options), jid)
224
+ self._binding(job, result)
225
+ if result["state"] not in ("completed", "cancelled", "failed"):
226
+ raise RemoteProtocolError("cancellation was not persisted")
227
+ return result
228
+
229
+ def _session(self, response):
230
+ if (set(response) != {"protocol_version", "session_id", "state", "expires_at", "heartbeat_interval"}
231
+ or not nonempty(response["session_id"]) or response["state"] not in ("active", "closed", "expired")
232
+ or any(type(response[k]) not in (int, float) or not math.isfinite(response[k]) or response[k] <= 0
233
+ for k in ("expires_at", "heartbeat_interval"))):
234
+ raise RemoteProtocolError("invalid session response")
235
+ return response
236
+
237
+ def _create_session(self, key):
238
+ response = self._session(self._request("POST", "/v1/sessions", payload={}, key=key))
239
+ if response["state"] != "active":
240
+ raise RemoteSubmissionError("session_closed", "client session has ended")
241
+ return response
242
+
243
+ def _heartbeat(self, session_id):
244
+ response = self._session(self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}:heartbeat",
245
+ payload={}, attempts=1, budget=30))
246
+ if response["session_id"] != session_id or response["state"] != "active":
247
+ raise RemoteProtocolError("invalid session heartbeat")
248
+ return response
249
+
250
+ def _close_session(self, session_id, budget):
251
+ response = self._session(self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}:close",
252
+ payload={}, attempts=1, budget=budget))
253
+ if response["session_id"] != session_id or response["state"] == "active":
254
+ raise RemoteProtocolError("session cancellation was not persisted")
255
+ return response
annoscript/metrics.py ADDED
@@ -0,0 +1,153 @@
1
+ """Version 1 metric values and validation (stdlib only, Python 3.10+).
2
+
3
+ Observed subtotals are never substituted for unknown totals. Token details such
4
+ as reasoning/cached tokens are subsets, not additional billable tokens.
5
+ """
6
+
7
+ from collections import Counter
8
+ import math
9
+
10
+ from annoscript.job_protocol import TERMINAL, integer, validate_status
11
+ from annoscript.remote import RemoteProtocolError
12
+
13
+
14
+ def normalize_usage(usage):
15
+ result = {}
16
+ for field, aliases in (("input_tokens", ("input_tokens", "prompt_tokens")),
17
+ ("output_tokens", ("output_tokens", "completion_tokens"))):
18
+ present = [key for key in aliases if type(usage) is dict and key in usage]
19
+ values = [usage[key] for key in present]
20
+ reason = ("missing" if not present else "invalid" if not all(integer(v) for v in values)
21
+ else "conflicting" if len(set(values)) != 1 else None)
22
+ result[field] = {"value": values[0] if reason is None else None,
23
+ "sources": present, "reason": reason}
24
+ return result
25
+
26
+
27
+ def quantile(values, fraction):
28
+ return sorted(values)[max(0, math.ceil(len(values) * fraction) - 1)] if values else None
29
+
30
+
31
+ def measure(values, *, history=True, duration=False):
32
+ known = [value for value in values if value is not None]
33
+ result = {"observed": sum(known), "total": sum(known) if history and len(known) == len(values) else None,
34
+ "known": len(known), "unknown": len(values) - len(known)}
35
+ if duration:
36
+ result.update(p50=quantile(known, .5), p95=quantile(known, .95))
37
+ return result
38
+
39
+
40
+ def aggregate(attempts, requests, *, history, waits):
41
+ """Aggregate sanitized persisted records; never distribute batch usage by question."""
42
+ history = history and all(a["metered"] for a in attempts)
43
+ result = {
44
+ "history_complete": bool(history),
45
+ "attempts": {"count": len(attempts), "statuses": dict(sorted(Counter(a["status"] for a in attempts).items())),
46
+ "seconds": measure([a["duration_seconds"] for a in attempts], history=history, duration=True)},
47
+ "queue_wait_seconds": measure(waits, history=history, duration=True),
48
+ "backends": {},
49
+ }
50
+ for backend in ("decision", "generation"):
51
+ rows = [r for r in requests if r["backend"] == backend]
52
+ unknown = sum(r["http_status"] is None for r in rows)
53
+ item = {"requests": len(rows), "confirmed_requests": len(rows) - unknown,
54
+ "unknown_requests": unknown, "total_requests": len(rows) if history and not unknown else None,
55
+ "questions": sum(r["questions"] for r in rows),
56
+ "confirmed_questions": sum(r["questions"] for r in rows if r["http_status"] is not None),
57
+ "total_questions": sum(r["questions"] for r in rows) if history and not unknown else None,
58
+ "http_seconds": measure([r["seconds"] for r in rows], history=history, duration=True)}
59
+ for field in ("input_tokens", "output_tokens"):
60
+ tokens = [r["usage"][field] for r in rows]
61
+ item[field] = {**measure([t["value"] for t in tokens], history=history),
62
+ "sources": sorted({s for t in tokens for s in t["sources"]}),
63
+ "reasons": dict(sorted(Counter(t["reason"] for t in tokens if t["reason"]).items()))}
64
+ result["backends"][backend] = item
65
+ return result
66
+
67
+
68
+ def validate_metrics(value, job_id):
69
+ """Reject all malformed JSON shapes through the public protocol error type."""
70
+ try:
71
+ return _validate_metrics(value, job_id)
72
+ except RemoteProtocolError:
73
+ raise
74
+ except (KeyError, TypeError, ValueError, OverflowError, RecursionError):
75
+ raise RemoteProtocolError("invalid job metrics") from None
76
+
77
+
78
+ def _validate_metrics(value, job_id):
79
+ def require(ok):
80
+ if not ok:
81
+ raise RemoteProtocolError("invalid job metrics")
82
+
83
+ def number(v):
84
+ return type(v) in (int, float) and 0 <= v < float("inf")
85
+
86
+ def metric(m, *, duration=False, tokens=False):
87
+ keys = {"observed", "total", "known", "unknown"}
88
+ if duration:
89
+ keys |= {"p50", "p95"}
90
+ if tokens:
91
+ keys |= {"sources", "reasons"}
92
+ require(type(m) is dict and set(m) == keys)
93
+ require(integer(m["known"]) and integer(m["unknown"]) and number(m["observed"]))
94
+ require(m["total"] is None or (number(m["total"]) and m["total"] == m["observed"] and not m["unknown"]))
95
+ require(m["known"] != 0 or m["observed"] == 0)
96
+ if tokens:
97
+ require(integer(m["observed"]) and (m["total"] is None or integer(m["total"])))
98
+ require(type(m["sources"]) is list and all(s in ("input_tokens", "prompt_tokens", "output_tokens", "completion_tokens") for s in m["sources"]))
99
+ require(len(set(m["sources"])) == len(m["sources"]))
100
+ require(type(m["reasons"]) is dict and all(k in ("missing", "invalid", "conflicting") and integer(v, 1) for k, v in m["reasons"].items()))
101
+ require(sum(m["reasons"].values()) == m["unknown"])
102
+ if duration:
103
+ require(all((m[k] is None if not m["known"] else number(m[k])) for k in ("p50", "p95")))
104
+ require(not m["known"] or m["p50"] <= m["p95"] <= m["observed"] + 1e-9)
105
+
106
+ def summary(s):
107
+ require(type(s) is dict and set(s) == {"history_complete", "attempts", "queue_wait_seconds", "backends"})
108
+ require(type(s["history_complete"]) is bool)
109
+ a = s["attempts"]
110
+ require(type(a) is dict and set(a) == {"count", "statuses", "seconds"} and integer(a["count"]))
111
+ require(type(a["statuses"]) is dict and all(k in ("running", "finished", "failed", "cancelled", "abandoned") and integer(v, 1) for k, v in a["statuses"].items()))
112
+ require(sum(a["statuses"].values()) == a["count"])
113
+ metric(a["seconds"], duration=True)
114
+ require(a["seconds"]["known"] + a["seconds"]["unknown"] == a["count"])
115
+ metric(s["queue_wait_seconds"], duration=True)
116
+ require(s["history_complete"] or (a["seconds"]["total"] is None and s["queue_wait_seconds"]["total"] is None))
117
+ require(type(s["backends"]) is dict and set(s["backends"]) == {"decision", "generation"})
118
+ for b in s["backends"].values():
119
+ require(type(b) is dict and set(b) == {"requests", "confirmed_requests", "unknown_requests", "total_requests", "questions", "confirmed_questions", "total_questions", "http_seconds", "input_tokens", "output_tokens"})
120
+ require(all(integer(b[k]) for k in ("requests", "confirmed_requests", "unknown_requests", "questions", "confirmed_questions")))
121
+ require(b["confirmed_requests"] + b["unknown_requests"] == b["requests"] and b["confirmed_questions"] <= b["questions"])
122
+ complete = s["history_complete"] and not b["unknown_requests"]
123
+ require(all(b[k] is None or integer(b[k]) for k in ("total_requests", "total_questions")))
124
+ require(b["total_requests"] == (b["requests"] if complete else None))
125
+ require(b["total_questions"] == (b["questions"] if complete else None))
126
+ for key in ("http_seconds", "input_tokens", "output_tokens"):
127
+ metric(b[key], duration=key == "http_seconds", tokens=key != "http_seconds")
128
+ require(b[key]["known"] + b[key]["unknown"] == b["requests"])
129
+ require(s["history_complete"] or b[key]["total"] is None)
130
+ return s
131
+
132
+ require(type(value) is dict and set(value) == {"protocol_version", "metrics_version", "job", "provisional", "summary", "inputs"})
133
+ require(type(value["protocol_version"]) is int and value["protocol_version"] == 1)
134
+ require(type(value["metrics_version"]) is int and value["metrics_version"] == 1)
135
+ job = validate_status(value["job"])
136
+ require(job["job_id"] == job_id and type(value["provisional"]) is bool and value["provisional"] == (job["state"] not in TERMINAL))
137
+ summary(value["summary"])
138
+ require(type(value["inputs"]) is list and len(value["inputs"]) == len(job["input_indices"]))
139
+ indices = []
140
+ for item in value["inputs"]:
141
+ require(type(item) is dict and set(item) == {"index", "metrics"} and integer(item["index"]))
142
+ indices.append(item["index"])
143
+ summary(item["metrics"])
144
+ require(indices == job["input_indices"])
145
+ require(value["summary"]["attempts"]["count"] == sum(i["metrics"]["attempts"]["count"] for i in value["inputs"]))
146
+ for backend in ("decision", "generation"):
147
+ for k in ("requests", "questions", "unknown_requests", "confirmed_requests", "confirmed_questions"):
148
+ require(value["summary"]["backends"][backend][k] == sum(i["metrics"]["backends"][backend][k] for i in value["inputs"]))
149
+ for k in ("input_tokens", "output_tokens", "http_seconds"):
150
+ observed = sum(i["metrics"]["backends"][backend][k]["observed"] for i in value["inputs"])
151
+ actual = value["summary"]["backends"][backend][k]["observed"]
152
+ require(math.isclose(actual, observed, rel_tol=1e-12, abs_tol=1e-9) if k == "http_seconds" else actual == observed)
153
+ return value