feat(viz): graph_sources lector operations.db + streaming (issue 0049g)
- graph_load_from_operations: SQLite read-only, schema-detect (type_ref/type, from_entity/source, to_entity/target, name/type, weight, updated_at). - 16-color indigo palette por hash FNV1a32 del nombre de tipo. user_data por nodo es FNV1a64(entity.id) — deterministico entre cargas. - Label pool interno: metadata.name (JSON simple) > entities.name > id. - graph_free libera nodes/edges/types/rel_types/labels/strdup'd names via arena_map (GraphData* -> arena). - Streaming pull-based con tiebreak (updated_at, id) y crecimiento x2 de capacidad. Tipos nuevos descubiertos en stream se anaden a types. - Tests: fixture in-memory (3 entity types, 2 rel types, 10 entities, 15 relations) + smoke contra apps/script_navegador/operations.db. - Issue movido a completed/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: graph_sources
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats)"
|
||||
description: "Lectores de grafos para GraphData con firma uniforme GraphLoadFn. Primera implementacion: operations.db (entities + relations) con variante streaming pull-based"
|
||||
tags: [graph, sources, sqlite, operations, streaming, loader, viz]
|
||||
uses_functions: []
|
||||
uses_types: ["GraphData_cpp_viz", "EntityType_cpp_viz", "RelationType_cpp_viz"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["test_graph_sources"]
|
||||
test_file_path: "cpp/tests/test_graph_sources.cpp"
|
||||
file_path: "cpp/functions/viz/graph_sources.cpp"
|
||||
framework: "imgui"
|
||||
params:
|
||||
- name: db_path
|
||||
desc: "Ruta a un fichero SQLite operations.db (schema con entities + relations). Solo se abre en modo READONLY"
|
||||
- name: out
|
||||
desc: "GraphData destino. La funcion aloca nodes/edges/types/rel_types y un string pool interno; el caller debe liberar con graph_free()"
|
||||
- name: stats
|
||||
desc: "GraphLoadStats opcional con conteos y un buffer error_msg de 256 bytes; si la carga falla, errors > 0 y error_msg describe el motivo (BD ausente, tabla faltante, columna desconocida)"
|
||||
output: "true si la carga fue correcta. false con stats->errors > 0 y error_msg poblado en error. Tras un retorno true: out->nodes/edges apuntan a memoria interna; out->types/rel_types listan los tipos descubiertos con color por hash del nombre y SHAPE_CIRCLE/EDGE_SOLID por defecto. user_data por nodo es FNV1a64 del entity.id (deterministico). label_idx apunta a metadata.name si existe, else entity.name, else entity.id. Streaming: graph_stream_operations_open captura MAX(updated_at, id) actuales; graph_stream_pull devuelve cuantas filas append y crece capacity en x2 si hace falta"
|
||||
---
|
||||
|
||||
# graph_sources
|
||||
|
||||
Lectores de grafos para `GraphData` (issue 0049g). Disenado como un set de funciones con la misma firma `GraphLoadFn` para que anadir un backend nuevo (JSON, JSONL, GraphML, Neo4j export, etc.) sea declarar otra funcion compatible — el resto del codigo (apps, viewport, force layout, renderer) no cambia.
|
||||
|
||||
## API
|
||||
|
||||
```cpp
|
||||
typedef bool (*GraphLoadFn)(const char* uri, GraphData* out, GraphLoadStats* stats);
|
||||
|
||||
bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats);
|
||||
void graph_free(GraphData* graph);
|
||||
const char* graph_label(const GraphData* graph, uint32_t label_idx);
|
||||
|
||||
// Streaming pull-based (poll cada N ms desde el caller).
|
||||
GraphStreamSource* graph_stream_operations_open(const char* db_path, int poll_ms);
|
||||
int graph_stream_pull(GraphStreamSource*, GraphData*);
|
||||
void graph_stream_close(GraphStreamSource*);
|
||||
```
|
||||
|
||||
## Mapeo operations.db → GraphData
|
||||
|
||||
`operations.db` es la BD de cada app del registry. La funcion detecta el schema via `PRAGMA table_info`:
|
||||
|
||||
| Concepto | Columna preferida | Fallback |
|
||||
|----------|-------------------|----------|
|
||||
| Tipo de entidad | `entities.type_ref` | `entities.type` |
|
||||
| Source / target de relacion | `relations.from_entity` / `relations.to_entity` | `relations.source` / `relations.target` |
|
||||
| Tipo de relacion | `relations.type` | `relations.name` |
|
||||
| Etiqueta de nodo | `metadata.name` (JSON) | `entities.name` o `entities.id` |
|
||||
| Tiebreak streaming | `(updated_at, id)` | `id` solo |
|
||||
|
||||
Cada valor distinto de `type_ref` produce un `EntityType` con color tomado de un palette de 16 (FNV1a32 sobre el nombre → `palette[h & 0xF]`), `shape = SHAPE_CIRCLE`, `default_size = 6.0`, `icon_id = 0`. La app consumidora puede sobreescribir esa apariencia via `types.yaml` (issue 0049k).
|
||||
|
||||
`user_data` por nodo es `FNV1a64(entity.id)` — deterministico entre cargas y util como handle estable para joins con metadata externa.
|
||||
|
||||
## Errores
|
||||
|
||||
La funcion no usa excepciones. En error:
|
||||
- Retorna `false`.
|
||||
- `stats->errors >= 1`.
|
||||
- `stats->error_msg` contiene un texto corto (`"open: ..."`, `"missing table: entities"`, `"entities: missing type_ref/type column"`, ...).
|
||||
|
||||
Relaciones con `from_entity` / `to_entity` que apunten a entities inexistentes se cuentan en `stats->errors` y se descartan — el grafo resultante es siempre consistente.
|
||||
|
||||
## Streaming
|
||||
|
||||
El streaming es pull-based: el caller decide la cadencia y llama `graph_stream_pull` cuando quiere chequear cambios. La fuente guarda `(MAX(updated_at), MAX(id))` al abrir y avanza el cursor con tiebreak `(updated_at, id)`:
|
||||
|
||||
```sql
|
||||
WHERE (updated_at > ?) OR (updated_at = ? AND id > ?)
|
||||
ORDER BY updated_at, id
|
||||
```
|
||||
|
||||
`graph_stream_pull` crece la capacidad de `nodes`/`edges` en x2 si hace falta — el caller no necesita pre-allocar. Tipos nuevos descubiertos durante el stream se anaden al final de `types`. Operaciones inversas (deletes) no se propagan — para reset, el caller debe `graph_free` y recargar.
|
||||
|
||||
## Memoria
|
||||
|
||||
`graph_load_from_operations` aloca toda la memoria que cuelga del `GraphData` devuelto (incluido el string pool de labels y los nombres de tipos via `strdup`). El caller libera todo con `graph_free(graph)`. La asociacion `GraphData* → arena` se mantiene en un mapa estatico interno; `graph_label` y `graph_stream_pull` lo consultan para acceder al pool.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
GraphData g{};
|
||||
graph::GraphLoadStats s{};
|
||||
if (!graph::graph_load_from_operations("apps/script_navegador/operations.db", &g, &s)) {
|
||||
fprintf(stderr, "load failed: %s\n", s.error_msg);
|
||||
return 1;
|
||||
}
|
||||
printf("nodes=%d edges=%d types=%d rel_types=%d\n",
|
||||
s.nodes_loaded, s.edges_loaded, s.types_discovered, s.rel_types_discovered);
|
||||
|
||||
// Render con graph_renderer + force layout via graph_force_layout.
|
||||
// ...
|
||||
|
||||
// Watcher en otro hilo:
|
||||
auto* src = graph::graph_stream_operations_open("apps/script_navegador/operations.db", 500);
|
||||
while (running) {
|
||||
sleep_ms(500);
|
||||
int n = graph::graph_stream_pull(src, &g);
|
||||
if (n > 0) printf("appended %d new rows\n", n);
|
||||
}
|
||||
graph::graph_stream_close(src);
|
||||
graph::graph_free(&g);
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- **v1.0** (2026-04-29, issue 0049g): primera version. Lector sincrono + streaming poll-based. Tests con fixture in-memory: 10 entities + 15 relations, 3 entity types, 2 relation types. Determinismo del `user_data` y resolucion de aristas verificados.
|
||||
- La firma `GraphLoadFn` esta diseniada para futuros backends. Anadir uno (ej. `graph_load_from_jsonl`) consiste en declarar una funcion con la misma firma; nada en el resto del pipeline (renderer, layout, viewport) cambia.
|
||||
Reference in New Issue
Block a user