@@ -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 ("\n SELFTEST PASS" if not fails else "\n SELFTEST FAIL: " + ", " .join (fails ))
266+ return 1 if fails else 0
267+
268+
169269def 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