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
264 lines
13 KiB
Python
Executable File
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())
|