#!/usr/bin/env python3
"""COMPLEXA C251–C260: source-derived adaptive learning and decision processes 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
Every panel derives from the manuscript-defined examples, never unreported 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')




# Every plotted parameter is drawn from the didactic examples in the ten source manuscripts.
# No chart is an estimate from an empirical population or a clinical dataset.
from itertools import product


def build251():
    rows=[]
    for k in range(17):
        vals=[10,14-4*(.75**k),14-4*(.5**k)]
        rows.append((k,*vals,abs(14-vals[1]),abs(14-vals[2]),(.25/(2-.25))*9,(.5/(2-.5))*9))
    assert abs(rows[1][2]-11)<1e-12 and abs(rows[2][2]-11.75)<1e-12
    assert abs(rows[5][4]-0.94921875)<1e-9
    f=Figure(251,'Aprender más rápido también amplifica ruido','Previsión inicial 10 · nueva demanda 14 · dos tasas de actualización y una referencia fija',
      'Las curvas son soluciones exactas de q nuevo igual a q previo más alfa por error. Con alfa 0,25 el error decae como cuatro por 0,75 elevado a k; con alfa 0,5 es más veloz y su varianza estacionaria es mayor.')
    a=Axes(f,xmin=0,xmax=16,ymin=9.7,ymax=14.4,xlabel='Observaciones después del cambio (k)',ylabel='Previsión q (unidades)')
    a.ticks([(v,str(v)) for v in (0,2,4,6,8,10,12,14,16)],[(v,str(v)) for v in (10,11,12,13,14)])
    a.curve([(k,14) for k in range(17)],GREEN,2.3)
    for idx,col in ((2,TEAL),(3,ORANGE),(1,PLUM)):
        a.curve([(r[0],r[idx]) for r in rows],col,3.1)
    a.dot(5,rows[5][2],TEAL,6)
    f.legend([('Tasa 0,25',TEAL),('Tasa 0,5',ORANGE),('Sin aprendizaje',PLUM),('Consumo real 14',GREEN)],745,158)
    f.note('Contraste analítico','Con ruido de varianza 9, la previsión tiene varianza 9/7 o 3 según la tasa.')
    return f.output(),csv_content(['observaciones','q_sin_actualizar','q_alfa_025','q_alfa_050','error_alfa_025','error_alfa_050','varianza_estacionaria_025','varianza_estacionaria_050'],rows)


def build252():
    rows=[]
    for z in range(61):
        c=z/10
        rows.append((c,5-c,8-2*c,(8-2*c)-(5-c),2,1/32))
    assert rows[10][1]==4 and rows[10][2]==6
    assert rows[40][1]==1 and rows[40][2]==0
    f=Figure(252,'Buscar hasta ocho solo conviene si cuesta menos de tres','Ofertas 2 u 8 equiprobables · coste c por consulta · expectativas exactas de dos estrategias',
      'El neto esperado al aceptar la primera oferta es cinco menos c. Esperar la primera oferta de ocho tiene valor ocho menos dos c; la segunda supera a la primera solo cuando c es menor que tres.')
    a=Axes(f,xmin=0,xmax=6,ymin=-4.4,ymax=8.3,xlabel='Costo por consulta (c)',ylabel='Resultado neto esperado')
    a.ticks([(i,str(i)) for i in range(7)],[(v,str(v)) for v in (-4,-2,0,2,4,6,8)])
    a.curve([(x,y) for x,y,*_ in rows],TEAL)
    a.curve([(x,y) for x,_,y,*_ in rows],ORANGE)
    a.dotted(3,2,PLUM);a.dot(3,2,PLUM,6)
    f.txt(565,246,'Intersección: costo = 3',18,INK,True)
    f.legend([('Aceptar primera',TEAL),('Esperar oferta 8',ORANGE)],760,160)
    f.note('Convenciones que hacen válida la fórmula','Ofertas i.i.d. con probabilidad 1/2, horizonte ilimitado y consultas con reposición.')
    return f.output(),csv_content(['costo_consulta','aceptar_primera','esperar_hasta_8','ventaja_esperar','consultas_esperadas_alta','prob_mas_de_5_consultas'],rows)


def build253():
    rewards=[1,0,1];qconstant=qmean=0
    rows=[(0,0,0,0,0,0)]
    for k,v in enumerate(rewards,1):
        qconstant+=.5*(v-qconstant)
        qmean+=(v-qmean)/k
        rows.append((k,v,qconstant,qmean,.5,1/k))
    assert abs(rows[-1][2]-.625)<1e-12 and abs(rows[-1][3]-2/3)<1e-12
    f=Figure(253,'Dos tasas, dos recuerdos de las mismas recompensas','Una sola acción · recompensas observadas [1, 0, 1] · valor inicial cero',
      'Al usar alfa igual a medio, los valores sucesivos son 0,5, 0,25 y 0,625. Con alfa igual a 1/n, los valores son 1, 0,5 y dos tercios. Ambos cálculos utilizan exactamente las mismas tres recompensas.')
    a=Axes(f,xmin=0,xmax=3,ymin=0,ymax=1.12,xlabel='Número de observaciones de la acción',ylabel='Estimación Q')
    a.ticks([(i,str(i)) for i in range(4)],[(x,fmt(x,2)) for x in (0,.25,.5,.75,1)])
    a.curve([(r[0],r[2]) for r in rows],TEAL,4)
    a.curve([(r[0],r[3]) for r in rows],ORANGE,4)
    for k,*v in rows: a.dot(k,v[1],TEAL,5);a.dot(k,v[2],ORANGE,4)
    f.legend([('Tasa constante 0,5',TEAL),('Promedio: tasa 1/n',ORANGE)],748,158)
    f.txt(548,247,'Q final: 0,625 vs. 0,6667',19,INK,True)
    f.note('La estimación requiere una regla temporal','La media muestral y la memoria exponencial dan resultados distintos sobre los mismos datos.')
    return f.output(),csv_content(['visitas_a_la_accion','recompensa_recibida','valor_alfa_constante','valor_media_muestral','tasa_constante','tasa_media'],rows)


def build254():
    vs=vt=0;rows=[]
    for step in range(31):
        qa_s=-1+.45*(vt+vs)
        qa_t=1+.9*vt
        rows.append((step,vs,vt,qa_s,qa_t,max(2,qa_s),max(6,qa_t)))
        vs,vt=max(2,qa_s),max(6,qa_t)
    assert abs(rows[1][1]-2)<1e-12 and abs(rows[1][2]-6)<1e-12
    assert abs(rows[2][1]-2.6)<1e-12 and abs(rows[2][2]-6.4)<1e-12
    assert 0<70/11-rows[-1][1]<.3 and 0<10-rows[-1][2]<.3
    f=Figure(254,'La iteración de Bellman se acerca al valor óptimo','Estados S y T · descuento 0,9 · partir de V(S)=V(T)=0 y aplicar máximos',
      'La iteración de valor aplica V(S) igual al máximo entre 2 y menos uno más 0,45 por la suma de V(T) y V(S); y V(T) máximo entre 6 y 1 más 0,9 por V(T). Los puntos fijos son 70/11 y 10.')
    a=Axes(f,xmin=0,xmax=30,ymin=0,ymax=10.5,xlabel='Iteración simultánea',ylabel='Valor descontado de cada estado')
    a.ticks([(v,str(v)) for v in (0,5,10,15,20,25,30)],[(v,str(v)) for v in (0,2,4,6,8,10)])
    a.curve([(r[0],r[1]) for r in rows],TEAL,3.5)
    a.curve([(r[0],r[2]) for r in rows],ORANGE,3.5)
    a.curve([(0,70/11),(30,70/11)],TEAL,1.2)
    a.curve([(0,10),(30,10)],ORANGE,1.2)
    f.legend([('Estado S; límite 70/11',TEAL),('Estado T; límite 10',ORANGE)],748,158)
    f.note('Referencias matemáticas independientes','El estado terminal tiene valor cero; intentar en S y mantener en T son óptimos.')
    return f.output(),csv_content(['iteracion','valor_S','valor_T','accion_intentar_S','accion_mantener_T','siguiente_S','siguiente_T'],rows)


def build255():
    rows=[('antes',6,4,5.4,3.6,5.4,3.6),('despues_revaluar_A',1,4,.9,3.6,5.4,3.6)]
    f=Figure(255,'El mismo árbol, dos maneras de reutilizar una experiencia','Izquierda → A · derecha → B · descuento 0,9 · recompensa en A cambia de 6 a 1',
      'Después de cambiar A de recompensa seis a uno, un agente con modelo que reevalúa el árbol asigna valor 0,9 a izquierda y 3,6 a derecha. Un agente de valores directos que no actualiza sus entradas sigue asignando 5,4 y 3,6.')
    labs=['Con modelo: izquierda','Con modelo: derecha','Valores directos: izquierda','Valores directos: derecha']
    before=[5.4,3.6,5.4,3.6];after=[.9,3.6,5.4,3.6]
    for i,(lab,b,a) in enumerate(zip(labs,before,after)):
        y=200+i*81
        f.txt(84,y+4,lab,15,INK,True)
        f.rect(331,y-15,b/6*590,17,GRID,3)
        f.rect(331,y+7,a/6*590,23,TEAL if i<2 else ORANGE,3)
        f.txt(337+b/6*590,y-2,fmt(b,1),12,MUTED)
        f.txt(340+a/6*590,y+24,fmt(a,1),13,INK,True)
    f.legend([('Valor posterior con modelo',TEAL),('Q sin nueva experiencia',ORANGE)],645,540)
    f.note('La revaluación es una manipulación conocida','Ambas rutas eligen izquierda antes; solo el agente con modelo cambia inmediatamente a derecha.')
    return f.output(),csv_content(['fase','recompensa_A','recompensa_B','modelo_valor_izquierda','modelo_valor_derecha','directo_Q_izquierda','directo_Q_derecha'],rows)


def build256():
    rows=[]
    for horizon in range(1,21):
        explore=.5+.8*(horizon-1)
        safe=.6*horizon
        rows.append((horizon,explore,safe,explore-safe,.8*horizon, .8*horizon-explore))
    assert abs(rows[1][1]-1.3)<1e-12 and rows[0][3]<0 and rows[1][3]>0
    f=Figure(256,'La decisión de explorar cambia con el horizonte','Segura: 0,6 siempre · desconocida: tipo bueno o malo, ambos con probabilidad 1/2',
      'Probar la desconocida una sola vez produce retorno esperado de 0,5 más 0,8 por H menos uno. Elegir siempre la segura produce 0,6 por H. Explorar es mejor con horizontes enteros a partir de dos elecciones.')
    a=Axes(f,xmin=1,xmax=20,ymin=0,ymax=17,xlabel='Número de decisiones disponibles (H)',ylabel='Recompensa total esperada')
    a.ticks([(x,str(x)) for x in (1,2,5,10,15,20)],[(x,str(x)) for x in (0,4,8,12,16)])
    a.curve([(r[0],r[1]) for r in rows],TEAL,3.5)
    a.curve([(r[0],r[2]) for r in rows],ORANGE,3.5)
    a.dot(2,rows[1][1],TEAL,6)
    f.legend([('Explorar primero',TEAL),('Segura siempre',ORANGE)],754,158)
    f.txt(455,338,'H = 2: explorar gana 0,1',18,INK,True)
    f.note('Explorar tiene costo inmediato','El beneficio posterior depende de revelar el tipo y disponer de decisiones futuras.')
    return f.output(),csv_content(['horizonte','retorno_explorar_primero','retorno_segura_siempre','ventaja_explorar','retorno_optimo_informado','costo_contra_informado'],rows)


def build257():
    gamma=.9;lam=.8;alpha=.5;reward=8
    labels=['S0','S1','S2'];steps=[]
    for i in range(3):
        credit=(gamma*lam)**(2-i)
        traces=alpha*reward*credit
        mc=alpha*reward*(gamma**(2-i))
        steps.append((labels[i],i,credit,traces,mc,alpha,reward))
    assert abs(steps[0][3]-2.0736)<1e-12
    assert abs(steps[1][3]-2.88)<1e-12
    assert abs(steps[2][3]-4)<1e-12
    f=Figure(257,'Cómo llega el crédito a estados anteriores','Cadena S0 → S1 → S2 → terminal · recompensa final 8 · α=0,5; γ=0,9; λ=0,8',
      'Las trazas asignan cambios de valor 2,0736, 2,88 y 4 a S0, S1 y S2 durante el primer episodio. La actualización Monte Carlo de episodio entero asigna 3,24, 3,6 y 4.')
    for i,row in enumerate(steps):
        y=220+i*104
        f.txt(95,y+17,row[0],18,INK,True)
        f.rect(160,y-16,row[3]/4*630,31,TEAL,4)
        f.rect(160,y+20,row[4]/4*630,13,ORANGE,3)
        f.txt(800,y+11,fmt(row[3],4),15,INK,True)
        f.txt(800,y+37,fmt(row[4],4),13,MUTED)
    f.legend([('Primera corrección TD(λ)',TEAL),('Retorno completo Monte Carlo',ORANGE)],660,164)
    f.note('Crédito no equivale a causalidad física','Todas las cifras son actualizaciones algorítmicas tras una recompensa terminal de ocho.')
    return f.output(),csv_content(['estado','distancia_a_terminal','traza_en_ultimo_error','cambio_td_lambda','cambio_montecarlo','tasa_aprendizaje','recompensa_final'],steps)


def posterior_dynamic(sequence,change=.1,correct=.8):
    b=.5
    for i,signal in enumerate(sequence):
        if i>0:b=change+(1-2*change)*b
        like=correct if signal=='L' else 1-correct
        reverse=1-correct if signal=='L' else correct
        b=b*like/(b*like+(1-b)*reverse)
    return b


def posterior_static(sequence):
    n=sequence.count('L');other=len(sequence)-n
    a=(.8**n)*(.2**other);b=(.2**n)*(.8**other)
    return a/(a+b)


def build258():
    sequences=[''.join(s) for s in product('LR',repeat=3)]
    rows=[(seq,seq.count('L'),posterior_static(seq),posterior_dynamic(seq),.8,.1) for seq in sequences]
    assert abs(next(x[3] for x in rows if x[0]=='LLR')-269/481)<1e-12
    assert abs(next(x[3] for x in rows if x[0]=='RLL')-404/481)<1e-12
    f=Figure(258,'Con estado cambiante importa el orden de las señales','Estado L/R · sensor correcto con probabilidad 0,8 · transición oculta de 0,1 por paso',
      'La posterior de L para LLR es 269/481 y para RLL 404/481 con transición de estado; en el modelo de estado fijo ambas secuencias dan probabilidad 0,8. La figura enumera exactamente las ocho palabras de longitud tres.')
    a=Axes(f,x0=110,y0=202,w=840,h=302,xmin=-.5,xmax=7.5,ymin=0,ymax=1.03,xlabel='Secuencia de tres observaciones',ylabel='P(estado actual = L)')
    a.ticks([(i,seq) for i,seq in enumerate(sequences)],[(x,fmt(x,1)) for x in (0,.2,.4,.6,.8,1)])
    for i,row in enumerate(rows):
        for jj,idx,color in ((-1,2,TEAL),(1,3,ORANGE)):
            px,py=a.xy(i+jj*.17,row[idx]);_,b=a.xy(0,0)
            f.rect(px-12,py,24,b-py,color,2)
    f.legend([('Estado fijo',TEAL),('Cambio oculto 0,1',ORANGE)],748,158)
    f.note('La antigua evidencia se descuenta según una transición','LLR y RLL contienen el mismo número de señales, pero tienen posteriores distintas.')
    return f.output(),csv_content(['secuencia','numero_senales_L','posterior_estado_fijo','posterior_con_cambio','exactitud_sensor','probabilidad_transicion'],rows)


def stationary_error(alpha,Q=.04,R=1):
    return (((1-alpha)**2)*Q+alpha**2*R)/(1-(1-alpha)**2)


def build259():
    rows=[]
    for i in range(1,200):
        alpha=i/100
        rows.append((alpha,stationary_error(alpha),alpha/(2-alpha),.04,1,((1-alpha)**2)*.04,alpha**2))
    opt=(math.sqrt(.04*.04+4*.04)-.04)/2
    assert abs(opt-.1809975124224)<1e-12
    assert abs(stationary_error(.1)-.2231578947368421)<1e-12
    f=Figure(259,'Plasticidad óptima: ni demasiado lenta ni demasiado ruidosa','Variable latente con varianza de cambio Q=0,04 · error de medida R=1',
      'Error cuadrático estacionario como función de tasa alpha según la recursión de seguimiento. El mínimo es alpha aproximadamente 0,180998 y P aproximadamente 0,180998; con tasas excesivas la varianza vuelve a aumentar.')
    a=Axes(f,xmin=0,xmax=1.2,ymin=0,ymax=1.53,xlabel='Tasa de actualización α',ylabel='Error cuadrático estacionario P')
    a.ticks([(x,fmt(x,2)) for x in (0,.2,.4,.6,.8,1,1.2)],[(x,fmt(x,1)) for x in (0,.3,.6,.9,1.2,1.5)])
    a.curve([(r[0],r[1]) for r in rows if r[0]<=1.2],TEAL,3.2)
    a.dot(opt,stationary_error(opt),ORANGE,7)
    f.txt(480,244,'α óptima ≈ 0,181',18,INK,True)
    f.legend([('P(α), Q=0,04; R=1',TEAL),('Mínimo analítico',ORANGE)],743,160)
    f.note('La tasa minimiza error para este entorno','Un sensor más ruidoso o una variable latente más móvil modifica el punto óptimo.')
    return f.output(),csv_content(['alfa','error_cuadratico_estacionario','varianza_media_constante_q0','Q','R','numerador_ruido_estado','numerador_ruido_sensor'],rows)


def count_A(alpha,beta,horizon=20):
    q=1.;n=0
    while n<horizon and q>=beta: # desempate favorece A
        n+=1;q*=1-alpha
    return n


def build260():
    rows=[]
    for alpha in (.1,.25,.5):
        for beta in (.2,.4,.6):
            ref=count_A(alpha,beta)
            rows.append((alpha,beta,ref,ref,0,20-ref,ref*0+(20-ref)*beta,20*beta))
    assert [r[2] for r in rows]==[16,9,5,6,4,2,3,2,1]
    f=Figure(260,'Misma perseverancia; distintas causas computacionales','Dos modelos equivalentes en conducta inicial · intervención de una acción forzada B',
      'En las nueve combinaciones alfa-beta, los modelos E y P eligen el mismo número de acciones A sin intervención. Tras imponer B, el modelo E conserva esas elecciones, pero P elige B desde la primera decisión libre.')
    xs=[.2,.4,.6]; ys=[.1,.25,.5]
    f.txt(94,197,'Alfa ↓      Beta →',16,INK,True)
    for j,beta in enumerate(xs):f.txt(365+j*156,217,fmt(beta,2),16,MUTED,True,anchor='middle')
    for i,alpha in enumerate(ys):
        y=242+i*93;f.txt(204,y+44,fmt(alpha,2),17,INK,True,anchor='middle')
        for j,beta in enumerate(xs):
            z=next(r[2] for r in rows if r[0]==alpha and r[1]==beta)
            x=292+j*156
            f.rect(x,y,137,77,LT,10,GRID)
            f.rect(x,y,137*z/20,77,TEAL,7)
            f.txt(x+68.5,y+40,f'{z} A',23,INK,True,'middle')
    f.rect(796,236,245,280,LT,10,GRID)
    f.txt(818,271,'Sin acción forzada',18,INK,True)
    f.txt(818,309,'E = P',22,TEAL,True)
    f.line(819,330,1015,330,GRID,2)
    f.txt(818,359,'Tras imponer B',18,INK,True)
    f.txt(818,405,'E: misma cuenta',16,TEAL,True)
    f.txt(818,439,'P: cero A',16,ORANGE,True)
    f.txt(818,474,'en las 9 pruebas',14,MUTED)
    f.note('El contraste separa solo los mecanismos especificados','Veinte decisiones libres, recompensa A=0, recompensa B=β, empate a favor de A.')
    return f.output(),csv_content(['alfa','beta','A_referencia_E_y_P','A_tras_B_forzada_E','A_tras_B_forzada_P','B_referencia','recompensa_referencia','recompensa_P_tras_B'],rows)


CASES={n:globals()[f'build{n}'] for n in range(251,261)}

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

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

def run(check,root):
    import base64
    import xml.etree.ElementTree as ET
    data=generated();assert len(data)==20
    for n in CASES:
        svg=data[f'figures/complexa-c{n}-fig01.svg']
        node=ET.fromstring(svg)
        assert node.attrib.get('role')=='img'
        assert any(c.tag.endswith('title') for c in node)
        assert any(c.tag.endswith('desc') for c in node)
        rows=list(csv.reader(io.StringIO(data[f'repro/c251-260/c{n}.csv'].decode('utf8'))))
        assert len(rows)>=3 and len(rows[0])>=4,(n,len(rows),len(rows[0]))
        assert all(len(r)==len(rows[0]) for r in rows),n
    extra={'repro/c251-260/make_figures.py':BASE.joinpath('make_figures.py').read_bytes(),
           'repro/c251-260/README.md':BASE.joinpath('README.md').read_bytes()}
    archive=bundle({**data,**extra})
    target=root/'zip-data/complexa-c251-c260-reproducible.zip.b64'
    if check:
        for path,s in data.items():
            dest=root/path
            assert dest.is_file() and dest.read_bytes()==s,f'SVG/CSV mismatch: {path}'
        assert target.is_file() and base64.b64decode(target.read_text().strip())==archive,'ZIP mismatch'
        print('PASS: C251–C260 10 SVG 10 CSV 1 reproducible ZIP, numerical and XML tests')
    else:
        for path,s in data.items():
            dest=root/path;dest.parent.mkdir(parents=True,exist_ok=True);dest.write_bytes(s)
        target.parent.mkdir(parents=True,exist_ok=True)
        target.write_text(base64.b64encode(archive).decode('ascii')+'\n')
        (root/'complexa-c251-c260-reproducible.zip').write_bytes(archive)
        print('PASS C251–C260:',len(data),'source files; ZIP',len(archive),'bytes; SHA256',hashlib.sha256(archive).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)
