tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

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

4190c7b43e5bd323a9b166340cfa16cd324ca2e4 · por Sergio · (hace 1 mes) · padre 9bbd2e42c · ver el árbol en este commit

Delta semántico · 10

scripts/test-llama-cpp.py

2 archivos · +429 −0

diff --git a/recipes/llama-cpp.toml b/recipes/llama-cpp.toml
new file mode 100644
index 00000000..bb3a32d7
--- /dev/null
+++ b/recipes/llama-cpp.toml
@@ -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"]
diff --git a/scripts/test-llama-cpp.py b/scripts/test-llama-cpp.py
new file mode 100755
index 00000000..4d09c436
--- /dev/null
+++ b/scripts/test-llama-cpp.py
@@ -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())