Saltar a contenido

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_label busca el nombre sin distinguir mayúsculas en cualquier label y devuelve uno arbitrario (LIMIT 1), mientras que merge_node depende 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áctica status es texto libre, incluidos valores en el idioma del usuario, así que las consultas que filtran por open / active pierden nodos sin avisar.
  • Dos significados de Insight. Reflect escribe nodos Insight para 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

  1. Resolvedor de entidades (engrama/core/resolve.py), que usa engrama_remember para 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:
  2. 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;
  3. 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 warn se siguen escribiendo algunos duplicados. Es el valor por defecto solo porque block necesita 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 EntityDecider enchufable 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 aliases que 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 (Event o Session) para registros de sesión e informes periódicos, que hoy los agentes guardan como Insight a 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_of y las señales de los stubs.
  • 93: resolución de relaciones inline por confianza.