gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

ia: el modelo de chat, pineado — Qwen2.5-1.5B-Instruct Q4_K_M (Apache-2.0)

Elegido por tres cosas, y las tres medidas antes de pinearlo: licencia Apache-2.0 (lo que una distro
puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), habla español —se le preguntó qué es
una distribución de GNU/Linux y contestó dos frases correctas— y corre en CPU: 20,1 tokens/s en el
hub sin GPU, 1,04 GiB.

El objeto pineado es un tar que envuelve el .gguf, publicado en el mirror y servido por sha256:
takana extrae toda fuente con `tar` y un GGUF pelado no lo es. Es el camino de firefox-pgo-profile.
La receta anota el sha256 del GGUF DE UPSTREAM (el lfs.oid de HuggingFace, verificado al bajarlo) y
no sólo el del tar nuestro, para que nadie tenga que confiar en nuestro tar. Round-trip verificado:
apartados el caché y el artefacto, el build lo bajó del mirror y selló el mismo ArtifactHash.

Guardián en la receta, porque el fallo es callado: un fichero truncado o un HTML de error renombrado
a .gguf se instala igual y sella en verde. Se comprueban el mágico GGUF y el tamaño.

⚠ Y el modelo de verdad destapó una CARRERA en el guardián del §6.7: el censo de motores contaba en
el instante del cierre — con el de juguete daba 0 y con el de la imagen daba 1, que se lee como fuga
cuando en realidad matar un proceso con un giga mapeado tarda ~1 s. Ahora espera hasta 15 s y anota
cuánto tardó. La rotura a propósito sigue fallando, ahora con el tiempo a la vista.

Falta decidir en qué imágenes se declara (con el motor son ~1,25 GiB por perfil) y pinear el de
embeddings (multilingual-e5-small) para la mitad semántica del §6.3.

54bf3e810e1f1e48a51f6dd3ad5151410512f105 · ruwaq Sergio · (1 killa ñawpaq) · tayta 3038195ea · kay commitpi sach'ata qhaway

Semántico sayk'uy · 2

scripts/test-atuq-ia.py

  • silq'uyninpi t'ikrakurqan fn correr
  • ukhunpi t'ikrakurqan fn main

4 qillqakuna · +156 −23

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index 5dcbcdae..a4488b33 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -1004,11 +1004,40 @@ intento de romperlo **tampoco rompía nada**: el motor de mentira moría al hace
 socket estaba en `/salida`, que se comparte entre las dos corridas; con el socket en `/tmp` —tmpfs
 nuevo por corrida— la rotura rompe.
 
-**Lo que queda es UNA decisión: qué modelo se pinea.** No es trabajo pendiente: la cadena funciona y
-el modelo entra por sha256 como el perfil de PGO de firefox. Son dos —uno de chat para esto y uno de
-embeddings para el §6.3— y las dos preguntas que las gobiernan son el tamaño de la imagen y la
-licencia. Hasta que se decidan, la imagen no trae modelo y la barra lateral **lo dice**:
-`no hay modelo en /usr/share/takana/ia/modelo.gguf: esta imagen no trae ninguno pineado`.
+**El modelo de chat, PINEADO (2026-09-11).** `recipes/ia-modelo-chat.toml` ⇒ **Qwen2.5-1.5B-Instruct
+Q4_K_M**, 1.117.320.736 bytes. Elegido por tres cosas y las tres discutibles cambiando un sha256:
+**Apache-2.0** (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma),
+**habla español** —medido antes de pinearlo: se le preguntó qué es una distribución de GNU/Linux y
+contestó dos frases correctas— y **corre en CPU**: 20,1 tokens/s en el hub sin GPU.
+
+El objeto pineado es un **tar que envuelve el `.gguf`**, publicado en el mirror de fuentes y servido
+por sha256, porque takana extrae toda fuente con `tar` y un GGUF pelado no es un tar. Es el camino de
+`firefox-pgo-profile`. La procedencia queda escrita en la receta con **el sha256 del GGUF de
+upstream** (el `lfs.oid` que publica HuggingFace, verificado al bajarlo), no sólo el del tar nuestro.
+Round-trip verificado como manda el ADR 0013: apartados el caché local y el artefacto, `takana build`
+lo **bajó del mirror** y selló el mismo `ArtifactHash`.
+
+La receta trae guardián propio, porque el modo de fallo es callado: un fichero truncado o un HTML de
+error renombrado a `.gguf` se instala igual y sella en verde. Se comprueban el mágico `GGUF` y el
+tamaño.
+
+⚠ **Y el modelo de verdad destapó una carrera en el guardián del §6.7.bis.** El censo de motores
+contaba **en el instante** del cierre: con el modelo de juguete daba 0 y con el de la imagen daba 1 —
+y eso se lee como fuga cuando lo que pasa es que matar un proceso con un giga mapeado tarda ~1 s.
+Ahora el censo **espera hasta 15 s** y anota cuánto tardó: `0 llama-server vivos (1s después)`. La
+rotura a propósito sigue fallando, ahora con el tiempo a la vista.
+
+`scripts/test-atuq-ia.py --image-model` corre la cadena entera con el modelo de la imagen (llega como
+**capa**, no copiado: mover 1 GiB a un tmpfs llenó `/tmp` y mató al guardián con un ENOSPC que no
+tenía nada que ver con lo que mide). El default sigue siendo el modelo de juguete, que certifica la
+misma cadena en un segundo.
+
+**Lo que queda decidir: qué imágenes lo llevan.** `llama-cpp` (199 M) y el modelo (1,04 GiB) suman
+~1,25 GiB a cada perfil donde se declaren, y `atuq` está en cuatro escritorios. Sigue sin declararse
+en ninguno: una imagen no crece 1,25 G por decisión de un commit.
+
+**Y falta el de embeddings** para la mitad semántica del §6.3 (multilingual-e5-small, decidido pero
+no pineado todavía).
 
 ### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
 
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index 7976d61e..19c8dd12 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -262,8 +262,10 @@ no puede distinguir «no hay modelo» de «el modelo dijo eso».
 comprueba además que al cerrarse el navegador queden **cero** `llama-server`. Control negativo: sin
 modelo, la causa aparece y **no hay respuesta inventada**.
 
-⚠ **La imagen todavía no trae modelo**, y es una decisión pendiente (tamaño y licencia), no trabajo:
-ver el §6.7 del SDD 26.
+El modelo ya está elegido y pineado: **Qwen2.5-1.5B-Instruct Q4_K_M** (`recipes/ia-modelo-chat.toml`,
+Apache-2.0, 1,04 GiB, 20,1 tok/s en CPU, habla español — las tres cosas medidas antes de pinearlo).
+Lo que sigue sin decidirse es **en qué imágenes se declara**: con el motor son ~1,25 GiB por perfil.
+Mientras no se declare, la imagen no lo trae y el panel lo dice.
 
 ## `foco` — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5)
 
diff --git a/recipes/ia-modelo-chat.toml b/recipes/ia-modelo-chat.toml
new file mode 100644
index 00000000..c02e8d7e
--- /dev/null
+++ b/recipes/ia-modelo-chat.toml
@@ -0,0 +1,70 @@
+# ia-modelo-chat — el modelo que contesta en la barra lateral de `atuq` (SDD 26 §6.7).
+#
+# ══ QUÉ ES ═════════════════════════════════════════════════════════════════════════════════════
+# **Qwen2.5-1.5B-Instruct, cuantizado Q4_K_M**: 1.117.320.736 bytes de GGUF. Es el modelo de chat de
+# la distro, y entra como los pesos entran en cualquier lado — **fuente pineada por sha256**, no un
+# build que lo produzca. Nadie entrena nada acá.
+#
+# ══ POR QUÉ ÉSTE ═══════════════════════════════════════════════════════════════════════════════
+# Tres cosas, y las tres se pueden discutir cambiando un sha256:
+#
+#   · **licencia Apache-2.0**, que es lo que una distro puede shipear sin letra chica. Las
+#     alternativas obvias (Llama-3.2, Gemma) traen licencias propias con restricciones de uso, y eso
+#     en una imagen que se distribuye no es un detalle;
+#   · **habla español**, que es el idioma de quien va a usar esto. Medido antes de pinearlo, no
+#     supuesto: se le preguntó qué es una distribución de GNU/Linux y contestó dos frases correctas
+#     en español;
+#   · **entra en la imagen y corre en CPU**: 1,04 GiB y **20,1 tokens/s** medidos en el hub sin GPU
+#     (89 tokens en 4,4 s). Un modelo de 7B con la misma licencia daría mejores respuestas y
+#     quintuplicaría el peso, con la mitad de la velocidad.
+#
+# ══ POR QUÉ EL TARBALL ES NUESTRO Y LA URL NO RESUELVE ═════════════════════════════════════════
+# takana extrae toda fuente con `tar`, y un `.gguf` pelado no es un tar. Así que el objeto pineado es
+# un tar que envuelve el fichero, publicado en el mirror de fuentes y servido **por sha256** — el
+# mismo camino que `firefox-pgo-profile`. La URL `.invalid` no resuelve a propósito (ADR 0013: la URL
+# no entra en `hash_inputs`, sólo el sha256).
+#
+# La PROCEDENCIA, para que nadie tenga que confiar en nuestro tar:
+#   upstream  https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF
+#   fichero   qwen2.5-1.5b-instruct-q4_k_m.gguf
+#   sha256    6a1a2eb6d15622bf3c96857206351ba97e1af16c30d7a74ee38970e434e9407e   ← del GGUF, no del tar
+# Ese sha256 es el que HuggingFace publica como `lfs.oid`, y es el que se verificó al bajarlo.
+#
+# ⚠ `strip_components = 0`: el tar lleva el fichero en la raíz. Con el default (1) se recorta lo
+# único que hay y el install muere con un `cp: cannot stat` que no menciona el recorte.
+name    = "ia-modelo-chat"
+version = "qwen2.5-1.5b-instruct-q4_k_m"
+license = "Apache-2.0"
+
+[source]
+tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-chat.tar"
+sha256  = "2560ee9452a979116ae24278615c51ecced38d2e62288ffe29f4a4bf7ddfc1c6"
+strip_components = 0
+
+[build]
+compiler = "zig-cc"
+
+[build.phases]
+configure = "true"
+compile   = "true"
+install = '''
+set -e
+mkdir -p /out/usr/share/takana/ia
+cp qwen2.5-1.5b-instruct-q4_k_m.gguf /out/usr/share/takana/ia/modelo.gguf
+
+M=/out/usr/share/takana/ia/modelo.gguf
+
+# ── GUARDIÁN: QUE SEA UN GGUF, NO UN FICHERO GRANDE ─────────────────────────────────────────
+# El modo de fallo callado acá es el de siempre: un fichero truncado o un HTML de error de 200 K
+# renombrado a `.gguf` se instala igual, sella en verde, y lo único que falla es la respuesta —
+# cuando ya no hay nadie mirando el build. Se comprueban las dos cosas que un GGUF de verdad tiene:
+# el número mágico `GGUF` en los primeros cuatro bytes, y el tamaño.
+head -c 4 "$M" | grep -q GGUF || {
+  echo "!! el fichero instalado no empieza con el mágico GGUF" >&2
+  head -c 64 "$M" | od -c | head -3 >&2
+  exit 1
+}
+n=$(stat -c%s "$M")
+test "$n" -gt 900000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; }
+echo "guardián: GGUF válido — $n bytes"
+'''
diff --git a/scripts/test-atuq-ia.py b/scripts/test-atuq-ia.py
index 1c68104c..565b5d3b 100644
--- a/scripts/test-atuq-ia.py
+++ b/scripts/test-atuq-ia.py
@@ -3,6 +3,7 @@
 
     python3 scripts/test-atuq-ia.py                      # sale una respuesta generada acá
     python3 scripts/test-atuq-ia.py --negative-control    # sin modelo: lo DICE, no inventa
+    python3 scripts/test-atuq-ia.py --image-model         # con el modelo DE LA IMAGEN (1 GiB, lento)
 
 ── QUÉ SE EJERCITA, DE PUNTA A PUNTA ─────────────────────────────────────────────────────────
 El navegador abre el panel de la extensión con una pregunta (`panel.html?q=…`), el panel se la pasa
@@ -75,15 +76,18 @@ def modelo_de_prueba():
     return str(mod.bajar_modelo())
 
 
-def correr(atuq, costura, llama, modelo, salida, url, negativo):
+def correr(atuq, costura, llama, capa_modelo, salida, url, negativo, tope=90):
     borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
+    # El modelo de la imagen llega como CAPA (su artefacto ya lo pone en el path de producción); el
+    # de juguete se copia, que son 1,1 MB.
+    poner_modelo = "true" if capa_modelo else f"cp /salida/modelo.gguf {MODELO_EN_IMAGEN}"
     guion = f"""
     mkdir -p /root /salida/hogar /salida/run {os.path.dirname(MODELO_EN_IMAGEN)}
     [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
     export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run
-    cp /salida/modelo.gguf {MODELO_EN_IMAGEN}
+    {poner_modelo}
     {borrar}
-    timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
+    timeout {tope} /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
         > /salida/consola.log 2>&1
     # ¿Se fue el motor con el navegador? Se cuenta ACÁ ADENTRO, con el namespace de PIDs todavía
     # vivo: desde el hub no se vería nada —`--unshare-pid` se lleva todo al cerrar la jaula— y esa
@@ -92,17 +96,27 @@ def correr(atuq, costura, llama, modelo, salida, url, negativo):
     # Se cuenta leyendo /proc a mano, y no con `pgrep -c`: busybox NO tiene esa opción, y el
     # `|| echo 0` que parecía prudente convertía el error en un CERO — o sea en un veredicto verde.
     # Medido: con un motor vivo a propósito, el guardián pasaba igual.
-    n=0
-    for p in /proc/[0-9]*; do
-        [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1))
+    # Se espera a que SE MUERA, hasta 15 s, y se anota cuánto tardó. Matar a un proceso con un
+    # giga de modelo mapeado no es instantáneo, y contar en el instante exacto del cierre confundía
+    # «tardando en morir» con «fuga»: con el modelo de juguete daba 0 y con el de la imagen daba 1.
+    esperado=0
+    while [ $esperado -lt 15 ]; do
+        n=0
+        for p in /proc/[0-9]*; do
+            [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1))
+        done
+        [ "$n" = "0" ] && break
+        esperado=$((esperado+1))
+        sleep 1
     done
-    echo $n > /salida/motores-vivos
+    echo "$n $esperado" > /salida/motores-vivos
     exit 0
     """
     subprocess.run([
         "bwrap",
         "--overlay-src", ALPINE, "--overlay-src", RFS,
         "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", llama,
+        *(["--overlay-src", capa_modelo] if capa_modelo else []),
         "--tmp-overlay", "/",
         "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
         "--tmpfs", "/tmp", "--unshare-pid", "--unshare-net",
@@ -128,6 +142,10 @@ def correr(atuq, costura, llama, modelo, salida, url, negativo):
 
 def main():
     negativo = "--negative-control" in sys.argv[1:]
+    # Por defecto se mide con el modelo de juguete: 1,1 MB, instantáneo, y alcanza para certificar la
+    # CADENA. Con `--image-model` se mide el de la imagen — la misma cadena, pero contestando de
+    # verdad; cuesta 1 GiB de overlay y unos segundos de generación, y por eso no es el default.
+    de_la_imagen = "--image-model" in sys.argv[1:]
     if negativo:
         print("== MODO CONTROL NEGATIVO: sin modelo en la imagen; el panel tiene que DECIR la causa")
     if not os.path.isdir(RFS):
@@ -137,20 +155,30 @@ def main():
     llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
     print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}"
           f"\n            {os.path.basename(llama)}")
-    modelo = modelo_de_prueba()
-    print(f"modelo de prueba: {os.path.basename(modelo)}")
+    capa_modelo = None
+    modelo = None
+    if de_la_imagen:
+        # Llega como CAPA: su artefacto ya instala el modelo en el path de producción. Copiarlo
+        # sería mover 1 GiB a un tmpfs — lento, y la primera vez llenó /tmp y el guardián murió con
+        # un ENOSPC que no tenía nada que ver con lo que mide.
+        capa_modelo = artefacto("ia-modelo-chat", "ia-modelo-chat")
+        print(f"modelo: el de la imagen — {os.path.basename(capa_modelo)}")
+    else:
+        modelo = modelo_de_prueba()
+        print(f"modelo de prueba: {os.path.basename(modelo)}")
 
     tmp = tempfile.mkdtemp(prefix="atuq-ia-")
     salida = os.path.join(tmp, "salida")
     os.makedirs(os.path.join(salida, "perfil"))
-    shutil.copy(modelo, os.path.join(salida, "modelo.gguf"))
+    if modelo:
+        shutil.copy(modelo, os.path.join(salida, "modelo.gguf"))
     try:
         with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
             fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
             fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
 
         # Primera corrida: descubrir la URL base que Gecko le asignó a la extensión (es por perfil).
-        lineas, log, _ = correr(atuq, costura, llama, modelo, salida, "about:blank", negativo)
+        lineas, log, _ = correr(atuq, costura, llama, capa_modelo, salida, "about:blank", negativo)
         base = next((l[len("BASE "):].strip() for l in lineas if l.startswith("BASE ")), None)
         if not base:
             destino = os.path.join(ROOT, "work", "atuq-ia.log")
@@ -160,7 +188,9 @@ def main():
         print(f"\nextensión en {base}")
 
         panel = f"{base}panel.html?q={PREGUNTA.replace(' ', '%20')}"
-        lineas, log, motores = correr(atuq, costura, llama, modelo, salida, panel, negativo)
+        # 512 tokens a ~20 tok/s son ~25 s, más la carga del modelo: el tope se acompaña solo.
+        lineas, log, motores = correr(atuq, costura, llama, capa_modelo, salida, panel, negativo,
+                                      tope=180 if de_la_imagen else 90)
         for l in lineas:
             print("   " + l)
 
@@ -188,13 +218,15 @@ def main():
         texto = r.split("texto=", 1)[1] if "texto=" in r else ""
         if len(texto.strip(' "')) < 2:
             fatal(f"la respuesta vino VACÍA: {r}")
-        if motores != "0":
-            fatal(f"quedaron {motores} `llama-server` vivos al cerrarse el navegador —"
-                  " el motor tiene que irse con él (§6.7): medio giga de modelo huérfano no se ve")
+        quedaron, esperado = (motores.split() + ["?"])[:2]
+        if quedaron != "0":
+            fatal(f"quedaron {quedaron} `llama-server` vivos {esperado}s después de cerrarse el"
+                  " navegador — el motor tiene que irse con él (§6.7): medio giga de modelo huérfano"
+                  " no se ve hasta que la máquina se queda sin RAM")
         print("\n✓ el navegador preguntó y un modelo de ESTA máquina contestó")
         print("✓ y nadie levantó el servidor: no hay servicio de IA en la imagen — lo levantó el host")
         print("✓ la jaula corre con `--unshare-net`: no hubo red para consultar a nadie más")
-        print("✓ y el motor se fue con el navegador: 0 `llama-server` vivos al cerrarse")
+        print(f"✓ y el motor se fue con el navegador: 0 `llama-server` vivos ({esperado}s después)")
         return 0
     finally:
         shutil.rmtree(tmp, ignore_errors=True)