riggrade 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.
pricewatch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Daily listed-price recorder for the RigGrade GPU index (docs/index-method.md)."""
2
+
3
+ METHOD_VERSION = "v0-draft"
@@ -0,0 +1,90 @@
1
+ """Pure aggregation for the listed-price series (method sections 3 and 5).
2
+
3
+ Input is a list of normalised offers:
4
+ {"gpu_name": str, "gpu_memory_gb": float, "num_gpus": int,
5
+ "total_price_per_hour": float, "verified": bool or None}
6
+ No identifiers enter this module, so none can reach the output.
7
+ """
8
+
9
+ from riggrade.classes import PEER_CLASSES, identify
10
+
11
+ MIN_OFFERS = 10
12
+
13
+
14
+ def weighted_percentile(pairs, q):
15
+ """pairs: (value, weight). Percentile q in [0, 1], weight-aware: the
16
+ smallest value whose cumulative weight reaches q of the total."""
17
+ pairs = sorted((v, w) for v, w in pairs if w > 0)
18
+ if not pairs:
19
+ return None
20
+ total = sum(w for _v, w in pairs)
21
+ acc = 0.0
22
+ for value, weight in pairs:
23
+ acc += weight
24
+ if acc >= q * total - 1e-12:
25
+ return value
26
+ return pairs[-1][0]
27
+
28
+
29
+ def _summary(pairs, verified_gpus=None, known_gpus=None):
30
+ gpus = sum(w for _v, w in pairs)
31
+ out = {"offers": len(pairs), "gpus": gpus}
32
+ if len(pairs) < MIN_OFFERS:
33
+ out["status"] = "insufficient"
34
+ return out
35
+ out["status"] = "ok"
36
+ out.update({"p25": round(weighted_percentile(pairs, 0.25), 4),
37
+ "median": round(weighted_percentile(pairs, 0.50), 4),
38
+ "p75": round(weighted_percentile(pairs, 0.75), 4)})
39
+ if known_gpus:
40
+ out["verified_share"] = round(verified_gpus / known_gpus, 3)
41
+ return out
42
+
43
+
44
+ def by_class(offers):
45
+ """One source's snapshot -> {class_id: summary}, plus unmatched count."""
46
+ pairs, verified, known, unmatched = {}, {}, {}, 0
47
+ for o in offers:
48
+ n = int(o.get("num_gpus") or 0)
49
+ price = o.get("total_price_per_hour")
50
+ if n <= 0 or price is None or price <= 0:
51
+ unmatched += 1
52
+ continue
53
+ cls = identify(o.get("gpu_name"), o.get("gpu_memory_gb"))
54
+ if cls is None:
55
+ unmatched += 1
56
+ continue
57
+ pairs.setdefault(cls, []).append((price / n, n))
58
+ if o.get("verified") is not None:
59
+ known[cls] = known.get(cls, 0) + n
60
+ verified[cls] = verified.get(cls, 0) + (n if o["verified"] else 0)
61
+ classes = {c: _summary(p, verified.get(c), known.get(c)) for c, p in pairs.items()}
62
+ return {"classes": classes, "unmatched_offers": unmatched}, pairs
63
+
64
+
65
+ def combined(per_source_pairs, per_source_summary):
66
+ """Method 3: median across sources that met the minimum sample, per class."""
67
+ pooled = {}
68
+ for source, pairs in per_source_pairs.items():
69
+ for cls, items in pairs.items():
70
+ if per_source_summary[source]["classes"][cls]["status"] == "ok":
71
+ pooled.setdefault(cls, []).extend(items)
72
+ out = {}
73
+ for cls in PEER_CLASSES:
74
+ items = pooled.get(cls, [])
75
+ out[cls] = ({"status": "ok", "gpus": sum(w for _v, w in items),
76
+ "median": round(weighted_percentile(items, 0.5), 4)}
77
+ if items else {"status": "missing"})
78
+ return out
79
+
80
+
81
+ def performance_adjusted(median_price_per_gpu_hour, throughputs_per_second, min_rigs=5):
82
+ """Method 5: price per million units of measured work (for inference,
83
+ tokens). `throughputs_per_second` is one first-session value per graded
84
+ rig. Returns None until `min_rigs` rigs exist."""
85
+ vals = sorted(throughputs_per_second)
86
+ if median_price_per_gpu_hour is None or len(vals) < min_rigs:
87
+ return None
88
+ mid = len(vals) // 2
89
+ med = vals[mid] if len(vals) % 2 else (vals[mid - 1] + vals[mid]) / 2
90
+ return round(median_price_per_gpu_hour / (med * 3600) * 1e6, 4)
@@ -0,0 +1,210 @@
1
+ """Reconstructed listed-price series from third-party records
2
+ (docs/index-reconstructed-method.md). v0: GetDeploying's CC BY 4.0 weekly
3
+ dataset files, mapped to the v0 peer classes through the published table
4
+ below. Each source stays separate; weekly figures stay weekly; history is
5
+ append-only (a changed past value becomes a correction beside the original).
6
+
7
+ python3 -m pricewatch.reconstructed --out riggrade/index/reconstructed-getdeploying.json
8
+ python3 -m pricewatch.reconstructed --payload weekly.json --out ... --now 2026-11-01T00:00:00Z
9
+ """
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import hashlib
14
+ import json
15
+ import sys
16
+ import urllib.request
17
+ from pathlib import Path
18
+
19
+ METHOD_VERSION = "reconstructed-v0-draft"
20
+
21
+ SOURCE = {
22
+ "name": "getdeploying",
23
+ "title": "GetDeploying GPU price history (weekly)",
24
+ "url": "https://getdeploying.com/dataset/gpu-prices/weekly.json",
25
+ "home": "https://getdeploying.com/gpus",
26
+ "licence": "CC BY 4.0",
27
+ "licence_url": "https://creativecommons.org/licenses/by/4.0/",
28
+ "attribution": "GetDeploying, https://getdeploying.com/gpus, CC BY 4.0. Figures changed: "
29
+ "mapped to RigGrade peer classes and re-published per class; on-demand "
30
+ "billing rows only; the source's own medians, not re-aggregated.",
31
+ "basis": "median_price and provider_median_price as the source states them per week "
32
+ "(USD per GPU per hour, on-demand). Weekly stays weekly.",
33
+ }
34
+
35
+ BILLING = "ON_DEMAND"
36
+
37
+ # The published mapping table (method section 3). A source name that could
38
+ # fit two v0 classes maps to neither: the source's "nvidia-h100" carries no
39
+ # form (SXM or PCIe) and "nvidia-a100" no memory size, so both are dropped
40
+ # and counted. Only unambiguous names map.
41
+ MAPPING = {
42
+ "nvidia-h200": "h200-sxm-141",
43
+ "nvidia-l40s": "l40s-48",
44
+ "nvidia-rtx-5090": "rtx5090-32",
45
+ "nvidia-rtx-4090": "rtx4090-24",
46
+ "nvidia-rtx-3090": "rtx3090-24",
47
+ }
48
+ AMBIGUOUS = {"nvidia-h100": "form not stated: could be h100-sxm-80 or h100-pcie-80",
49
+ "nvidia-a100": "memory not stated: could be a100-sxm-80, a100-pcie-80 or a100-pcie-40"}
50
+
51
+ # Amendment A1 (method text accepted in review on 2026-11-01; 14 days' notice):
52
+ # a chip-family series for the two ambiguous names, in its own namespace,
53
+ # never a peer class, alongside and never spliced. Published only from the
54
+ # date the notice ends. Section 3 is unchanged: the names stay dropped and
55
+ # counted in the peer-class series whether or not A1 publishes.
56
+ A1_NOTICE_ENDS = "2026-11-15"
57
+ A1_METHOD_VERSION = "reconstructed-v0-draft-A1"
58
+ CHIP_FAMILY = {"nvidia-h100": "chip:h100", "nvidia-a100": "chip:a100"}
59
+ CHIP_FAMILY_LABEL = "chip family (form and memory not stated by the source)"
60
+ CHIP_FAMILY_MIXES = {
61
+ "chip:h100": "The source does not state the form (SXM or PCIe); this figure mixes h100-sxm-80 "
62
+ "and h100-pcie-80 in whatever shares the source's offerings had that week.",
63
+ "chip:a100": "The source does not state the form or the memory size; this figure mixes "
64
+ "a100-sxm-80, a100-pcie-80 and a100-pcie-40 in whatever shares the source's "
65
+ "offerings had that week.",
66
+ }
67
+ CHIP_FAMILY_RULE = ("Never a peer class, never spliced into one, never used for a grade, a bar, "
68
+ "a reference, a consistency rule or a performance-adjusted price.")
69
+
70
+
71
+ def a1_published(now):
72
+ """True once the notice period has ended, judged on the import date."""
73
+ return str(now)[:10] >= A1_NOTICE_ENDS
74
+
75
+ FIELDS = ("median_price", "provider_median_price", "min_price", "max_price",
76
+ "provider_count", "offering_count")
77
+
78
+
79
+ class SchemaError(ValueError):
80
+ pass
81
+
82
+
83
+ def fetch(url=SOURCE["url"], opener=urllib.request.urlopen, timeout=120):
84
+ req = urllib.request.Request(url, headers={"User-Agent": "riggrade-pricewatch"})
85
+ with opener(req, timeout=timeout) as resp:
86
+ return resp.read()
87
+
88
+
89
+ def parse(raw):
90
+ """The dataset file -> (meta, rows); loud on any shape surprise."""
91
+ try:
92
+ doc = json.loads(raw.decode("utf-8"))
93
+ except (UnicodeDecodeError, ValueError) as exc:
94
+ raise SchemaError("getdeploying: not JSON: %s" % exc)
95
+ if not isinstance(doc, dict) or not isinstance(doc.get("data"), list):
96
+ raise SchemaError("getdeploying: expected {meta, data[]}")
97
+ meta = doc.get("meta") or {}
98
+ if "CC-BY" not in str(meta.get("license", "")).upper().replace(" ", "-"):
99
+ raise SchemaError("getdeploying: licence field is not CC BY: %r" % meta.get("license"))
100
+ for row in doc["data"]:
101
+ for key in ("gpu_slug", "date", "billing_type") + FIELDS:
102
+ if key not in row:
103
+ raise SchemaError("getdeploying: row missing %r" % key)
104
+ return meta, doc["data"]
105
+
106
+
107
+ def _week_value(row):
108
+ return {k: row[k] for k in FIELDS}
109
+
110
+
111
+ def _merge_append_only(series, previous, corrections, now, kind):
112
+ """Carry every recorded week forward; a changed value becomes a
113
+ correction beside the original; a missing week stays recorded."""
114
+ for key, old in (previous or {}).items():
115
+ new = series.setdefault(key, {"source_name": old.get("source_name"), "weeks": {}})
116
+ for week, old_value in (old.get("weeks") or {}).items():
117
+ new_value = new["weeks"].get(week)
118
+ if new_value is None:
119
+ new["weeks"][week] = old_value # a gap in the new file stays as recorded
120
+ elif new_value != old_value:
121
+ corrections.append({kind: key, "week": week, "recorded": old_value,
122
+ "source_now_states": new_value, "noticed_at": now})
123
+ new["weeks"][week] = old_value # history is never rewritten
124
+ for c in series.values():
125
+ c["weeks"] = dict(sorted(c["weeks"].items()))
126
+ c["latest_week"] = max(c["weeks"]) if c["weeks"] else None
127
+
128
+
129
+ def build(raw, existing=None, now=None):
130
+ """The series document. `existing` is the previous document, so past
131
+ weeks are never rewritten: a changed value is appended as a correction
132
+ and the original stays."""
133
+ now = now or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
134
+ meta, rows = parse(raw)
135
+ classes = {cid: {"source_name": slug, "weeks": {}} for slug, cid in MAPPING.items()}
136
+ publish_a1 = a1_published(now)
137
+ families = ({fid: {"source_name": slug, "label": CHIP_FAMILY_LABEL, "mixes": CHIP_FAMILY_MIXES[fid],
138
+ "rule": CHIP_FAMILY_RULE, "weeks": {}} for slug, fid in CHIP_FAMILY.items()}
139
+ if publish_a1 else {})
140
+ dropped = {}
141
+ for row in rows:
142
+ if row["billing_type"] != BILLING:
143
+ continue
144
+ slug = row["gpu_slug"]
145
+ cid = MAPPING.get(slug)
146
+ if cid is None:
147
+ dropped[slug] = dropped.get(slug, 0) + 1 # section 3: ambiguous names stay dropped and counted
148
+ fid = CHIP_FAMILY.get(slug)
149
+ if fid and publish_a1:
150
+ families[fid]["weeks"][row["date"]] = _week_value(row)
151
+ continue
152
+ classes[cid]["weeks"][row["date"]] = _week_value(row)
153
+ corrections = list((existing or {}).get("corrections") or [])
154
+ _merge_append_only(classes, (existing or {}).get("classes"), corrections, now, "class")
155
+ if publish_a1:
156
+ _merge_append_only(families, (existing or {}).get("chip_families"), corrections, now, "chip_family")
157
+ imports = list((existing or {}).get("imports") or [])
158
+ imports.append({"retrieved_at": now, "url": SOURCE["url"], "bytes": len(raw),
159
+ "sha256": hashlib.sha256(raw).hexdigest(),
160
+ "source_latest_week": meta.get("latest_week"),
161
+ "source_last_snapshot": meta.get("last_snapshot")})
162
+ return {"series": "reconstructed", "method": A1_METHOD_VERSION if publish_a1 else METHOD_VERSION,
163
+ "a1_notice_ends": A1_NOTICE_ENDS, "chip_families": families,
164
+ "method_url": "https://github.com/nnyycc77-ship-it/riggrade/blob/main/docs/index-reconstructed-method.md",
165
+ "note": "Information, not an offer, a rate or advice. Listed prices as a third party "
166
+ "recorded them, mapped to peer classes; never joined to the live series.",
167
+ "source": dict(SOURCE, disclaimer=meta.get("disclaimer")),
168
+ "billing": BILLING, "mapping": MAPPING, "ambiguous_dropped": AMBIGUOUS,
169
+ "dropped_rows_by_source_name": dict(sorted(dropped.items())),
170
+ "classes": classes, "corrections": corrections, "imports": imports}
171
+
172
+
173
+ def to_csv(doc):
174
+ header = ("class,source_name,week,median_price,provider_median_price,min_price,max_price,"
175
+ "provider_count,offering_count,licence,attribution")
176
+ lines = [header]
177
+ rows = sorted(doc["classes"].items()) + sorted((doc.get("chip_families") or {}).items())
178
+ for cid, c in rows:
179
+ for week, v in c["weeks"].items():
180
+ lines.append(",".join(str(x) for x in (
181
+ cid, c["source_name"], week, v["median_price"], v["provider_median_price"],
182
+ v["min_price"], v["max_price"], v["provider_count"], v["offering_count"],
183
+ doc["source"]["licence"], '"' + doc["source"]["home"] + '"')))
184
+ return "\n".join(lines) + "\n"
185
+
186
+
187
+ def main(argv=None):
188
+ ap = argparse.ArgumentParser()
189
+ ap.add_argument("--out", required=True)
190
+ ap.add_argument("--csv", default="")
191
+ ap.add_argument("--payload", default="", help="a downloaded weekly.json instead of fetching")
192
+ ap.add_argument("--now", default="")
193
+ a = ap.parse_args(argv)
194
+ raw = Path(a.payload).read_bytes() if a.payload else fetch()
195
+ out = Path(a.out)
196
+ existing = json.loads(out.read_text(encoding="utf-8")) if out.exists() else None
197
+ doc = build(raw, existing, a.now or None)
198
+ out.parent.mkdir(parents=True, exist_ok=True)
199
+ out.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n", encoding="utf-8")
200
+ if a.csv:
201
+ Path(a.csv).write_text(to_csv(doc), encoding="utf-8")
202
+ mapped = sum(len(c["weeks"]) for c in doc["classes"].values())
203
+ sys.stdout.write("reconstructed: %d class-weeks across %d classes; %d corrections; latest %s\n" % (
204
+ mapped, len(doc["classes"]), len(doc["corrections"]),
205
+ max((c["latest_week"] or "") for c in doc["classes"].values())))
206
+ return 0
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
pricewatch/record.py ADDED
@@ -0,0 +1,63 @@
1
+ """Take one daily snapshot and append it to a hash-chained JSONL file.
2
+
3
+ python3 -m pricewatch.record --out data/index/listed-prices.jsonl [--source vast]
4
+
5
+ Only aggregates are written. A source that fails is recorded as failed for
6
+ that day, never filled in. The file is append-only; an edit or removal
7
+ breaks the chain (riggrade.salelog.read_chain checks it).
8
+ """
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import sys
13
+
14
+ from riggrade import salelog
15
+ from . import METHOD_VERSION, aggregate, sources
16
+
17
+
18
+ def snapshot(source_objs, now=None):
19
+ now = now or dt.datetime.now(dt.timezone.utc)
20
+ per_summary, per_pairs, failed = {}, {}, {}
21
+ for src in source_objs:
22
+ if getattr(src, "terms_ok", True) is not True:
23
+ # Method section 2: no run rests on a source whose terms do not allow it.
24
+ failed[src.name] = "refused: " + getattr(src, "terms_note", "terms not verified")
25
+ continue
26
+ try:
27
+ summary, pairs = aggregate.by_class(src.offers())
28
+ except Exception as exc: # noqa: BLE001 - recorded, not hidden
29
+ failed[src.name] = "%s: %s" % (type(exc).__name__, str(exc)[:200])
30
+ continue
31
+ per_summary[src.name], per_pairs[src.name] = summary, pairs
32
+ return {"date": now.strftime("%Y-%m-%d"),
33
+ "taken_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
34
+ "method": METHOD_VERSION,
35
+ "sources": per_summary,
36
+ "failed_sources": failed,
37
+ "combined": aggregate.combined(per_pairs, per_summary)}
38
+
39
+
40
+ def record(path, source_objs, now=None):
41
+ snap = snapshot(source_objs, now)
42
+ existing = salelog.read_chain(path) # raises if the chain is broken
43
+ if any(r.get("date") == snap["date"] for r in existing):
44
+ raise SystemExit("a snapshot for %s is already recorded" % snap["date"])
45
+ salelog.append(path, snap)
46
+ return snap
47
+
48
+
49
+ def main(argv=None):
50
+ ap = argparse.ArgumentParser(description="RigGrade index: daily listed-price snapshot")
51
+ ap.add_argument("--out", required=True)
52
+ ap.add_argument("--source", action="append", choices=sorted(sources.SOURCES))
53
+ args = ap.parse_args(argv)
54
+ snap = record(args.out, [sources.SOURCES[s]() for s in (args.source or sorted(sources.SOURCES))])
55
+ ok = {s: sum(c["status"] == "ok" for c in v["classes"].values())
56
+ for s, v in snap["sources"].items()}
57
+ print("recorded %s: classes ok per source %s; failed %s"
58
+ % (snap["date"], ok, snap["failed_sources"] or "none"))
59
+ return 1 if snap["failed_sources"] and not snap["sources"] else 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
pricewatch/sources.py ADDED
@@ -0,0 +1,72 @@
1
+ """Source adapters. Each turns one source's public listings into normalised
2
+ offers (see aggregate.py) and drops every identifier.
3
+
4
+ An adapter checks the fields it relies on and raises SchemaError when they
5
+ are missing, so a format change stops the run loudly instead of producing a
6
+ quiet wrong number (method section 6: gaps stay gaps).
7
+ """
8
+
9
+ import json
10
+ import urllib.request
11
+
12
+
13
+ class SchemaError(ValueError):
14
+ pass
15
+
16
+
17
+ def _get_json(url, opener=urllib.request.urlopen, timeout=60):
18
+ req = urllib.request.Request(url, headers={"User-Agent": "riggrade-pricewatch",
19
+ "Accept": "application/json"})
20
+ with opener(req, timeout=timeout) as resp:
21
+ return json.loads(resp.read().decode("utf-8"))
22
+
23
+
24
+ class VastOffers:
25
+ """Vast.ai on-demand offers.
26
+
27
+ TERMS DO NOT PERMIT USE (checked 1 Nov 2026). Vast's terms forbid using
28
+ its data or services "to construct, publish, or maintain any index,
29
+ benchmark, pricing-comparison service, market-data product" without a
30
+ separate written agreement, and its public pricing feed says index use
31
+ "requires a data license". So `terms_ok` is False and the recorder
32
+ refuses this source. Kept only so a future written licence needs no new
33
+ code; the field names below are still unverified.
34
+ """
35
+
36
+ name = "vast"
37
+ terms_ok = False
38
+ terms_note = ("Vast terms (checked 2026-11-01) forbid index or market-data use "
39
+ "without a written agreement")
40
+ URL = ("https://console.vast.ai/api/v0/bundles/?q=" +
41
+ urllib.request.quote(json.dumps({"rentable": {"eq": True},
42
+ "rented": {"eq": False},
43
+ "type": "on-demand",
44
+ "limit": 5000})))
45
+ REQUIRED = ("gpu_name", "num_gpus", "gpu_ram", "dph_total")
46
+
47
+ def __init__(self, fetch=None):
48
+ self.fetch = fetch or (lambda: _get_json(self.URL))
49
+
50
+ def normalise(self, payload):
51
+ offers = payload.get("offers") if isinstance(payload, dict) else None
52
+ if not isinstance(offers, list):
53
+ raise SchemaError("vast: no 'offers' list in response")
54
+ out = []
55
+ for o in offers:
56
+ missing = [k for k in self.REQUIRED if k not in o]
57
+ if missing:
58
+ raise SchemaError("vast: offer missing fields %s" % missing)
59
+ out.append({"gpu_name": o["gpu_name"],
60
+ # gpu_ram is per GPU in MB in Vast's listings
61
+ "gpu_memory_gb": (o["gpu_ram"] or 0) / 1024,
62
+ "num_gpus": o["num_gpus"],
63
+ "total_price_per_hour": o["dph_total"],
64
+ "verified": (o["verification"] == "verified")
65
+ if "verification" in o else o.get("verified")})
66
+ return out
67
+
68
+ def offers(self):
69
+ return self.normalise(self.fetch())
70
+
71
+
72
+ SOURCES = {"vast": VastOffers}
riggrade/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RigGrade: independent GPU rig grading. See docs/grading-spec.md."""
2
+
3
+ BENCH_VERSION = "v0.1"
riggrade/acceptance.py ADDED
@@ -0,0 +1,99 @@
1
+ """Paid cluster acceptance certificate (decided 1 Nov 2026).
2
+
3
+ The renter runs runner.cluster on the rented nodes and submits the
4
+ evidence pack it wrote (acceptance.json). The server recomputes every
5
+ check and the verdict from the claims and node reports with the same
6
+ rules, refuses a pack that does not recompute, and on payment issues a
7
+ signed certificate it also stores, so anyone with the id can fetch and
8
+ verify it. Measured, not promised: the certificate states what the
9
+ cluster delivered during that test against what its provider claimed.
10
+ """
11
+
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+
16
+ from . import cluster_rules, signing
17
+
18
+ PRODUCT = "riggrade_cluster_acceptance"
19
+ STATEMENT = ("Measured, not promised. What this cluster delivered during the named test, "
20
+ "against what its provider claimed; no promise about future delivery.")
21
+
22
+
23
+ def canonical(obj):
24
+ return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
25
+
26
+
27
+ def validate(pack):
28
+ """Returns the error text for a malformed pack, else None."""
29
+ if not isinstance(pack, dict):
30
+ return "evidence_pack must be the acceptance.json object"
31
+ if not isinstance(pack.get("claims"), dict):
32
+ return "evidence_pack.claims must be an object"
33
+ nodes = pack.get("nodes")
34
+ if not isinstance(nodes, list) or not nodes or not all(isinstance(n, dict) for n in nodes):
35
+ return "evidence_pack.nodes must be a non-empty list of node reports"
36
+ if not isinstance(pack.get("checks"), list) or not isinstance(pack.get("verdict"), str):
37
+ return "evidence_pack must carry the checks and verdict the evaluate step wrote"
38
+ return None
39
+
40
+
41
+ def recompute(pack):
42
+ """(checks, result) from the pack's claims and nodes, by the shared rules."""
43
+ nodes = sorted(pack["nodes"], key=lambda n: n.get("rank", 0))
44
+ return cluster_rules.evaluate(pack["claims"], nodes)
45
+
46
+
47
+ def certificate(pack, checks, result, secret, now, cert_id):
48
+ nodes = pack["nodes"]
49
+ cert = {"product": PRODUCT, "certificate_id": cert_id, "issued_at": now,
50
+ "method": pack.get("method"), "rules": cluster_rules.METHOD_VERSION,
51
+ "pack_sha256": hashlib.sha256(canonical(pack)).hexdigest(),
52
+ "claims": pack["claims"],
53
+ "images": sorted({n.get("image") or "unrecorded" for n in nodes}),
54
+ "nodes": len(nodes),
55
+ "checks": checks,
56
+ "verdict": result["verdict"], "exceptions": result["exceptions"],
57
+ "rejections": result["rejections"],
58
+ "statement": STATEMENT}
59
+ cert["signature"] = sign(cert, secret)
60
+ return cert
61
+
62
+
63
+ SIGNATURE_FIELDS = ("signature", "ed25519")
64
+
65
+
66
+ def body(cert):
67
+ """The signed content: every field but the signatures themselves."""
68
+ return {k: v for k, v in cert.items() if k not in SIGNATURE_FIELDS}
69
+
70
+
71
+ def sign(cert, secret):
72
+ """The service's own check (HMAC); kept beside the Ed25519 signature."""
73
+ return hmac.new(secret, canonical(body(cert)), hashlib.sha256).hexdigest()
74
+
75
+
76
+ def verify(cert, secret):
77
+ return hmac.compare_digest(sign(cert, secret), cert.get("signature", ""))
78
+
79
+
80
+ def attach_ed25519(cert, signer):
81
+ """Independently verifiable signature with the published key; a no-op
82
+ without a signer (HMAC-only certificate). The certificate's keys_url
83
+ (set by the issuer before either signature) names the published key
84
+ set the signature is anchored to; both signatures cover it."""
85
+ if signer is None:
86
+ return cert
87
+ cert["ed25519"] = {"kid": signer.kid, "alg": signing.ALG, "public_key": signer.public_key_b64,
88
+ "signature": signer.sign(canonical(body(cert))),
89
+ "canonicalisation": signing.CANONICAL_RULE}
90
+ return cert
91
+
92
+
93
+ def verify_ed25519(cert, public_key_b64=None):
94
+ """True, False, or None when the certificate carries no Ed25519 object."""
95
+ sig = cert.get("ed25519") if isinstance(cert, dict) else None
96
+ if not isinstance(sig, dict):
97
+ return None
98
+ return signing.verify(public_key_b64 or sig.get("public_key", ""), canonical(body(cert)),
99
+ sig.get("signature", ""))