#!/usr/bin/env python3
"""COMPLEXA C201–C210: source-derived information-theory SVG/CSV illustrations.

Pure Python 3.12+ standard library; deterministic charts, numerical audits and ZIP.
Run: python make_figures.py --out /path/to/content/supplementary
     python make_figures.py --check /path/to/content/supplementary
Each figure is calculated from explicit original chapter examples, NOT observations.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import io
import math
from pathlib import Path
from xml.sax.saxutils import escape
from zipfile import ZipFile, ZipInfo, ZIP_STORED

BASE = Path(__file__).resolve().parent
TEAL = '#087f8c'; BLUE = '#326d98'; ORANGE = '#c26c38'; PLUM = '#754c96'
INK = '#183348'; MUTED = '#566c78'; GRID = '#dae4eb'; BG = '#ffffff'; LT = '#f3f7fa'; GREEN='#2d7865'
COLORS=[BLUE,TEAL,ORANGE,PLUM,GREEN]
W,H=1120,680

def h(p):
    if p<=0 or p>=1:
        assert 0<=p<=1
        return 0.
    return -p*math.log2(p)-(1-p)*math.log2(1-p)

def entropy(p):
    return -sum(v*math.log2(v) for v in p if v>0)

def kl(p,q):
    return sum(a*math.log2(a/b) for a,b in zip(p,q) if a>0)

def fmt(x,n=6):
    return f'{x:.{n}f}'.rstrip('0').rstrip('.') if isinstance(x,float) else str(x)

def number(x):
    return f'{x:.10f}' if isinstance(x,float) else str(x)

def csv_content(cols,rows):
    out=io.StringIO(newline='')
    writer=csv.writer(out,lineterminator='\n');writer.writerow(cols)
    for row in rows: writer.writerow([number(x) for x in row])
    return out.getvalue().encode('utf-8')

class Figure:
    def __init__(self,n,title,subtitle,detail):
        self.n=n;self.a=[]
        self.add(f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" role="img" aria-labelledby="title desc">')
        self.add(f'<title id="title">{escape(title)}</title><desc id="desc">{escape(detail)}</desc>')
        self.rect(0,0,W,H,BG);self.rect(0,0,W,9,TEAL)
        self.txt(65,47,f'COMPLEXA · FIGURA {n}.1',14,TEAL,True)
        self.txt(65,87,title,26,INK,True)
        self.txt(65,119,subtitle,14,MUTED)
        self.line(65,139,W-65,139,GRID,1.5)
    def add(self,s): self.a.append(s)
    def rect(self,x,y,w,h,color,r=0,stroke=None):
        b=f' stroke="{stroke}"' if stroke else ''
        self.add(f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" rx="{r:.1f}" fill="{color}"{b}/>')
    def line(self,x,y,x2,y2,color=GRID,width=1,dash=None):
        dd=f' stroke-dasharray="{dash}"' if dash else ''
        self.add(f'<line x1="{x:.2f}" y1="{y:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" stroke="{color}" stroke-width="{width:.2f}"{dd}/>')
    def txt(self,x,y,s,size=14,color=INK,bold=False,anchor='start'):
        self.add(f'<text x="{x:.2f}" y="{y:.2f}" text-anchor="{anchor}" fill="{color}" font-family="Arial,Helvetica,sans-serif" font-size="{size:.1f}" font-weight="{700 if bold else 400}">{escape(str(s))}</text>')
    def circle(self,x,y,r,fill):self.add(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{r:.2f}" fill="{fill}" stroke="#fff" stroke-width="1.6"/>')
    def poly(self,xy,col,w=3,dash=None):
        pts=' '.join(f'{x:.2f},{y:.2f}' for x,y in xy)
        d=f' stroke-dasharray="{dash}"' if dash else ''
        self.add(f'<polyline points="{pts}" fill="none" stroke="{col}" stroke-width="{w:.2f}" stroke-linejoin="round" stroke-linecap="round"{d}/>')
    def legend(self,parts,x=735,y=166):
        for i,(name,color) in enumerate(parts):
            yy=y+i*23
            self.rect(x,yy-12,24,9,color,2);self.txt(x+34,yy-3,name,13,MUTED)
    def note(self,lead,txt):
        self.rect(65,583,W-130,68,LT,8,GRID)
        self.txt(82,610,lead,14,TEAL,True)
        self.txt(82,635,txt,13,MUTED)
    def output(self):return ('\n'.join(self.a+['</svg>'])+'\n').encode('utf-8')

class Axes:
    def __init__(self,f,x0=119,y0=188,w=847,h=322,xmin=0,xmax=1,ymin=0,ymax=1,xlabel='',ylabel=''):
        assert xmax>xmin and ymax>ymin
        self.f=f;self.x0=x0;self.y0=y0;self.w=w;self.h=h
        self.xmin=xmin;self.xmax=xmax;self.ymin=ymin;self.ymax=ymax
        f.rect(x0,y0,w,h,BG,4,GRID)
        f.line(x0,y0+h,x0+w,y0+h,INK,1.8);f.line(x0,y0,x0,y0+h,INK,1.8)
        f.txt(x0+w/2,y0+h+53,xlabel,15,INK,anchor='middle')
        f.txt(x0-13,y0-14,ylabel,13,MUTED)
    def xy(self,x,y):return (self.x0+(x-self.xmin)/(self.xmax-self.xmin)*self.w,
                                  self.y0+self.h-(y-self.ymin)/(self.ymax-self.ymin)*self.h)
    def ticks(self,xs,ys):
        for x,s in xs:
            px,_=self.xy(x,self.ymin);self.f.line(px,self.y0,px,self.y0+self.h,GRID,1)
            self.f.txt(px,self.y0+self.h+24,s,12,MUTED,anchor='middle')
        for y,s in ys:
            _,py=self.xy(self.xmin,y);self.f.line(self.x0,py,self.x0+self.w,py,GRID,1)
            self.f.txt(self.x0-12,py+4,s,12,MUTED,anchor='end')
    def curve(self,pts,col,width=3):self.f.poly([self.xy(*xy) for xy in pts],col,width)
    def dot(self,x,y,color=ORANGE,r=5):self.f.circle(*self.xy(x,y),r,color)
    def dotted(self,x,y,col=ORANGE):
        p1=self.xy(x,self.ymin);p2=self.xy(x,y);self.f.line(*p1,*p2,col,1.5,'4 6')


def build201():
    ps=[.5,.25,.125,.125];surprise=[-math.log2(p) for p in ps];contrib=[a*b for a,b in zip(ps,surprise)]
    assert sum(contrib)==1.75
    f=Figure(201,'Sorpresa individual y entropía promedio','Cuatro símbolos, probabilidades 1/2, 1/4, 1/8 y 1/8','Barras de sorpresa en bits y contribuciones ponderadas: la entropía total es 1,75 bits.')
    a=Axes(f,xmin=.3,xmax=4.7,ymax=3.5,xlabel='Símbolo y probabilidad',ylabel='Información (bits)')
    a.ticks([(i,f'{s} · p={fmt(ps[i-1],3)}') for i,s in enumerate('ABCD',1)],[(i,str(i)) for i in range(4)])
    for i,(v,c) in enumerate(zip(surprise,contrib),1):
        for d,b,col in [(-.17,v,BLUE),(.17,c,ORANGE)]:
            x1,y1=a.xy(i+d-.12,0);x2,y2=a.xy(i+d+.12,b)
            f.rect(x1,y2,x2-x1,y1-y2,col,3)
    f.legend([('Sorpresa −log₂ p',BLUE),('Contribución p · sorpresa',ORANGE)],685,165)
    f.note('Resultado matemático','La entropía es la suma de contribuciones: 0,5 + 0,5 + 0,375 + 0,375 = 1,75 bits.')
    return f.output(),csv_content(['simbolo','probabilidad','sorpresa_bits','contribucion_bits'],[(s,p,v,c) for s,p,v,c in zip('ABCD',ps,surprise,contrib)])

def build202():
    p=[[.45,.10],[.05,.40]];hx=entropy([.55,.45]);hy=1.0
    hj=entropy([v for row in p for v in row]);hxy=hj-hy;hyx=hj-hx
    assert abs(hj-(hy+hxy))<1e-12 and abs(hj-(hx+hyx))<1e-12
    f=Figure(202,'Dos rutas hacia la misma entropía conjunta','P(X,Y) = [[0,45; 0,10], [0,05; 0,40]]','Dos barras apiladas muestran la regla de la cadena: H(Y)+H(X dado Y) y H(X)+H(Y dado X), ambas igual a 1,595462 bits.')
    a=Axes(f,xmin=0,xmax=2,ymax=1.85,xlabel='Descomposición de la regla de la cadena',ylabel='Entropía (bits)')
    a.ticks([(0.57,'Primero Y'),(1.45,'Primero X')],[(0,'0'),(.5,'0,5'),(1,'1'),(1.5,'1,5')])
    for x,vals in [(.57,[(hy,BLUE),(hxy,ORANGE)]),(1.45,[(hx,TEAL),(hyx,PLUM)])]:
        lo=0
        for value,c in vals:
            left,_=a.xy(x-.24,lo);right,top=a.xy(x+.24,lo+value)
            _,bottom=a.xy(0,lo);f.rect(left,top,right-left,bottom-top,c,3);lo+=value
        X,Y=a.xy(x,lo);f.txt(X,Y-12,fmt(lo,6)+' bits',15,INK,True,anchor='middle')
    f.legend([('H(Y) = 1',BLUE),('H(X | Y) = 0,595462',ORANGE),('H(X) = 0,992774',TEAL),('H(Y | X) = 0,602687',PLUM)],762,170)
    f.note('Identidad verificada','H(X,Y) = H(Y)+H(X | Y) = H(X)+H(Y | X). No implica dirección causal.')
    rows=[('Primero_Y','H_Y',hy,hj),('Primero_Y','H_X_cond_Y',hxy,hj),('Primero_X','H_X',hx,hj),('Primero_X','H_Y_cond_X',hyx,hj)]
    return f.output(),csv_content(['orden','componente','valor_bits','entropia_conjunta_bits'],rows)

def build203():
    rows=[]
    for k in range(101):
        e=k/200
        q=.01*(1-e)+.99*e
        rows.append((e,1-h(e),h(q)-h(e)))
    assert abs(rows[20][1]-.5310044)<1e-6
    f=Figure(203,'Información mutua y frecuencia de entrada','Canal binario simétrico: P(error) = ε','Curvas de información mutua para entrada uniforme y entrada con P(X=1)=0,01. Ambas llegan a cero para error 0,5.')
    a=Axes(f,xmax=.5,ymax=1.05,xlabel='Probabilidad de inversión ε',ylabel='Información mutua (bits)')
    a.ticks([(v,fmt(v,2).replace('.',',')) for v in [0,.1,.2,.3,.4,.5]],[(v,fmt(v,2).replace('.',',')) for v in [0,.25,.5,.75,1]])
    a.curve([(e,iu) for e,iu,ir in rows],BLUE);a.curve([(e,ir) for e,iu,ir in rows],TEAL)
    a.dotted(.1,1-h(.1));a.dot(.1,1-h(.1));a.dot(.1,h(.108)-h(.1),TEAL)
    f.legend([('Entrada equiprobable',BLUE),('Entrada rara, p=0,01',TEAL)],728,165)
    f.note('ε = 0,1','La misma tasa de inversión produce 0,531 bits o 0,024858 bits según la entrada.')
    return f.output(),csv_content(['error_epsilon','I_uniforme_bits','I_rara_p001_bits'],rows)

def build204():
    P=[.9,.1];rows=[]
    for k in range(1,96):
        q=.05+k*.0095;Q=[q,1-q]
        rows.append((q,kl(P,Q),kl(Q,P)))
    assert abs(kl(P,[.5,.5])-.53100440641)<1e-10
    assert abs(kl([.5,.5],P)-.73696559417)<1e-10
    f=Figure(204,'La divergencia KL tiene orientación','P = (0,9; 0,1) fija, Q = (q; 1−q) variable','Dos curvas muestran D_KL(P || Q) y D_KL(Q || P), que no coinciden aunque las categorías sean iguales.')
    a=Axes(f,xmin=.05,xmax=.96,ymax=3.75,xlabel='Probabilidad q del primer resultado bajo Q',ylabel='Divergencia (bits)')
    a.ticks([(v,fmt(v,2).replace('.',',')) for v in [.1,.3,.5,.7,.9]],[(v,fmt(v,1).replace('.',',')) for v in [0,1,2,3]])
    a.curve([(q,u) for q,u,v in rows if u<=3.75],BLUE);a.curve([(q,v) for q,u,v in rows if v<=3.75],ORANGE)
    for value,c in [(kl(P,[.5,.5]),BLUE),(kl([.5,.5],P),ORANGE)]:a.dot(.5,value,c,6)
    f.legend([('D_KL(P || Q)',BLUE),('D_KL(Q || P)',ORANGE)],778,162)
    f.note('Referencia Q = (0,5; 0,5)','D_KL(P || Q) ≈ 0,531004 bits; D_KL(Q || P) ≈ 0,736966 bits.')
    return f.output(),csv_content(['q','D_KL_P_versus_Q_bits','D_KL_Q_versus_P_bits'],rows)

def build205():
    e=.1; iy=1-h(e);rows=[]
    for j in range(101):
        delta=j/200;q=e+delta-2*e*delta
        rows.append((delta,q,iy,1-h(q),iy-(1-h(q))))
    assert abs(rows[40][1]-.26)<1e-12 and abs(rows[40][3]-.173253)<1e-5
    f=Figure(205,'El procesamiento agrega ruido y pierde información','X → Y: error 0,1; Y → Z: error adicional δ, independiente','La información I(X;Z) decae con δ, mientras I(X;Y)=0,531004 bits permanece fija.')
    a=Axes(f,xmax=.5,ymax=.56,xlabel='Error añadido por el procesador δ',ylabel='Información sobre X (bits)')
    a.ticks([(v,fmt(v,1).replace('.',',')) for v in [0,.1,.2,.3,.4,.5]],[(v,fmt(v,2).replace('.',',')) for v in [0,.1,.2,.3,.4,.5]])
    a.curve([(t,x) for t,q,y,x,loss in rows],TEAL)
    a.curve([(t,y) for t,q,y,x,loss in rows],BLUE)
    a.dot(.2,1-h(.26),ORANGE,6);a.dotted(.2,1-h(.26))
    f.legend([('Antes: I(X;Y)',BLUE),('Después: I(X;Z)',TEAL)],735,164)
    f.note('δ = 0,2','El error compuesto vale 0,26; I(X;Z)=0,173254 bits y la pérdida ≈ 0,357751.')
    return f.output(),csv_content(['delta','error_final_q','I_XY_bits','I_XZ_bits','perdida_bits'],rows)

def build206():
    p=[.4,.3,.2,.1];codes=['0','10','110','111'];lengths=[len(s) for s in codes];surprise=[-math.log2(x) for x in p]
    Hsource=entropy(p);Lsource=sum(x*n for x,n in zip(p,lengths))
    assert abs(Hsource-1.84643934467)<1e-8 and abs(Lsource-1.9)<1e-12
    assert sum(2**(-n) for n in lengths)==1
    f=Figure(206,'Huffman: longitudes frente a sorpresa','Fuente de cuatro símbolos; códigos 0, 10, 110 y 111','Barras comparan longitud de código Huffman y autoinformación de cada símbolo; la longitud media 1,9 supera H=1,846439 bits.')
    a=Axes(f,xmin=.3,xmax=4.7,ymax=3.7,xlabel='Símbolo y probabilidad',ylabel='Bits por símbolo')
    a.ticks([(i,f'{s} · p={fmt(p[i-1],1)}') for i,s in enumerate('ABCD',1)],[(i,str(i)) for i in range(4)])
    for i,(L,surp) in enumerate(zip(lengths,surprise),1):
        for dx,y,col in [(-.16,L,BLUE),(.16,surp,ORANGE)]:
            x1,bot=a.xy(i+dx-.12,0);x2,top=a.xy(i+dx+.12,y);f.rect(x1,top,x2-x1,bot-top,col,2)
    f.legend([('Longitud Huffman',BLUE),('Sorpresa −log₂p',ORANGE)],755,164)
    f.note('Costos ponderados','H = 1,846439 bits/símbolo; L = 1,9; redundancia = 0,053561 bits/símbolo.')
    return f.output(),csv_content(['simbolo','probabilidad','codigo_Huffman','longitud_bits','sorpresa_bits'],[(s,v,code,n,surp) for s,v,code,n,surp in zip('ABCD',p,codes,lengths,surprise)])

def build207():
    rows=[]
    for j in range(101):
        x=j/200;rows.append((x,1-h(x),1-x))
    f=Figure(207,'Capacidad: inversión frente a borrado identificado','Dos canales distintos, misma fracción de perturbaciones','Capacidad del canal binario de inversión 1−h₂(ε) y de borrado conocido 1−α, en bits por uso.')
    a=Axes(f,xmax=.5,ymax=1.05,xlabel='Probabilidad de inversión ε o borrado α',ylabel='Capacidad (bits por uso)')
    a.ticks([(v,fmt(v,1).replace('.',',')) for v in [0,.1,.2,.3,.4,.5]],[(v,fmt(v,1).replace('.',',')) for v in [0,.2,.4,.6,.8,1]])
    a.curve([(x,c) for x,c,e in rows],BLUE);a.curve([(x,e) for x,c,e in rows],TEAL)
    a.dot(.1,1-h(.1),BLUE);a.dot(.1,.9,TEAL)
    f.legend([('Inversión (BSC)',BLUE),('Borrado conocido (BEC)',TEAL)],754,164)
    f.note('Comparación de mecanismos','Con perturbación 0,1: BSC ≈ 0,531 y BEC = 0,9 bits/uso; las reglas de error difieren.')
    return f.output(),csv_content(['probabilidad_perturbacion','capacidad_BSC_bits_uso','capacidad_BEC_bits_uso'],rows)

def build208():
    p=.1;rows=[]
    for j in range(101):
        D=j/200;rb=max(0,1-h(D));rr=max(0,h(.1)-h(D)) if D<=.1 else 0.
        rows.append((D,rb,rr))
    assert abs((h(.1)-h(.05))-.1826)<.001
    f=Figure(208,'Tasa y distorsión dependen de la fuente','Bits independientes, error de Hamming; p=0,5 frente a p=0,1','R(D) de Bernoulli equiprobable e infrecuente, mostrando que la segunda puede codificarse a tasa cero para D≥0,1.')
    a=Axes(f,xmax=.5,ymax=1.05,xlabel='Distorsión tolerada D (fracción errónea)',ylabel='Tasa mínima R(D), bits/símbolo')
    a.ticks([(v,fmt(v,2).replace('.',',')) for v in [0,.1,.2,.3,.4,.5]],[(v,fmt(v,2).replace('.',',')) for v in [0,.25,.5,.75,1]])
    a.curve([(D,r) for D,r,_ in rows],BLUE);a.curve([(D,r) for D,_,r in rows],TEAL)
    a.dot(.05,h(.1)-h(.05),TEAL)
    f.legend([('P(uno)=0,5',BLUE),('P(uno)=0,1',TEAL)],776,161)
    f.note('Caso D = 0,05','R(D) para p=0,1 ≈ 0,1826 bits/símbolo; no garantiza proteger cada uno raro.')
    return f.output(),csv_content(['distorsion_D','R_equiprobable_bits','R_rara_p01_bits'],rows)

def build209():
    lo=0.;hi=1.
    for _ in range(90):
        la=(lo+hi)/2;p=[math.exp(la*i) for i in range(1,7)];s=sum(p);p=[x/s for x in p]
        mean=sum((i+1)*v for i,v in enumerate(p))
        if mean<4.5:lo=la
        else:hi=la
    lam=(lo+hi)/2;p=[math.exp(lam*i) for i in range(1,7)];p=[x/sum(p) for x in p]
    assert abs(sum(i*v for i,v in enumerate(p,1))-4.5)<1e-12
    assert abs(lam-.371049)<1e-6
    f=Figure(209,'Máxima entropía con media del dado igual a 4,5','P(cara i) ∝ exp(λ i), λ ≈ 0,371049','Barras del modelo de máxima entropía comparadas con un dado uniforme; la primera distribución tiene media 4,5.')
    a=Axes(f,xmin=.4,xmax=6.6,ymax=.41,xlabel='Cara del dado',ylabel='Probabilidad')
    a.ticks([(i,str(i)) for i in range(1,7)],[(v,fmt(v,2).replace('.',',')) for v in [0,.1,.2,.3,.4]])
    for i,v in enumerate(p,1):
        for dx,y,col in [(-.16,v,TEAL),(.16,1/6,BLUE)]:
            x1,b=a.xy(i+dx-.12,0);x2,t=a.xy(i+dx+.12,y);f.rect(x1,t,x2-x1,b-t,col,2)
    f.legend([('Máx. entropía · media 4,5',TEAL),('Referencia uniforme · media 3,5',BLUE)],726,166)
    f.note('Restricción comprobada',f'λ = {fmt(lam,6)}; media = 4,5; H = {fmt(entropy(p),5)} bits, frente a log₂(6) = {fmt(math.log2(6),5)}.')
    return f.output(),csv_content(['cara','probabilidad_maxent','probabilidad_uniforme','lambda'],[(i,v,1/6,lam) for i,v in enumerate(p,1)])

def build210():
    cases=[('Fijo',64,64,True),('Prefijo',56,56,True),('Histograma',18,18,False),('Rachas',33,117,True),('Selector',34,57,True)]
    assert cases[3][1]==5+7*4 and cases[3][2]==5+7*16
    f=Figure(210,'El menor archivo puede perder la pregunta','Registros S y T de 32 símbolos: mismas frecuencias, distinto orden','Barras muestran tamaños de cinco formatos para registros S y T; histograma pierde el orden y la detección de episodios D.')
    a=Axes(f,xmin=.3,xmax=5.7,ymax=130,xlabel='Formato y conservación del orden',ylabel='Carga útil (bits)')
    a.ticks([(i,name) for i,(name,s,t,exact) in enumerate(cases,1)],[(y,str(y)) for y in [0,30,60,90,120]])
    for i,(name,s,t,exact) in enumerate(cases,1):
        for dx,y,col in [(-.17,s,BLUE),(.17,t,ORANGE)]:
            x1,bot=a.xy(i+dx-.13,0);x2,top=a.xy(i+dx+.13,y);f.rect(x1,top,x2-x1,bot-top,col if exact else '#899ba8',2)
            f.txt((x1+x2)/2,top-9,str(y),13,INK,True,anchor='middle')
    f.legend([('Registro S',BLUE),('Registro T',ORANGE),('Histograma sin orden','#899ba8')],766,165)
    f.note('Garantía científica','El histograma ahorra bits pero no distingue S (episodio D≥3) de T (sin episodio D≥3).')
    return f.output(),csv_content(['formato','bits_S','bits_T','conserva_frecuencias','conserva_orden'],[(name,s,t,'si','si' if exact else 'no') for name,s,t,exact in cases])

CASES={i:globals()[f'build{i}'] for i in range(201,211)}

def generated():
    result={}
    for n,fn in CASES.items():
        svg,data=fn();result[f'figures/complexa-c{n}-fig01.svg']=svg
        result[f'repro/c201-210/c{n}.csv']=data
    return result

def bundle(files):
    buf=io.BytesIO()
    with ZipFile(buf,'w',compression=ZIP_STORED) as z:
        for path,content in sorted(files.items()):
            name=path.removeprefix('figures/').removeprefix('repro/c201-210/')
            inf=ZipInfo(name,(2026,1,1,0,0,0));inf.compress_type=ZIP_STORED
            inf.create_system=3;inf.external_attr=0o644<<16
            z.writestr(inf,content)
    return buf.getvalue()

def run(check:bool,root:Path):
    files=generated()
    for n in CASES:
        import xml.etree.ElementTree as ET
        ET.fromstring(files[f'figures/complexa-c{n}-fig01.svg'])
        data=files[f'repro/c201-210/c{n}.csv'].decode().splitlines()
        assert len(data)>=5 and len(data[0].split(','))>=3, n
    extra={'repro/c201-210/make_figures.py':BASE.joinpath('make_figures.py').read_bytes(),
           'repro/c201-210/README.md':BASE.joinpath('README.md').read_bytes()}
    zip_bytes=bundle({**files,**extra})
    if check:
        for path,expected in files.items():
            target=root/path;assert target.is_file() and target.read_bytes()==expected,f'CHECK FAIL: {target}'
        zp=root/'zip-data/complexa-c201-c210-reproducible.zip.b64'
        import base64
        assert zp.is_file() and base64.b64decode(zp.read_text().strip())==zip_bytes,'CHECK FAIL: zip'
        print('PASS C201–C210: 10 SVG, 10 CSV, ZIP bitwise reproducible; numerical checks.');return
    for path,data in files.items():
        target=root/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(data)
    import base64
    zp=root/'zip-data/complexa-c201-c210-reproducible.zip.b64';zp.parent.mkdir(parents=True,exist_ok=True)
    zp.write_text(base64.b64encode(zip_bytes).decode()+'\n')
    direct=root/'complexa-c201-c210-reproducible.zip';direct.write_bytes(zip_bytes)
    print(f'PASS C201–C210: {len(files)} SVG+CSV; ZIP {len(zip_bytes)} bytes; SHA256 {hashlib.sha256(zip_bytes).hexdigest()}')

if __name__=='__main__':
    ap=argparse.ArgumentParser();g=ap.add_mutually_exclusive_group(required=True)
    g.add_argument('--out',type=Path);g.add_argument('--check',type=Path)
    args=ap.parse_args()
    run(args.check is not None,args.check or args.out)
