Skip to content

Commit e5da02b

Browse files
committed
test(d-miner): rollup honesty-regression KAT (sampler selftest mode)
D-MINER.1 sampler/rollup had a 15-KAT notify engine downstream but zero regression coverage on the rollup accounting itself. Add a SAFE-ADDITIVE selftest subcommand (rig-free, in-memory sqlite) pinning the honesty- critical math so a refactor cannot silently re-stub it: - presence rule: live hashrate>0 => online, ==0 => offline - hours_online = online_samples * interval - avg_hashrate averaged over ONLINE samples only (zeros excluded) - per-worker stale% = dead/(live+dead) - pool __pool__ row = shares DELTA (max-min), clamped, not absolute - rollup idempotent across restart (INSERT OR REPLACE, no double-count) - per-UTC-day grouping - raw pruned >90d while daily summary kept forever Pure additive: new argparse choice + new function; no change to sample/ rollup/record_sample logic. 16/16 checks green.
1 parent 062183e commit e5da02b

1 file changed

Lines changed: 103 additions & 1 deletion

File tree

scripts/miner_presence_sampler.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,116 @@ def rollup(con, interval):
166166
print(f"rolled up {n_rows} per-worker/day rows; pruned raw < {RAW_RETENTION_DAYS}d")
167167

168168

169+
def _selftest():
170+
"""SAFE-ADDITIVE regression KAT for D-MINER.1 rollup math (rig-free, in-memory).
171+
172+
Pins the honesty-critical accounting so a future refactor cannot silently
173+
re-stub it: presence rule, hours/avg/stale derivation, pool shares-delta,
174+
rollup idempotency (restart-safe), and raw retention pruning.
175+
"""
176+
DAY = 86400
177+
fails = []
178+
179+
def check(name, cond):
180+
print(("ok " if cond else "FAIL ") + name)
181+
if not cond:
182+
fails.append(name)
183+
184+
def fresh():
185+
return connect(":memory:")
186+
187+
def add_raw(con, ts, worker, hr, dr):
188+
con.execute(
189+
"INSERT INTO samples(ts,worker,hashrate,dead_hashrate,online) VALUES(?,?,?,?,?)",
190+
(ts, worker, hr, dr, 1 if hr > 0 else 0))
191+
192+
def daily(con):
193+
return {(d, w): (ho, ah, n, sh, st) for d, w, ho, ah, n, sh, st in
194+
con.execute("SELECT day,worker,hours_online,avg_hashrate,samples,shares,stale_pct FROM daily")}
195+
196+
# 1) presence rule: hashrate>0 -> online=1, zero -> online=0 (via record_sample)
197+
con = fresh()
198+
record_sample(con, {"miner_hash_rates": {"A": 5000.0, "B": 0.0},
199+
"miner_dead_hash_rates": {"A": 100.0}, "shares": {}})
200+
on = dict(con.execute("SELECT worker,online FROM samples"))
201+
check("presence: live>0 online", on.get("A") == 1)
202+
check("presence: live==0 offline", on.get("B") == 0)
203+
204+
# 2) accounting: hours_online, avg over online-only, per-worker stale%
205+
con = fresh()
206+
base = 100 * DAY # fixed epoch day; no wall-clock dependence
207+
for k in range(3):
208+
add_raw(con, base + k * 3600, "A", 6000.0, 2000.0) # 3 online samples
209+
add_raw(con, base + 3 * 3600, "A", 0.0, 0.0) # 1 offline sample
210+
rollup(con, 3600) # interval=1h/sample
211+
d = daily(con)
212+
day = list({k[0] for k in d})[0]
213+
ho, ah, n, sh, st = d[(day, "A")]
214+
check("hours_online = online_samples * interval", abs(ho - 3.0) < 1e-9)
215+
check("avg_hashrate over ONLINE samples only", abs(ah - 6000.0) < 1e-9)
216+
check("samples counts ALL rows (online+offline)", n == 4)
217+
check("stale% = dead/(live+dead) [6000/24000=25]", abs(st - 25.0) < 1e-9)
218+
219+
# 3) pool __pool__ row: shares DELTA (max-min), clamped >=0, not absolute
220+
con = fresh()
221+
add_raw(con, base, "A", 6000.0, 0.0) # rollup gates on worker samples; shares imply workers
222+
con.execute("INSERT OR REPLACE INTO pool_samples VALUES(?,?,?,?)", (base, 1000, 0, 10))
223+
con.execute("INSERT OR REPLACE INTO pool_samples VALUES(?,?,?,?)", (base + 7200, 1500, 0, 40))
224+
rollup(con, 3600)
225+
d = daily(con)
226+
pday = list({k[0] for k in d if k[1] == "__pool__"})[0]
227+
ho, ah, n, sh, st = d[(pday, "__pool__")]
228+
check("pool shares = delta(max-min) not absolute", sh == 500)
229+
check("pool stale% from delta [30/500=6]", abs(st - 6.0) < 1e-9)
230+
check("pool row carries no per-worker hours", ho == 0.0)
231+
232+
# 4) restart/idempotency: re-running rollup REPLACEs, never double-counts
233+
con = fresh()
234+
for k in range(2):
235+
add_raw(con, base + k * 3600, "A", 6000.0, 0.0)
236+
rollup(con, 3600)
237+
first = daily(con)
238+
rollup(con, 3600) # simulate a restarted rollup over the same raw rows
239+
second = daily(con)
240+
check("rollup idempotent (restart-safe, no doubling)", first == second)
241+
242+
# 5) multi-day grouping: samples in distinct date buckets -> distinct rows
243+
con = fresh()
244+
add_raw(con, base, "A", 6000.0, 0.0)
245+
add_raw(con, base + DAY, "A", 6000.0, 0.0)
246+
rollup(con, 3600)
247+
days = {k[0] for k in daily(con) if k[1] == "A"}
248+
check("rollup groups per UTC day", len(days) == 2)
249+
250+
# 6) retention: raw >90d pruned, but its daily row kept forever
251+
con = fresh()
252+
old = now_ts() - (RAW_RETENTION_DAYS + 5) * DAY
253+
recent = now_ts() - DAY
254+
add_raw(con, old, "A", 6000.0, 0.0)
255+
add_raw(con, recent, "A", 6000.0, 0.0)
256+
rollup(con, 3600)
257+
raw_ts = [r[0] for r in con.execute("SELECT ts FROM samples")]
258+
check("raw >90d pruned", old not in raw_ts)
259+
check("raw <90d retained", recent in raw_ts)
260+
old_day = datetime.fromtimestamp(old, timezone.utc).strftime("%Y-%m-%d")
261+
has_old_daily = con.execute(
262+
"SELECT 1 FROM daily WHERE day=? AND worker=?", (old_day, "A")).fetchone()
263+
check("daily kept forever (pruned-day summary survives)", has_old_daily is not None)
264+
265+
print("\nSELFTEST PASS" if not fails else "\nSELFTEST FAIL: " + ", ".join(fails))
266+
return 1 if fails else 0
267+
268+
169269
def main():
170270
ap = argparse.ArgumentParser(description="c2pool miner presence sampler + rollup (D-MINER.1)")
171-
ap.add_argument("mode", choices=["sample", "rollup"])
271+
ap.add_argument("mode", choices=["sample", "rollup", "selftest"])
172272
ap.add_argument("--db", default="miner_presence.db")
173273
ap.add_argument("--url", default=DEFAULT_URL, help="dashboard /local_stats URL")
174274
ap.add_argument("--interval", type=int, default=60, help="sample interval seconds")
175275
ap.add_argument("--once", action="store_true", help="sample: take one sample and exit")
176276
args = ap.parse_args()
277+
if args.mode == "selftest":
278+
sys.exit(_selftest())
177279
con = connect(args.db)
178280
try:
179281
if args.mode == "sample":

0 commit comments

Comments
 (0)