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
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}")