DDR-006 — El motor, y no el agente, mantiene el grafo conectado¶
Versión: 0.1.0 | Fecha: 2026-09-24 | Estado: Aceptado
Contexto¶
El contrato de escritura de Engrama depende de la disciplina del agente.
El system prompt (v0.5/v0.6) y el docstring de engrama_relate dicen
"llama a relate justo después de remember", y engrama_remember
responde relations_created: 0 sin más comentario cuando el agente no lo
hace. El uso continuado muestra qué pasa cuando un agente se olvida,
pierde el contexto o recurre a un tag en lugar de una arista. El grafo se
degrada de formas reconocibles:
- Tags en lugar de aristas. Hay nodos que llevan un tag que es literalmente el nombre de un nodo ancla existente (un proyecto, un curso, un dominio), pero sin arista hacia él. La búsqueda los sigue encontrando, pero el grafo no los conecta, y una parte importante de los nodos vivos puede acabar sin ninguna arista.
- Stubs que acaban siendo hubs. Las relaciones inline crean un stub
como destino (
status: stub) cuando no hay coincidencia. Nada quita nunca ese estado ni pide enriquecerlo, así que un stub puede acumular muchas aristas sin tener summary. Esos stubs sostienen estructura real: si se eliminan, el grafo se parte, así que la solución es enriquecerlos, no borrarlos. - Duplicados entre labels y nombres.
lookup_node_labelbusca el nombre sin distinguir mayúsculas en cualquier label y devuelve uno arbitrario (LIMIT 1), mientras quemerge_nodedepende del label, así que la misma entidad puede existir con dos labels. La mayor parte de la duplicación es semántica y no léxica (un producto, "la API del producto" y "el certificado del producto" como tres nodos sin enlazar), así que comparar nombres exactos encuentra pocas. - Deriva de labels y de estados. Los labels se estiran más allá de su
significado (
Domain, pensado para áreas de conocimiento, usado para nombres de dominio de internet). En la prácticastatuses texto libre, incluidos valores en el idioma del usuario, así que las consultas que filtran poropen/activepierden nodos sin avisar. - Dos significados de
Insight. Reflect escribe nodosInsightpara su revisión, y los agentes también usan el label, con acierto, para síntesis que redactan ellos mismos: lecciones aprendidas, postmortems, conclusiones. Esos Insights escritos a mano suelen estar bien documentados y bien enlazados, pero todos los detectores y las consultas estructurales ignoran:Insight, así que nunca entran en la curación.
La resolución difusa que introdujo #93 es un buen comienzo, pero tiene
tres límites. Es solo léxica (difflib). Nunca se aplica al nodo que se
recuerda, solo a los destinos inline. Recorre una lista de candidatos
ordenada alfabéticamente y truncada (list_existing_nodes,
LIMIT 1000), así que por encima de 1000 nodos el final del alfabeto
nunca es candidato. Además, el recorrido es lineal: en un benchmark
sintético tarda 13 ms por escritura con 1 000 nodos y 300 ms con 20 000.
Por último, resuelve hacia nodos archivados, que la búsqueda oculta.
Decisión¶
Toda escritura pasa por un único resolvedor de entidades, y toda respuesta de escritura le dice al agente la consecuencia estructural de lo que acaba de hacer. El motor garantiza la resolución, el feedback y la higiene de los stubs, sea cual sea el cuidado del agente. El rechazo sigue siendo opcional.
En concreto¶
- Resolvedor de entidades (
engrama/core/resolve.py), que usaengrama_rememberpara el propio nodo y para cada destino inline. La política es pura e idéntica en todos los backends; los stores solo aportan candidatos acotados y filtrados por scope: name_candidates: nodos cuyo nombre contiene un fragmento de tres caracteres de cada palabra del destino normalizado (casefold, sin tildes,-/_/espacio unificados), con los nombres exactos y las longitudes parecidas primero y un máximo de 200. A diferencia de una búsqueda fulltext, encuentra el candidato aunque el destino tenga una errata;- kNN vectorial, solo para el nodo recordado, reutilizando el embedding que ya se calcula al escribir. Los vecinos se vuelven a puntuar con un coseno exacto, porque el índice vectorial de cada backend expresa la similitud en una escala distinta.
Los nombres se comparan tras normalizarlos (ratio de difflib). Se
mantiene la política de tres salidas de #93 (conectar / preguntar /
crear), con umbrales configurables (ENGRAMA_RESOLVE_*). Solo las
coincidencias léxicas casi seguras conectan automáticamente; la
similitud semántica solo avisa. Los nodos archivados nunca se enlazan
en silencio: una coincidencia exacta con uno archivado se informa
como revive_candidate.
2. Resolución del propio nodo recordado. Si un nodo con otra clave (o
con la misma clave bajo otro label) se resuelve por encima del umbral,
la escritura se hace igualmente. La respuesta incluye
possible_duplicate_of: [{label, name, score, reason}].
ENGRAMA_REMEMBER_DEDUPE=off|warn|block fija el comportamiento, con
warn por defecto. block devuelve un error con did_you_mean
salvo que la llamada pase force_new: true.
3. Búsqueda consciente del label. Las coincidencias exactas prefieren
el label pedido. Una coincidencia exacta bajo otro label se informa
como label_conflict en vez de elegir uno en silencio (sustituye al
LIMIT 1).
4. Feedback de grado. Toda respuesta de engrama_remember incluye el
degree resultante del nodo. Si es 0, añade orphan_warning y
suggested_relations, construidas a partir del resolvedor y de los
tags ancla (punto 5). La opción ENGRAMA_REQUIRE_RELATIONS=true
rechaza las escrituras de grado 0 excepto para labels ancla
(Project, Domain, Client, Course, Person).
5. Anclaje por tags. Un tag que se resuelve de forma exacta
(normalizado o alias) a un nodo ancla del scope se convierte en
relación sugerida, con un tipo por defecto según el label
(Project → BELONGS_TO, Course → COVERS invertida,
Client → FOR, Domain → IN_DOMAIN).
ENGRAMA_TAG_LINKING=off|suggest|auto, con suggest por defecto. Con
auto, las aristas se crean y la respuesta las lista en
relations_from_tags, así que quien llama ve exactamente qué se ha
añadido. (Las aristas no tienen propiedades en el backend SQLite, así
que no se marcan.)
6. Ciclo de vida de los stubs. Un stub conserva status: stub solo
hasta que se enriquece. Una escritura que aporte summary o details
lo quita (al estado que indique quien llama o, si no, active). El
label de un stub viene del label explícito, después del resolvedor y
después del tipo de relación. La inferencia por tipo de relación queda
solo como último recurso. Las respuestas que tocan un stub con
grado ≥ 3 incluyen enrich_hint.
7. Vocabularios controlados.
- status se valida contra un enum por label declarado en el perfil.
Los valores desconocidos se normalizan si existe una equivalencia; si
no, se guardan tal cual en status_note, el estado queda en active
y se emite un aviso.
- Insight conserva sus dos significados, que se distinguen por el
origen. Un Insight de sistema es el que lleva source_query,
que reflect siempre fija. Solo los Insights de sistema siguen el
ciclo de revisión y las reglas propias de los Insights en DDR-007 y
DDR-008. Un Insight escrito a mano es un nodo de dominio más para el
resolvedor, los detectores y las métricas estructurales. Las
escrituras no cambian y no se reetiqueta nada. Un único helper,
is_system_insight(node), aplica la regla en los dos backends.
- La descripción del label Domain se precisa como "área de
conocimiento" en los perfiles y en la documentación de las tools.
Consecuencias¶
- Positivas:
- La calidad estructural del grafo deja de depender de que el agente siga el prompt. Un agente que ha perdido el contexto sigue recibiendo avisos de duplicados, de huérfanos y de las anclas que olvidó enlazar.
- El resolvedor sustituye un recorrido lineal y truncado por consultas a índices, y el coste medido de obtener candidatos apenas crece con el tamaño del grafo: kNN de sqlite-vec con scope tarda 0,9 ms con 1 000 vectores y 5,3 ms con 5 000.
- Negativas:
- Las respuestas crecen. Los agentes que dependan de la forma anterior siguen funcionando, porque los campos son aditivos, pero ven más campos.
- Con
warnse siguen escribiendo algunos duplicados. Es el valor por defecto solo porqueblocknecesita un ciclo de reintento en el modelo. - La normalización de estados reescribe valores en la siguiente
escritura; el original se conserva en
status_note. - Neutras:
- El resolvedor funciona con o sin embedder. Sin él, se queda en
resolución léxica. El núcleo nunca requiere un LLM. Un
decisor enchufable (protocolo
EntityDecider) puede sustituir la política de umbrales por un clasificador local calibrado; queda como seguimiento abierto.
Seguimientos abiertos (no bloqueantes)¶
- Un
EntityDeciderenchufable para la resolución, la elección del tipo de relación y la supersesión, calibrado con datos reales de aceptaciones y rechazos. - Enriquecimiento diferido de stubs: una tarea en segundo plano que redacte un summary para los hub stubs a partir de su vecindario. Necesita un LLM, así que queda fuera del núcleo.
- Una propiedad
aliasesque consulte el resolvedor. Los alias nacen al fusionar nodos, así que llegarán con la operación de fusión (DDR-008). - Vocabularios de estado por perfil para los módulos de dominio
(
hacking,teaching,photography,ai). - Un label episódico (
EventoSession) para registros de sesión e informes periódicos, que hoy los agentes guardan comoInsighta falta de algo mejor.
Referencias¶
- DDR-001: clasificación facetada (labels y tipos de relación).
- DDR-003: embeddings y búsqueda híbrida, que reutiliza el resolvedor.
- DDR-007: relevancia en el tiempo.
DDR-008: reflect como curador, que consume
possible_duplicate_ofy las señales de los stubs. -
93: resolución de relaciones inline por confianza.¶