#!/usr/bin/env python3
"""COMPLEXA C211–C220: 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')



# Each construction is derived from the stated theoretical examples in C211-C220.
# Neither stochastic simulations nor empirical measurements are used.

def build211():
    q=.1;rate=h(q)
    rows=[(n,1+(n-1)*rate,float(n),rate) for n in range(1,33)]
    assert abs(rows[-1][1]-15.5388634)<1e-5
    f=Figure(211,'La memoria cambia la entropía de bloques',
        'Cadena estacionaria: P(cambio)=0,1; comparación con 32 bits independientes',
        'La entropía de bloque de una cadena binaria persistente crece como 1+(n−1)h₂(0,1); una fuente independiente crece como n.')
    a=Axes(f,xmin=1,xmax=32,ymin=0,ymax=33,xlabel='Longitud del bloque n (símbolos)',ylabel='Entropía H(n), bits')
    a.ticks([(x,str(x)) for x in [1,8,16,24,32]],[(x,str(x)) for x in [0,8,16,24,32]])
    a.curve([(n,m) for n,m,i,r in rows],TEAL)
    a.curve([(n,i) for n,m,i,r in rows],BLUE)
    a.dot(32,rows[-1][1],ORANGE,6)
    f.legend([('Cadena: H(n)=1+(n−1)h₂(0,1)',TEAL),('Fuente independiente: H(n)=n',BLUE)],700,164)
    f.note('Bloque de 32 símbolos',f'H(cadena) = {fmt(rows[-1][1],3)} bits frente a 32 bits sin memoria; tasa = {fmt(rate,6)} bits/símbolo.')
    return f.output(),csv_content(['n_simbolos','H_cadena_bits','H_independiente_bits','tasa_cadena_bits_simbolo'],rows)


def build212():
    q=.1;rate=h(q)
    hm=lambda n:1+(n-1)*rate
    hp=lambda n:1 if n==1 else 2
    rows=[(L,2*hm(L)-hm(2*L),2*hp(L)-hp(2*L),0.) for L in range(1,11)]
    assert abs(rows[0][2])<1e-12 and all(abs(r[2]-2)<1e-12 for r in rows[1:])
    assert all(abs(r[1]-(1-rate))<1e-12 for r in rows)
    f=Figure(212,'Ventanas cortas pueden ocultar toda la memoria',
        'Información pasado–futuro I(L)=2H(L)−H(2L), ventanas adyacentes de longitud L',
        'Una cadena Markov comparte 0,531 bits desde L=1, un patrón 0011 de fase uniforme comparte cero en L=1 y dos bits desde L=2.')
    a=Axes(f,xmin=.75,xmax=10.25,ymin=0,ymax=2.24,xlabel='Longitud L de cada ventana (símbolos)',ylabel='Información compartida (bits)')
    a.ticks([(x,str(x)) for x in [1,2,4,6,8,10]],[(v,fmt(v,1)) for v in [0,.5,1,1.5,2]])
    for j,col in [(1,TEAL),(2,ORANGE),(3,BLUE)]:
        series=[(r[0],r[j]) for r in rows]
        a.curve(series,col)
        for x,y in series:a.dot(x,y,col,4)
    f.legend([('Cadena persistente',TEAL),('Ciclo 0011, fase uniforme',ORANGE),('Fuente independiente',BLUE)],720,167)
    f.note('Peligro del contexto corto','En el ciclo 0011, I(1)=0 no significa ausencia de memoria: I(L)=2 bits desde L=2.')
    return f.output(),csv_content(['L_ventana','I_cadena_bits','I_periodica_bits','I_independiente_bits'],rows)


def build213():
    e=.1;rows=[]
    for j in range(101):
        p=j/200
        py=e+(1-2*e)*p
        forward=h(py)-h(e)
        rows.append((p,py,forward,0.))
    assert abs(rows[2][2]-.024858)<5e-6 and abs(rows[-1][2]-.53100440641)<1e-10
    f=Figure(213,'La transferencia depende de la distribución de entrada',
        'Copia de Xₜ hacia Yₜ₊₁ con inversión independiente ε=0,1',
        'Transferencia en bits para distintas probabilidades de entrada; bajo el mecanismo unidireccional la transferencia inversa es nula.')
    a=Axes(f,xmin=0,xmax=.5,ymin=0,ymax=.58,xlabel='Probabilidad de uno en la fuente p',ylabel='Transferencia (bits por predicción)')
    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,.1,.2,.3,.4,.5]])
    a.curve([(p,i) for p,py,i,rev in rows],TEAL)
    a.curve([(p,rev) for p,py,i,rev in rows],BLUE)
    a.dot(.01,rows[2][2],ORANGE,6);a.dot(.5,rows[-1][2],TEAL,6)
    f.legend([('X → Y: transferencia de un paso',TEAL),('Y → X: cero en este modelo',BLUE)],740,163)
    f.note('Regla de copia fija','P(X=1)=0,01: 0,024858 bits; P(X=1)=0,5: 0,531004 bits. No cambia ε.')
    return f.output(),csv_content(['p_fuente','p_objetivo','transferencia_X_a_Y_bits','transferencia_Y_a_X_bits'],rows)


def cond_mi_proxy(e_x=.1,e_y=.2,e_w=.2):
    # Enumerate the latent equiprobable bit and three independent noisy copies.
    P={}
    for u in (0,1):
      for ex in (0,1):
       for ey in (0,1):
        for ew in (0,1):
          prob=.5 * (e_x if ex else 1-e_x) * (e_y if ey else 1-e_y) * (e_w if ew else 1-e_w)
          x=u^ex;y=u^ey;w=u^ew
          P[x,y,w]=P.get((x,y,w),0.)+prob
    def pr(*conds):
        return sum(v for key,v in P.items() if all(key[i]==value for i,value in conds))
    cmi=0.
    for x in (0,1):
      for y in (0,1):
       for w in (0,1):
        a=P[x,y,w];pw=pr((2,w));pxw=pr((0,x),(2,w));pyw=pr((1,y),(2,w))
        if a:cmi += a*math.log2((a*pw)/(pxw*pyw))
    return cmi,P


def build214():
    e_x=.1;e_y=.2;e_w=.2
    e_xy=e_x+e_y-2*e_x*e_y
    raw=1-h(e_xy)
    partial,P=cond_mi_proxy(e_x,e_y,e_w)
    assert abs(e_xy-.26)<1e-12 and abs(raw-.173253)<2e-6
    assert abs(partial-.097093)<2e-6 and abs(sum(P.values())-1)<1e-12
    rows=[('Sin controlar',raw),('Control W ruidoso',partial),('Causa U observada',0.)]
    f=Figure(214,'Controlar un indicador imperfecto no elimina la asociación',
        'Causa común U uniforme; X, objetivo y control W son copias con errores 0,1; 0,2; 0,2',
        'La información entre las dos señales es 0,173254 bits sin control, 0,097093 condicionando en un proxy ruidoso y cero condicionando en la causa real.')
    a=Axes(f,xmin=.35,xmax=3.65,ymin=0,ymax=.205,xlabel='Conjunto de variables de control',ylabel='Información condicional (bits)')
    a.ticks([(i,row[0]) for i,row in enumerate(rows,1)],[(v,fmt(v,2).replace('.',',')) for v in [0,.05,.1,.15,.2]])
    for i,(_,val) in enumerate(rows,1):
        left,bot=a.xy(i-.27,0);right,top=a.xy(i+.27,val)
        if val:f.rect(left,top,right-left,bot-top,[BLUE,ORANGE,TEAL][i-1],4)
        else:f.line(left,bot,right,bot,TEAL,5)
        f.txt((left+right)/2,top-12,fmt(val,6).replace('.',','),16,INK,True,anchor='middle')
    f.note('Sin flecha causal X → objetivo','Una asociación residual al controlar W no identifica un efecto causal: W mide U con error.')
    return f.output(),csv_content(['control','informacion_condicional_bits','error_X','error_objetivo','error_W'],[(lab,val,e_x,e_y,e_w) for lab,val in rows])


def build215():
    h_and=h(.25)
    rows=[('Copias',1.,1.,1.),('XOR',0.,0.,1.),('AND',h_and-.5,h_and-.5,h_and)]
    assert abs(rows[-1][-1]-.811278)<1e-6 and abs(rows[-1][1]-.311278)<1e-6
    f=Figure(215,'Una pareja puede informar más que cada fuente aislada',
        'Dos fuentes binarias equilibradas; objetivo: copia, XOR o AND',
        'Información mutua individual I(X1;Y), I(X2;Y) y conjunta I((X1,X2);Y) para tres funciones deterministas.')
    a=Axes(f,xmin=.3,xmax=3.7,ymin=0,ymax=1.12,xlabel='Regla que define el objetivo Y',ylabel='Información sobre Y (bits)')
    a.ticks([(i,r[0]) for i,r in enumerate(rows,1)],[(v,fmt(v,1).replace('.',',')) for v in [0,.2,.4,.6,.8,1]])
    for i,(_,u,v,joint) in enumerate(rows,1):
        for dx,val,col in [(-.24,u,BLUE),(0,v,TEAL),(.24,joint,ORANGE)]:
            left,bot=a.xy(i+dx-.095,0);right,top=a.xy(i+dx+.095,val)
            if val>0:f.rect(left,top,right-left,bot-top,col,3)
    f.legend([('I(X₁;Y)',BLUE),('I(X₂;Y)',TEAL),('I((X₁,X₂);Y)',ORANGE)],793,165)
    f.note('XOR: sinergia elemental','I(X₁;Y)=I(X₂;Y)=0, pero juntas aportan 1 bit; una suma aislada lo perdería.')
    return f.output(),csv_content(['modelo','I_fuente1_bits','I_fuente2_bits','I_pareja_bits'],rows)


def build216():
    # Target Y=(A,B), source X1=A, X2=B; A and B iid fair bits.
    ia=1.;ib=1.;ij=2.
    rows=[('Información específica mínima',1.,0.,0.,1.),('BROJA',0.,1.,1.,0.)]
    assert all(abs(sum(row[1:])-ij)<1e-12 for row in rows)
    assert all(abs(row[1]+row[2]-ia)<1e-12 and abs(row[1]+row[3]-ib)<1e-12 for row in rows)
    f=Figure(216,'Una misma distribución, dos descomposiciones PID',
        'Objetivo Y=(A,B); fuentes X₁=A y X₂=B, bits independientes y equilibrados',
        'Repartos exactos de dos bits de información conjunta: el mínimo específico da un bit redundante y un bit sinérgico; BROJA da un bit exclusivo a cada fuente.')
    a=Axes(f,xmin=0,xmax=2.12,ymin=0,ymax=3.2,xlabel='Información total distribuida (bits)',ylabel='Método PID')
    a.ticks([(v,fmt(v,1)) for v in [0,.5,1,1.5,2]],[(1,'Mínimo específico'),(2.4,'BROJA')])
    for yi,row in [(1,rows[0]),(2.4,rows[1])]:
        left=0
        for val,col in zip(row[1:],[TEAL,BLUE,PLUM,ORANGE]):
            if not val:continue
            x1,y1=a.xy(left,yi-.31);x2,y2=a.xy(left+val,yi+.31)
            f.rect(x1,y2,x2-x1,y1-y2,col,2)
            f.txt((x1+x2)/2,(y1+y2)/2+5,str(int(val)),16,'#ffffff',True,anchor='middle')
            left+=val
    f.legend([('Redundancia',TEAL),('Exclusiva X₁',BLUE),('Exclusiva X₂',PLUM),('Sinergia',ORANGE)],754,161)
    f.note('Ambas satisfacen identidades de Shannon','La redundancia exige declarar una definición; las identidades de Shannon no bastan.')
    return f.output(),csv_content(['definicion','redundancia_bits','exclusiva_1_bits','exclusiva_2_bits','sinergia_bits','total_bits'],[(lab,r,u,v,s,r+u+v+s) for lab,r,u,v,s in rows])


def renyi_binary(q,p=.75):
    if abs(q-1)<1e-12:return h(p)
    if q==0:return 1.
    return math.log2(p**q+(1-p)**q)/(1-q)


def build217():
    p=.75
    rows=[(j/20,renyi_binary(j/20,p),1.) for j in range(101)]
    assert abs(renyi_binary(1)-.811278124459)<1e-10
    assert abs(renyi_binary(2)-.678071905113)<1e-10
    assert abs(renyi_binary(4)-.5474877953)<2e-5
    f=Figure(217,'La dimensión depende del orden de Rényi',
        'Cascada binomial diádica: reparto de masa 3/4 frente a 1/4 en cada nivel',
        'Dimensiones generalizadas Dq = log2(p^q+(1−p)^q)/(1−q); D1 se obtiene por continuidad; soporte geométrico de dimensión uno.')
    a=Axes(f,xmin=0,xmax=5,ymin=.35,ymax=1.04,xlabel='Orden de Rényi q',ylabel='Dimensión generalizada Dq')
    a.ticks([(i,str(i)) for i in range(6)],[(v,fmt(v,1).replace('.',',')) for v in [.4,.5,.6,.7,.8,.9,1]])
    a.curve([(q,v) for q,v,d0 in rows],TEAL)
    a.curve([(q,d0) for q,v,d0 in rows],BLUE)
    for q in [0,1,2,4]:a.dot(q,renyi_binary(q),ORANGE,5)
    f.legend([('Dq de la cascada',TEAL),('Dimensión del soporte = 1',BLUE)],745,163)
    f.note('Dos operaciones diferentes','El soporte no cambia al variar las masas: D0=1, D1≈0,8113 y D2≈0,6781.')
    return f.output(),csv_content(['orden_q','D_q','dimension_soporte','p_izquierda'],[(q,v,d,p) for q,v,d in rows])


def build218():
    rows=[]
    for j in range(101):
        e=j/200
        macro=1-h(e)
        micro=h(.25+e/2)-h(e)
        rows.append((e,micro,macro,macro-micro))
    assert abs(rows[20][1]-.412295)<2e-6 and abs(rows[20][2]-.5310044)<1e-6
    assert abs(rows[0][3]-.188721875541)<1e-10
    f=Figure(218,'El protocolo de intervención cambia la información efectiva',
        'Cuatro microestados (A,A,A,B) y dos macroestados (A,B): salto de grupo ε',
        'Con entradas uniformes distintas por escala, EI micro es h2(1/4+ε/2)−h2(ε), EI macro es 1−h2(ε); la ventaja no viola el procesamiento de datos.')
    a=Axes(f,xmin=0,xmax=.5,ymin=0,ymax=1.04,xlabel='Probabilidad ε de cambiar de grupo',ylabel='Información efectiva (bits)')
    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([(e,micro) for e,micro,macro,d in rows],BLUE)
    a.curve([(e,macro) for e,micro,macro,d in rows],TEAL)
    a.dot(.1,rows[20][1],BLUE,6);a.dot(.1,rows[20][2],TEAL,6)
    f.legend([('Micro: cuatro estados uniformes',BLUE),('Macro: dos grupos uniformes',TEAL)],715,163)
    f.note('Las distribuciones de entrada difieren','Con ε=0,1: micro≈0,412295 y macro≈0,531004 bits. Bajo entrada común no aumenta I.')
    return f.output(),csv_content(['epsilon','EI_micro_bits','EI_macro_bits','diferencia_bits'],rows)


def build219():
    M=1_000_000;truth=math.log2(M)
    rows=[(10**k,k,min(math.log2(10**k),truth),truth,truth-min(math.log2(10**k),truth)) for k in range(1,7)]
    assert abs(rows[2][4]-9.96578428466)<1e-9
    f=Figure(219,'La escasez de categorías impone una cota insalvable',
        'Población teórica uniforme de 1.000.000 de categorías, entropía = log₂(1.000.000)',
        'El estimador de entropía de frecuencias no supera log2(N) con N observaciones; curva de cota superior, no estimación ni simulación.')
    a=Axes(f,xmin=.8,xmax=6.2,ymin=0,ymax=21.5,xlabel='Tamaño muestral N (log₁₀ N)',ylabel='Entropía (bits)')
    a.ticks([(k,'10'+['¹','²','³','⁴','⁵','⁶'][k-1]) for k in range(1,7)],[(v,str(v)) for v in [0,5,10,15,20]])
    a.curve([(k,mx) for N,k,mx,t,d in rows],TEAL)
    a.curve([(k,t) for N,k,mx,t,d in rows],BLUE)
    for N,k,mx,t,d in rows:a.dot(k,mx,TEAL,5)
    f.legend([('Cota máxima de H empírica',TEAL),('H verdadera de la población',BLUE)],738,162)
    f.note('N = 1.000','H empírica ≤ 9,9658 bits frente a 19,9316 verdaderos; déficit ≥ 9,9658 bits.')
    return f.output(),csv_content(['N_observaciones','log10_N','cota_H_empirica_bits','H_poblacional_bits','deficit_minimo_bits'],rows)


def build220():
    rate=h(.1)
    rows=[('A independiente',1.,1.,0.),('B persistente',1.,rate,1-rate),('C ciclo 0011',1.,0.,2.),('D constante',1.,0.,1.)]
    assert all(x[1]==1 for x in rows) and rows[-2][3]==2
    f=Figure(220,'Una misma entropía marginal, cuatro historias distintas',
        'Cantidades teóricas de conjunto: A independiente, B Markov (cambio 0,1), C fase 0011, D constante aleatoria',
        'Comparación de H1, tasa de entropía h y entropía excesiva E para cuatro procesos; todos comparten H1=1 bit, pero diferentes memoria y aleatoriedad.')
    a=Axes(f,xmin=.4,xmax=4.6,ymin=0,ymax=2.24,xlabel='Proceso generador (distribución de conjunto)',ylabel='Cantidad informacional (bits)')
    a.ticks([(i,name.split()[0]) for i,(name,H1,hbar,E) in enumerate(rows,1)],[(v,fmt(v,1).replace('.',',')) for v in [0,.5,1,1.5,2]])
    for i,(_,H1,hbar,E) in enumerate(rows,1):
        for dx,v,col in [(-.23,H1,BLUE),(0,hbar,TEAL),(.23,E,ORANGE)]:
            left,bot=a.xy(i+dx-.075,0);right,top=a.xy(i+dx+.075,v)
            if v>0:f.rect(left,top,right-left,bot-top,col,3)
    f.legend([('H₁: símbolo aislado',BLUE),('h: incertidumbre nueva',TEAL),('E: memoria pasado–futuro',ORANGE)],751,163)
    f.note('Precisión sobre el proceso D','H₁=1 para el conjunto de constantes aleatorias; una trayectoria individual constante tiene H observado=0.')
    return f.output(),csv_content(['proceso','H1_bits','tasa_entropia_bits_simbolo','entropia_excesiva_bits'],rows)


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

def generated():
    result={}
    for n,fn in CASES.items():
        svg,data=fn()
        result[f'figures/complexa-c{n}-fig01.svg']=svg
        result[f'repro/c211-220/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/c211-220/')
            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()
    import xml.etree.ElementTree as ET
    for n in CASES:
        ET.fromstring(files[f'figures/complexa-c{n}-fig01.svg'])
        data=files[f'repro/c211-220/c{n}.csv'].decode().splitlines()
        assert len(data)>=3 and len(data[0].split(','))>=3,n
        assert all(len(x.split(','))==len(data[0].split(',')) for x in data[1:]),n
    extra={'repro/c211-220/make_figures.py':BASE.joinpath('make_figures.py').read_bytes(),
           'repro/c211-220/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-c211-c220-reproducible.zip.b64'
        import base64
        assert zp.is_file() and base64.b64decode(zp.read_text().strip())==zip_bytes,'CHECK FAIL: zip'
        print('PASS C211–C220: 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-c211-c220-reproducible.zip.b64';zp.parent.mkdir(parents=True,exist_ok=True)
    zp.write_text(base64.b64encode(zip_bytes).decode()+'\n')
    direct=root/'complexa-c211-c220-reproducible.zip';direct.write_bytes(zip_bytes)
    print(f'PASS C211–C220: {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)
