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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user