puremethod 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 (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
puretrace/client.py ADDED
@@ -0,0 +1,320 @@
1
+ """PureTrace REST v1 adapter (02-puretrace.md).
2
+
3
+ Routes used, all under `/v1`: rubrics, papers (list, submit, get, report) and paper-type
4
+ detection. There is deliberately no code path to the certification or author-response
5
+ routes (INV-1, INV-2): the client can only send the requests built below.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import json
13
+ import time
14
+ import uuid
15
+ from collections.abc import Awaitable, Callable
16
+ from datetime import UTC, datetime
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from .types import (
22
+ ASSESSED_STATUSES,
23
+ PAPER_TYPES,
24
+ TERMINAL_STATUSES,
25
+ USES,
26
+ Artifacts,
27
+ Assessment,
28
+ AssessmentFailed,
29
+ AssessmentTimeout,
30
+ AuthError,
31
+ CallRecord,
32
+ Dimension,
33
+ Fact,
34
+ NotApplicable,
35
+ NotFound,
36
+ PureTraceError,
37
+ Question,
38
+ QuotaExhausted,
39
+ RateLimited,
40
+ RequestRejected,
41
+ Rubric,
42
+ ServiceUnavailable,
43
+ TypeGuess,
44
+ Use,
45
+ UseNotEnabled,
46
+ )
47
+
48
+
49
+ def _now() -> str:
50
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
51
+
52
+
53
+ def _sha256(data: bytes) -> str:
54
+ return hashlib.sha256(data).hexdigest()
55
+
56
+
57
+ def _canonical(value: Any) -> str:
58
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
59
+
60
+
61
+ class PureTrace:
62
+ """Built from the configuration, the run id and the uses the run enables (KB-015).
63
+
64
+ The HTTP client is created here: use one instance from one event loop, and `aclose` it.
65
+ """
66
+
67
+ def __init__(self, api_base: str, api_key: str, run_id: str, enabled_uses: list[str], *,
68
+ poll_interval_s: float = 10.0, poll_max_s: float = 60.0,
69
+ assessment_timeout_s: float = 1800.0, request_timeout_s: float = 120.0,
70
+ retries: int = 3, transport: httpx.AsyncBaseTransport | None = None,
71
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
72
+ monotonic: Callable[[], float] = time.monotonic) -> None:
73
+ unknown = set(enabled_uses) - set(USES)
74
+ if unknown:
75
+ raise ValueError(f"unknown uses: {sorted(unknown)}")
76
+ if not api_key:
77
+ raise ValueError("an API key is required")
78
+ self._base = api_base.rstrip("/")
79
+ self._key = api_key
80
+ self._run_id = run_id
81
+ self._uses = frozenset(enabled_uses)
82
+ self._poll = poll_interval_s
83
+ self._poll_max = poll_max_s
84
+ self._timeout = assessment_timeout_s
85
+ self._retries = retries
86
+ self._sleep = sleep
87
+ self._monotonic = monotonic
88
+ self._client = httpx.AsyncClient(base_url=self._base, transport=transport,
89
+ timeout=request_timeout_s,
90
+ headers={"Authorization": f"Bearer {api_key}"})
91
+ self._rubrics: list[Rubric] | None = None
92
+
93
+ async def aclose(self) -> None:
94
+ await self._client.aclose()
95
+
96
+ # ---- transport ---------------------------------------------------------------------
97
+
98
+ def _scrub(self, text: str) -> str:
99
+ return text.replace(self._key, "[REDACTED]")
100
+
101
+ async def _request(self, method: str, path: str, records: list[CallRecord], *,
102
+ json_body: Any = None, files: Any = None, data: Any = None,
103
+ params: dict[str, Any] | None = None,
104
+ headers: dict[str, str] | None = None,
105
+ described_body: Any = None) -> Any:
106
+ """One logical request with retries. Every attempt appends a CallRecord."""
107
+ if not path.startswith("/v1/"):
108
+ raise ValueError("only /v1 routes are called (INV-7)")
109
+ body_text = _canonical(described_body if described_body is not None
110
+ else {"json": json_body, "params": params})
111
+ delay = 1.0
112
+ for attempt in range(self._retries + 1):
113
+ started, t0 = _now(), self._monotonic()
114
+ response: httpx.Response | None = None
115
+ error_type: str | None = None
116
+ try:
117
+ response = await self._client.request(method, path, json=json_body,
118
+ files=files, data=data, params=params,
119
+ headers=headers)
120
+ except httpx.TransportError as exc:
121
+ error_type = f"transport:{type(exc).__name__}"
122
+ text = self._scrub(response.text) if response is not None else None
123
+ code = None
124
+ if response is not None and response.status_code >= 400:
125
+ try:
126
+ code = response.json().get("error", {}).get("code")
127
+ except (ValueError, AttributeError):
128
+ code = None
129
+ error_type = f"http_{response.status_code}:{code or 'unknown'}"
130
+ records.append(CallRecord(
131
+ call_id=uuid.uuid4().hex, endpoint=f"{method} {path}",
132
+ request_digest=_sha256(body_text.encode()),
133
+ response_digest=_sha256(text.encode()) if text is not None else None,
134
+ status=response.status_code if response is not None else None,
135
+ started_at=started, duration_ms=int((self._monotonic() - t0) * 1000),
136
+ service_version=response.headers.get("X-API-Version") if response else None,
137
+ quota_remaining=response.headers.get("RateLimit-Remaining") if response else None,
138
+ error_type=error_type, request_body=body_text, response_body=text))
139
+ if response is not None and response.status_code < 400:
140
+ try:
141
+ return response.json()
142
+ except ValueError:
143
+ raise ServiceUnavailable(f"PureTrace {path} returned a non-JSON body",
144
+ list(records)) from None
145
+ retryable, wait = self._retry_decision(response, code)
146
+ if retryable and attempt < self._retries:
147
+ await self._sleep(wait if wait is not None else delay)
148
+ delay *= 2
149
+ continue
150
+ raise self._error(response, code, records)
151
+ raise AssertionError("unreachable")
152
+
153
+ @staticmethod
154
+ def _retry_decision(response: httpx.Response | None,
155
+ code: str | None) -> tuple[bool, float | None]:
156
+ if response is None:
157
+ return True, None
158
+ status = response.status_code
159
+ if status == 429 and code != "quota_exceeded":
160
+ try:
161
+ return True, float(response.headers.get("Retry-After", ""))
162
+ except ValueError:
163
+ return True, None
164
+ if status == 409 and code == "idempotency_in_progress":
165
+ return True, None
166
+ return status >= 500, None
167
+
168
+ def _error(self, response: httpx.Response | None, code: str | None,
169
+ records: list[CallRecord]) -> PureTraceError:
170
+ if response is None:
171
+ return ServiceUnavailable("PureTrace unreachable", list(records))
172
+ status = response.status_code
173
+ msg = f"PureTrace {status} {code or ''}".strip()
174
+ if status in (401, 403):
175
+ return AuthError(msg, list(records), code)
176
+ if status == 404:
177
+ return NotFound(msg, list(records), code)
178
+ if status == 429:
179
+ if code == "quota_exceeded":
180
+ return QuotaExhausted(msg, list(records), code)
181
+ return RateLimited(msg, list(records), code)
182
+ if status >= 500 or (status == 409 and code == "idempotency_in_progress"):
183
+ return ServiceUnavailable(msg, list(records), code)
184
+ return RequestRejected(msg, list(records), code)
185
+
186
+ # ---- interface ---------------------------------------------------------------------
187
+
188
+ async def preflight(self) -> list[CallRecord]:
189
+ """Checks the key reads rubrics and papers. `papers:write` cannot be checked
190
+ without submitting; a missing write scope surfaces on the first submit."""
191
+ records: list[CallRecord] = []
192
+ await self._request("GET", "/v1/rubrics", records)
193
+ await self._request("GET", "/v1/papers", records, params={"limit": 1})
194
+ return records
195
+
196
+ async def rubrics(self) -> tuple[list[Rubric], list[CallRecord]]:
197
+ records: list[CallRecord] = []
198
+ if self._rubrics is None:
199
+ page = await self._request("GET", "/v1/rubrics", records)
200
+ out = []
201
+ for summary in page["data"]:
202
+ detail = await self._request("GET", f"/v1/rubrics/{summary['paper_type']}",
203
+ records)
204
+ out.append(Rubric(paper_type=detail["paper_type"], label=detail["label"],
205
+ rubric_version=detail.get("rubric_version"),
206
+ total_weight=float(detail["total_weight"]),
207
+ dimensions=list(detail["dimensions"])))
208
+ self._rubrics = out
209
+ return list(self._rubrics), records
210
+
211
+ async def detect_type(self, title: str, abstract: str | None = None
212
+ ) -> TypeGuess | NotApplicable:
213
+ records: list[CallRecord] = []
214
+ body: dict[str, Any] = {"title": title}
215
+ if abstract:
216
+ body["abstract"] = abstract
217
+ resp = await self._request("POST", "/v1/paper-types/detect", records, json_body=body)
218
+ guess = str(resp["paper_type"])
219
+ if guess not in PAPER_TYPES:
220
+ return NotApplicable(reason="detected type is not one of the five PureTrace types",
221
+ detected=guess, records=records)
222
+ return TypeGuess(paper_type=guess, confidence=float(resp["confidence"]),
223
+ records=records)
224
+
225
+ async def assess(self, pdf: bytes, title: str, authors: str, artifacts: Artifacts,
226
+ paper_type: str | None, *, use: Use) -> Assessment | NotApplicable:
227
+ if use not in self._uses:
228
+ raise UseNotEnabled(f"the run did not enable PureTrace {use}")
229
+ records: list[CallRecord] = []
230
+ if paper_type is None:
231
+ guess = await self.detect_type(title)
232
+ records.extend(guess.records)
233
+ if isinstance(guess, NotApplicable):
234
+ guess.records = records
235
+ return guess
236
+ paper_type = guess.paper_type
237
+ elif paper_type not in PAPER_TYPES:
238
+ return NotApplicable(reason="paper type is not one of the five PureTrace types",
239
+ detected=paper_type, records=records)
240
+
241
+ pdf_hash = _sha256(pdf)
242
+ fields = {"title": title, "authors": authors, "paper_type": paper_type,
243
+ **{k: v for k, v in artifacts.__dict__.items() if v is not None}}
244
+ idempotency_key = _sha256(f"{self._run_id}:{pdf_hash}".encode())
245
+ try:
246
+ paper = await self._request(
247
+ "POST", "/v1/papers", records, data=fields,
248
+ files={"file": ("paper.pdf", pdf, "application/pdf")},
249
+ headers={"Idempotency-Key": idempotency_key},
250
+ described_body={"form": fields, "pdf_sha256": pdf_hash,
251
+ "idempotency_key": idempotency_key, "use": use})
252
+ paper = await self._poll_until_done(paper, records)
253
+ return await self._assessment(paper, records)
254
+ except PureTraceError as exc:
255
+ exc.records = list(records)
256
+ raise
257
+
258
+ async def report(self, paper_id: str) -> Assessment:
259
+ records: list[CallRecord] = []
260
+ try:
261
+ paper = await self._request("GET", f"/v1/papers/{paper_id}", records)
262
+ if paper["status"] == "failed":
263
+ raise AssessmentFailed(f"paper {paper_id} failed", list(records))
264
+ if paper["status"] not in ASSESSED_STATUSES:
265
+ raise RequestRejected(f"paper {paper_id} is still {paper['status']}",
266
+ list(records), "not_assessed")
267
+ return await self._assessment(paper, records)
268
+ except PureTraceError as exc:
269
+ exc.records = list(records)
270
+ raise
271
+
272
+ # ---- internals ---------------------------------------------------------------------
273
+
274
+ async def _poll_until_done(self, paper: dict[str, Any],
275
+ records: list[CallRecord]) -> dict[str, Any]:
276
+ deadline = self._monotonic() + self._timeout
277
+ interval = self._poll
278
+ while paper["status"] not in TERMINAL_STATUSES:
279
+ if self._monotonic() >= deadline:
280
+ raise AssessmentTimeout(f"paper {paper['id']} still {paper['status']}")
281
+ await self._sleep(interval)
282
+ interval = min(interval * 1.5, self._poll_max)
283
+ paper = await self._request("GET", f"/v1/papers/{paper['id']}", records)
284
+ if paper["status"] == "failed":
285
+ raise AssessmentFailed(f"paper {paper['id']} failed")
286
+ return paper
287
+
288
+ async def _assessment(self, paper: dict[str, Any],
289
+ records: list[CallRecord]) -> Assessment:
290
+ rep = await self._request("GET", f"/v1/papers/{paper['id']}/report", records)
291
+ try:
292
+ return self._parse_assessment(paper, rep, records)
293
+ except (KeyError, TypeError, ValueError) as exc:
294
+ # A response missing the §4 fields is an upstream fault, never a partial score.
295
+ raise ServiceUnavailable(f"unexpected report shape: {type(exc).__name__} {exc}",
296
+ list(records)) from None
297
+
298
+ @staticmethod
299
+ def _parse_assessment(paper: dict[str, Any], rep: dict[str, Any],
300
+ records: list[CallRecord]) -> Assessment:
301
+ score = rep["score"]
302
+ return Assessment(
303
+ paper_id=paper["id"], status=paper["status"], paper_type=score["paper_type"],
304
+ rubric_version=score.get("rubric_version"), overall=float(score["overall"]),
305
+ provisional=bool(score.get("provisional", False)),
306
+ dimensions=[Dimension(key=d["key"], label=d["label"], score=float(d["score"]),
307
+ weight=float(d["weight"]),
308
+ confidence=float(d["confidence"]),
309
+ rationale=d.get("rationale"),
310
+ needs_author_input=bool(d.get("needs_author_input", False)))
311
+ for d in score["dimensions"]],
312
+ facts=[Fact(key=f["key"], label=f["label"], value=str(f["value"]),
313
+ confidence=f.get("confidence"), source_excerpt=f.get("source_excerpt"))
314
+ for f in rep["facts"]],
315
+ open_questions=[Question(question_id=q["question_id"], fact_key=q["fact_key"],
316
+ label=q["label"], prompt=q["prompt"],
317
+ required=bool(q.get("required", False)))
318
+ for q in rep.get("questions", [])],
319
+ paper_hash=rep.get("paper_hash") or paper.get("paper_hash"),
320
+ records=list(records))
puretrace/types.py ADDED
@@ -0,0 +1,166 @@
1
+ """PureTrace adapter types (02-puretrace.md §3, §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ PaperType = Literal["vision", "llm_finetune", "llm_pretrain", "rl", "graph_ml"]
9
+ PAPER_TYPES: tuple[str, ...] = ("vision", "llm_finetune", "llm_pretrain", "rl", "graph_ml")
10
+ Use = Literal["screening", "selfcheck"]
11
+ USES: tuple[str, ...] = ("screening", "selfcheck")
12
+
13
+ # Polling stops on these. `anchoring` and `certified` can come back when PureTrace returns an
14
+ # existing paper for a PDF the organization already holds (deduplication); the adapter never
15
+ # certifies anything itself (INV-1).
16
+ ASSESSED_STATUSES = ("needs_author_input", "ready_to_certify", "anchoring", "certified")
17
+ TERMINAL_STATUSES = (*ASSESSED_STATUSES, "failed")
18
+
19
+
20
+ @dataclass
21
+ class CallRecord:
22
+ """One HTTP call (01-purescholar.md §4 shape, KB-015). Never contains the key."""
23
+
24
+ call_id: str
25
+ endpoint: str
26
+ request_digest: str
27
+ response_digest: str | None
28
+ status: int | None # HTTP status, or None when no response arrived
29
+ started_at: str
30
+ duration_ms: int
31
+ service_version: str | None
32
+ quota_remaining: str | None
33
+ error_type: str | None
34
+ request_body: str
35
+ response_body: str | None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return dict(self.__dict__)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Artifacts:
43
+ code_repository_url: str | None = None
44
+ dataset_url: str | None = None
45
+ model_weights_url: str | None = None
46
+ environment_url: str | None = None
47
+ evaluation_url: str | None = None
48
+ notes: str | None = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Dimension:
53
+ key: str
54
+ label: str
55
+ score: float
56
+ weight: float
57
+ confidence: float
58
+ rationale: str | None
59
+ needs_author_input: bool
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Fact:
64
+ key: str
65
+ label: str
66
+ value: str
67
+ confidence: float | None
68
+ source_excerpt: str | None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Question:
73
+ question_id: str
74
+ fact_key: str
75
+ label: str
76
+ prompt: str
77
+ required: bool
78
+
79
+
80
+ @dataclass
81
+ class Assessment:
82
+ """A PureTrace result. The score always travels with its type, rubric version and
83
+ provisional flag (INV-4); scores are not comparable across paper types."""
84
+
85
+ paper_id: str
86
+ status: str
87
+ paper_type: str
88
+ rubric_version: str | None
89
+ overall: float
90
+ provisional: bool
91
+ dimensions: list[Dimension]
92
+ facts: list[Fact]
93
+ open_questions: list[Question]
94
+ paper_hash: str | None
95
+ records: list[CallRecord] = field(default_factory=list)
96
+
97
+
98
+ @dataclass
99
+ class TypeGuess:
100
+ paper_type: str
101
+ confidence: float
102
+ records: list[CallRecord] = field(default_factory=list)
103
+
104
+
105
+ @dataclass
106
+ class NotApplicable:
107
+ """The paper is outside the five PureTrace types (INV-5). Nothing was submitted."""
108
+
109
+ reason: str
110
+ detected: str | None = None
111
+ records: list[CallRecord] = field(default_factory=list)
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class Rubric:
116
+ paper_type: str
117
+ label: str
118
+ rubric_version: str | None
119
+ total_weight: float
120
+ dimensions: list[dict[str, Any]]
121
+
122
+
123
+ # ---- errors (KB-015) --------------------------------------------------------------------
124
+
125
+ class PureTraceError(Exception):
126
+ def __init__(self, message: str, records: list[CallRecord] | None = None,
127
+ code: str | None = None) -> None:
128
+ super().__init__(message)
129
+ self.records: list[CallRecord] = records or []
130
+ self.code = code
131
+
132
+
133
+ class AuthError(PureTraceError):
134
+ """401, or 403 (for example `insufficient_scope`)."""
135
+
136
+
137
+ class NotFound(PureTraceError):
138
+ """404: does not exist, or belongs to another organization."""
139
+
140
+
141
+ class RequestRejected(PureTraceError):
142
+ """400, 409 (other than `idempotency_in_progress`), 422."""
143
+
144
+
145
+ class RateLimited(PureTraceError):
146
+ """`429 rate_limit_exceeded` after the retries."""
147
+
148
+
149
+ class QuotaExhausted(PureTraceError):
150
+ """`429 quota_exceeded`: the monthly allowance is spent; not retried (KB-016)."""
151
+
152
+
153
+ class ServiceUnavailable(PureTraceError):
154
+ """5xx, timeouts or connection failures after the retries."""
155
+
156
+
157
+ class AssessmentFailed(PureTraceError):
158
+ """PureTrace marked the paper `failed`."""
159
+
160
+
161
+ class AssessmentTimeout(PureTraceError):
162
+ """The assessment did not finish within the configured timeout."""
163
+
164
+
165
+ class UseNotEnabled(PureTraceError):
166
+ """The run did not enable this use; nothing was submitted (INV-3)."""
writer/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """writer: the paper and the run bundle, built from the ledger
2
+ (docs/knowledge_base/06-writer.md)."""
3
+
4
+ from .core import Writer
5
+ from .types import BuildResult, CompilerUnavailable, PaperOptions, RunContext, WriterError
6
+
7
+ __all__ = ["BuildResult", "CompilerUnavailable", "PaperOptions", "RunContext", "Writer",
8
+ "WriterError"]