---
id: 0035
title: Agrupar resultados de enrichers cuando exceden un umbral — nodos `Group` cuadrados expandibles
status: pending
priority: high
created: 2026-05-03
depends_on: [0026]
---

## Contexto

Hoy un enricher como `web_search` con `limit=200`, `extract_links` sobre una pagina densa, o un futuro `gliner_extract` sobre un texto largo, vomitan N nodos sueltos en el viewport. Con N grande (cientos, miles) el grafo se vuelve ilegible: cluster apretado de bolitas anonimas alrededor del source, layouts saturados, panel Inspector inservible.

Ademas, queremos poder llegar a **millones de nodos** sin que la app se ahogue al renderizar — no porque los necesitemos todos en pantalla a la vez, sino porque la fase de investigacion OSINT genuinamente produce esos volumenes (todos los enlaces de un sitio, todos los IoCs de un dump de logs, todos los followers de una cuenta...).

Solucion: cuando un enricher produce >= N hijos (default 50, override en manifest), creamos un nodo cuadrado `Group` que los contiene. El renderer dibuja solo el cuadrado por defecto; los hijos se materializan en pantalla cuando el usuario "entra" al grupo.

## Decisiones tomadas

### 1. Storage — opcion C (hibrida)

Los hijos **son entidades reales** en `operations.db`, no un blob serializado. Cada uno lleva un campo `group_id` que apunta a la entidad `Group` contenedora. El renderer filtra al cargar:

```
mostrar nodo si nodo.group_id IS NULL OR group(nodo.group_id).expanded
```

Beneficios:
- FTS5 sigue encontrando el hijo aunque este colapsado.
- Las relaciones cruzadas (un hijo de un grupo conectado a un nodo fuera del grupo) son aristas reales.
- La expansion es perezosa: al colapsar, los hijos se descargan del grafo en RAM (siguen en BD).

### 2. Umbral

- **Default global** (50) configurable en settings.
- **Override por enricher**: el manifest puede declarar `auto_group_threshold: 100` (o el valor que tenga sentido para ese flujo concreto).
- Sin override por ejecucion (de momento) — el modal de configuracion del enricher no incluye un checkbox "agrupar". Lo añadimos despues si hay friccion.

### 3. Modo "Twitter / Reddit"

Cuando el resultado excede el umbral, NO metemos los N hijos en el grupo de golpe. Estilo timeline:

- Los primeros **K hijos** (K = 10-20 configurable, default 10) van **sueltos** colgando del source con relacion normal — el usuario los ve como preview directo.
- Los **N - K restantes** van dentro de un nodo `Group` que cuelga del source con la **misma relacion** (ej: `SEARCH_RESULT_OF`), etiquetado `+ {N-K} mas`.

Por que: muchas veces los primeros resultados son los mas relevantes (DDG ranking, anchors prioritarios). El usuario ve lo importante sin friccion; el resto queda accesible pero compactado. Si el usuario quiere todo, abre el grupo.

### 4. Doble click en el grupo → vista de tabla

El grupo NO comparte el comportamiento del doble-click de un nodo normal (que abre Notes). En su lugar:

- Doble click en un `Group` cuadrado → abre la **vista de tabla** (`tableview`) listando todos los hijos con sus columnas (id, name, type_ref, status, updated_at, ...) + filtros y busqueda.
- Desde esa tabla el usuario puede:
  - Seleccionar filas y **promover** los nodos seleccionados (sacarlos del grupo y plantarlos como nodos sueltos en el canvas — `group_id = NULL`).
  - **Re-agrupar** un subconjunto por tipo: seleccionar varios `Url` y "agrupar por type_ref" crea un sub-grupo `Url (X)` dentro del actual.
- Las **relaciones nunca se pierden**: agrupar/promover solo toca `group_id`, no las filas de `relations`. Un hijo dentro de un grupo sigue teniendo sus aristas reales en BD; lo unico que cambia es como las dibujamos (ver decision 5).

Asi el grupo es un *contenedor de organizacion*, no una nueva identidad — promover y reagrupar son baratos.

### 5. Aristas — v1 simple, sin agregacion

Mientras el grupo este colapsado:
- Aristas **internas** (entre dos hijos del mismo grupo): se ocultan.
- Aristas **cruzando el limite** (hijo del grupo ↔ nodo fuera del grupo): se dibujan tal cual, conectando el nodo externo al grupo cuadrado (la coordenada del extremo "interno" se reemplaza por la del cuadrado). Una linea por arista, sin sumar pesos ni colapsar duplicados.

Cuando el grupo esta expandido, las aristas se dibujan a sus extremos reales (entre hijos individuales).

Implicacion visual: si 87 hijos del grupo tienen cada uno una relacion `BELONGS_TO` con un mismo `Domain` externo, ves 87 lineas dibujadas sobre el cuadrado → maraña. Lo aceptamos para v1; la agregacion con peso (1 sola linea con grosor proporcional a 87) queda para una v2.

### 6. Cascada y multi-tipo — un enricher, un (o varios) grupos planos

Caso: tengo un grupo "web_search: tomate" con 87 Urls. Lanzo `extract_links` sobre **todos**. Cada Url devuelve hasta 50 enlaces. Resultado bruto: hasta 4350 nodos nuevos. ¿Como los organizamos?

**Decision: A flat con procedencia, partido por tipo cuando el enricher emite varios.**

Reglas:

- **Una ejecucion produce 1 grupo por tipo de salida** que exceda el umbral.
  - Single-type (`extract_links` → todo Url): 1 grupo.
  - Multi-type (`extract_text_entities` → Email + IP + Domain + ...): hasta N grupos, uno por tipo. Los tipos que individualmente NO superan el umbral quedan sueltos.
- **Cada grupo cuelga del trigger** con la **misma relacion** que tendrian sus hijos individuales (`EXTRACTED_FROM`, `LINKS_TO`, `SEARCH_RESULT_OF`, etc.). Si el trigger es a su vez un grupo (cascada masiva), el nuevo grupo cuelga del grupo origen.
- **Procedencia: la relacion ya la lleva**. El hijo tiene su `EXTRACTED_FROM <source_specific>` real en `relations` — no se duplica como `metadata.source_node_id`. El filtro "muestrame los hijos que vienen de X" en tableview es una query a `relations`.
- **`batch_id` en metadata** del grupo y de cada hijo: UUID compartido por todos los nodos de la misma ejecucion (incluso si la salida se parte en varios grupos + nodos sueltos). Sirve para deshacer/filtrar/exportar la operacion completa.
- **Iconografia del grupo**: hereda color e icono del tipo que contiene (si es homogeneo). Cuadrado con shape de grupo + glyph del tipo. El usuario lee el canvas sin expandir.
- **Preview Twitter/Reddit aplica por tipo**: para cada tipo que excede umbral, los primeros K hijos quedan sueltos colgando del trigger; el resto va al grupo de ese tipo.
- **Excepcion a la regla 5 de aristas**: cuando ambos extremos caen en grupos colapsados (posiblemente el mismo), dibujamos UNA linea por par de grupos. Sin peso visual ni grosor — solo deduplicacion para evitar maranias topologicas.

Por que A con esta forma:

- Procedencia via filtro (queryable por relacion existente), no via estructura forzada del grafo.
- No hay problema de umbral marginal: la cascada en bloque siempre evalua el total contra el umbral, no source-por-source.
- Particion por tipo recobra significado visual del cuadrado (color/icono del contenido).
- "Extraer subset por source X" se queda como feature de fase 2 cuando madure la seleccion en tableview — el dato ya esta en BD desde el principio.

Caveat aceptado: con tableview filtrando por `source_node_id`, el usuario tiene que pedirlo explicitamente para ver subsets. La estructura por defecto es plana por tipo.

## Plan por fases

### Fase 1 — MVP, un solo enricher

- Tipo `Group` en `examples/types.yaml` (square, icono `ti-stack-2` o `ti-folder`, color distintivo).
- Columna `group_id TEXT` añadida al schema de `entities` (nullable).
- Aplicar solo a `web_search`: si `len(results) >= threshold`, crear `Group` + insertar los `N - K` restantes con `group_id` apuntando al grupo.
- Renderer ignora hijos con `group_id != NULL` salvo que el grupo este expandido (flag en RAM, no persistido aun).
- Doble click en `Group` → abre tableview filtrada por `group_id = <id>`.
- Sin promote/regroup todavia. Sin cascada. Sin agregacion de aristas.

Goal: validar end-to-end con un caso real antes de generalizar.

### Fase 2 — generalizar

- Aplicar a los 5 enrichers de sistema. Manifests declaran `auto_group_threshold`.
- Promote desde tableview (mover hijos fuera del grupo).
- Re-agrupar por tipo desde tableview.
- Persistir el flag `expanded` por grupo (en `local_files/<project>/<app>.db`, no en operations.db).
- Cascada (decision 6): definir e implementar.

### Fase 3 — escala y pulido

- Layouts internos del grupo cuando esta expandido (los hijos se posicionan en el "area" del cuadrado, no fuera).
- Agregacion de aristas con peso visual.
- Drill-in con breadcrumb (`tomate / search results / domain X`) como vista alternativa al tableview.
- Exportar subgrafos respetando jerarquia de grupos (issue 0024).

## Notas tecnicas (sin codigo)

- El renderer carga TODA `entities` hoy. Con millones de hijos, eso se rompe — fase 2/3 requiere cambiar el load para que filtre con `WHERE group_id IS NULL OR group_id IN (lista expandida)` desde la query.
- El layout `place_orphans_near_neighbors` ya cluster bien los hermanos de un mismo anchor (cambio reciente). Eso sigue valiendo: un `Group` colgando del source es un orphan mas que se posiciona cerca del padre.
- El `tableview` ya existe (issue 0010-0011), solo hay que poder abrirlo filtrado por `group_id`.
