"""Immutable daily exports and a prospective 30-calendar-day report, standard library only. Daily filenames refer to poll coverage; cohort states are AS OF the recorded export time. No historic states are reconstructed. Never rewrite a published daily export or report. """ import csv,io,json,hashlib,os,zipfile,datetime,math from pathlib import Path from confirmation_observer import RETENTION DAY=86400 COLUMNS=['txid','first_seen','state','closed_at','confirmed_at','block_height','block_hash'] POLL_COLUMNS=['at','state','covered_seconds','baseline_excluded'] PROTOCOL='lw-confirmations-2026-09-27-v2' def csv_bytes(rows,columns): s=io.StringIO(newline='');w=csv.DictWriter(s,fieldnames=columns);w.writeheader();w.writerows(rows) return s.getvalue().encode() def atomic(path,data): path=Path(path);temp=path.with_suffix(path.suffix+'.tmp');temp.write_bytes(data);temp.chmod(0o600);os.replace(temp,path) def encode(value):return (json.dumps(value,sort_keys=True,separators=(',',':'))+'\n').encode() def day_name(at):return datetime.datetime.fromtimestamp(at,datetime.timezone.utc).strftime('%Y-%m-%d') def digest(data):return hashlib.sha256(data).hexdigest() class Archive: def __init__(self,root,db,now): self.root=Path(root);self.root.mkdir(exist_ok=True);self.db=db self.db.execute('CREATE TABLE IF NOT EXISTS study_metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL)') self.db.execute("INSERT OR IGNORE INTO study_metadata VALUES('archive_started',?)",(now,));self.db.commit() def meta(self,key): row=self.db.execute('SELECT value FROM study_metadata WHERE key=?',(key,)).fetchone();return row[0] if row else None def mark_ready(self,now): # The first full UTC day after the first valid collecting interval starts the cohort. self.db.execute("INSERT OR IGNORE INTO study_metadata VALUES('study_start',?)",((now//DAY+1)*DAY,));self.db.commit() def freeze(self,day,now): name=day_name(day)+'.zip';dest=self.root/name if dest.exists():return end=day+DAY polls=[dict(r) for r in self.db.execute('SELECT * FROM polls WHERE at>=? AND at=? AND first_seen=end and not report_path.exists(): days=[day_name(d) for d in range(start,end,DAY)] if all((self.root/(d+'.zip')).exists() for d in days): report=build_report(self.root,start) atomic(report_path,encode(report)) if report_path.exists():status['report']={'file':report_path.name,'sha256':digest(report_path.read_bytes())} atomic(self.root/'index.json',encode(status)) return status def build_report(root,start): root=Path(root);end=start+30*DAY;daily=[];inputs=[];last=None for day in range(start,end,DAY): path=root/(day_name(day)+'.zip');raw=path.read_bytes() with zipfile.ZipFile(io.BytesIO(raw)) as z: m=json.loads(z.read('manifest.json')) if m['protocol']!=PROTOCOL or m['coverage_start']!=day:raise ValueError('Archive protocol/day mismatch') for name,sha in m['files'].items(): if digest(z.read(name))!=sha:raise ValueError('Archive checksum mismatch') rows=list(csv.DictReader(io.StringIO(z.read('polls.csv').decode()))) coverage=sum(int(p['covered_seconds']) for p in rows) if coverage!=m['covered_seconds']:raise ValueError('Coverage mismatch') daily.append({'day':m['coverage_day'],'covered_seconds':coverage,'recorded_polls':len(rows),'baseline_exclusions':m['baseline_exclusions'],'poll_states':m['poll_states']}) if day==end-DAY: last=list(csv.DictReader(io.StringIO(z.read('cohort.csv').decode())));snapshot_at=m['snapshot_at'] inputs.append({'file':path.name,'sha256':digest(raw)}) cohort=[r for r in last if start<=int(r['first_seen'])