modelspec-dev 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 (63) hide show
  1. api/__init__.py +0 -0
  2. api/class_fit.py +334 -0
  3. api/classes.py +557 -0
  4. api/ranking/__init__.py +12 -0
  5. api/ranking/engine.py +1943 -0
  6. cli/__init__.py +0 -0
  7. cli/modelspec/__init__.py +0 -0
  8. cli/modelspec/cli.py +1819 -0
  9. cli/modelspec/commands/__init__.py +0 -0
  10. cli/modelspec/decide_cmd.py +333 -0
  11. cli/modelspec/offline.py +623 -0
  12. cli/modelspec/snapshot.py +698 -0
  13. cli/modelspec/snapshot_build_cmd.py +49 -0
  14. cli/modelspec/verify_cmd.py +125 -0
  15. cli/modelspec/vocab_cmd.py +204 -0
  16. cli/modelspec/vocabulary_cache.py +54 -0
  17. decision/__init__.py +13 -0
  18. decision/capability.py +872 -0
  19. decision/computed.py +125 -0
  20. decision/contract.py +1575 -0
  21. decision/engine.py +238 -0
  22. decision/excluded.py +34 -0
  23. decision/explain.py +908 -0
  24. decision/filter.py +796 -0
  25. decision/model.py +438 -0
  26. decision/normalise.py +604 -0
  27. decision/optimise.py +320 -0
  28. decision/registry.py +717 -0
  29. decision/relax.py +132 -0
  30. decision/resolve.py +111 -0
  31. decision/schema.py +21 -0
  32. decision/snapshot.py +1483 -0
  33. decision/sources.py +544 -0
  34. decision/templates.py +134 -0
  35. decision/verify.py +1745 -0
  36. decision/vocabulary.py +433 -0
  37. modelspec_dev-0.1.0.dist-info/METADATA +101 -0
  38. modelspec_dev-0.1.0.dist-info/RECORD +63 -0
  39. modelspec_dev-0.1.0.dist-info/WHEEL +4 -0
  40. modelspec_dev-0.1.0.dist-info/entry_points.txt +2 -0
  41. modelspec_dev-0.1.0.dist-info/licenses/LICENSE +43 -0
  42. modelspec_dev-0.1.0.dist-info/licenses/LICENSE-DATA +428 -0
  43. pipeline/__init__.py +0 -0
  44. pipeline/class_export.py +172 -0
  45. pipeline/hardware.py +434 -0
  46. pipeline/hosts.py +247 -0
  47. pipeline/load.py +224 -0
  48. pipeline/ranking.py +551 -0
  49. registry/domains.yaml +130 -0
  50. registry/facets.yaml +888 -0
  51. registry/harnesses.yaml +79 -0
  52. registry/providers.yaml +354 -0
  53. registry/sources.yaml +3059 -0
  54. registry/templates.yaml +166 -0
  55. schema/__init__.py +0 -0
  56. schema/applicability.py +147 -0
  57. schema/benchmark.py +175 -0
  58. schema/benchmark_eligibility.py +304 -0
  59. schema/card.py +1463 -0
  60. schema/enrichment.py +162 -0
  61. schema/enums.py +327 -0
  62. schema/graph.py +406 -0
  63. schema/suppliers.py +72 -0
@@ -0,0 +1,698 @@
1
+ """The free path: a local snapshot of the published export, usable offline.
2
+
3
+ The CLI's graph commands need FalkorDB on localhost:6382. That is fine for
4
+ someone exploring the graph locally and useless for everyone else, including
5
+ dpf's ticket author, who needs an answer on a machine that has never run a
6
+ database.
7
+
8
+ This module downloads the versioned export from modelspec.dev, caches it, and
9
+ answers from the cache. No credential, no account, no network once fetched.
10
+
11
+ MODEL-71 adds one thing and changes nothing else: `fetch` can present a key to
12
+ an origin that keys its export, so a caller entitled to the current tree gets a
13
+ snapshot whose `fetched_at` is now rather than one built from a 90-day-delayed
14
+ public export. The default origin is unkeyed and the free path is untouched —
15
+ without a credential this module behaves exactly as it did. The credential is
16
+ carried in a `Credential`, which has no printable form of the secret, so a key
17
+ cannot reach a log line by being interpolated into one.
18
+
19
+ The pin identity is `build.commit` plus `build.export_schema_version`. The
20
+ latter is the published JSON tree, not the CLI `--json` envelope
21
+ (`offline.SCHEMA_VERSION`) and not `rankings.json`'s `schema_version`.
22
+
23
+ The continuity rule matters more than freshness: an answer from a snapshot three
24
+ weeks old, clearly labelled as three weeks old, is far more useful than an error.
25
+ Callers are told the age and decide for themselves.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import secrets
35
+ import shutil
36
+ import time
37
+ from collections.abc import Mapping
38
+ from dataclasses import dataclass, field, replace
39
+ from datetime import UTC, datetime
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ DEFAULT_ORIGIN = "https://modelspec.dev"
44
+
45
+ #: Where the credential for a keyed origin is read from. The environment is the
46
+ #: supported way to supply it: `docs/agent-commerce-assessment.md` §4 notes that
47
+ #: a credential on the command line lands in process listings, shell history and
48
+ #: logs, and an environment variable lands in fewer of those.
49
+ API_KEY_ENV = "MODELSPEC_API_KEY"
50
+
51
+ #: How a key identifies itself in a message a human or a log will see. The same
52
+ #: 12 hex characters of SHA-256 the origin calls `key_id` (MODEL-69,
53
+ #: `api/worker/src/access_keys.py`), so a support conversation can name the same
54
+ #: key from both ends without either end quoting the secret.
55
+ KEY_ID_LENGTH = 12
56
+
57
+ #: What replaces the secret if one ever reaches a string that is about to be
58
+ #: printed. Nothing should get that far; this is the backstop, not the plan.
59
+ REDACTED = "[redacted]"
60
+
61
+ #: Files that make a usable snapshot. Kept small on purpose — the whole point is
62
+ #: that this works on a laptop tethered to a phone.
63
+ PARTS = {
64
+ "index": "/api/index.json",
65
+ "candidates": "/api/rank/candidates.json",
66
+ "profiles": "/api/rank/profiles.json",
67
+ "hardware": "/api/graph/views/hardware.json",
68
+ }
69
+
70
+ #: Parts a snapshot may lack. An export older than MODEL-26 phase B has no
71
+ #: hosts.json; the snapshot still answers everything except `fit --host`.
72
+ OPTIONAL_PARTS = {
73
+ "hosts": "/api/hosts.json",
74
+ }
75
+
76
+ DECISION_SNAPSHOT_ROUTE = "/api/decision/snapshot.json.gz"
77
+ DECISION_VOCABULARY_ROUTE = "/api/decision/vocabulary.json"
78
+ DECISION_DIRECTORY = "decision"
79
+ DECISION_SNAPSHOT_FILENAME = "snapshot.json.gz"
80
+ DECISION_VOCABULARY_FILENAME = "vocabulary.json"
81
+ DECISION_SNAPSHOT_ID = re.compile(r"snap_[0-9a-f]{16}")
82
+
83
+ #: Past this, the snapshot is still served but every answer says it is stale.
84
+ #: Model releases move weekly, so a month-old snapshot is a different world.
85
+ STALE_AFTER_DAYS = 30
86
+
87
+ #: Major.minor of `build.export_schema_version` this CLI will consume.
88
+ #: Must match `pipeline.export.EXPORT_SCHEMA_VERSION`. A different major is
89
+ #: refused so a breaking export cannot be ranked as if it were the old shape.
90
+ #: 2.0 since MODEL-77 reshaped the policy fields on the published cards.
91
+ EXPORT_SCHEMA_VERSION = "3.0"
92
+
93
+ #: What a snapshot with no `export_schema_version` at all actually is: an
94
+ #: export from before the field was added, which is the 1.x tree. It is named
95
+ #: rather than defaulted to the current version, because "the field is missing"
96
+ #: and "the field says whatever this CLI happens to be" stopped being the same
97
+ #: statement the moment the current version left 1.x — and assuming the latter
98
+ #: would read a pre-MODEL-77 card as if `commercial_use` were still a bool.
99
+ PRE_VERSIONED_EXPORT_SCHEMA_VERSION = "1.0"
100
+
101
+
102
+ def cache_dir() -> Path:
103
+ """Respects XDG, so a user can point it somewhere else or clear it."""
104
+ override = os.environ.get("MODELSPEC_CACHE")
105
+ if override:
106
+ return Path(override).expanduser()
107
+ base = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
108
+ return Path(base).expanduser() / "modelspec"
109
+
110
+
111
+ def _decision_root(directory: Path | None = None) -> Path:
112
+ return (directory or cache_dir()) / DECISION_DIRECTORY
113
+
114
+
115
+ def _validate_decision_generation(generation: Path, *, require_named: bool = True) -> str:
116
+ from decision.snapshot import load_snapshot
117
+
118
+ snapshot_path = generation / DECISION_SNAPSHOT_FILENAME
119
+ vocabulary_path = generation / DECISION_VOCABULARY_FILENAME
120
+ decision = load_snapshot(snapshot_path, key=None, include_archive=True)
121
+ vocabulary = json.loads(vocabulary_path.read_text(encoding="utf-8"))
122
+ if not isinstance(vocabulary, dict):
123
+ raise ValueError("cached decision vocabulary is not a JSON object")
124
+ if vocabulary.get("snapshot") != decision.snapshot_id:
125
+ raise ValueError("cached decision vocabulary does not name the decision snapshot")
126
+ if require_named and generation.name != decision.snapshot_id:
127
+ raise ValueError("cached decision generation does not name the decision snapshot")
128
+ return decision.snapshot_id
129
+
130
+
131
+ def _current_decision_generation(
132
+ directory: Path | None = None,
133
+ ) -> tuple[Path | None, str | None]:
134
+ root = _decision_root(directory)
135
+ try:
136
+ snapshot_id = (root / "current").read_text(encoding="utf-8").strip()
137
+ if DECISION_SNAPSHOT_ID.fullmatch(snapshot_id) is None:
138
+ raise ValueError("decision/current contains an invalid snapshot ID")
139
+ generation = root / snapshot_id
140
+ _validate_decision_generation(generation)
141
+ return generation, None
142
+ except FileNotFoundError:
143
+ return None, None
144
+ except (OSError, UnicodeError, ValueError, KeyError, json.JSONDecodeError) as exc:
145
+ return None, str(exc)
146
+
147
+
148
+ def decision_snapshot_path(directory: Path | None = None) -> Path:
149
+ generation, _error = _current_decision_generation(directory)
150
+ return ((generation / DECISION_SNAPSHOT_FILENAME) if generation else
151
+ (_decision_root(directory) / ".absent" / DECISION_SNAPSHOT_FILENAME))
152
+
153
+
154
+ def decision_vocabulary_path(directory: Path | None = None) -> Path:
155
+ generation, _error = _current_decision_generation(directory)
156
+ return ((generation / DECISION_VOCABULARY_FILENAME) if generation else
157
+ (_decision_root(directory) / ".absent" / DECISION_VOCABULARY_FILENAME))
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Snapshot:
162
+ path: Path
163
+ fetched_at: datetime
164
+ origin: str
165
+ build_commit: str
166
+ build_at: str
167
+ data: dict[str, Any]
168
+ export_schema_version: str = EXPORT_SCHEMA_VERSION
169
+ decision_fetch: dict[str, Any] | None = None
170
+
171
+ @property
172
+ def age_days(self) -> float:
173
+ return (datetime.now(UTC) - self.fetched_at).total_seconds() / 86400
174
+
175
+ @property
176
+ def is_stale(self) -> bool:
177
+ return self.age_days > STALE_AFTER_DAYS
178
+
179
+ def freshness(self) -> dict[str, Any]:
180
+ """What every machine-readable answer carries, so nobody has to guess."""
181
+ return {
182
+ "fetched_at": self.fetched_at.isoformat(),
183
+ "age_days": round(self.age_days, 2),
184
+ "stale": self.is_stale,
185
+ "stale_after_days": STALE_AFTER_DAYS,
186
+ "origin": self.origin,
187
+ "build_commit": self.build_commit,
188
+ "built_at": self.build_at,
189
+ "export_schema_version": self.export_schema_version,
190
+ }
191
+
192
+
193
+ @dataclass(frozen=True, repr=False)
194
+ class Credential:
195
+ """A key for a keyed origin, in a shape that cannot be printed by accident.
196
+
197
+ The secret is a field with no `repr`, and `__repr__`/`__str__` are replaced
198
+ by ones that emit the key id instead. That is the same reasoning the origin
199
+ applies to its key store (`docs/api-access.md`, "Keys are never written
200
+ down"): make "the secret is never logged" a property of the shape rather
201
+ than a rule every future caller has to remember.
202
+ """
203
+
204
+ secret: str = field(repr=False)
205
+ #: `environment` or `flag`. Reported so a message can name where a rejected
206
+ #: key came from, and so the CLI can warn about the riskier of the two.
207
+ source: str = "environment"
208
+
209
+ @property
210
+ def key_id(self) -> str:
211
+ """The short, loggable identifier. It identifies; it cannot authenticate."""
212
+ return hashlib.sha256(self.secret.encode("utf-8")).hexdigest()[:KEY_ID_LENGTH]
213
+
214
+ def headers(self) -> dict[str, str]:
215
+ """How the key is presented. A header, never a query parameter.
216
+
217
+ URLs are written into access logs, proxy caches and referrer headers by
218
+ everything they pass through. `Authorization` is also the one header
219
+ httpx drops when a redirect crosses to another host, so a misconfigured
220
+ redirect cannot carry the key somewhere it was not meant to go.
221
+ """
222
+ return {"Authorization": f"Bearer {self.secret}"}
223
+
224
+ def redact(self, text: str) -> str:
225
+ """Backstop: strip the secret out of anything on its way to a stream."""
226
+ return text.replace(self.secret, REDACTED) if self.secret else text
227
+
228
+ def __repr__(self) -> str:
229
+ return f"Credential(source={self.source!r}, key_id={self.key_id!r})"
230
+
231
+ __str__ = __repr__
232
+
233
+
234
+ def resolve_credential(flag: str | None = None,
235
+ environ: Mapping[str, str] | None = None) -> Credential | None:
236
+ """The key to present, or `None` for the free, unkeyed path.
237
+
238
+ The environment is the preferred source and the flag is the override: a
239
+ caller who typed `--api-key` meant that key for this invocation. Preferring
240
+ the environment is a recommendation about how to supply a key, not a rule
241
+ that silently ignores the one in front of us.
242
+ """
243
+ if flag:
244
+ return Credential(secret=flag, source="flag")
245
+ from_env = (environ if environ is not None else os.environ).get(API_KEY_ENV)
246
+ if from_env and from_env.strip():
247
+ return Credential(secret=from_env.strip(), source="environment")
248
+ return None
249
+
250
+
251
+ class SnapshotMissing(RuntimeError): # noqa: N818 - public compatibility name
252
+ """No snapshot has been fetched yet."""
253
+
254
+
255
+ class SnapshotInvalid(RuntimeError): # noqa: N818 - follows SnapshotMissing naming
256
+ """A snapshot exists but cannot be read or does not have the export shape."""
257
+
258
+
259
+ class FetchError(RuntimeError):
260
+ """A fetch that failed for a reason the caller can act on.
261
+
262
+ The four subclasses are the four things that go wrong against a keyed
263
+ origin. They exist so the CLI can give each one its own exit code and its
264
+ own sentence, instead of one `could not fetch` for everything.
265
+ """
266
+
267
+
268
+ class OriginUnreachableError(FetchError):
269
+ """The origin did not answer: DNS, TLS, connection, timeout."""
270
+
271
+
272
+ class KeyRequiredError(FetchError):
273
+ """The origin wants a key and none was presented."""
274
+
275
+
276
+ class KeyRejectedError(FetchError):
277
+ """The origin knows what a key is and will not accept this one."""
278
+
279
+
280
+ class RateLimitedError(FetchError):
281
+ """The key is good and its window is spent."""
282
+
283
+ def __init__(self, message: str, retry_after_seconds: int | None = None) -> None:
284
+ super().__init__(message)
285
+ self.retry_after_seconds = retry_after_seconds
286
+
287
+
288
+ def _export_schema_major(version: str) -> int:
289
+ head = str(version).strip().split(".", 1)[0]
290
+ if not head.isdigit():
291
+ raise ValueError(
292
+ f"export_schema_version {version!r} is not a dotted major.minor version"
293
+ )
294
+ return int(head)
295
+
296
+
297
+ def _declared_export_schema_version(data: dict[str, Any]) -> str:
298
+ """Read the tree version from index.build.
299
+
300
+ A missing value is not "whatever this CLI is". It is the tree as it stood
301
+ before the field existed, which is 1.0 — so once this CLI moved past 1.x,
302
+ such a snapshot is refused like any other incompatible major instead of
303
+ being parsed as the current shape.
304
+ """
305
+ index = data.get("index")
306
+ build = index.get("build") if isinstance(index, dict) else None
307
+ if not isinstance(build, dict):
308
+ return PRE_VERSIONED_EXPORT_SCHEMA_VERSION
309
+ raw = build.get("export_schema_version")
310
+ if raw is None:
311
+ return PRE_VERSIONED_EXPORT_SCHEMA_VERSION
312
+ return str(raw)
313
+
314
+
315
+ def _require_compatible_export_schema(version: str) -> str:
316
+ try:
317
+ major = _export_schema_major(version)
318
+ except ValueError as exc:
319
+ raise SnapshotInvalid(str(exc)) from exc
320
+ expected = _export_schema_major(EXPORT_SCHEMA_VERSION)
321
+ if major != expected:
322
+ raise SnapshotInvalid(
323
+ f"export_schema_version {version} is incompatible with this CLI "
324
+ f"(expected {expected}.x). That field is the published JSON tree, "
325
+ "not the CLI --json envelope schema_version."
326
+ )
327
+ return version
328
+
329
+
330
+ def _snapshot_from_raw(path: Path, raw: Any) -> Snapshot:
331
+ if not isinstance(raw, dict):
332
+ raise ValueError("top-level JSON value must be an object")
333
+ meta = raw["meta"]
334
+ data = raw["data"]
335
+ if not isinstance(meta, dict) or not isinstance(data, dict):
336
+ raise ValueError("meta and data must be objects")
337
+ for key in ("fetched_at", "origin", "build_commit", "built_at"):
338
+ if key not in meta:
339
+ raise ValueError(f"meta is missing {key!r}")
340
+ for key in ("index", "candidates", "profiles", "hardware"):
341
+ if key not in data or not isinstance(data[key], dict):
342
+ raise ValueError(f"data is missing object {key!r}")
343
+ if not isinstance(data["candidates"].get("candidates"), list):
344
+ raise ValueError("data.candidates.candidates must be a list")
345
+ if not isinstance(data["profiles"].get("profiles"), dict):
346
+ raise ValueError("data.profiles.profiles must be an object")
347
+ if not isinstance(data["hardware"].get("nodes"), list):
348
+ raise ValueError("data.hardware.nodes must be a list")
349
+ fetched_at = datetime.fromisoformat(str(meta["fetched_at"]))
350
+ if fetched_at.tzinfo is None:
351
+ raise ValueError("meta.fetched_at must include a timezone")
352
+ version = _require_compatible_export_schema(_declared_export_schema_version(data))
353
+ return Snapshot(
354
+ path=path,
355
+ fetched_at=fetched_at,
356
+ origin=str(meta["origin"]),
357
+ build_commit=str(meta["build_commit"]),
358
+ build_at=str(meta["built_at"]),
359
+ data=data,
360
+ export_schema_version=version,
361
+ )
362
+
363
+
364
+ #: Statuses the origin uses to refuse a call it understood (MODEL-69,
365
+ #: `docs/api-access.md`). Anything else stays on the pre-existing path:
366
+ #: `raise_for_status`, wrapped by the CLI as a plain runtime error.
367
+ HTTP_UNAUTHORIZED = 401
368
+ HTTP_FORBIDDEN = 403
369
+ HTTP_TOO_MANY_REQUESTS = 429
370
+
371
+ #: `error.code` values the origin sends. Read to tell "you sent no key" from
372
+ #: "that key is not one of ours", which are different things for the caller.
373
+ MISSING_KEY_CODE = "missing_api_key"
374
+
375
+
376
+ def _origin_error(response: Any) -> tuple[str, str]:
377
+ """The origin's own `(code, message)`, or empty strings if it sent neither.
378
+
379
+ The origin's refusals already say the useful thing — where to get a key,
380
+ when a window resets — so they are relayed rather than paraphrased. A body
381
+ that is not the documented shape is not trusted to be one.
382
+ """
383
+ try:
384
+ body = response.json()
385
+ except Exception: # noqa: BLE001 - a refusal is not required to be JSON
386
+ return "", ""
387
+ error = body.get("error") if isinstance(body, dict) else None
388
+ if not isinstance(error, dict):
389
+ return "", ""
390
+ code = error.get("code")
391
+ message = error.get("message")
392
+ return (str(code) if code else "", str(message) if message else "")
393
+
394
+
395
+ def _retry_after(response: Any) -> int | None:
396
+ header = str(response.headers.get("retry-after") or "").strip()
397
+ if header.isdigit():
398
+ return int(header)
399
+ try:
400
+ body = response.json()
401
+ except Exception: # noqa: BLE001 - as above
402
+ return None
403
+ error = body.get("error") if isinstance(body, dict) else None
404
+ seconds = error.get("retry_after_seconds") if isinstance(error, dict) else None
405
+ return int(seconds) if isinstance(seconds, int) else None
406
+
407
+
408
+ def _check_refusal(response: Any, origin: str, presented: Credential | None) -> None:
409
+ """Turn the origin's refusal into the typed error that matches it.
410
+
411
+ Nothing here interpolates the key. `key_id` is the short fingerprint, which
412
+ identifies the key in the origin's logs without being usable against it.
413
+ """
414
+ status = response.status_code
415
+ if status not in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN, HTTP_TOO_MANY_REQUESTS):
416
+ return
417
+ code, said = _origin_error(response)
418
+ tail = f" The origin said: {said}" if said else ""
419
+ if status == HTTP_TOO_MANY_REQUESTS:
420
+ seconds = _retry_after(response)
421
+ wait = f" Retry after {seconds}s." if seconds is not None else ""
422
+ raise RateLimitedError(
423
+ f"{origin} rate-limited this key"
424
+ f"{f' (key {presented.key_id})' if presented else ''}."
425
+ f"{wait}{tail}",
426
+ retry_after_seconds=seconds,
427
+ )
428
+ if presented is None or code == MISSING_KEY_CODE:
429
+ raise KeyRequiredError(
430
+ f"{origin} requires an API key and none was presented. Set "
431
+ f"{API_KEY_ENV} in the environment, or pass --api-key (which is "
432
+ f"visible in shell history and in `ps`)." + tail
433
+ )
434
+ raise KeyRejectedError(
435
+ f"{origin} rejected the API key supplied by the {presented.source} "
436
+ f"(key {presented.key_id}). The key value is not shown and was not "
437
+ f"logged.{tail}"
438
+ )
439
+
440
+
441
+ def fetch(origin: str = DEFAULT_ORIGIN, target: Path | None = None,
442
+ credential: Credential | None = None) -> Snapshot:
443
+ """Download the export. The only command that needs the network.
444
+
445
+ With no `credential` this is the call it has always been. With one, the key
446
+ rides on an `Authorization` header — never in the URL, never in the cached
447
+ snapshot — and the origin's refusals come back as typed errors.
448
+ """
449
+ import httpx
450
+
451
+ directory = target or cache_dir()
452
+ directory.mkdir(parents=True, exist_ok=True)
453
+ payload: dict[str, Any] = {}
454
+ headers = credential.headers() if credential is not None else {}
455
+
456
+ def get(route: str) -> Any:
457
+ try:
458
+ response = client.get(origin + route)
459
+ except httpx.HTTPError as exc:
460
+ raise OriginUnreachableError(
461
+ f"could not reach {origin}{route}: {type(exc).__name__}"
462
+ ) from None
463
+ _check_refusal(response, origin, credential)
464
+ response.raise_for_status()
465
+ return response
466
+
467
+ with httpx.Client(timeout=60.0, follow_redirects=True, headers=headers) as client:
468
+ for name, route in PARTS.items():
469
+ payload[name] = get(route).json()
470
+ for name, route in OPTIONAL_PARTS.items():
471
+ try:
472
+ payload[name] = get(route).json()
473
+ except (KeyRequiredError, KeyRejectedError, RateLimitedError):
474
+ # A credential problem is a credential problem on any route, and
475
+ # saying so beats a snapshot that silently lost `fit --host`.
476
+ # An unreachable optional route is still skipped, as before.
477
+ raise
478
+ except Exception: # noqa: BLE001 - optional; `fit --host` reports its absence
479
+ continue
480
+ build = (payload.get("index") or {}).get("build") or {}
481
+ meta = {
482
+ "fetched_at": datetime.now(UTC).isoformat(),
483
+ "origin": origin,
484
+ "build_commit": build.get("commit", "unknown"),
485
+ "built_at": build.get("built_at", "unknown"),
486
+ }
487
+ raw = {"meta": meta, "data": payload}
488
+ final = directory / "snapshot.json"
489
+ # Validate before replacing the cache, so a bad fetch cannot clobber a
490
+ # snapshot that still answers.
491
+ try:
492
+ parsed = _snapshot_from_raw(final, raw)
493
+ except SnapshotInvalid:
494
+ raise
495
+ except (TypeError, ValueError, KeyError) as exc:
496
+ raise SnapshotInvalid(f"fetched export is unreadable or invalid: {exc}") from exc
497
+ raw["meta"]["export_schema_version"] = parsed.export_schema_version
498
+ writes = ((directory / f".snapshot.{os.getpid()}.tmp", final,
499
+ json.dumps(raw).encode("utf-8")),)
500
+ try:
501
+ for tmp, _destination, data in writes:
502
+ tmp.write_bytes(data)
503
+ for tmp, destination, _data in writes:
504
+ tmp.replace(destination)
505
+ finally:
506
+ for tmp, _destination, _data in writes:
507
+ tmp.unlink(missing_ok=True)
508
+ fetched = load(directory)
509
+ decision_fetch = _fetch_decision_files(origin, directory, credential)
510
+ return replace(fetched, decision_fetch=decision_fetch)
511
+
512
+
513
+ def _fetch_decision_files(origin: str, directory: Path,
514
+ credential: Credential | None) -> dict[str, Any]:
515
+ """Try to refresh the public decision pair without affecting rank fetch."""
516
+ import httpx
517
+
518
+ from decision.snapshot import SnapshotIntegrityError, load_snapshot_bytes
519
+
520
+ class DecisionRouteUnavailable(RuntimeError): # noqa: N818 - describes route state
521
+ pass
522
+
523
+ def download(candidate_origin: str, *, send_credential: bool) -> tuple[bytes, Any]:
524
+ candidate_headers = credential.headers() if credential and send_credential else {}
525
+ with httpx.Client(timeout=60.0, follow_redirects=True,
526
+ headers=candidate_headers) as client:
527
+ responses = []
528
+ for route in (DECISION_SNAPSHOT_ROUTE, DECISION_VOCABULARY_ROUTE):
529
+ try:
530
+ response = client.get(candidate_origin + route)
531
+ except httpx.HTTPError as exc:
532
+ raise DecisionRouteUnavailable(
533
+ f"could not reach {candidate_origin}{route}: {type(exc).__name__}"
534
+ ) from None
535
+ if response.status_code in {401, 403, 404}:
536
+ raise DecisionRouteUnavailable(
537
+ f"{candidate_origin}{route} returned HTTP {response.status_code}"
538
+ )
539
+ try:
540
+ response.raise_for_status()
541
+ except Exception as exc: # noqa: BLE001 - optional download is reported
542
+ raise RuntimeError(
543
+ f"{candidate_origin}{route} returned HTTP {response.status_code}"
544
+ ) from exc
545
+ responses.append(response)
546
+ return responses[0].content, responses[1].json()
547
+
548
+ source = origin
549
+ try:
550
+ try:
551
+ decision_data, vocabulary_raw = download(origin, send_credential=True)
552
+ except DecisionRouteUnavailable:
553
+ if origin.rstrip("/") == DEFAULT_ORIGIN:
554
+ raise
555
+ source = DEFAULT_ORIGIN
556
+ decision_data, vocabulary_raw = download(source, send_credential=False)
557
+
558
+ # A public client cannot verify the HMAC without the publishing secret.
559
+ # It still verifies the content hash and snapshot ID.
560
+ decision = load_snapshot_bytes(
561
+ decision_data, key=None, include_archive=True,
562
+ source=source + DECISION_SNAPSHOT_ROUTE,
563
+ )
564
+ if not isinstance(vocabulary_raw, dict):
565
+ raise SnapshotInvalid("fetched decision vocabulary is not a JSON object")
566
+ if vocabulary_raw.get("snapshot") != decision.snapshot_id:
567
+ raise SnapshotInvalid(
568
+ "fetched decision vocabulary snapshot does not match the decision snapshot "
569
+ f"({vocabulary_raw.get('snapshot')!r} != {decision.snapshot_id!r})"
570
+ )
571
+ except SnapshotIntegrityError as exc:
572
+ return {"available": False,
573
+ "error": f"fetched decision snapshot failed integrity check: {exc}"}
574
+ except Exception as exc: # noqa: BLE001 - decision data is optional for rank fetch
575
+ return {"available": False, "error": str(exc)}
576
+
577
+ root = _decision_root(directory)
578
+ generation = root / decision.snapshot_id
579
+ temporary = root / f".tmp-{os.getpid()}-{secrets.token_hex(6)}"
580
+ current_tmp = root / f".current-{os.getpid()}.tmp"
581
+ try:
582
+ root.mkdir(parents=True, exist_ok=True)
583
+ temporary.mkdir()
584
+ (temporary / DECISION_SNAPSHOT_FILENAME).write_bytes(decision_data)
585
+ (temporary / DECISION_VOCABULARY_FILENAME).write_text(
586
+ json.dumps(vocabulary_raw), encoding="utf-8"
587
+ )
588
+ _validate_decision_generation(temporary, require_named=False)
589
+ if generation.exists():
590
+ try:
591
+ _validate_decision_generation(generation)
592
+ except (OSError, ValueError, KeyError, json.JSONDecodeError):
593
+ shutil.rmtree(generation)
594
+ os.replace(temporary, generation)
595
+ else:
596
+ shutil.rmtree(temporary)
597
+ else:
598
+ os.replace(temporary, generation)
599
+ current_tmp.write_text(decision.snapshot_id + "\n", encoding="utf-8")
600
+ os.replace(current_tmp, root / "current")
601
+ except Exception as exc: # noqa: BLE001 - decision data cannot fail rank fetch
602
+ return {"available": False, "error": f"could not cache decision generation: {exc}"}
603
+ finally:
604
+ try:
605
+ current_tmp.unlink(missing_ok=True)
606
+ except OSError:
607
+ pass
608
+ shutil.rmtree(temporary, ignore_errors=True)
609
+
610
+ _prune_decision_generations(root, decision.snapshot_id)
611
+ return {"available": True, "origin": source, "snapshot_id": decision.snapshot_id}
612
+
613
+
614
+ def _prune_decision_generations(root: Path, current_id: str) -> None:
615
+ """Keep the active and previous generations; cleanup must not fail fetch."""
616
+ try:
617
+ generations = [
618
+ path for path in root.iterdir()
619
+ if path.is_dir() and not path.name.startswith(".tmp-")
620
+ ]
621
+ generations.sort(key=lambda path: path.stat().st_mtime, reverse=True)
622
+ previous = [path.name for path in generations if path.name != current_id][:1]
623
+ keep = {current_id, *previous}
624
+ for path in generations:
625
+ if path.name not in keep:
626
+ shutil.rmtree(path, ignore_errors=True)
627
+ for path in root.glob(".tmp-*"):
628
+ shutil.rmtree(path, ignore_errors=True)
629
+ except OSError:
630
+ pass
631
+
632
+
633
+ def load(directory: Path | None = None) -> Snapshot:
634
+ """Read the cached snapshot. Never touches the network."""
635
+ path = (directory or cache_dir()) / "snapshot.json"
636
+ if not path.exists():
637
+ raise SnapshotMissing(
638
+ f"no snapshot at {path}. Run `modelspec snapshot fetch` once; "
639
+ "everything after that works offline."
640
+ )
641
+ try:
642
+ if not path.is_file():
643
+ raise ValueError("snapshot.json is not a regular file")
644
+ raw = json.loads(path.read_text(encoding="utf-8"))
645
+ return _snapshot_from_raw(path, raw)
646
+ except SnapshotInvalid:
647
+ raise
648
+ except (OSError, UnicodeError, TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
649
+ raise SnapshotInvalid(f"snapshot at {path} is unreadable or invalid: {exc}") from exc
650
+
651
+
652
+ def status(directory: Path | None = None) -> dict[str, Any]:
653
+ directory = directory or cache_dir()
654
+ generation, generation_error = _current_decision_generation(directory)
655
+ decision_path = ((generation / DECISION_SNAPSHOT_FILENAME) if generation else
656
+ (_decision_root(directory) / "current"))
657
+ if generation is not None:
658
+ from decision.snapshot import load_snapshot
659
+
660
+ try:
661
+ decision = load_snapshot(decision_path, key=None, include_archive=True)
662
+ decision_status: dict[str, Any] = {
663
+ "present": True,
664
+ "valid": True,
665
+ "path": str(decision_path),
666
+ "snapshot_id": decision.snapshot_id,
667
+ "as_of": decision.as_of.isoformat() if decision.as_of else None,
668
+ "age_days": round(age_of(decision_path), 2),
669
+ "signature_verified": decision.signature_verified,
670
+ }
671
+ except (OSError, ValueError) as exc:
672
+ decision_status = {
673
+ "present": True,
674
+ "valid": False,
675
+ "path": str(decision_path),
676
+ "error": f"cached decision snapshot is invalid: {exc}",
677
+ }
678
+ elif generation_error is not None:
679
+ decision_status = {
680
+ "present": True,
681
+ "valid": False,
682
+ "path": str(decision_path),
683
+ "error": f"cached decision snapshot is invalid: {generation_error}",
684
+ }
685
+ else:
686
+ decision_status = {"present": False, "path": str(decision_path)}
687
+ try:
688
+ snap = load(directory)
689
+ except SnapshotMissing as exc:
690
+ return {"present": False, "message": str(exc),
691
+ "decision_snapshot": decision_status}
692
+ size = snap.path.stat().st_size
693
+ return {"present": True, "size_bytes": size, "path": str(snap.path),
694
+ "decision_snapshot": decision_status, **snap.freshness()}
695
+
696
+
697
+ def age_of(path: Path) -> float:
698
+ return (time.time() - path.stat().st_mtime) / 86400