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.
This commit is contained in:
Sergio
2026-09-11 22:41:46 +00:00
parent 3038195ea7
commit 54bf3e810e
4 changed files with 156 additions and 23 deletions
+34 -5
View File
@@ -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)
+4 -2
View File
@@ -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)
+70
View File
@@ -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"
'''
+48 -16
View File
@@ -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)