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.
- api/__init__.py +0 -0
- api/class_fit.py +334 -0
- api/classes.py +557 -0
- api/ranking/__init__.py +12 -0
- api/ranking/engine.py +1943 -0
- cli/__init__.py +0 -0
- cli/modelspec/__init__.py +0 -0
- cli/modelspec/cli.py +1819 -0
- cli/modelspec/commands/__init__.py +0 -0
- cli/modelspec/decide_cmd.py +333 -0
- cli/modelspec/offline.py +623 -0
- cli/modelspec/snapshot.py +698 -0
- cli/modelspec/snapshot_build_cmd.py +49 -0
- cli/modelspec/verify_cmd.py +125 -0
- cli/modelspec/vocab_cmd.py +204 -0
- cli/modelspec/vocabulary_cache.py +54 -0
- decision/__init__.py +13 -0
- decision/capability.py +872 -0
- decision/computed.py +125 -0
- decision/contract.py +1575 -0
- decision/engine.py +238 -0
- decision/excluded.py +34 -0
- decision/explain.py +908 -0
- decision/filter.py +796 -0
- decision/model.py +438 -0
- decision/normalise.py +604 -0
- decision/optimise.py +320 -0
- decision/registry.py +717 -0
- decision/relax.py +132 -0
- decision/resolve.py +111 -0
- decision/schema.py +21 -0
- decision/snapshot.py +1483 -0
- decision/sources.py +544 -0
- decision/templates.py +134 -0
- decision/verify.py +1745 -0
- decision/vocabulary.py +433 -0
- modelspec_dev-0.1.0.dist-info/METADATA +101 -0
- modelspec_dev-0.1.0.dist-info/RECORD +63 -0
- modelspec_dev-0.1.0.dist-info/WHEEL +4 -0
- modelspec_dev-0.1.0.dist-info/entry_points.txt +2 -0
- modelspec_dev-0.1.0.dist-info/licenses/LICENSE +43 -0
- modelspec_dev-0.1.0.dist-info/licenses/LICENSE-DATA +428 -0
- pipeline/__init__.py +0 -0
- pipeline/class_export.py +172 -0
- pipeline/hardware.py +434 -0
- pipeline/hosts.py +247 -0
- pipeline/load.py +224 -0
- pipeline/ranking.py +551 -0
- registry/domains.yaml +130 -0
- registry/facets.yaml +888 -0
- registry/harnesses.yaml +79 -0
- registry/providers.yaml +354 -0
- registry/sources.yaml +3059 -0
- registry/templates.yaml +166 -0
- schema/__init__.py +0 -0
- schema/applicability.py +147 -0
- schema/benchmark.py +175 -0
- schema/benchmark_eligibility.py +304 -0
- schema/card.py +1463 -0
- schema/enrichment.py +162 -0
- schema/enums.py +327 -0
- schema/graph.py +406 -0
- schema/suppliers.py +72 -0
decision/sources.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""The source registry's re-check: deterministic change detection (MODEL-137).
|
|
2
|
+
|
|
3
|
+
Every fact and piece of evidence names the registered sources it was read from,
|
|
4
|
+
and the cited region of each source it depends on (design §5). A re-check
|
|
5
|
+
fetches each source with plain HTTP, normalises it (``decision.normalise``) and
|
|
6
|
+
compares the fingerprint of each cited region with the last snapshot:
|
|
7
|
+
|
|
8
|
+
- ``unchanged``: every fact citing the region is re-confirmed, at no agent cost;
|
|
9
|
+
- ``changed``: only the facts citing that region are re-queued for re-extraction
|
|
10
|
+
and two-key verification, and a governance region raises an alert event;
|
|
11
|
+
- ``unreachable``: counted against a grace period, after which the source's facts
|
|
12
|
+
are quarantined.
|
|
13
|
+
|
|
14
|
+
Nothing here calls a model or a paid scraper. The re-check is a pure function of
|
|
15
|
+
the sources, their last states and what the origin servers return; agents run
|
|
16
|
+
downstream, only on what ``RecheckReport.requeue`` lists.
|
|
17
|
+
|
|
18
|
+
Source records use the shared types in ``decision.model``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
29
|
+
from dataclasses import dataclass, field, replace
|
|
30
|
+
from datetime import datetime, timedelta
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Literal
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
from decision.model import CitedRegion as CitedRegion
|
|
40
|
+
from decision.model import Source, SourceSnapshot
|
|
41
|
+
from decision.normalise import (
|
|
42
|
+
NORMALISERS,
|
|
43
|
+
Locator,
|
|
44
|
+
UnsupportedContentError,
|
|
45
|
+
canonical_url,
|
|
46
|
+
fingerprint,
|
|
47
|
+
normalise_document,
|
|
48
|
+
select_region,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FetchMode(StrEnum):
|
|
53
|
+
HTTP = "http"
|
|
54
|
+
CONDITIONAL_HTTP = "conditional_http"
|
|
55
|
+
#: Needs a rendered browser to show its content. Declared, not implemented: a
|
|
56
|
+
#: re-check records the need and fetches nothing.
|
|
57
|
+
RENDERED = "rendered"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def relative_cost(self) -> int:
|
|
61
|
+
"""What one check costs relative to a plain fetch."""
|
|
62
|
+
return 20 if self is FetchMode.RENDERED else 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_sources(path: str | Path) -> dict[str, Source]:
|
|
66
|
+
"""Load the canonical ``registry/sources.yaml`` format.
|
|
67
|
+
|
|
68
|
+
The file is ``{schema_version: 1, sources: [...]}``, and each source row is
|
|
69
|
+
validated by :class:`decision.model.Source`. A missing file is an empty
|
|
70
|
+
registry so a checkout with no registered sources still builds an empty
|
|
71
|
+
snapshot.
|
|
72
|
+
"""
|
|
73
|
+
path = Path(path)
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
return {}
|
|
76
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
77
|
+
if not isinstance(data, Mapping) or data.get("schema_version") != 1:
|
|
78
|
+
raise ValueError(f"{path}: schema_version must be 1")
|
|
79
|
+
rows = data.get("sources")
|
|
80
|
+
if not isinstance(rows, list):
|
|
81
|
+
raise ValueError(f"{path}: sources must be a list")
|
|
82
|
+
registered: dict[str, Source] = {}
|
|
83
|
+
for i, raw in enumerate(rows):
|
|
84
|
+
try:
|
|
85
|
+
source = Source.model_validate(raw)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise ValueError(f"{path}: sources[{i}]: {exc}") from exc
|
|
88
|
+
if source.id in registered:
|
|
89
|
+
raise ValueError(f"{path}: duplicate source ID {source.id!r}")
|
|
90
|
+
registered[source.id] = source
|
|
91
|
+
return registered
|
|
92
|
+
|
|
93
|
+
# --- what cites a region, and how often it is re-checked -----------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FactKind(StrEnum):
|
|
97
|
+
PRICE = "price"
|
|
98
|
+
RATE_LIMIT = "rate_limit"
|
|
99
|
+
GOVERNANCE = "governance"
|
|
100
|
+
LIVE_LEADERBOARD = "live_leaderboard"
|
|
101
|
+
STATIC_EVIDENCE = "static_evidence"
|
|
102
|
+
MODEL_SPEC = "model_spec"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
#: Design §5. Model specifications are also checked at release; that is an event,
|
|
106
|
+
#: not an interval.
|
|
107
|
+
DEFAULT_INTERVALS: Mapping[FactKind, timedelta] = {
|
|
108
|
+
FactKind.PRICE: timedelta(days=7),
|
|
109
|
+
FactKind.RATE_LIMIT: timedelta(days=7),
|
|
110
|
+
FactKind.GOVERNANCE: timedelta(days=7),
|
|
111
|
+
FactKind.LIVE_LEADERBOARD: timedelta(days=7),
|
|
112
|
+
FactKind.STATIC_EVIDENCE: timedelta(days=91),
|
|
113
|
+
FactKind.MODEL_SPEC: timedelta(days=30),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#: Consecutive unreachable checks before a source's facts are quarantined.
|
|
117
|
+
DEFAULT_GRACE = 3
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Citation:
|
|
122
|
+
"""A fact or piece of evidence (``ref``) that depends on one cited region."""
|
|
123
|
+
|
|
124
|
+
ref: str
|
|
125
|
+
source_id: str
|
|
126
|
+
region_id: str
|
|
127
|
+
kind: FactKind
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class SourceState:
|
|
132
|
+
source_id: str
|
|
133
|
+
snapshot: SourceSnapshot | None = None
|
|
134
|
+
consecutive_failures: int = 0
|
|
135
|
+
quarantined: bool = False
|
|
136
|
+
last_attempt_at: datetime | None = None
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"source_id": self.source_id,
|
|
141
|
+
"snapshot": self.snapshot.model_dump(mode="json") if self.snapshot else None,
|
|
142
|
+
"consecutive_failures": self.consecutive_failures,
|
|
143
|
+
"quarantined": self.quarantined,
|
|
144
|
+
"last_attempt_at": self.last_attempt_at.isoformat() if self.last_attempt_at else None,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, data: Mapping[str, Any]) -> SourceState:
|
|
149
|
+
snapshot = data.get("snapshot")
|
|
150
|
+
attempt = data.get("last_attempt_at")
|
|
151
|
+
return cls(
|
|
152
|
+
source_id=data["source_id"],
|
|
153
|
+
snapshot=SourceSnapshot.model_validate(snapshot) if snapshot else None,
|
|
154
|
+
consecutive_failures=data.get("consecutive_failures", 0),
|
|
155
|
+
quarantined=data.get("quarantined", False),
|
|
156
|
+
last_attempt_at=datetime.fromisoformat(attempt) if attempt else None,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def due(
|
|
161
|
+
source: Source,
|
|
162
|
+
now: datetime,
|
|
163
|
+
*,
|
|
164
|
+
state: SourceState | None,
|
|
165
|
+
citations: Iterable[Citation],
|
|
166
|
+
intervals: Mapping[FactKind, timedelta] = DEFAULT_INTERVALS,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Whether ``source`` is due: its interval is the shortest of the facts citing it.
|
|
169
|
+
|
|
170
|
+
A source nothing cites is never due; one never checked always is.
|
|
171
|
+
"""
|
|
172
|
+
kinds = {c.kind for c in citations if c.source_id == source.id}
|
|
173
|
+
if not kinds:
|
|
174
|
+
return False
|
|
175
|
+
last = state and (state.last_attempt_at or (state.snapshot and state.snapshot.retrieved_at))
|
|
176
|
+
if not last:
|
|
177
|
+
return True
|
|
178
|
+
return now - last >= min(intervals[k] for k in kinds)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --- fetching ------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
USER_AGENT = "ModelSpec-SourceCheck/1.0 (+https://modelspec.dev)"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class FetchResult:
|
|
188
|
+
outcome: Literal["ok", "not_modified", "unreachable"]
|
|
189
|
+
status: int | None = None
|
|
190
|
+
body: bytes = b""
|
|
191
|
+
content_type: str = ""
|
|
192
|
+
charset: str | None = None
|
|
193
|
+
etag: str | None = None
|
|
194
|
+
last_modified: str | None = None
|
|
195
|
+
error: str | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Fetcher:
|
|
199
|
+
"""Plain HTTP with conditional requests, timeouts, retries with exponential backoff
|
|
200
|
+
(honouring a numeric ``Retry-After``) and a minimum interval between requests to
|
|
201
|
+
one host. ``sleep`` and ``clock`` are injectable so tests never wait."""
|
|
202
|
+
|
|
203
|
+
RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
client: httpx.Client | None = None,
|
|
208
|
+
*,
|
|
209
|
+
timeout: float = 20.0,
|
|
210
|
+
retries: int = 2,
|
|
211
|
+
backoff: float = 1.0,
|
|
212
|
+
max_backoff: float = 60.0,
|
|
213
|
+
min_host_interval: float = 1.0,
|
|
214
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
215
|
+
clock: Callable[[], float] = time.monotonic,
|
|
216
|
+
user_agent: str = USER_AGENT,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.client = client or httpx.Client()
|
|
219
|
+
self.timeout = timeout
|
|
220
|
+
self.retries = retries
|
|
221
|
+
self.backoff = backoff
|
|
222
|
+
self.max_backoff = max_backoff
|
|
223
|
+
self.min_host_interval = min_host_interval
|
|
224
|
+
self.sleep = sleep
|
|
225
|
+
self.clock = clock
|
|
226
|
+
self.user_agent = user_agent
|
|
227
|
+
self._last_request: dict[str, float] = {}
|
|
228
|
+
|
|
229
|
+
def _wait_for_host(self, host: str) -> None:
|
|
230
|
+
last = self._last_request.get(host)
|
|
231
|
+
if last is not None:
|
|
232
|
+
remaining = self.min_host_interval - (self.clock() - last)
|
|
233
|
+
if remaining > 0:
|
|
234
|
+
self.sleep(remaining)
|
|
235
|
+
self._last_request[host] = self.clock()
|
|
236
|
+
|
|
237
|
+
def fetch(
|
|
238
|
+
self, url: str, *, etag: str | None = None, last_modified: str | None = None
|
|
239
|
+
) -> FetchResult:
|
|
240
|
+
headers = {"user-agent": self.user_agent}
|
|
241
|
+
if etag:
|
|
242
|
+
headers["if-none-match"] = etag
|
|
243
|
+
if last_modified:
|
|
244
|
+
headers["if-modified-since"] = last_modified
|
|
245
|
+
host = urlsplit(url).hostname or ""
|
|
246
|
+
error = "no attempt"
|
|
247
|
+
delay = 0.0
|
|
248
|
+
for attempt in range(self.retries + 1):
|
|
249
|
+
if attempt:
|
|
250
|
+
self.sleep(delay)
|
|
251
|
+
self._wait_for_host(host)
|
|
252
|
+
delay = min(self.backoff * 2**attempt, self.max_backoff)
|
|
253
|
+
try:
|
|
254
|
+
response = self.client.get(
|
|
255
|
+
url, headers=headers, timeout=self.timeout, follow_redirects=True
|
|
256
|
+
)
|
|
257
|
+
except httpx.TimeoutException as exc:
|
|
258
|
+
error = f"timeout: {exc}"
|
|
259
|
+
continue
|
|
260
|
+
except httpx.TransportError as exc:
|
|
261
|
+
error = f"transport error: {type(exc).__name__}: {exc}"
|
|
262
|
+
continue
|
|
263
|
+
if response.status_code == 304:
|
|
264
|
+
return FetchResult(
|
|
265
|
+
"not_modified",
|
|
266
|
+
304,
|
|
267
|
+
etag=response.headers.get("etag"),
|
|
268
|
+
last_modified=response.headers.get("last-modified"),
|
|
269
|
+
)
|
|
270
|
+
if response.status_code in self.RETRY_STATUSES:
|
|
271
|
+
error = f"HTTP {response.status_code}"
|
|
272
|
+
retry_after = response.headers.get("retry-after", "")
|
|
273
|
+
if retry_after.strip().isdigit():
|
|
274
|
+
delay = min(float(retry_after), self.max_backoff)
|
|
275
|
+
continue
|
|
276
|
+
if not response.is_success:
|
|
277
|
+
return FetchResult(
|
|
278
|
+
"unreachable", response.status_code, error=f"HTTP {response.status_code}"
|
|
279
|
+
)
|
|
280
|
+
return FetchResult(
|
|
281
|
+
"ok",
|
|
282
|
+
response.status_code,
|
|
283
|
+
body=response.content,
|
|
284
|
+
content_type=response.headers.get("content-type", ""),
|
|
285
|
+
charset=response.charset_encoding,
|
|
286
|
+
etag=response.headers.get("etag"),
|
|
287
|
+
last_modified=response.headers.get("last-modified"),
|
|
288
|
+
)
|
|
289
|
+
return FetchResult("unreachable", error=error)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- retained copies -----------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
_REF = re.compile(r"^sha256:([0-9a-f]{64})$")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class CopyStore:
|
|
298
|
+
"""Content-addressed retained copies (``sha256:<hex>`` → file), outside git.
|
|
299
|
+
|
|
300
|
+
The root is ``root``, else ``$MODELSPEC_SOURCE_CACHE``, else
|
|
301
|
+
``~/.cache/modelspec/sources``.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def __init__(self, root: Path | str | None = None) -> None:
|
|
305
|
+
env = os.environ.get("MODELSPEC_SOURCE_CACHE")
|
|
306
|
+
self.root = Path(root or env or Path.home() / ".cache" / "modelspec" / "sources")
|
|
307
|
+
|
|
308
|
+
def path(self, ref: str) -> Path:
|
|
309
|
+
m = _REF.match(ref)
|
|
310
|
+
if not m:
|
|
311
|
+
raise ValueError(f"not a copy ref: {ref!r}")
|
|
312
|
+
digest = m.group(1)
|
|
313
|
+
return self.root / digest[:2] / digest
|
|
314
|
+
|
|
315
|
+
def put(self, body: bytes) -> str:
|
|
316
|
+
ref = fingerprint_bytes(body)
|
|
317
|
+
target = self.path(ref)
|
|
318
|
+
if not target.exists():
|
|
319
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=".tmp-")
|
|
321
|
+
with os.fdopen(fd, "wb") as fh:
|
|
322
|
+
fh.write(body)
|
|
323
|
+
os.replace(tmp, target)
|
|
324
|
+
return ref
|
|
325
|
+
|
|
326
|
+
def get(self, ref: str) -> bytes:
|
|
327
|
+
return self.path(ref).read_bytes()
|
|
328
|
+
|
|
329
|
+
def has(self, ref: str) -> bool:
|
|
330
|
+
return self.path(ref).exists()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def fingerprint_bytes(body: bytes) -> str:
|
|
334
|
+
return "sha256:" + hashlib.sha256(body).hexdigest()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# --- the re-check --------------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class RegionStatus(StrEnum):
|
|
341
|
+
UNCHANGED = "unchanged"
|
|
342
|
+
CHANGED = "changed"
|
|
343
|
+
UNREACHABLE = "unreachable"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True)
|
|
347
|
+
class RegionResult:
|
|
348
|
+
source_id: str
|
|
349
|
+
region_id: str
|
|
350
|
+
status: RegionStatus
|
|
351
|
+
previous: str | None
|
|
352
|
+
current: str | None
|
|
353
|
+
detail: str | None = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True)
|
|
357
|
+
class GovernanceChange:
|
|
358
|
+
"""An alert event: a region cited by a governance fact changed. Consumers come later."""
|
|
359
|
+
|
|
360
|
+
source_id: str
|
|
361
|
+
region_id: str
|
|
362
|
+
url: str
|
|
363
|
+
refs: tuple[str, ...]
|
|
364
|
+
detected_at: datetime
|
|
365
|
+
previous_fingerprint: str | None
|
|
366
|
+
current_fingerprint: str | None
|
|
367
|
+
previous_copy_ref: str
|
|
368
|
+
current_copy_ref: str
|
|
369
|
+
|
|
370
|
+
def to_dict(self) -> dict[str, Any]:
|
|
371
|
+
return {
|
|
372
|
+
"event": "governance_source_changed",
|
|
373
|
+
"source_id": self.source_id,
|
|
374
|
+
"region_id": self.region_id,
|
|
375
|
+
"url": self.url,
|
|
376
|
+
"refs": list(self.refs),
|
|
377
|
+
"detected_at": self.detected_at.isoformat(),
|
|
378
|
+
"previous_fingerprint": self.previous_fingerprint,
|
|
379
|
+
"current_fingerprint": self.current_fingerprint,
|
|
380
|
+
"previous_copy_ref": self.previous_copy_ref,
|
|
381
|
+
"current_copy_ref": self.current_copy_ref,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass(frozen=True)
|
|
386
|
+
class Skipped:
|
|
387
|
+
"""A source the deterministic re-check could not assess, and why."""
|
|
388
|
+
|
|
389
|
+
source_id: str
|
|
390
|
+
reason: str
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class RecheckReport:
|
|
395
|
+
regions: list[RegionResult] = field(default_factory=list)
|
|
396
|
+
#: Facts and evidence citing a changed region: re-extract and re-verify these.
|
|
397
|
+
requeue: list[str] = field(default_factory=list)
|
|
398
|
+
#: Facts and evidence whose every cited region is unchanged.
|
|
399
|
+
reconfirmed: list[str] = field(default_factory=list)
|
|
400
|
+
#: Facts and evidence on a source past its unreachable grace period.
|
|
401
|
+
quarantine: list[str] = field(default_factory=list)
|
|
402
|
+
alerts: list[GovernanceChange] = field(default_factory=list)
|
|
403
|
+
skipped: list[Skipped] = field(default_factory=list)
|
|
404
|
+
#: Every source's state after this run; persist these for the next one.
|
|
405
|
+
states: dict[str, SourceState] = field(default_factory=dict)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def recheck(
|
|
409
|
+
sources: Iterable[Source],
|
|
410
|
+
states: Mapping[str, SourceState],
|
|
411
|
+
citations: Iterable[Citation],
|
|
412
|
+
*,
|
|
413
|
+
fetcher: Fetcher,
|
|
414
|
+
store: CopyStore,
|
|
415
|
+
now: datetime,
|
|
416
|
+
grace: int = DEFAULT_GRACE,
|
|
417
|
+
) -> RecheckReport:
|
|
418
|
+
"""Re-check ``sources`` against their last states. See the module docstring."""
|
|
419
|
+
sources = list(sources)
|
|
420
|
+
citations = list(citations)
|
|
421
|
+
regions_of = {s.id: {r.id for r in s.cited_regions} for s in sources}
|
|
422
|
+
for c in citations:
|
|
423
|
+
if c.source_id in regions_of and c.region_id not in regions_of[c.source_id]:
|
|
424
|
+
raise ValueError(f"{c.ref} cites unknown region {c.source_id}#{c.region_id}")
|
|
425
|
+
|
|
426
|
+
report = RecheckReport(states=dict(states))
|
|
427
|
+
for source in sources:
|
|
428
|
+
state = states.get(source.id) or SourceState(source.id)
|
|
429
|
+
report.states[source.id] = _check_one(source, state, fetcher, store, now, grace, report)
|
|
430
|
+
|
|
431
|
+
by_region: dict[tuple[str, str], list[Citation]] = {}
|
|
432
|
+
for c in citations:
|
|
433
|
+
by_region.setdefault((c.source_id, c.region_id), []).append(c)
|
|
434
|
+
|
|
435
|
+
requeue: set[str] = set()
|
|
436
|
+
unchanged: set[str] = set()
|
|
437
|
+
for result in report.regions:
|
|
438
|
+
cited = by_region.get((result.source_id, result.region_id), [])
|
|
439
|
+
if result.status is RegionStatus.CHANGED:
|
|
440
|
+
requeue.update(c.ref for c in cited)
|
|
441
|
+
governance = tuple(sorted(c.ref for c in cited if c.kind is FactKind.GOVERNANCE))
|
|
442
|
+
previous = states.get(result.source_id)
|
|
443
|
+
if governance and previous and previous.snapshot:
|
|
444
|
+
current = report.states[result.source_id].snapshot
|
|
445
|
+
assert current is not None
|
|
446
|
+
report.alerts.append(
|
|
447
|
+
GovernanceChange(
|
|
448
|
+
source_id=result.source_id,
|
|
449
|
+
region_id=result.region_id,
|
|
450
|
+
url=next(s.url for s in sources if s.id == result.source_id),
|
|
451
|
+
refs=governance,
|
|
452
|
+
detected_at=now,
|
|
453
|
+
previous_fingerprint=result.previous,
|
|
454
|
+
current_fingerprint=result.current,
|
|
455
|
+
previous_copy_ref=previous.snapshot.copy_ref,
|
|
456
|
+
current_copy_ref=current.copy_ref,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
elif result.status is RegionStatus.UNCHANGED:
|
|
460
|
+
unchanged.update(c.ref for c in cited)
|
|
461
|
+
|
|
462
|
+
quarantined_sources = {sid for sid, st in report.states.items() if st.quarantined}
|
|
463
|
+
quarantine = {c.ref for c in citations if c.source_id in quarantined_sources}
|
|
464
|
+
report.requeue = sorted(requeue - quarantine)
|
|
465
|
+
report.reconfirmed = sorted(unchanged - requeue - quarantine)
|
|
466
|
+
report.quarantine = sorted(quarantine)
|
|
467
|
+
return report
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _check_one(
|
|
471
|
+
source: Source,
|
|
472
|
+
state: SourceState,
|
|
473
|
+
fetcher: Fetcher,
|
|
474
|
+
store: CopyStore,
|
|
475
|
+
now: datetime,
|
|
476
|
+
grace: int,
|
|
477
|
+
report: RecheckReport,
|
|
478
|
+
) -> SourceState:
|
|
479
|
+
if source.fetch == FetchMode.RENDERED.value:
|
|
480
|
+
report.skipped.append(Skipped(source.id, "rendered_fetch_required"))
|
|
481
|
+
return state
|
|
482
|
+
|
|
483
|
+
previous = state.snapshot
|
|
484
|
+
conditional = source.fetch == FetchMode.CONDITIONAL_HTTP.value and previous is not None
|
|
485
|
+
result = fetcher.fetch(
|
|
486
|
+
canonical_url(str(source.url)),
|
|
487
|
+
etag=previous.etag if conditional and previous else None,
|
|
488
|
+
last_modified=previous.last_modified if conditional and previous else None,
|
|
489
|
+
)
|
|
490
|
+
old = previous.region_fingerprints if previous else {}
|
|
491
|
+
|
|
492
|
+
if result.outcome == "not_modified" and previous is not None:
|
|
493
|
+
snapshot = previous.model_copy(update={
|
|
494
|
+
"retrieved_at": now,
|
|
495
|
+
"etag": result.etag or previous.etag,
|
|
496
|
+
"last_modified": result.last_modified or previous.last_modified,
|
|
497
|
+
})
|
|
498
|
+
for region in source.cited_regions:
|
|
499
|
+
fp = old.get(region.id)
|
|
500
|
+
status = RegionStatus.UNCHANGED if fp is not None else RegionStatus.CHANGED
|
|
501
|
+
report.regions.append(RegionResult(source.id, region.id, status, fp, fp, "HTTP 304"))
|
|
502
|
+
return SourceState(source.id, snapshot, 0, False, now)
|
|
503
|
+
|
|
504
|
+
if result.outcome != "ok":
|
|
505
|
+
failures = state.consecutive_failures + 1
|
|
506
|
+
detail = result.error or "HTTP 304 without a baseline"
|
|
507
|
+
for region in source.cited_regions:
|
|
508
|
+
fp = old.get(region.id)
|
|
509
|
+
report.regions.append(
|
|
510
|
+
RegionResult(source.id, region.id, RegionStatus.UNREACHABLE, fp, None, detail)
|
|
511
|
+
)
|
|
512
|
+
return SourceState(source.id, previous, failures, failures >= grace, now)
|
|
513
|
+
|
|
514
|
+
rules = NORMALISERS[source.normaliser]
|
|
515
|
+
try:
|
|
516
|
+
if "pdf" in result.content_type.lower():
|
|
517
|
+
raise UnsupportedContentError("pdf")
|
|
518
|
+
doc = normalise_document(result.body, rules, charset=result.charset)
|
|
519
|
+
except UnsupportedContentError as exc:
|
|
520
|
+
report.skipped.append(Skipped(source.id, f"unsupported_content:{exc.kind}"))
|
|
521
|
+
return replace(state, last_attempt_at=now)
|
|
522
|
+
|
|
523
|
+
current: dict[str, str | None] = {}
|
|
524
|
+
for region in source.cited_regions:
|
|
525
|
+
kind = "heading" if region.locator.kind == "heading_anchor" else region.locator.kind
|
|
526
|
+
text = select_region(doc, Locator(kind, region.locator.value))
|
|
527
|
+
fp = fingerprint(text) if text is not None else None
|
|
528
|
+
current[region.id] = fp
|
|
529
|
+
before = old.get(region.id)
|
|
530
|
+
unchanged = fp is not None and fp == before
|
|
531
|
+
status = RegionStatus.UNCHANGED if unchanged else RegionStatus.CHANGED
|
|
532
|
+
note = None if fp is not None else "locator matched nothing"
|
|
533
|
+
report.regions.append(RegionResult(source.id, region.id, status, before, fp, note))
|
|
534
|
+
|
|
535
|
+
snapshot = SourceSnapshot(
|
|
536
|
+
source_id=source.id,
|
|
537
|
+
retrieved_at=now,
|
|
538
|
+
page_fingerprint=fingerprint(doc.text),
|
|
539
|
+
region_fingerprints=current,
|
|
540
|
+
copy_ref=store.put(result.body),
|
|
541
|
+
etag=result.etag,
|
|
542
|
+
last_modified=result.last_modified,
|
|
543
|
+
)
|
|
544
|
+
return SourceState(source.id, snapshot, 0, False, now)
|
decision/templates.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Load data-defined partial decision specs from ``registry/templates.yaml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from decision.contract import SpecError, parse_spec
|
|
13
|
+
from decision.registry import REGISTRY_DIR, RegistryError, default
|
|
14
|
+
from decision.resolve import resolve
|
|
15
|
+
|
|
16
|
+
TEMPLATES_PATH = REGISTRY_DIR / "templates.yaml"
|
|
17
|
+
_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
|
21
|
+
if not isinstance(value, Mapping):
|
|
22
|
+
raise RegistryError(f"{path} must be a mapping")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(value: Any, path: str) -> str:
|
|
27
|
+
if not isinstance(value, str) or not value.strip():
|
|
28
|
+
raise RegistryError(f"{path} must be a non-empty string")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _expanded(row: Mapping[str, Any], *, registry: Any) -> dict[str, Any]:
|
|
33
|
+
template_id = _text(row.get("id"), "templates[].id")
|
|
34
|
+
if _ID.fullmatch(template_id) is None:
|
|
35
|
+
raise RegistryError(f"template id {template_id!r} must be a lowercase kebab-case id")
|
|
36
|
+
|
|
37
|
+
where_rows = row.get("where")
|
|
38
|
+
if not isinstance(where_rows, list):
|
|
39
|
+
raise RegistryError(f"template {template_id}: where must be a list")
|
|
40
|
+
where = []
|
|
41
|
+
for index, raw in enumerate(where_rows):
|
|
42
|
+
item = _mapping(raw, f"template {template_id}.where[{index}]")
|
|
43
|
+
where.append({
|
|
44
|
+
"condition": _text(
|
|
45
|
+
item.get("condition"), f"template {template_id}.where[{index}].condition"
|
|
46
|
+
),
|
|
47
|
+
"reason": _text(item.get("reason"), f"template {template_id}.where[{index}].reason"),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
weight_rows = _mapping(row.get("weights"), f"template {template_id}.weights")
|
|
51
|
+
weights: dict[str, dict[str, Any]] = {}
|
|
52
|
+
for facet_id, raw in weight_rows.items():
|
|
53
|
+
item = _mapping(raw, f"template {template_id}.weights.{facet_id}")
|
|
54
|
+
weight = item.get("weight")
|
|
55
|
+
if isinstance(weight, bool) or not isinstance(weight, int | float) or weight <= 0:
|
|
56
|
+
raise RegistryError(f"template {template_id}: weight for {facet_id} must be positive")
|
|
57
|
+
weights[str(facet_id)] = {
|
|
58
|
+
"weight": float(weight),
|
|
59
|
+
"reason": _text(
|
|
60
|
+
item.get("reason"), f"template {template_id}.weights.{facet_id}.reason"
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
needs = _mapping(row.get("needs"), f"template {template_id}.needs")
|
|
65
|
+
classes = needs.get("classes")
|
|
66
|
+
domains = needs.get("domains")
|
|
67
|
+
if not isinstance(classes, list) or not all(isinstance(item, str) for item in classes):
|
|
68
|
+
raise RegistryError(f"template {template_id}: needs.classes must be a list of ids")
|
|
69
|
+
if not isinstance(domains, list) or not all(isinstance(item, str) for item in domains):
|
|
70
|
+
raise RegistryError(f"template {template_id}: needs.domains must be a list of ids")
|
|
71
|
+
allowed_classes = registry.allowed_values(registry.facet("model.class")) or frozenset()
|
|
72
|
+
for class_id in classes:
|
|
73
|
+
if class_id not in allowed_classes:
|
|
74
|
+
raise RegistryError(f"template {template_id}: unknown model class {class_id!r}")
|
|
75
|
+
for domain_id in domains:
|
|
76
|
+
registry.domain(domain_id)
|
|
77
|
+
|
|
78
|
+
spec: dict[str, Any] = {
|
|
79
|
+
"spec_version": 1,
|
|
80
|
+
"where": [item["condition"] for item in where],
|
|
81
|
+
"optimize": {"weights": {
|
|
82
|
+
facet_id: item["weight"] for facet_id, item in weights.items()
|
|
83
|
+
}},
|
|
84
|
+
}
|
|
85
|
+
if "task_tokens" in row:
|
|
86
|
+
spec["task_tokens"] = row["task_tokens"]
|
|
87
|
+
try:
|
|
88
|
+
resolve(parse_spec(spec, facets=registry.facet), facets=registry.facet)
|
|
89
|
+
except SpecError as exc:
|
|
90
|
+
raise RegistryError(f"template {template_id}: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"id": template_id,
|
|
94
|
+
"name": _text(row.get("name"), f"template {template_id}.name"),
|
|
95
|
+
"purpose": _text(row.get("purpose"), f"template {template_id}.purpose"),
|
|
96
|
+
"where": where,
|
|
97
|
+
"weights": weights,
|
|
98
|
+
**({"task_tokens": spec["task_tokens"]} if "task_tokens" in spec else {}),
|
|
99
|
+
"needs": {"classes": list(classes), "domains": list(domains)},
|
|
100
|
+
"teaches": _text(row.get("teaches"), f"template {template_id}.teaches"),
|
|
101
|
+
"spec": spec,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_templates(path: Path = TEMPLATES_PATH, *, registry: Any = None) -> list[dict[str, Any]]:
|
|
106
|
+
"""Return validated templates in registry order."""
|
|
107
|
+
registry = registry or default()
|
|
108
|
+
try:
|
|
109
|
+
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
110
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
111
|
+
raise RegistryError(f"cannot load {path}: {exc}") from exc
|
|
112
|
+
root = _mapping(document, str(path))
|
|
113
|
+
if root.get("schema_version") != 1:
|
|
114
|
+
raise RegistryError(f"{path}: schema_version must be 1")
|
|
115
|
+
rows = root.get("templates")
|
|
116
|
+
if not isinstance(rows, list):
|
|
117
|
+
raise RegistryError(f"{path}: templates must be a list")
|
|
118
|
+
templates = [_expanded(_mapping(row, "templates[]"), registry=registry) for row in rows]
|
|
119
|
+
ids = [row["id"] for row in templates]
|
|
120
|
+
if len(ids) != len(set(ids)):
|
|
121
|
+
raise RegistryError(f"{path}: template ids must be unique")
|
|
122
|
+
return templates
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def template_by_id(
|
|
126
|
+
template_id: str, templates: list[dict[str, Any]] | None = None
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Return one template or raise an error that names every valid id."""
|
|
129
|
+
rows = templates if templates is not None else load_templates()
|
|
130
|
+
for row in rows:
|
|
131
|
+
if row["id"] == template_id:
|
|
132
|
+
return row
|
|
133
|
+
valid = ", ".join(row["id"] for row in rows)
|
|
134
|
+
raise ValueError(f"unknown template {template_id!r}; valid ids: {valid}")
|