nanomem

Memoria para hechos que cambian. Un registro recuperado se marca como SUPERSEDIDO, indicando cuándo un registro posterior lo reemplazó, para que un asistente no responda con un valor que ya no es verdadero.

Documentación

nanomem

CI

Un almacén integrado para hechos que cambian.

Un hecho que tu aplicación recuerda no es un documento. Se corrige — la gente se muda, cambia de trabajo, cambia de número de teléfono. Un almacén vectorial guarda ambas afirmaciones y devuelve la que esté redactada más cerca de la pregunta, que es como un asistente termina repitiendo con confianza una dirección que dejaste hace dos años. nanomem conserva la cadena y sabe qué extremo de ella es el actual.

Evidencia — cómo se compara con FAISS, sqlite-vec y Chroma (incluyendo dónde pierde), qué sucede cuando el proceso se mata a mitad de una escritura, y el comando pytest que vuelve a ejecutar la mayoría de esas afirmaciones en la copia que acabas de instalar.

Inicio rápido: dale a tu asistente una memoria que sepa qué cambió

pip install nanomem

Añade esto a la configuración de tu cliente MCP. En macOS claude_desktop_config.json vive en ~/Library/Application Support/Claude/:

{
  "mcpServers": {
    "nanomem": {
      "command": "nanomem-mcp"
    }
  }
}

Esa es toda la configuración. La bóveda por defecto está en ~/.nanomem/memory.dat; establece NANOMEM_VAULT, o pasa --vault /some/path.dat, para ponerla en otro lugar.

Reinicia el cliente y dile algo que cambiará más tarde:

"Recuerda que trabajo en Acme Corp." (una semana después) "En realidad me mudé — ahora estoy en Globex." "¿Dónde trabajo? ¿Y dónde trabajaba antes?"

Responde Globex, y puede decirte que solía ser Acme — no porque la segunda frase estuviera redactada más cerca de la pregunta, sino porque nanomem conservó la cadena y sabe qué extremo de ella es el actual. Pregúntale "¿qué creía sobre esto en marzo?" y también puede responder eso.

Siete herramientas: nanomem_add, nanomem_search, nanomem_history, nanomem_as_of, nanomem_changes, nanomem_volatility, nanomem_stats — para que el asistente pueda preguntar qué SOLÍA ser un hecho, qué creía la memoria en un momento pasado, qué cambió la semana pasada, y cuáles de sus propias creencias se han vuelto obsoletas.

La bóveda es un archivo ordinario. Apunta la CLI o un script de Python a la misma ruta para leer lo que el asistente escribió — una escritura está en disco antes de que se envíe su respuesta, así que otro proceso la ve inmediatamente y detener el servidor no puede perderla.

Hasta 0.7.17 eso no era cierto: el servidor solo volcaba en una salida limpia, y un cliente MCP detiene sus servidores con SIGTERM. Veinte llamadas a nanomem_add, cada una respondida con "Stored …", luego SIGTERM, dejaron cero filas en la bóveda. Si ejecutaste una versión anterior, cualquier cosa que el asistente "recordara" en una sesión que no se cerró limpiamente nunca se escribió.

O úsalo desde Python

pip install nanomem
import time
from nanomem import Vault

DAY, now = 86400, time.time()
job = {"entity": "employer"}

v = Vault("memory.dat")
v.add("I work at Acme Corp.",                    metadata=job, timestamp=now - 300*DAY)
v.add("I moved jobs, I now work at Initech.",    metadata=job, timestamp=now - 155*DAY)
v.add("I switched again, I work at Globex now.", metadata=job, timestamp=now - 10*DAY)

print(v.search("where do I work")[0]["text"])
# I switched again, I work at Globex now.

for r in v.history("where do I work"):
    print(r["revision"], r["superseded"], r["text"])
# 1 True I work at Acme Corp.
# 2 True I moved jobs, I now work at Initech.
# 3 False I switched again, I work at Globex now.

print(v.search("where do I work", as_of=now - 200*DAY)[0]["text"])
# I work at Acme Corp.

f = v.volatility()[0]
print(f["entity"], f["n_revisions"], round(f["median_interval"]/DAY))
# employer 3 145

Un archivo en disco. Una dependencia de ejecución (numpy). Sin servidor, sin demonio, sin índice que reconstruir. La búsqueda es exacta — un escaneo completo de coseno, no un índice aproximado — así que el recuerdo es 100% por construcción y cada pregunta interesante trata sobre el tiempo más que sobre la clasificación.

Te dice cuándo la respuesta se queda corta

search devuelve como máximo top_k registros. Ahora también te dice lo que dejó atrás, lo cual importa más cuando quien llama es un modelo que no puede mirar:

r = vault.search("revenue of every company in every year", top_k=3, min_score=0.6)

len(r)                      # 3   — it is a list; every existing caller is unchanged
r.truncated                 # True
r.n_above_floor             # 21  — how many cleared your min_score
r.explain()                 # "This answer is incomplete -- showing 3 of 21 records
                            #  scoring at or above your min_score of 0.60. ..."

Para una pregunta de varias partes, el número útil es qué partes no obtuvieron nada:

r = vault.search("What is Acme revenue? ... What port does staging use?")
r.unanswered_sub_queries    # the clauses that got no slot

explain() devuelve "" cuando no se cortó nada informativo, así que es seguro añadirlo incondicionalmente — y la herramienta MCP nanomem_search hace exactamente eso.

Solo habla cuando hay un min_score. Con el 0.0 por defecto cada registro supera el umbral, así que "mostrando 3 de 101" sería cierto para cada consulta jamás hecha, incluyendo una cuya respuesta realmente es un solo registro. Una señal que se dispara cada vez no transmite nada. El conteo sigue en r.n_above_floor de todos modos.

Los conteos son gratuitos: el escaneo es exhaustivo, así que ambos números ya existían en la línea que aplica top_k y se estaban descartando.

Marca respuestas que ya no son ciertas

Una marca de tiempo dice cuándo se escribió un registro. No puede decir si sigue siendo cierto — un hecho escrito hace diez años puede ser actual, y uno escrito la semana pasada ya puede estar muerto. La diferencia es si un registro posterior lo reemplazó, que es lo que sabe la cadena de revisiones.

Busca un hecho que ha cambiado y obtienes varios de sus valores, porque eso es lo que es una cadena. Cada uno ahora dice dónde está:

for h in vault.search("where do I work", top_k=3):
    print(h["superseded"], h["text"])
# False  I switched again, I work at Globex now.
# True   I moved, I work at Initech.
# True   I work at Acme Corp.

ask() pone eso en el prompt, así que al modelo se le dice qué hechos están muertos antes de que escriba; la herramienta MCP los marca en el texto que un asistente lee:

[2] (2025-08-17) [SUPERSEDED - replaced 8 months ago; this was true
    when written, not now]: I moved, I work at Initech.

Un hecho de diez años que nunca cambió no se marca con nada. superseded es None — no False — para un registro sin cadena, porque ahí "nada lo reemplazó" es desconocido más que cierto.

Dile qué es un atributo

metadata={"entity": "employer"} está haciendo trabajo real arriba, y vale la pena un párrafo porque poco más importa tanto aquí.

Nombra el atributo y nanomem sabe que esas tres afirmaciones son un hecho, así que las mantiene como una cadena. Déjalo fuera y un etiquetador léxico adivina desde el texto — medido, en 100 cadenas por rama, cada miembro de una cadena obtuvo la misma etiqueta correcta en 70 de 100 cadenas de redacción clara y 0 de 100 en redacción narrativa. En el ejemplo de arriba etiqueta "cambié de trabajo, ahora trabajo en Initech." como location en lugar de career, porque "cambié" pesa más que "trabajo en", y la cadena se divide silenciosamente en dos.

Así que si tu aplicación tiene atributos propios, decláralos. Todo lo que nanomem hace que un almacén vectorial no hace se basa en saber qué afirmaciones son sobre lo mismo — y tú lo sabes, mientras que el etiquetador está adivinando.


Paquete 0.8.1 · motor 3.4.6 · formato de contenedor 3 · formato de caché de arena 4.

Licencia: Apache-2.0. Úsalo comercialmente, modifícalo, envíalo dentro de un producto de código cerrado — conserva los archivos LICENSE y NOTICE con cualquier redistribución, di qué cambiaste, y no uses el nombre del proyecto o del autor para respaldar el tuyo. Esa es toda la obligación.

nanomem fue AGPL-3.0-o-posterior desde 0.6.0 hasta 0.7.22, con una licencia comercial junto a ella. Esa combinación protegía algo que valía menos que los usuarios que estaba alejando: la mayoría de las empresas prohíben AGPL por política y muchos desarrolladores la omiten sin leerla. Las copias distribuidas bajo los términos antiguos los conservan, y ambos textos reemplazados todavía se incluyen — LICENSE.agpl-3.0-or-later.md y LICENSE.preview-v1.0.md. Hasta 0.6.0 los metadatos de la rueda decían Apache-2.0 mientras que el archivo LICENSE decía Todos los Derechos Reservados; esa contradicción se resolvió en 0.6.0 y ha seguido resuelta.


Ejecuta la demo

cd nanomem_standalone
python3 demo.py          # stores, updates, searches, prints real stats()
python3 demo_stale.py    # the one worth seeing: a fact going stale over 8 months

demo_stale.py son ocho meses de sesiones de trabajo ordinarias donde nadie anuncia un cambio — el hecho del equipo llega dos veces, ambas dentro de una pregunta sobre otra cosa. Luego el asistente escribe una biografía, la similitud pone el antiguo equipo primero porque la pregunta está redactada como el trabajo antiguo, y nanomem lo entrega marcado como SUPERSEDED - replaced 4 months ago. Cada línea que imprime se calcula; edita las sesiones al principio y vuelve a ejecutarlo.

Almacena algunos hechos, actualiza uno de ellos para mostrar el manejo de revisiones, busca con citas, e imprime el stats() real de la bóveda — número de documentos, tamaño del archivo, el active_heap_ram_kb medido, y si el archivo está cifrado (por defecto no lo está).

Ejecuta las pruebas

python3 -m pytest -q

837 pruebas, sin necesidad de red.

No hay test_security.py. Versiones anteriores de este README te decían que ejecutaras uno para "probar que no existe texto plano en disco"; ese archivo nunca existió, y la afirmación era incorrecta de todos modos — una bóveda es un archivo de texto plano a menos que le des una frase de contraseña. Para comprobarlo tú mismo:

python3 -m nanomem.cli init demo.dat
python3 -m nanomem.cli add "the office wifi password is hunter2" --vault demo.dat
strings demo.dat | grep hunter2          # plaintext vault: it is there
python3 -m nanomem.cli rekey --vault demo.dat --new-password-stdin
strings demo.dat | grep hunter2          # password mode: it is not

Úsalo desde tu propio script

from nanomem import Vault

with Vault("my_knowledge.dat") as vault:            # plaintext by default
    doc_id = vault.add("Server backup runs daily at 02:00 UTC")
    vault.add("The staging database is on port 5433")

    hits = vault.search("When does the backup run?")
    print(hits[0]["text"], hits[0]["cosine"])

add() devuelve el id del documento, así que puedes get, update o delete por él más tarde. Para una bóveda cifrada, pasa password="…" (o establece NANOMEM_PASSWORD).

Los embeddings son tuyos para elegir. El predeterminado es nomic-embed-text en un demonio local compatible con Ollama, pero cualquier modelo de cualquier ancho funciona — el ancho se sondea desde el propio modelo, y la bóveda se dimensiona a partir de lo que devuelve:

Vault("m.dat", embed_model="all-minilm")                    # 384-d
Vault("m.dat", embed_model="mxbai-embed-large")             # 1024-d
Vault("m.dat", embed_model="text-embedding-3-small",
      base_url="https://api.openai.com/v1/embeddings")      # 1536-d
Vault("m.dat", embedder=MyOwnEmbedder())                    # anything with
                                                            # .embed/.embed_batch/.dim

Apunta a cualquier lugar con base_url= o NANOMEM_EMBED_URL. Pasa dim= a EmbeddingProvider para omitir la sonda por completo (instalaciones aisladas). El ancho de una bóveda existente siempre gana sobre el que solicitas, así que no puedes corromper silenciosamente una bóveda nombrando un modelo diferente más tarde — obtienes una advertencia y el archivo conserva su propio ancho. Ese manejador entonces solo es parcialmente utilizable: tu codificador sigue siendo el modelo que nombres, así que cada llamada que necesita un vector NUEVO — add, update(text=), search, history — genera una excepción hasta que reabras con uno del ancho del archivo. Las lecturas siguen funcionando, y delete, prune, compact y forget_superseded siguen ejecutándose y siguen reescribiendo el archivo, comportándose exactamente como lo hacen a través de un manejador coincidente (medido en 8 operaciones: 0 difirieron). Esto decía "cada llamada genera una excepción" hasta 0.7.20, que es lo que llevó a un revisor a leer un prune normal como destrucción silenciosa. Nada se pierde, pero no continúa silenciosamente.

Sin un demonio hay un respaldo, y vale la pena saber qué es: un codificador léxico determinista a 768-d — palabras y trigramas de caracteres con hash, sin semántica. Obtiene "the server is up" contra "el servidor está caído" at 0.783, and "conduzco un coche" against "poseo un automóvil" en 0.286. Los opuestos se ven idénticos, los sinónimos se ven no relacionados. Mantiene el pipeline funcionando sin conexión y es adecuado para una prueba de humo; no es un sustituto de un modelo de embeddings, y no hay pesos neuronales incluidos.


Qué lo hace diferente de un almacén vectorial

nanomem es un registro de solo añadir, así que conserva cada valor que un hecho ha tenido alguna vez, no solo el actual. Eso hace que cuatro preguntas sean respondibles que un índice vectorial no puede representar, porque ninguno conserva el historial para responder desde él.

with Vault("memory.dat") as v:
    v.history("where do I work")          # every value, oldest first, current last
    v.search("where do I work",
             as_of=1735689600.0)          # the answer as the memory stood back then
    v.changes(since=1735689600.0)         # what was written in a window, no query
    v.volatility()                        # how often each fact actually changes

as_of y since son marcas de tiempo unix en Python; la CLI de abajo también acepta YYYY-MM-DD. history() devuelve cada valor con su marca de tiempo y una bandera superseded; la última entrada es la actual. Un hecho que nunca cambió devuelve una entrada, que es una respuesta, no un resultado vacío.

volatility() es la que hay que mirar. Lee el registro de revisiones e informa, por hecho, cuántas veces ha sido reformulado, el intervalo típico entre cambios, y cuánto tiempo ha estado el valor actual sin confirmar — así un agente puede deducir cuáles de sus propias creencias se han vuelto obsoletas y preguntar de nuevo:

fact             restated   changes every   last confirmed
mobile_phone            4          195 d           2237 d   ← ask again
employer                3          807 d             47 d

Sin modelo y sin consulta: marcas de tiempo diferenciadas de dos columnas residentes, fuera de la ruta de búsqueda. staleness() convertirá eso en una probabilidad, pero devuelve None a menos que pases assume_memoryless=True — la tasa por hecho solo superó una tasa única a nivel de corpus en datos generados para coincidir con su propia suposición, así que no está activada por defecto (evidence/staleness_calibration.json).

Qué limita esto. Dos registros solo se tratan como un hecho cuando nanomem puede decir que son el mismo atributo, y eso necesita o un vocabulario o la consulta — cinco señales sin vocabulario se midieron y todas están al azar contra atributos hermanos como dirección de casa-vs-oficina (evidence/grouping_signal_results.json). Con redacción canónica cada miembro de una cadena obtiene la misma etiqueta correcta en 70 de 100 cadenas; con redacción narrativa ("porté la línea durante el fin de semana, contáctame en …") en 0 de 100 (evidence/temporal_drift_results.json). Si tu aplicación conoce sus propios atributos, decláralos — pasa metadata={"entity": "employer"} al escribir — y establece group_floor_sim=0.45, que vale +19.0 puntos de top-1 exactamente en la redacción con la que el etiquetador lucha (floor_retune_results.json). Déjalos solos si dependes del etiquetador; el mismo ajuste cuesta 13.9 puntos allí. Decláralo dondequiera que escribas. metadata={"entity": ...} en Python, entity en la herramienta MCP nanomem_add, --entity en nanomem add. Hasta 0.7.12 los dos últimos no existían, por lo que las dos superficies a través de las cuales la mayoría de los llamadores se integran estaban bloqueadas en el etiquetador sin que nada lo dijera.

Lo que cuesta cuando no puedes declarar uno. En un grupo que el etiquetador infirió, search y history pueden discrepar sobre qué valor es el actual: history resuelve la cadena etiquetada y lee el contador de revisiones, mientras que search también aplica un umbral de relevancia que puede descartar el valor más nuevo cuando está redactado más lejos de la pregunta que uno más antiguo. Eximir al miembro más nuevo de un grupo inferido fue medido y rechazado: cuesta -19.4 puntos en el conjunto de chat de 3 personas, y un barrido de 0.40 a 0.80 no encontró ningún umbral que comprara uno sin pagar el otro (evidence/floor_current_value_results.json). Así que esto es un intercambio deliberado, no un descuido.

0.7.12 decía aquí que history es autoritativo y debe creerse por encima de search. Eso estaba mal y la afirmación fue retirada: history podría en sí misma devolver una cadena más corta de la que existía, en el peor caso una entrada marcada superseded=False, que esta biblioteca define como "este hecho nunca cambió". Esa truncación está corregida en 0.7.14 y 0.7.15: una cadena declarada ahora devuelve cada revisión que contiene, y changes() está de acuerdo con ella.

Lo que queda es el desacuerdo en sí mismo. Hasta 0.7.16 esta sección decía "declara la entidad y el desacuerdo desaparece", basándose en tres cadenas que puntuaron 3/3 (evidence/entity_declaration_results.json). Una tercera revisión independiente de caja negra encontró una cadena declarada donde no lo hace, y la afirmación estaba equivocada de una manera que la medición no podía ver: en las tres cadenas, cada revisión REAFIRMA el atributo ("ahora trabajo en Initech"), lo que mantiene a los miembros de la cadena cerca entre sí. Cuando la revisión 1 nombra el atributo y las revisiones posteriores se refieren a él implícitamente — "Mi escritorio está en el tercer piso de Kestrel House" y luego "Ahora estacionado en el edificio Maple Wharf" — los miembros se separan, y el impulso que promueve el valor actual estaba limitado por debajo de lo que esa separación necesita. search[0] devolvió una ubicación de escritorio dos movimientos atrás mientras history() devolvió la correcta, con la entidad declarada.

0.7.16 eleva ese límite en un barrido sobre ocho brazos y dos incrustadores (evidence/revision_lead_cap_results.json). Medido sobre doce cadenas de la redacción que lo rompió, cada una sola en su bóveda, en dos regímenes de marca de tiempo:

cadenas declaradas, search top-1 == valor actual de history0.7.150.7.16
revisiones posteriores se refieren implícitamente, modelo real (24 casos)41.7%100.0%
las mismas cadenas en el codificador de respaldo sin conexión (12)25.0%75.0%
redacción a la deriva, conjunto generado (100)71.0%100.0%

y left to the tagger permanece en 2/3, sin cambios y aún medido solo en tres cadenas (evidence/entity_declaration_results.json).

Declarar la entidad es lo que hace que la cadena sea resoluble, y en un modelo de incrustación real es lo que hace que search y history coincidan en estos conjuntos. No es una garantía. En las doce cadenas del codificador sin conexión medidas aquí, cada fallo restante tiene la misma causa: ese codificador es léxico, la revisión más nueva cae tan lejos de la pregunta que la pantalla de relevancia la elimina del resultado por completo, y ningún impulso de clasificación puede promover un registro que nunca fue devuelto (6 de 6 fallos, revision_lead_cap_results.json brazo H).

Esa es la causa en ESTE corpus, no la única que existe. Un revisor independiente, construyendo cadenas de la misma forma descrita, midió fallos de un segundo tipo: la revisión más nueva SÍ es devuelta, el impulso se aplica y satura en max_boost, y el impulso necesario para liderar la cadena (~0.68) aún excede el límite de 0.60. Esos se corregirían con un límite más grande; los medidos aquí no, que es por lo que el barrido publicado muestra este brazo plano de 0.60 a 1.50. Dos corpus de la misma forma pueden diferir tanto en un codificador léxico, así que trata el 75.0% como lo que es: una medición de doce cadenas, no una propiedad del respaldo. tests/test_revision_lead_cap.py falla si el límite se alcanza alguna vez en los conjuntos que verifica. changes() es la vista sin filtrar para verificar en contraste.


Construyendo RAG sobre ello

La recuperación es el núcleo; la generación es opcional y no usa dependencias adicionales (urllib de la biblioteca estándar a cualquier endpoint al que lo apuntes).

v.search("what port does staging use?", top_k=3)     # retrieval only
v.ask("What port does staging use?", llm="llama3.2") # -> {question, answer, citations}
v.ingest_file("runbook.md")                          # chunk + store a document

nanomem proxy además sirve un /v1/chat/completions compatible con OpenAI que inyecta memoria, por lo que una aplicación existente puede apuntar a nanomem en lugar de su proveedor y ganar memoria sin cambios de código.

Proveedores. Pasa base_url= y api_key= a ask()/chat(), o configúralos en el Vault. La URL base decide la forma de la solicitud:

Ollama (predeterminado)http://localhost:11434/api/generate nativo
OpenAI, Groq, Together, Mistral, DeepSeek, OpenRouter, Fireworkscualquier base que termine en /v1
LM Studio, vLLM, llama.cpp:1234, :8000, :8080
Anthropichttps://api.anthropic.com/v1/v1/messages, x-api-key
Azure OpenAIpasa la URL de implementación completa que termine en /chat/completions

Las incrustaciones provienen de NANOMEM_EMBED_URL (predeterminado nomic-embed-text en Ollama). El enrutamiento se decide por el PUERTO ANALIZADO, no por una subcadena de la URL: antes de 0.6.1, un host llamado web8000.internal se enrutaba mal y Anthropic se enviaba por POST a /v1/chat/completions, que devuelve 404.



CLI

python3 -m nanomem.cli init company.dat
python3 -m nanomem.cli add "DB port is 5433" --vault company.dat
python3 -m nanomem.cli search "DB port" -k 3 --vault company.dat
python3 -m nanomem.cli history "DB port" --vault company.dat
python3 -m nanomem.cli search "DB port" --as-of 2026-03-01 --vault company.dat
python3 -m nanomem.cli changes --since 2026-01-01 --vault company.dat
python3 -m nanomem.cli stats --vault company.dat
python3 -m nanomem.cli rekey --vault company.dat --new-password-stdin

add imprime el id del documento y su propio tiempo transcurrido.


Lo que hace, medido

La búsqueda es un escaneo lineal exacto. Para el texto que escanea, devuelve lo que un escaneo exhaustivo de coseno fp32 devuelve: 0 de 120 diferencias de orden top-4 en un corpus de 1,190 documentos (evidence/exactness_v3r2.json) — y su latencia por lo tanto crece con el corpus.

Qué texto escanea depende de decompose, que por defecto es True. Una pregunta de múltiples cláusulas se divide y cada subconsulta se escanea exhaustivamente, luego los mejores resultados se intercalan — por lo que el resultado es la respuesta exhaustiva a cada cláusula, no el top-k exhaustivo de toda la oración, y para una pregunta de múltiples cláusulas los dos difieren. Ese es el punto de la descomposición: una pregunta de dos partes obtiene ambas partes respondidas. Pasa decompose=False cuando quieras que la cadena completa se trate como una sola consulta y que la afirmación de exactitud anterior se aplique de principio a fin.

Corpusp50recall@4¿igual que numpy exhaustivo?¿igual que FAISS plano?índiceRSS por doc
1,1900.052 ms68.3 %2.5 MB
10,0000.345 ms70.4 %22.0 MB8.4 KB
71,4331.762 ms60.6 %155.9 MB8.8 KB

evidence/scale_results_v3r4.json, headtohead_v3.json, rss_v3r4.json. Medido en Apple M4 Pro / Python 3.12, nomic-embed-text 768-d, orden de inserción aleatorio, 500 preguntas reservadas, ingesta → cerrar → reabrir → buscar. (Cada ruta evidence/… en estos documentos es relativa a la raíz del repositorio, no a esta carpeta; los JSONs y los scripts que los escribieron viven allí.)

Las columnas "igual que exhaustivo" son recall. El orden también es idéntico en 1,190 documentos (0 de 120 diferencias de orden top-4); en 10,000 y 71,433 algunos desempates difieren — 2 de 500 y 5 de 500 preguntas, la mayor brecha de coseno 2.2e-05 — porque los vectores en disco son fp16 (evidence/verify_round3_v3r3.json).

Las puntuaciones son exactas en el mismo sentido y ni un poco más: dos matmuls float32 de diferentes formas se reducen en órdenes diferentes, por lo que la puntuación adjunta a un resultado puede diferir en su último bit o dos entre un BLAS y otro — medido 2.98e-08, dos ulps, entre Apple Accelerate y OpenBLAS en la misma consulta. La respuesta no se mueve con ello. Sobre 4,000 documentos en 128 y 768 dimensiones, el peor error |fp32 − fp64| es 2.01e-07 mientras que la brecha más pequeña entre el rango 4 y el rango 5 es 4.46e-06 — veinte veces más grande — y 0 de 150 consultas quedaron sin decidir en k = 1, 4 o 10 (evidence/screen_exactness_results.json). Así que "devuelve lo que un escaneo exhaustivo devuelve" es una afirmación sobre qué documentos regresan y en qué orden — excepto entre documentos que la clasificación genuinamente no puede separar, donde el corpus no contiene ningún desempate al que ser fiel. No es una afirmación sobre el patrón de bits del flotante junto a ellos, y nunca podría haberlo sido.

La memoria residente es aproximadamente 8–9 KB por documento. La documentación anterior afirmaba una huella de "< 500 KB RAM" o "160 KB de heap activo"; esas eran constantes impresas por stats(), no mediciones, y se han ido.

Las escrituras son planas a medida que la bóveda crece: media de 19.81 µs sobre los primeros 500 de 4,000 agregados, 19.32 µs sobre los últimos 500, excluyendo la incrustación (evidence/headtohead_v3.json).

Eliminar un registro es una reescritura atómica completa — 7.5 ms en 1,000 registros, 70.5 ms en 10,000 (evidence/rewrite_cost_v3r4.json). No hay marcadores de eliminación en 3.0.


Documentación

Lee nanomem.THREAT_MODEL antes de confiar en el modo de contraseña opcional. Es scrypt + un flujo de claves SHAKE256 + una etiqueta HMAC-SHA256, construido desde la biblioteca estándar de Python. No es AES, no es "cifrado de 256 bits", y no ha sido auditado.