chore(issues): mover 7 issues completadas a issues/completed/

Status sincronizado con master:
  - 0001 chat con Claude     -> shipped como panel Echo
  - 0003 enricher web        -> shipped (0028 + 0028b)
  - 0026 sistema de jobs     -> shipped
  - 0027 tipo Webpage        -> shipped
  - 0028 fetch_webpage       -> shipped
  - 0028b extract trio       -> shipped
  - 0031 layout estable      -> shipped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 00:14:58 +02:00
parent 9ec832ea9a
commit 4ef6a5f7db
7 changed files with 0 additions and 0 deletions
@@ -0,0 +1,48 @@
---
id: 0001
title: Chat con Claude sobre el grafo
status: pending
priority: high
created: 2026-04-30
---
## Objetivo
Panel "Chat" dentro de graph_explorer que permita conversar con Claude
(Anthropic API) usando el grafo activo como contexto. El agente debe poder:
- Leer el grafo (entidades, relaciones, metadata) via tool-use sobre operations.db.
- Responder preguntas tipo: "muestrame los nodos relacionados con X", "que
patrones ves en estas conexiones", "que falta investigar".
- Proponer mutaciones (crear nodo, etiquetar relacion) que el usuario aprueba
con un click antes de aplicarse.
## Alcance tecnico
- Cliente HTTP minimo (libcurl o WinHTTP) → POST a `https://api.anthropic.com/v1/messages`.
- Modelo por defecto: `claude-sonnet-4-6` (revisar al implementar).
- API key desde env var `ANTHROPIC_API_KEY` o `~/.fn_anthropic_key`.
- Tool-use: definir tools `query_entities`, `query_relations`, `propose_node`,
`propose_relation`. Las "propose_*" no mutan: insertan en una cola que el
usuario revisa antes de aplicar.
- Estado de conversacion en memoria (lista de messages). Persistencia opcional
en `graph_explorer.db` tabla `chat_sessions`.
- Streaming SSE para feedback en vivo (puede dejarse para v2 — primer hit
bloqueante esta bien).
## Decisiones a tomar
- Renderizado de markdown en ImGui (TextWrapped basico vs lib externa).
- Threading: bloqueante en hilo aparte → cola de mensajes → main thread lee.
## Trabajo previo
Ya existe en el registry `python/functions/agents/anthropic_chat_py_agents.py`
para inspiracion (usa el SDK Python). En C++ usaremos HTTP directo — sin SDK.
## Definicion de hecho
- Panel "Chat" dockeable.
- Conversacion con tool-use sobre operations.db funciona.
- Las mutaciones propuestas por el agente se confirman desde la UI antes de
llegar a la BD.
@@ -0,0 +1,38 @@
---
id: 0003
title: Enricher web — descargar URL/dominio y extraer texto
status: pending
priority: medium
created: 2026-04-30
---
## Objetivo
Right-click sobre un nodo `url` o `domain` → "Run enricher → Fetch & extract
text". Descarga el HTML, extrae el texto principal, crea un nodo `text`
conectado al origen con relacion `FETCHED_FROM`.
Despues el usuario puede encadenar: sobre ese nodo `text`, ejecutar el enricher
GLiNER+GLiREL (issue 0002) para extraer entidades.
## Alcance
- HTTP GET con timeout (libcurl o sys WinHTTP).
- Extraccion de texto: regex/strip de tags simple en v1; v2 usa una lib
(htmlparser2 / lexbor / boost.url + algo de heuristica).
- User-agent identificativo, respeto de robots.txt opcional (out-of-scope v1).
- Limite de tamaño descargable (1 MB) para evitar bloqueos.
## Modelo de etiquetado
- Nodo origen (url/domain) → arista `FETCHED_FROM` → nodo nuevo (text con
metadata={fetched_at, status_code, content_type, length}).
- Nombre del nodo text: titulo de la pagina (si <title> existe) o primeros
120 caracteres del cuerpo.
## Definicion de hecho
- Funciona contra una URL real (https con TLS).
- Maneja errores (404, timeout, redirects basicos) sin tumbar la app.
- El nodo creado es visible y el texto se puede consumir por el enricher
GLiNER+GLiREL del issue 0002.
+209
View File
@@ -0,0 +1,209 @@
---
id: 0026
title: Sistema de jobs — enrichers asincronos en background
status: in_progress
priority: high
created: 2026-05-01
blocks: [0027, 0028, 0029, 0030]
supersedes: [0001, 0002, 0003]
---
## Objetivo
Convertir el menu "Run enricher" (hoy placeholder en main.cpp:485) en un
sistema real de jobs asincronos: el usuario lanza un enricher sobre un
nodo, vuelve a la app y sigue trabajando mientras el enricher procesa
en background. Al terminar, el grafo se recarga automaticamente con las
entidades/relaciones nuevas.
Este issue solo cubre la **infra**. Los enrichers concretos se escriben
en 0028, 0029, 0030.
## Decisiones tomadas
1. **Workers concurrentes**: 2 por defecto, configurable via Settings.
2. **Cache de documentos**: `<app_dir>/cache/<sha256[0:2]>/<sha256>.{html,md,png}`. Carpeta gitignored en el sub-repo.
3. **Webpage vs Url**: tipos separados (issue 0027). Url = link suelto, Webpage = documento descargado con cuerpo.
4. **Subprocess Python por job** (no daemon residente): cold start ~200 ms aceptable. Si molesta, issue futura.
5. **Estado en `graph_explorer.db`** (NO en `operations.db`): jobs son especificos de la app, no del grafo.
## Tabla `jobs` (graph_explorer.db)
```sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, -- ULID
enricher_id TEXT NOT NULL, -- ej: "fetch_webpage"
node_id TEXT, -- nodo objetivo (NULL si batch)
node_name TEXT DEFAULT '', -- snapshot para mostrar en UI
params_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL, -- queued|running|done|error|cancelled
progress REAL NOT NULL DEFAULT 0, -- 0..1
stage TEXT NOT NULL DEFAULT '', -- mensaje corto: "fetching", "extracting"
result_json TEXT, -- {entities_added: N, relations_added: M, ...}
error TEXT,
pid INTEGER, -- subprocess pid para cancelar
created_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, created_at);
```
## Runtime C++
### `jobs.{h,cpp}` (nuevo)
- `JobRunner`: pool de N std::thread workers (default 2).
- Cola en memoria `std::queue<JobId>` + persistencia en BD.
- API publico:
- `bool jobs_init(const char* db_path, int n_workers);`
- `bool jobs_submit(const char* enricher_id, const char* node_id, const char* params_json, char* out_id);`
- `bool jobs_cancel(const char* job_id);`
- `void jobs_shutdown();`
- `int jobs_dirty_counter();` // incrementa al completar un job; render lo lee
- `bool jobs_list(std::vector<JobRow>* out);`
- Worker hace:
1. Pop de cola → mark running, started_at = now, pid = subprocess pid.
2. Spawn `python/.venv/bin/python3 enrichers/<id>/run.py` con stdin = JSON.
3. Lee stderr line-by-line buscando `PROGRESS:<float> <stage>` para actualizar fila.
4. Lee stdout completo al cerrar — JSON final con entities/relations/node_updates.
5. Aplica al `operations.db` desde el worker (entity_insert/relation_insert/entity_update).
6. Marca done o error con result_json/error, finished_at, increment dirty_counter.
- Al arrancar: marca jobs `running` huerfanos como `error: "process died"`.
### `enrichers.{h,cpp}` (nuevo)
- Escanea `enrichers/*/manifest.yaml` al arrancar.
- Estructura `EnricherSpec`:
```
std::string id, name, description;
std::vector<std::string> applies_to; // tipos validos
std::vector<EnricherParam> params;
std::string run_path; // enrichers/<id>/run.py absoluto
```
- API:
- `void enrichers_load(const char* enrichers_dir);`
- `std::vector<EnricherSpec> enrichers_for_type(const char* type_ref);`
## Contrato enricher (wire protocol)
Cada enricher vive en `apps/graph_explorer/enrichers/<id>/`:
```
enrichers/
fetch_webpage/
manifest.yaml
run.py
```
### `manifest.yaml`
```yaml
id: fetch_webpage
name: "Fetch web page"
description: "Descarga HTML, extrae markdown y guarda en cache."
applies_to: [Webpage, Url]
params:
- { name: timeout_s, type: int, default: 15 }
- { name: use_browser, type: bool, default: false }
```
### `run.py` — stdin/stdout/stderr
**stdin** (una linea JSON):
```json
{"node_id":"webpage_123","node_type":"Webpage","node_name":"...",
"metadata":{"url":"https://..."},"params":{"timeout_s":15},
"db_path":"/path/operations.db","cache_dir":"/path/cache",
"registry_root":"/home/lucas/fn_registry"}
```
**stderr** (lineas de progreso, opcional):
```
PROGRESS:0.10 connecting
PROGRESS:0.50 parsing
PROGRESS:0.90 writing
```
**stdout** (una linea JSON al final):
```json
{"node_updates":[
{"id":"webpage_123","metadata_patch":{"title":"...","status_code":200}}
],
"entities":[
{"type":"Domain","name":"example.com","metadata":{}}
],
"relations":[
{"from_id":"webpage_123","to_name":"example.com","to_type":"Domain","kind":"BELONGS_TO"}
],
"notes":""}
```
Resolucion de relaciones:
- Si `to_id` esta presente, se usa directamente.
- Si no, se busca por `(to_name, to_type)` en operations.db; si no existe, se crea primero.
## UI
### Panel "Jobs"
- Nuevo panel dockeable (entry en `g_panels[]`).
- Tabla con columnas: enricher, target node (clicable → centra viewport), status (badge), progress bar, stage, duracion, error tooltip.
- Botones inline por fila: cancelar (running), reintentar (error/cancelled), borrar (terminal).
- Filtro: all | active | done | error.
### Toolbar
- Badge en la toolbar superior con contador de `running + queued`. Click abre el panel Jobs.
### Context menu (main.cpp:485)
Reemplazar el `TextDisabled("coming soon")` por:
```cpp
auto specs = ge::enrichers_for_type(node->type_ref);
if (specs.empty()) {
ImGui::TextDisabled("(no hay enrichers para este tipo)");
} else {
for (const auto& s : specs) {
if (ImGui::MenuItem(s.name.c_str())) {
char job_id[64];
ge::jobs_submit(s.id.c_str(), node->id.c_str(), "{}", job_id);
}
}
}
```
## Fases del bucle reactivo en BD
- **CONSTRUIR**: enricher escrito en `enrichers/<id>/`.
- **EJECUTAR**: subprocess corre, escribe progress en `jobs`.
- **RECOPILAR**: stdout JSON parseado, entities/relations aplicadas a operations.db.
- **ANALIZAR**: jobs.result_json contiene metricas (entities_added, duration_ms).
- **MEJORAR**: si un enricher falla repetidamente, futura issue de health checks.
## Cancelacion
- Boton "Cancel" en panel Jobs:
- Lee `pid` de la fila.
- `kill(pid, SIGTERM)` (Linux/WSL) o `TerminateProcess` (Windows).
- El worker captura el exit code y marca `cancelled`.
- Si el job aun no salio de la cola (status = queued), el worker simplemente no lo coge — al pop chequea status y skipea cancelled.
## Definicion de hecho
- Tabla `jobs` se crea al arrancar la app.
- `JobRunner` con 2 workers acepta `jobs_submit` y procesa.
- Panel Jobs muestra estado en tiempo real (progress bar avanza visiblemente).
- Cancelar mata subprocess y marca `cancelled`.
- Al completar, el grafo se recarga (dirty_counter detectado en render).
- Subir el contador de jobs en la toolbar.
- Test manual: enricher dummy `noop` (incluido en este issue) que duerme 3 s emitiendo PROGRESS y termina sin entidades. Lanzarlo y comprobar UI.
## Trabajo posterior
- 0027: tipo Webpage + cache.
- 0028: primer enricher real (`fetch_webpage`) end-to-end.
- 0028b: enrichers extract_domain, extract_links, extract_text_entities.
- 0029: enrichers via CDP (browser headless).
- 0030: macro "Deep enrich" + expand_domain.
@@ -0,0 +1,82 @@
---
id: 0027
title: Tipo Webpage + cache de documentos descargados
status: pending
priority: high
created: 2026-05-01
depends_on: [0026]
blocks: [0028, 0029, 0030]
---
## Objetivo
Anadir un tipo `Webpage` al `examples/types.yaml` y un layout
estandarizado de cache donde los enrichers guardan HTML, markdown y
screenshots descargados. El tipo `Url` existente queda como link suelto;
`Webpage` es un documento descargado con cuerpo.
## Cambios en `examples/types.yaml`
Anadir tras el bloque `Url`:
```yaml
- name: Webpage
color: "#89E0FC"
icon: ti-file-text
principal_field: url
fields:
- { name: url, type: url, required: true }
- { name: title, type: string }
- { name: status_code, type: int }
- { name: content_type, type: string }
- { name: fetched_at, type: date }
- { name: html_path, type: string } # cache/<sha256[0:2]>/<sha256>.html
- { name: markdown_path, type: string } # cache/<sha256[0:2]>/<sha256>.md
- { name: screenshot_path,type: string } # cache/<sha256[0:2]>/<sha256>.png
- { name: text_length, type: int }
- { name: lang, type: string }
```
## Layout del cache
```
<app_dir>/cache/
ab/
abcd1234...ef.html
abcd1234...ef.md
abcd1234...ef.png
cd/
cdef5678...01.html
...
```
- `sha256[0:2]` para evitar miles de archivos en un solo dir.
- Path absoluto desde `<app_dir>` para que sea portable entre PCs (paths relativos en metadata).
- `cache/` se anade al `.gitignore` del sub-repo.
## Helper C++
Funcion en `data.{h,cpp}` (o nuevo `cache_paths.{h,cpp}`):
```cpp
namespace ge {
// Resuelve el path absoluto donde un enricher debe escribir el blob.
// Crea el dir si no existe. ext sin punto: "html", "md", "png".
// hash_input: tipicamente la URL canonica (normalizada).
std::string cache_path(const char* app_dir,
const char* hash_input,
const char* ext);
}
```
- SHA256 calculado en C++ (usar implementacion existente en cpp/functions/core/ si la hay; si no, vendor/sqlite3 trae uno o se anade simple).
- O: el enricher Python calcula el sha256 (mas simple) y lo devuelve como parte de `node_updates`. Decidido: Python calcula el sha256, C++ solo expone `app_dir/cache/` como path absoluto al enricher.
## Definicion de hecho
- `Webpage` aparece en types.yaml con icono `ti-file-text`.
- El icono se renderiza correctamente (existe en tabler_codepoint_by_name).
- `cache/` esta en `.gitignore` del sub-repo del app.
- C++ pasa `cache_dir` al enricher en el JSON de stdin.
- Test manual: crear nodo `Webpage` desde el inspector, comprobar que
aparece con el color/icono correctos.
@@ -0,0 +1,78 @@
---
id: 0028
title: Enricher fetch_webpage (MVP end-to-end)
status: pending
priority: high
created: 2026-05-01
depends_on: [0026, 0027]
---
## Objetivo
Primer enricher real sobre el sistema de jobs (0026). Right-click sobre
un nodo `Url` o `Webpage` → "Fetch web page". Descarga el HTML, lo
convierte a markdown, guarda los blobs en cache, actualiza el nodo
(o lo convierte a Webpage si era Url) y crea el nodo `Domain` con
relacion `BELONGS_TO`.
Este enricher valida el contrato entero. Los siguientes (0028b) reusan
exactamente el mismo wire protocol.
## Archivos
```
apps/graph_explorer/enrichers/fetch_webpage/
manifest.yaml
run.py
```
## `manifest.yaml`
```yaml
id: fetch_webpage
name: "Fetch web page"
description: "Descarga HTML, extrae markdown limpio y guarda en cache."
applies_to: [Webpage, Url]
emits: [Domain]
relations: [BELONGS_TO]
params:
- { name: timeout_s, type: int, default: 15 }
```
## `run.py`
Logica:
1. Lee JSON de stdin.
2. Saca `url` de `metadata.url` (o `metadata.address` si es Url legacy).
3. `PROGRESS:0.05 normalize``normalize_url_py_cybersecurity`.
4. `PROGRESS:0.20 fetching` — descarga via `requests.get(url, timeout=N)`.
5. `PROGRESS:0.60 parsing``html_to_markdown_py_core` con readabilipy.
6. `PROGRESS:0.85 writing` — calcula sha256(url), escribe `cache/<sha[0:2]>/<sha>.html` y `.md`.
7. Emite stdout JSON:
- `node_updates`: cambia type a Webpage si era Url, anade title/status_code/content_type/fetched_at/html_path/markdown_path/text_length.
- `entities`: `{type: Domain, name: <dominio>, metadata: {}}`.
- `relations`: `from_id: <node_id>, to_name: <dominio>, to_type: Domain, kind: BELONGS_TO`.
## Funciones del registry usadas
- `normalize_url_py_cybersecurity` — limpia tracking params.
- `html_to_markdown_py_core` — readabilipy + markdownify.
- `extract_domain` se hace inline en el enricher (regex trivial sobre la URL parseada).
## Manejo de errores
- HTTP error (4xx/5xx) → escribe status_code en metadata pero NO marca el job como error (el nodo guarda evidencia del fallo).
- Timeout / DNS error / etc → exit con error JSON en stdout: `{"error": "...", "node_updates": [], "entities": [], "relations": []}`.
- Si el enricher levanta excepcion, sale con codigo != 0 y stderr capturado va a `jobs.error`.
## Definicion de hecho
- Crear nodo Url con `https://example.com` → click derecho → "Fetch web page".
- En segundos aparece en panel Jobs como `running` con progress.
- Al terminar:
- El nodo cambia a tipo `Webpage` con icono `ti-file-text`.
- El inspector muestra title, status_code, html_path, markdown_path.
- Aparece nodo `Domain` "example.com" conectado por `BELONGS_TO`.
- El archivo `cache/<sha>.md` existe en disco.
- El job aparece en panel Jobs como `done` con `entities_added=1, relations_added=1`.
- Tirar la red (sin internet) → el job acaba en `error` con mensaje claro.
@@ -0,0 +1,72 @@
---
id: 0028b
title: Enrichers extract_domain, extract_links, extract_text_entities
status: pending
priority: high
created: 2026-05-01
depends_on: [0028]
---
## Objetivo
Tres enrichers Python adicionales que reusan el contrato validado por
`fetch_webpage`. Cada uno cubre un eje de extraccion distinto.
## 1. `extract_domain`
```
applies_to: [Url, Webpage, Email]
emits: [Domain]
relations: [BELONGS_TO]
```
- Saca el dominio de `metadata.url` o `metadata.address`.
- Crea nodo `Domain` si no existe + relacion `BELONGS_TO`.
- Util cuando el usuario tiene un Url/Email que aun no ha sido fetched
pero quiere ver el dominio en el grafo.
## 2. `extract_links`
```
applies_to: [Webpage]
emits: [Url]
relations: [LINKS_TO]
```
- Lee `metadata.markdown_path`. Si vacio → exit con error "run fetch_webpage first".
- `extract_urls_py_cybersecurity` sobre el contenido.
- Para cada URL distinta encontrada:
- Crea nodo `Url` con `metadata.url` (si no existe).
- Relacion `LINKS_TO` desde la Webpage origen.
- Param: `max_links` (default 50) para no saturar el grafo.
## 3. `extract_text_entities`
```
applies_to: [Webpage]
emits: [Person, Org, Email, Phone, Domain, Location, IPAddress, CVE, ...]
relations: [EXTRACTED_FROM, ...relaciones que GLiREL detecte]
```
- Lee `metadata.markdown_path`.
- Llama `extract_graph_hybrid_py_pipelines` (regex IoCs + GLiNER + GLiREL + LLM fallback).
- Para cada entidad detectada:
- Resuelve por `(name, type)` en operations.db. Si no existe la crea.
- Relacion `EXTRACTED_FROM` desde la entidad nueva al nodo Webpage.
- Para cada relacion detectada por GLiREL:
- Relacion entre las dos entidades con el `kind` predicho.
- Params:
- `chunk_size` (default 2000)
- `use_llm_fallback` (default false — evitar coste; el usuario lo activa en jobs concretos)
## Definicion de hecho
- Los tres enrichers aparecen en el menu "Run enricher" segun el tipo
del nodo right-clickado.
- En un nodo Webpage el menu muestra los 3 + fetch_webpage.
- Test integracion:
- Crear Url → fetch_webpage → run extract_links sobre el resultado
→ run extract_text_entities → grafo se llena con persons/orgs/etc.
- Cada paso es un job independiente visible en panel Jobs.
- `extract_text_entities` con LLM off termina sin coste y produce
entidades de IoC + entidades GLiNER (gratis).
@@ -0,0 +1,144 @@
---
id: 0031
title: Layout estable al recargar — auto-save, halo placement, sin fit, physics off
status: in_progress
priority: high
created: 2026-05-01
related_to: [0026]
---
## Problema
Cuando un enricher (issue 0026) crea entidades nuevas, el `dirty_counter`
dispara `want_reload` y el grafo pierde la disposicion que tenia el
usuario:
1. **`graph_viewport_fit()` se llama en cada reload** → recentra y
reescala la camara, sensacion de "todo se movio".
2. **`layout_store_save` solo se ejecuta al pulsar "Save layout"** →
si el usuario no lo pulsa, las posiciones en RAM se pierden y los
nodos viejos vuelven a (0,0) tras el reload.
3. **`layout_circular` se aplica si todos los nodos estan en (0,0)**
tras reload → si no hay nada guardado en `layout_store`, todo se
reorganiza en circulo.
4. **Nodos creados por enrichers llegan en (0,0)** → quedan apilados
sobre el origen tras `layout_store_load`. Con physics ON se reparten
violentamente al chocar entre si.
## Decisiones (confirmadas por el usuario)
- **A. Auto-save antes de cada reload**: preservar las posiciones que el
usuario tiene en RAM sin que tenga que pulsar "Save layout" jamas.
- **B. No `graph_viewport_fit` en reloads**: solo en la primera carga
de cada proyecto/archivo. La camara permanece donde la tenia el usuario.
- **C. Halo placement para nodos huerfanos**: nodos que tras
`layout_store_load` siguen en (0,0) se posicionan junto a su primer
vecino con coordenadas conocidas, **garantizando no solapamiento** con
nodos existentes ni entre ellos.
- **D. No `layout_circular` en reloads**: la condicion `zero_pos == node_count`
solo aplica en la primera carga.
- **E. Physics siempre pausadas**: `layout_running = false` al cargar y
al recargar. El usuario las activa explicitamente con el toggle
Physics si quiere ver fuerzas.
## Implementacion
### main.cpp:want_reload (sustituye el bloque actual)
```cpp
if (g_app.want_reload) {
g_app.want_reload = false;
// (A) auto-save: persistir posiciones actuales antes de liberar grafo.
if (g_loaded) ge::layout_store_save(g_graph_hash, g_graph);
graph::GraphLoadStats stats{};
if (ge::reload_graph(g_input, &g_graph, &stats)) {
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
g_graph.update_bounds();
// (B) NO graph_viewport_fit aqui.
int restored = ge::layout_store_load(g_graph_hash, g_graph);
// (C) huerfanos -> halo placement junto a vecinos.
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
if (restored > 0 || g_graph.node_count > 0) g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
// (E) physics siempre pausadas tras reload.
g_viewport.layout_running = false;
}
}
```
### load_input — distinguir primera carga de reload
Anadir flag `bool first_load`. La condicion `zero_pos == node_count` y
el `graph_viewport_fit()` solo se aplican si `first_load == true`.
```cpp
static bool load_input(bool first_load = true);
```
Internamente: `reload_graph()` ya no llama a `load_input`, sino a una
version que pasa `first_load=false`. O `want_reload` hace el flujo
manualmente como arriba (sin reusar load_input).
### Nuevo helper `place_orphans_near_neighbors`
Vive en `main.cpp` (O nuevo `layout_helpers.{h,cpp}` si crece).
```cpp
// Para cada nodo en (0,0) (huerfano tras reload):
// 1. Busca su primer vecino (via aristas) con posicion no-cero.
// 2. Coloca el huerfano en un anillo a r=80 px alrededor del padre,
// eligiendo el primer slot angular (de 12) que no colisione con
// ningun otro nodo a min_dist. Si todos ocupados, expande radio
// (140, 200, 280, 400). Jitter deterministico por user_data para
// que dos huerfanos del mismo padre no caigan en el mismo slot.
// 3. Si el huerfano no tiene vecinos colocados (ej. componente
// conexa nueva), lo aparca en una columna a la derecha del bbox
// del grafo, separados verticalmente min_dist.
//
// Complejidad O(N * orphans). Suficiente para grafos bajo 5k nodos.
static void place_orphans_near_neighbors(GraphData& g, float min_dist);
```
Algoritmo de un huerfano:
```cpp
int parent = first_placed_neighbor(g, i); // O(edges)
if (parent < 0) { park_in_free_column(...); continue; }
const float radii[] = {80, 140, 200, 280, 400};
const int slots = 12; // 30 grados
float jitter = ((g.nodes[i].user_data >> 16) & 0xFF) / 255.0f * (2*PI/slots);
for (float r : radii) {
for (int s = 0; s < slots; ++s) {
float a = jitter + s * (2*PI/slots);
float cx = g.nodes[parent].x + r * cosf(a);
float cy = g.nodes[parent].y + r * sinf(a);
if (no_collision(g, i, cx, cy, min_dist)) {
g.nodes[i].x = cx; g.nodes[i].y = cy;
goto placed;
}
}
}
// Fallback: ultima posicion del ultimo radio + slot 0 (acepta solape).
placed:
```
`no_collision` es O(n) — itera todos los nodos del grafo y rechaza si
algun otro esta a < min_dist. Marca el huerfano recien colocado para
que el siguiente huerfano sepa de el.
## Definicion de hecho
- Reload tras enricher NO mueve la camara.
- Reload tras enricher NO cambia las posiciones de los nodos que ya
tenian sitio.
- Las entidades nuevas creadas por el enricher aparecen visibles, cerca
de su nodo padre semantico, sin solaparse con nadie.
- Physics permanecen OFF tras el reload (el usuario las activa
manualmente si quiere).
- Si el usuario nunca pulsa "Save layout", el cierre normal de la app
no preserva estado, pero cualquier reload SI preserva (gracias al
auto-save antes de reload).