Files
takana/scripts/test-llama-cpp.py
SergioandClaude Opus 5 4190c7b43e atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2
La IA local de la barra lateral (§6.7) y la mitad semántica del archivo personal (§6.3) figuraban
como dos pendientes distintos. Son uno: el corpus no tenía con qué correr un modelo. `pluma-llm`
sólo trae backends de NUBE y `rimay-verbo-fastembed` DESCARGA onnxruntime (glibc) y el modelo de
HuggingFace en el primer arranque. `recipes/llama-cpp.toml` (b10901, estática, 199 M) derriba el
muro entero: el mismo binario sirve `/v1/chat/completions` y `/v1/embeddings`.

⚠ Lo que este commit deja medido, y es lo que no se podía deducir: el PRIMER sello llevaba sólo
`-DGGML_NATIVE=OFF` —leído en `ggml/CMakeLists.txt:141`, que con NATIVE=OFF debería ENCENDER las
perillas explícitas de ISA— y salió con CERO instrucciones vectoriales. La causa está 36 líneas
más arriba: `if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})` apaga
`GGML_NATIVE_DEFAULT`, y el sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`)
justamente para que los builds REPRODUZCAN. O sea: la variable que nos da reproducibilidad apagaba
todas las instrucciones vectoriales del motor de inferencia — y no falló nada. El artefacto selló,
`--version` contestaba, y el binario era x86-64 pelado: 0 `%ymm`, 0 `vfmadd`, 0 `roundps` en
1.634.770 líneas de `objdump -d`. Ahora las seis van declaradas una por una y el `install` LAS
COMPRUEBA en el binario: 42.356 `%ymm` / 1.298 `vfmadd` / 86 `roundps`.

`strip_debug = true` porque zig cc emite debug_info por defecto y son 16 binarios estáticos:
1,8 G → 199 M.

Guardián `scripts/test-llama-cpp.py`, sobre el artefacto VIGENTE (resuelto por `takana hash`, no
por `ls store/*`) y con control negativo vivo (`--sin-modelo`, que TIENE que fallar): ISA,
hermético (0 NEEDED), una pasada de inferencia REAL con `stories260K` pineado por sha256, y el
endpoint de embeddings — vector de verdad, el mismo texto da el mismo vector, dos textos distintos
dan vectores distintos, y no son ceros. Y `verificar-repro.sh`: REPRODUCE, 0 no-determinismos.

⚠ Esto es el MOTOR, no la función. Un motor sin modelo no contesta nada: el modelo de producción
es fuente pineada aparte, como el perfil de PGO de firefox, y es su propia unidad de trabajo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
2026-09-11 03:03:31 +00:00

264 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Guardián de `recipes/llama-cpp.toml` — el motor de inferencia local del §6.7 del SDD 26.
Mide CUATRO afirmaciones sobre el ARTEFACTO VIGENTE, y ninguna se deduce de que el build haya
salido 0:
1. ISA el binario trae AVX2/FMA/SSE4.2 de verdad.
2. hermético estáticamente enlazado, sin un solo NEEDED.
3. inferencia una pasada real contra un modelo pineado por sha256 ⇒ salen tokens.
4. embeddings `llama-server --embedding` + `POST /v1/embeddings` ⇒ sale un vector, y además:
· el MISMO texto dos veces da el MISMO vector (si no, no hay nada determinista),
· dos textos DISTINTOS dan vectores DISTINTOS (si no, es un stub de ceros).
El punto 4 es el que le importa al §6.3: la mitad semántica del archivo personal
se apoya EXACTAMENTE en ese endpoint.
⚠ EL CONTROL NEGATIVO DEL PUNTO 1 NO ES HIPOTÉTICO, ESTÁ MEDIDO. El PRIMER sello de esta receta
(`b3:e3c62185`) llevaba sólo `-DGGML_NATIVE=OFF`, selló, corrió, contestó `--version` — y tenía
**cero** `%ymm`, **cero** `vfmadd` y **cero** `roundps` en 1.634.770 líneas de `objdump -d`. La causa
está en la cabecera de la receta: el sandbox exporta `SOURCE_DATE_EPOCH`, ggml lee esa variable para
decidir `GGML_NATIVE_DEFAULT` y termina apagando `INS_ENB`. O sea: la variable que existe para que
el build REPRODUZCA apagaba todas las instrucciones vectoriales del motor, sin un aviso.
El control negativo VIVO es `--sin-modelo`: las mismas sondas contra un modelo que no existe tienen
que FALLAR. Sin eso, un arnés roto («no contestó») se lee igual que un motor que anda.
El modelo de prueba es `stories260K` (1,1 MB, 260 mil parámetros — el mismo que usa el CI de
llama.cpp). Se pinea por **sha256**, que es la identidad; la URL no lo es (ADR 0013) y por eso se
puede servir desde donde sea. No es un modelo útil: es un modelo REAL, que es lo que hace falta para
ejercitar la cadena entera. El modelo de producción es su propia unidad de trabajo.
scripts/test-llama-cpp.py
scripts/test-llama-cpp.py --sin-modelo # control negativo: TIENE que fallar
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
RECETA = RAIZ / "recipes/llama-cpp.toml"
TAKANA = RAIZ / "target/release/takana"
MODELO_SHA = "270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d"
MODELO_URL = "https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/stories260K.gguf"
MODELO_CACHE = RAIZ / "work/llama-modelos" / f"{MODELO_SHA[:16]}-stories260K.gguf"
fallos = []
def ok(msg):
print(f" \033[32m✓\033[0m {msg}")
def mal(msg):
print(f" \033[31m✗\033[0m {msg}")
fallos.append(msg)
def artefacto():
"""El artefacto VIGENTE, resuelto por `takana hash` — nunca por `ls store/*`.
Un `ls` agarra el primero que empiece igual, y el corpus es compartido: el de al lado puede ser
de un hash viejo que ya nadie construye. Pasó con `gmp` en el guardián del foco, y el síntoma
fue una librería que no relocaba.
"""
if os.environ.get("LLAMA_DIR"):
d = Path(os.environ["LLAMA_DIR"])
print(f"\033[33m⚠ LLAMA_DIR: corriendo contra {d} A SABIENDAS — el resultado NO se puede\033[0m")
print("\033[33m citar como «el llama-cpp de hoy pasa».\033[0m")
return d
h = subprocess.run([str(TAKANA), "--store", str(RAIZ / "store"), "hash", str(RECETA)],
capture_output=True, text=True, cwd=RAIZ)
if h.returncode != 0:
sys.exit(f"no se pudo hashear la receta: {h.stderr.strip()}")
d = RAIZ / "store" / f"{h.stdout.strip().removeprefix('b3:')}-llama-cpp"
if not d.is_dir():
sys.exit(f"el artefacto vigente NO está en el store: {d.name}\n"
f" construilo: flock -o work/.farm-build.lock ./target/release/takana "
f"--store ./store build recipes/llama-cpp.toml")
return d
def bajar_modelo():
if MODELO_CACHE.is_file():
got = hashlib.sha256(MODELO_CACHE.read_bytes()).hexdigest()
if got == MODELO_SHA:
return MODELO_CACHE
MODELO_CACHE.unlink()
MODELO_CACHE.parent.mkdir(parents=True, exist_ok=True)
print(f" · bajando el modelo de prueba ({MODELO_URL.rsplit('/', 1)[-1]}, 1,1 MB)…")
tmp = MODELO_CACHE.with_suffix(".incoming")
with urllib.request.urlopen(MODELO_URL, timeout=120) as r, open(tmp, "wb") as f:
f.write(r.read())
got = hashlib.sha256(tmp.read_bytes()).hexdigest()
if got != MODELO_SHA:
tmp.unlink()
sys.exit(f"el modelo bajado NO es el pineado:\n esperado {MODELO_SHA}\n obtenido {got}")
tmp.rename(MODELO_CACHE)
return MODELO_CACHE
# ── 1. ISA ────────────────────────────────────────────────────────────────────────────────────
def probar_isa(art):
print("\n\033[1m1. ISA — ¿el motor trae instrucciones vectoriales?\033[0m")
lib = art / "usr/lib/libggml-cpu.a"
if not lib.is_file():
return mal("no hay libggml-cpu.a en el artefacto")
d = subprocess.run(["objdump", "-d", str(lib)], capture_output=True, text=True)
if d.returncode != 0:
return mal(f"objdump falló sobre libggml-cpu.a: {d.stderr.strip()[:120]}")
for patron, quien in (("%ymm", "AVX2 (registros de 256 bits)"),
("vfmadd", "FMA"),
("roundps", "SSE4.2")):
n = d.stdout.count(patron)
(ok if n else mal)(f"{quien}: {n} apariciones de `{patron}`"
+ ("" if n else " ← INS_ENB quedó apagado (ver la cabecera)"))
# ── 2. hermético ──────────────────────────────────────────────────────────────────────────────
def probar_hermetico(art):
print("\n\033[1m2. Hermético — ¿el binario pide algo de afuera?\033[0m")
for nombre in ("llama-server", "llama-cli"):
b = art / "usr/bin" / nombre
if not b.is_file():
mal(f"{nombre} no está en el artefacto")
continue
r = subprocess.run(["readelf", "-d", str(b)], capture_output=True, text=True)
needed = re.findall(r"\(NEEDED\).*\[(.+?)\]", r.stdout)
(ok if not needed else mal)(
f"{nombre}: {'sin NEEDED (estático)' if not needed else 'pide ' + ', '.join(needed)}")
# ── 3. inferencia ─────────────────────────────────────────────────────────────────────────────
def probar_inferencia(art, modelo):
print("\n\033[1m3. Inferencia — ¿sale un token de un modelo de verdad?\033[0m")
r = subprocess.run([str(art / "usr/bin/llama-cli"), "-m", str(modelo),
"-p", "Once upon a time", "-n", "24", "--no-warmup",
"--single-turn", "--seed", "1"],
capture_output=True, text=True, timeout=180)
if r.returncode != 0:
return mal(f"llama-cli salió {r.returncode}: {r.stderr.strip().splitlines()[-1:] or ''}")
# La salida del turno viene después del prompt echoado; nos alcanza con que haya texto nuevo.
cuerpo = r.stdout.split("Once upon a time", 1)[-1]
generado = "".join(l for l in cuerpo.splitlines()
if l.strip() and not l.startswith(("[", ">", "Exiting")))
(ok if len(generado.strip()) >= 10 else mal)(
f"generó {len(generado.strip())} caracteres: {generado.strip()[:70]!r}")
# ── 4. embeddings ─────────────────────────────────────────────────────────────────────────────
def vector(puerto, texto):
req = urllib.request.Request(
f"http://127.0.0.1:{puerto}/v1/embeddings",
data=json.dumps({"input": texto, "model": "prueba"}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)["data"][0]["embedding"]
def probar_embeddings(art, modelo):
print("\n\033[1m4. Embeddings — el endpoint del que cuelga la mitad semántica del §6.3\033[0m")
puerto = 18131
srv = subprocess.Popen(
# ⚠ `--pooling mean` NO es un gusto, y la razón conviene saberla ANTES de pinear el
# modelo de producción: un GGUF declara su tipo de pooling en los metadatos, y los modelos
# de EMBEDDING de verdad (e5, bge, nomic) lo traen. `stories260K` es un LM causal de
# juguete y no lo trae, así que llama.cpp cae en `none` y el endpoint compatible con OpenAI
# contesta **400: "Pooling type \'none\' is not OAI compatible"** — no un vector vacío, no
# un 500: un 400 que parece un error del cliente. Se fuerza `mean`, que es lo que hace
# cualquier motor de embeddings sobre un LM causal.
[str(art / "usr/bin/llama-server"), "-m", str(modelo), "--embedding",
"--pooling", "mean",
"--host", "127.0.0.1", "--port", str(puerto), "-c", "512", "--no-warmup"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
listo = False
for _ in range(120):
if srv.poll() is not None:
break
# ⚠ `/health` contesta MIENTRAS carga, con 503 y `{"status":"loading model"}`. Tomar
# «contestó» por «listo» manda la primera consulta contra un servidor a medio levantar
# y devuelve un 503 que se lee como «el endpoint no existe». Se espera el `ok`.
try:
with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/health", timeout=2) as r:
if json.load(r).get("status") == "ok":
listo = True
break
except Exception:
pass
time.sleep(0.5)
if not listo:
return mal(f"llama-server no levantó (salió {srv.poll()})")
ok(f"llama-server --embedding escucha en :{puerto}")
a1 = vector(puerto, "el zorro salta sobre el perro")
a2 = vector(puerto, "el zorro salta sobre el perro")
b1 = vector(puerto, "la reunión de contabilidad es el martes")
(ok if len(a1) > 8 else mal)(f"devuelve un vector de {len(a1)} dimensiones")
(ok if a1 == a2 else mal)(
"el MISMO texto da el MISMO vector" if a1 == a2
else "el mismo texto dio DOS vectores distintos — no es determinista")
(ok if a1 != b1 else mal)(
"dos textos DISTINTOS dan vectores distintos" if a1 != b1
else "dos textos distintos dieron el MISMO vector — es un stub, no un modelo")
if any(x != 0.0 for x in a1):
ok("el vector no es todo ceros")
else:
mal("el vector es TODO CEROS — pasaría cualquier prueba de «¿hay un vector?»")
finally:
srv.terminate()
try:
srv.wait(timeout=10)
except subprocess.TimeoutExpired:
srv.kill()
def main():
p = argparse.ArgumentParser()
p.add_argument("--sin-modelo", action="store_true",
help="control negativo: las sondas 3 y 4 corren contra un modelo inexistente "
"y TIENEN que fallar")
a = p.parse_args()
art = artefacto()
print(f"\033[1martefacto:\033[0m {art.name}")
if a.sin_modelo:
print("\n\033[33m── CONTROL NEGATIVO: modelo inexistente; lo correcto es que FALLE ──\033[0m")
modelo = RAIZ / "work/llama-modelos/NO-EXISTE.gguf"
probar_inferencia(art, modelo)
probar_embeddings(art, modelo)
if fallos:
print(f"\n\033[32m✓ CONTROL NEGATIVO OK\033[0m — {len(fallos)} sondas fallaron, "
"como tenían que fallar. El arnés mide algo.")
return 0
print("\n\033[31m✗ CONTROL NEGATIVO ROTO\033[0m — todo pasó SIN modelo: el arnés no mide nada.")
return 1
probar_isa(art)
probar_hermetico(art)
modelo = bajar_modelo()
probar_inferencia(art, modelo)
probar_embeddings(art, modelo)
print()
if fallos:
print(f"\033[31m✗ {len(fallos)} fallo(s)\033[0m")
for f in fallos:
print(f" · {f}")
return 1
print("\033[32m✓ el motor de inferencia local del corpus infiere y vectoriza\033[0m")
return 0
if __name__ == "__main__":
sys.exit(main())