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 +3 -0
- pricewatch/aggregate.py +90 -0
- pricewatch/reconstructed.py +210 -0
- pricewatch/record.py +63 -0
- pricewatch/sources.py +72 -0
- riggrade/__init__.py +3 -0
- riggrade/acceptance.py +99 -0
- riggrade/certificate_page.py +136 -0
- riggrade/classes.py +62 -0
- riggrade/cluster_rules.py +149 -0
- riggrade/discovery.py +240 -0
- riggrade/endpoint.py +776 -0
- riggrade/engine.py +183 -0
- riggrade/fixit.py +248 -0
- riggrade/grading.py +347 -0
- riggrade/heartbeat.py +41 -0
- riggrade/index/reconstructed-getdeploying.csv +266 -0
- riggrade/index/reconstructed-getdeploying.json +2289 -0
- riggrade/index.py +98 -0
- riggrade/label.py +76 -0
- riggrade/mcp.py +279 -0
- riggrade/payment.py +353 -0
- riggrade/salelog.py +50 -0
- riggrade/screenlist.py +101 -0
- riggrade/serve.py +73 -0
- riggrade/session.py +173 -0
- riggrade/signing.py +98 -0
- riggrade/spec/param-distribution.json +15 -0
- riggrade/verify.py +96 -0
- riggrade-0.1.0.dist-info/METADATA +88 -0
- riggrade-0.1.0.dist-info/RECORD +47 -0
- riggrade-0.1.0.dist-info/WHEEL +5 -0
- riggrade-0.1.0.dist-info/entry_points.txt +4 -0
- riggrade-0.1.0.dist-info/licenses/LICENSE +202 -0
- riggrade-0.1.0.dist-info/top_level.txt +3 -0
- runner/__init__.py +7 -0
- runner/bundle.py +31 -0
- runner/client.py +42 -0
- runner/cluster.py +160 -0
- runner/gpu.py +165 -0
- runner/identity.py +55 -0
- runner/local.py +36 -0
- runner/main.py +154 -0
- runner/preflight.py +683 -0
- runner/rulers.py +186 -0
- runner/smoke.py +161 -0
- runner/stats.py +40 -0
pricewatch/__init__.py
ADDED
pricewatch/aggregate.py
ADDED
|
@@ -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
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", ""))
|