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
This commit is contained in:
Sergio
2026-09-11 03:03:31 +00:00
co-authored by Claude Opus 5
parent 9bbd2e42c9
commit 4190c7b43e
2 changed files with 429 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
# llama.cpp b10901 — el MOTOR DE INFERENCIA LOCAL del corpus. Es la pieza que destraba el §6.7 del
# SDD 26 (IA local en la barra lateral de `atuq`) y la mitad semántica del §6.3 (preguntarle al
# archivo personal en lenguaje natural).
#
# ══ POR QUÉ ESTA RECETA, Y NO «ENCHUFAR LO QUE YA HAY» ═════════════════════════════════════════
# El §6.7 figuraba como costo «bajo» porque las piezas parecían estar escritas en tawasuyu. Se fue
# a mirar y NO alcanzan, por dos razones independientes:
# 1. `pluma-llm` tiene backends `anthropic`, `cohere`, `claude-cli` y `mock` — TODOS de nube o de
# juguete. Ninguno corre un modelo en la máquina;
# 2. `rimay-verbo-fastembed` —el daemon de embeddings que `rag-motor` exige— trae `fastembed`
# con la feature `ort-download-binaries`: **baja onnxruntime precompilado (glibc) y el modelo
# de HuggingFace en el primer arranque**. En un build hermético y en una distro musl eso no es
# «lento», es imposible.
# O sea que los dos pendientes comparten UN muro y el muro tiene forma de receta. llama.cpp lo
# derriba entero: el mismo binario sirve `/v1/chat/completions` (el LLM del §6.7) y `/v1/embeddings`
# (lo que `rag-motor` necesita para la mitad semántica del §6.3), sin ONNX, sin Python y sin red.
#
# ⚠ ESTA RECETA ES EL MOTOR, NO LA FUNCIÓN. Un motor sin modelo no responde nada. El modelo es una
# FUENTE PINEADA aparte —igual que el perfil de PGO de firefox—, y es su propia unidad de trabajo.
# Nadie debería leer «IA local» y esperar que esto conteste algo todavía.
#
# ══ LA VERSIÓN ES UN COMMIT, NO UNA SERIE ═════════════════════════════════════════════════════
# llama.cpp no publica versiones semánticas: sus releases son `bNNNN`, una por commit de `master`
# (b10901 = 2026-09-10). El `LLAMA_VERSION` interno dice `0.4.0-dev` y no identifica nada. Se pinea
# el tag, que es lo único que nombra un árbol concreto, y subirlo es su propia unidad de trabajo.
#
# ══ LAS CUATRO PERILLAS QUE NO SON GUSTO ══════════════════════════════════════════════════════
#
# 1. **`-DLLAMA_USE_PREBUILT_UI=OFF` — viene ON DE FÁBRICA Y DESCARGA EN TIEMPO DE BUILD.**
# `tools/ui/CMakeLists.txt` cuelga un `add_custom_target` que corre `scripts/ui-assets.cmake`, y
# ése hace `file(DOWNLOAD …)` contra un bucket de HuggingFace para hornear la web UI dentro de
# `llama-server`. Leído en la fuente del tarball pineado, no supuesto. Hoy el sandbox no tiene
# red y el guión sólo avisa y sigue — pero apagarlo por AUSENCIA DE RED es exactamente la
# protección que funciona por accidente: el día que el sandbox tenga red, entraría un blob que no
# está en `hash_inputs` y el artefacto dejaría de reproducir sin que nadie tocara la receta. Se
# apaga por declaración. La web UI no se pierde: no la queríamos — acá el cliente es el host de
# native messaging del §7, que habla HTTP, no un navegador mirando el puerto.
#
# 2. **LAS SEIS PERILLAS DE ISA VAN UNA POR UNA, Y LA RAZÓN SE MIDIÓ ACÁ.**
# El primer sello de esta receta llevaba sólo `-DGGML_NATIVE=OFF`, apoyado en leer
# `ggml/CMakeLists.txt:141` — `if (GGML_NATIVE OR NOT GGML_NATIVE_DEFAULT) set(INS_ENB OFF)`—,
# o sea: con NATIVE=OFF, ggml debería ENCENDER las perillas explícitas (SSE4.2/AVX/AVX2/BMI2/
# FMA/F16C) en vez de apoyarse en `-march=native`. **Y es falso en ESTE sandbox**, por una línea
# 50 renglones más arriba (`ggml/CMakeLists.txt:105`):
#
# if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})
# set(GGML_NATIVE_DEFAULT OFF)
#
# El sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`) — que es lo que nos da
# binarios reproducibles. Con eso `GGML_NATIVE_DEFAULT` queda OFF, `NOT GGML_NATIVE_DEFAULT` es
# verdadero, e `INS_ENB` termina en **OFF**: las seis perillas apagadas. O sea que
# **la variable que existe para que el build reproduzca apagó todas las instrucciones
# vectoriales del motor de inferencia.**
#
# ⚠ Y no falló nada. El artefacto selló, `llama-cli --version` contesta, y el binario es x86-64
# pelado: **cero `%ymm`, cero `vfmadd`, cero `roundps`** en 1.634.770 líneas de `objdump -d`,
# contadas sobre el artefacto sellado. Un motor de inferencia sin AVX2 no está roto: está varias
# veces más lento, y ninguna métrica del repo lo dice. Es la figura de siempre — un ausente falla
# ruidosamente; un desviado llega hasta el final diciendo que todo fue bien.
#
# Por eso las seis van declaradas y NO se confía en el default de nadie. Y por eso el `install`
# las COMPRUEBA en el binario: es el único punto donde este modo de fallar se puede ver.
#
# ⚠ Eso fija un PISO declarado: x86-64-v3. Un CPU sin AVX2 (pre-2013) muere con SIGILL, no con un
# mensaje. Medido antes de elegirlo, no deducido: `momento`/gioser y el worker `dev.gioser.net`
# traen los seis flags, y el objetivo de metal (TigerLake, ver [[etapa-metal-usb]]) también.
# Bajar el piso o publicar variantes es `docs/plan-variantes-cpu.md`, no esta receta.
# `-DGGML_NATIVE=OFF` se queda igual, pero por lo que de verdad hace acá: prohibir `-march=native`,
# que sería un artefacto distinto por máquina — la familia del `MAKE_STAMP` de nftables.
#
# 3. **`-DGGML_CCACHE=OFF` — viene ON de fábrica y se autodetecta.** Si algún día el lab trae
# `ccache`, el build cambia de forma sin que el corpus haya cambiado en nada, y el lab NO entra
# en `hash_inputs` (ver [[lab-fuera-de-hash-inputs]]): dos labs sellarían bytes distintos en la
# misma dirección. Misma familia que el `CONFIG_SHELL=bash` que se rechazó en nftables.
#
# 4. **`-DGGML_OPENMP=OFF` — no es recorte, es evitar una dep invisible.** Con OpenMP encendido el
# binario arrastra la runtime de OpenMP del compilador. ggml trae su PROPIO pool de hilos y lo usa
# cuando OpenMP no está; el corpus prefiere no meterse la runtime de otro toolchain adentro
# (ver el `-static-libstdc++` de cmake.toml, misma lección).
#
# `-DLLAMA_OPENSSL=OFF`: su único uso es HTTPS para BAJAR modelos de HuggingFace desde el propio
# binario. Acá un modelo entra como entra cualquier fuente —pineado por sha256— así que esa función
# no sólo no hace falta: no queremos que exista. `-DLLAMA_BUILD_APP=OFF` por lo mismo (`app/` es el
# binario unificado con `download.cpp` adentro).
#
# ⚠ `LLAMA_BUILD_TOOLS=ON` NO es un extra: `llama-server` vive en `tools/server`, y `tools/CMakeLists.txt`
# sólo entra a `server/` (y a `cli/`) si `LLAMA_BUILD_SERVER` está encendido DENTRO de TOOLS. Con
# TOOLS=OFF no hay servidor. De yapa vienen `llama-quantize`, `llama-bench` y `llama-perplexity`,
# que son las tres herramientas con las que se mide un modelo antes de pinearlo.
#
# El wrapper `.zwrap` es el mismo de `poppler-glib` y por lo mismo: cmake le pasa
# `-Wl,--fatal-warnings` al linker y zig cc convierte avisos benignos en errores.
#
# El `install` comprueba que `llama-server` EXISTA. No es paranoia: si `LLAMA_BUILD_TOOLS` o
# `LLAMA_BUILD_SERVER` se apagan solos —que es lo que hizo `ENABLE_GLIB` en poppler—, el build sale
# 0 y sella un artefacto sin la única pieza por la que esta receta existe.
name = "llama-cpp"
version = "b10901"
license = "MIT"
[source]
tarball = "https://github.com/ggml-org/llama.cpp/archive/refs/tags/b10901.tar.gz"
sha256 = "2cf2d648c6a8ee94f67a94d8c70479620ba481e01a1d930a9075bc7780ff4f9f"
[build]
compiler = "zig-cc"
target = "x86_64-linux-musl"
link = "static"
flags = []
# Sin esto el artefacto pesa 1,8 G: zig cc emite debug_info por defecto y acá son 16 binarios
# ESTÁTICOS, o sea 16 copias de los símbolos de libllama+libggml+libc++. Medido: `llama-server`
# pasa de 133 M a una fracción. Entra en el hash (`recipe.rs:574`), como tiene que ser.
strip_debug = true
[build.phases]
configure = '''
ZW="$PWD/.zwrap"; mkdir -p "$ZW"
cat > "$ZW/cc" <<'W'
#!/bin/sh
a=""; for x in "$@"; do [ "$x" = "-Wl,--fatal-warnings" ] && continue; a="$a $x"; done
exec /opt/zig/zig cc -mcpu=baseline $a
W
cat > "$ZW/cxx" <<'W'
#!/bin/sh
a=""; for x in "$@"; do [ "$x" = "-Wl,--fatal-warnings" ] && continue; a="$a $x"; done
exec /opt/zig/zig c++ -mcpu=baseline $a
W
chmod +x "$ZW/cc" "$ZW/cxx"
cmake -B build -G Ninja -Wno-dev \
-DCMAKE_C_COMPILER="$ZW/cc" -DCMAKE_CXX_COMPILER="$ZW/cxx" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF -DBUILD_SHARED_LIBS=OFF \
-DGGML_NATIVE=OFF -DGGML_CCACHE=OFF -DGGML_OPENMP=OFF -DGGML_BLAS=OFF \
-DGGML_SSE42=ON -DGGML_AVX=ON -DGGML_AVX2=ON -DGGML_BMI2=ON -DGGML_FMA=ON -DGGML_F16C=ON \
-DGGML_RPC=OFF -DGGML_BACKEND_DL=OFF \
-DLLAMA_OPENSSL=OFF -DLLAMA_BUILD_UI=OFF -DLLAMA_USE_PREBUILT_UI=OFF \
-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_APP=OFF \
-DLLAMA_BUILD_TOOLS=ON -DLLAMA_BUILD_SERVER=ON
'''
compile = 'cmake --build build -j"$(nproc)"'
install = '''
set -e
DESTDIR=/out cmake --install build
# Ver la cabecera: si TOOLS o SERVER se apagan solos, esto sella sin la pieza que justifica la receta.
test -x /out/usr/bin/llama-server || {
echo "ERROR: no se instaló llama-server — ¿LLAMA_BUILD_TOOLS/LLAMA_BUILD_SERVER apagados?" >&2
exit 1
}
test -x /out/usr/bin/llama-cli || {
echo "ERROR: no se instaló llama-cli" >&2
exit 1
}
# Ver la perilla 2 de la cabecera: SOURCE_DATE_EPOCH apaga INS_ENB y el binario sale sin una sola
# instrucción vectorial, sellando igual y corriendo igual. Esto es lo único que lo ve.
objdump -d build/ggml/src/libggml-cpu.a 2>/dev/null | grep -q '%ymm' || {
echo "ERROR: libggml-cpu.a NO tiene registros %ymm — AVX2 quedó apagado (¿INS_ENB=OFF?)" >&2
exit 1
}
objdump -d build/ggml/src/libggml-cpu.a 2>/dev/null | grep -q 'vfmadd' || {
echo "ERROR: libggml-cpu.a NO tiene vfmadd — FMA quedó apagado" >&2
exit 1
}
'''
[deps]
build = ["cmake", "samurai", "busybox", "binutils", "pkgconf"]
+263
View File
@@ -0,0 +1,263 @@
#!/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())