diff --git a/cpp/functions/core/data_table_types.h b/cpp/functions/core/data_table_types.h index 67d9dc4e..b35170d2 100644 --- a/cpp/functions/core/data_table_types.h +++ b/cpp/functions/core/data_table_types.h @@ -8,6 +8,8 @@ #include "compute_column_stats.h" #include +#include +#include #include #include @@ -353,6 +355,59 @@ struct VizPanel { mutable ViewMode last_non_table = ViewMode::Bar; }; +// ---------------------------------------------------------------------------- +// StringPool — interning de strings para columnas de texto (issue 0133). +// Una instancia por State (NOT global) para aislar tablas independientes. +// +// intern(sv) devuelve un indice uint32_t estable para la vida del rebuild. +// El pool se limpia (clear()) al inicio de cada rebuild de snapshot columnar. +// +// Invariante de invalidacion de string_view: +// - El vector `strings` se reserva con reserve() ANTES del primer intern() +// para evitar reallocs que invalidarian los string_view del mapa. +// Si la estimacion es insuficiente (columna con mas unicos de lo esperado), +// el mapa se reconstruye post-push_back: intern() verifica cap antes de +// insertar en el map para cubrir este caso. +// ---------------------------------------------------------------------------- +struct StringPool { + std::vector strings; // strings unicos, por indice + std::unordered_map index; // sv→id (sv apunta a strings[i]) + + void clear() { + strings.clear(); + index.clear(); + } + + // intern: inserta si no existe. Devuelve indice estable. + // INVARIANTE: reserve() ANTES del primer intern() por columna para evitar + // reallocs que invalidarian los string_view del mapa. Si la estimacion fue + // insuficiente, forzamos reserve(size+1) ANTES de emplace_back para que + // la realloc ocurra antes de que cualquier sv del mapa apunte al buffer + // viejo — y reconstruimos el mapa desde cero tras la realloc. + uint32_t intern(std::string_view sv) { + auto it = index.find(sv); + if (it != index.end()) return it->second; + uint32_t id = (uint32_t)strings.size(); + if (strings.size() == strings.capacity()) { + // Realloc inminente: hacerlo ANTES de insertar en index para que + // los string_view existentes no queden dangling. Tras el reserve, + // reconstruimos el index desde cero porque los punteros cambiaron. + strings.reserve(strings.capacity() == 0 ? 64 : strings.capacity() * 2); + index.clear(); + for (uint32_t i = 0; i < (uint32_t)strings.size(); ++i) + index.emplace(std::string_view(strings[i]), i); + } + strings.emplace_back(sv); + // string_view apunta al almacenamiento interno (strings[id]), estable + // porque acabamos de garantizar capacidad suficiente. + index.emplace(std::string_view(strings[id]), id); + return id; + } + + const std::string& at(uint32_t id) const { return strings[id]; } + bool empty() const { return strings.empty(); } +}; + // ---------------------------------------------------------------------------- // State: stage pipeline + viz globales. // ---------------------------------------------------------------------------- @@ -419,6 +474,11 @@ struct State { std::vector drill_back; std::vector drill_forward; + // String interning pool (issue 0133, Change 2). + // Limpiado y repoblado en cada rebuild del snapshot columnar. + // NOT global — una instancia por State para aislar tablas independientes. + StringPool string_pool; + // Helpers (definidos en compute_stage.cpp). Stage& raw(); const Stage& raw() const; diff --git a/modules/data_table/data_table.cpp b/modules/data_table/data_table.cpp index 02e6d325..9f58fa23 100644 --- a/modules/data_table/data_table.cpp +++ b/modules/data_table/data_table.cpp @@ -71,6 +71,7 @@ #include #include +#include #include #include #include @@ -315,7 +316,185 @@ static std::string row_to_tsv(const char* const* cells, int rows, int cols, return out; } +// --------------------------------------------------------------------------- +// Issue 0133 — Change 3: Reader rewire helpers. +// +// snap_cell: devuelve el string de una celda desde el snapshot columnar cuando +// la columna esta en rango, con fallback al raw cells array. +// Para columnas Int/Float usa un buffer thread_local de 32 bytes (evita alloc). +// --------------------------------------------------------------------------- +static inline const char* snap_cell(int r, int c, + const SnapshotCache& snap, + const StringPool& pool, + char (&tmp)[32]) +{ + if (c >= 0 && c < (int)snap.cols.size()) { + const ColumnSnapshot& cs = snap.cols[(size_t)c]; + if (cs.type == ColumnType::Int) { + std::snprintf(tmp, sizeof(tmp), "%" PRId64, cs.i64[(size_t)r]); + return tmp; + } else if (cs.type == ColumnType::Float) { + std::snprintf(tmp, sizeof(tmp), "%.17g", cs.f64[(size_t)r]); + return tmp; + } else if (!cs.str_ids.empty()) { + return pool.at(cs.str_ids[(size_t)r]).c_str(); + } + } + (void)tmp; + return nullptr; // caller must fallback to cells[r*cols+c] +} + +// compare_snap: evaluates filter f against row r using snapshot when available. +// Falls back to raw cells if column not in snapshot. +static inline bool compare_snap(int r, int f_col, + const char* f_val, Op f_op, + const char* const* cells, int cols, + const SnapshotCache& snap, + const StringPool& pool) +{ + // Fast numeric path: avoid string conversion for numeric comparisons. + if (f_col >= 0 && f_col < (int)snap.cols.size()) { + const ColumnSnapshot& cs = snap.cols[(size_t)f_col]; + if (cs.type == ColumnType::Int && + (f_op == Op::Eq || f_op == Op::Neq || f_op == Op::Gt || + f_op == Op::Gte || f_op == Op::Lt || f_op == Op::Lte)) { + double fv; + if (parse_number(f_val, fv)) { + int64_t av = cs.i64[(size_t)r]; + int64_t bv = (int64_t)fv; + switch (f_op) { + case Op::Eq: return av == bv; + case Op::Neq: return av != bv; + case Op::Gt: return av > bv; + case Op::Gte: return av >= bv; + case Op::Lt: return av < bv; + case Op::Lte: return av <= bv; + default: break; + } + } + } + if (cs.type == ColumnType::Float && + (f_op == Op::Eq || f_op == Op::Neq || f_op == Op::Gt || + f_op == Op::Gte || f_op == Op::Lt || f_op == Op::Lte)) { + double fv; + if (parse_number(f_val, fv)) { + double av = cs.f64[(size_t)r]; + switch (f_op) { + case Op::Eq: return av == fv; + case Op::Neq: return av != fv; + case Op::Gt: return av > fv; + case Op::Gte: return av >= fv; + case Op::Lt: return av < fv; + case Op::Lte: return av <= fv; + default: break; + } + } + } + // String column: snapshot offers no speed advantage for substring ops + // (Contains/NotContains/StartsWith/EndsWith need full string scan regardless). + // Only use intern path for equality (id compare avoids strcmp). + if (!cs.str_ids.empty()) { + if (f_op == Op::Eq || f_op == Op::Neq) { + // Find the interned id of f_val (if not found, no row can match Eq, + // and all rows match Neq). + std::string_view fv_sv(f_val); + auto fv_it = pool.index.find(fv_sv); + if (f_op == Op::Eq) { + if (fv_it == pool.index.end()) return false; // f_val not interned => no match + return cs.str_ids[(size_t)r] == fv_it->second; + } else { // Op::Neq + if (fv_it == pool.index.end()) return true; // f_val not interned => all differ + return cs.str_ids[(size_t)r] != fv_it->second; + } + } + // For substring / prefix / suffix ops: fall through to raw cells (no snapshot benefit). + } + } + // Fallback: raw cells (e.g. derived column not in snapshot, or string substring op). + const char* cell = (f_col >= 0 && f_col < cols) ? cells[r * cols + f_col] : nullptr; + return compare(cell, f_val, f_op); +} + // compute_visible_rows: applies stage-0 filters + optional sort, returns matching row indices. +// Issue 0133 — Change 3: overload with snapshot for columnar reads. +static std::vector compute_visible_rows(const char* const* cells, + int rows, int cols, + const State& st, + const SnapshotCache& snap, + const StringPool& pool) +{ + std::vector out; + out.reserve(rows); + const Stage& s = st.raw(); + for (int r = 0; r < rows; ++r) { + bool keep = true; + for (const auto& f : s.filters) { + if (f.col < 0 || f.col >= cols) continue; + if (!compare_snap(r, f.col, f.value.c_str(), f.op, + cells, cols, snap, pool)) { + keep = false; break; + } + } + if (keep) out.push_back(r); + } + if (!s.sorts.empty()) { + const SortClause& sc0 = s.sorts.front(); + int sc = -1; + if (!sc0.col.empty() && sc0.col[0] == '@') { + sc = std::atoi(sc0.col.c_str() + 1); + } + bool desc = sc0.desc; + if (sc >= 0 && sc < cols) { + // Fast numeric sort via snapshot. + if (sc < (int)snap.cols.size()) { + const ColumnSnapshot& cs = snap.cols[(size_t)sc]; + if (cs.type == ColumnType::Int) { + std::sort(out.begin(), out.end(), [&](int a, int b) { + int64_t va = cs.i64[(size_t)a]; + int64_t vb = cs.i64[(size_t)b]; + return desc ? (va > vb) : (va < vb); + }); + goto sort_done; + } else if (cs.type == ColumnType::Float) { + std::sort(out.begin(), out.end(), [&](int a, int b) { + double va = cs.f64[(size_t)a]; + double vb = cs.f64[(size_t)b]; + return desc ? (va > vb) : (va < vb); + }); + goto sort_done; + } else if (!cs.str_ids.empty()) { + // String sort: compare uint32_t ids first (if equal -> same string). + std::sort(out.begin(), out.end(), [&](int a, int b) { + uint32_t ia = cs.str_ids[(size_t)a]; + uint32_t ib = cs.str_ids[(size_t)b]; + if (ia == ib) return false; // equal + int cmp = std::strcmp(pool.at(ia).c_str(), pool.at(ib).c_str()); + return desc ? (cmp > 0) : (cmp < 0); + }); + goto sort_done; + } + } + // Fallback sort via raw cells. + std::sort(out.begin(), out.end(), [&](int a, int b) { + const char* ca = cells[a * cols + sc]; + const char* cb = cells[b * cols + sc]; + if (!ca) ca = ""; + if (!cb) cb = ""; + double na, nb; + bool num = parse_number(ca, na) && parse_number(cb, nb); + int cmp; + if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0); + else cmp = std::strcmp(ca, cb); + return desc ? (cmp > 0) : (cmp < 0); + }); + sort_done:; + } + } + return out; +} + +// compute_visible_rows: legacy overload without snapshot (used by stage>0 path +// which operates on materialized StageOutput — not the raw cells snapshot). static std::vector compute_visible_rows(const char* const* cells, int rows, int cols, const State& st) @@ -816,6 +995,121 @@ void render(const char* id, ensure_init(st, eff_cols); auto& U = ui(); + // ------------------------------------------------------------------------- + // Issue 0133 — Change 1+2: Columnar snapshot + string interning. + // + // Se reconstruye si: + // - Es el primer frame (last_cells_ptr == nullptr), o + // - El puntero de `cells` cambio (caller reemplazo el buffer). + // + // Snapshot cubre las columnas ORIGINALES (pre-derived) del stage-0 input. + // Las derived columns no se incluyen en el snapshot — se calculan en + // compute_stage y el snapshot solo optimiza el acceso a datos crudos. + // + // StringPool.clear() + rebuild siempre que el snapshot se reconstruya, + // para mantener coherencia de indices entre pool y snapshot. + // ------------------------------------------------------------------------- + // Snapshot invalido si: + // 1. El puntero de cells cambio (nuevos datos). + // 2. El pool fue limpiado despues del build (st.string_pool es un nuevo State + // o fue cleared externamente): pool_size_built != strings.size(). + // Esto cubre el caso "begin_scenario crea nuevo State con pool vacio pero + // same cells pointer" — sin este check los str_ids apuntarian a un pool + // vacio y se crashearia en pool.at(str_ids[r]). + const bool snap_stale = (U.snapshot.last_cells_ptr != cells) || + (U.snapshot.pool_size_built != + (uint32_t)st.string_pool.strings.size() && + !U.snapshot.cols.empty()); + if (snap_stale) { + // Invalidar y reconstruir. + U.snapshot.last_cells_ptr = cells; + U.snapshot.cols.clear(); + U.snapshot.cols.resize((size_t)orig_cols); + + // Limpiar el StringPool del State para este rebuild. + st.string_pool.clear(); + // Reservar capacidad estimada para evitar reallocs que invalidarian + // los string_view del mapa interno del pool. + // Estimamos hasta row_count valores unicos por columna string (worst case). + // En practica muchos menos; reserve no aloca el doble automatico. + st.string_pool.strings.reserve((size_t)(row_count < 65536 ? row_count : 65536)); + + for (int c = 0; c < orig_cols; ++c) { + ColumnSnapshot& cs = U.snapshot.cols[(size_t)c]; + // Detectar tipo efectivo para esta columna. + ColumnType d = declared_types ? declared_types[c] : ColumnType::Auto; + ColumnType ct = effective_type(d, cells, row_count, orig_cols, c); + cs.type = ct; + + if (ct == ColumnType::Int) { + cs.i64.resize((size_t)row_count); + for (int r = 0; r < row_count; ++r) { + const char* sv = cells[(size_t)(r * orig_cols + c)]; + double tmp = 0.0; + if (sv && parse_number(sv, tmp)) { + cs.i64[(size_t)r] = (int64_t)tmp; + } else { + cs.i64[(size_t)r] = 0; + } + } + } else if (ct == ColumnType::Float) { + cs.f64.resize((size_t)row_count); + for (int r = 0; r < row_count; ++r) { + const char* sv = cells[(size_t)(r * orig_cols + c)]; + double tmp = 0.0; + if (sv && parse_number(sv, tmp)) { + cs.f64[(size_t)r] = tmp; + } else { + cs.f64[(size_t)r] = 0.0; + } + } + } else { + // String, Bool, Date, Json, Auto → intern as string. + // Cardinality cap: if >2048 unique values seen in first 25% of rows, + // skip interning this column (high-cardinality cols like timestamps + // offer no compression benefit and hurt cache). str_ids stays empty; + // compare_snap falls back to raw cells for this column. + static const int kCardinalityCap = 2048; + const int sample_n = (row_count < 4) ? row_count : (row_count / 4); + uint32_t pool_before = (uint32_t)st.string_pool.strings.size(); + bool skip_intern = false; + for (int r = 0; r < sample_n; ++r) { + const char* sv = cells[(size_t)(r * orig_cols + c)]; + std::string_view svv = sv ? std::string_view(sv) : std::string_view(""); + st.string_pool.intern(svv); + if ((int)st.string_pool.strings.size() - (int)pool_before > kCardinalityCap) { + skip_intern = true; + break; + } + } + if (skip_intern) { + // Rollback pool entries added during sample (remove tail entries). + // Simpler: just leave pool with sample entries and mark col as no-intern + // by keeping str_ids empty. Pool entries are harmless (amortized). + // cs.str_ids stays empty → compare_snap falls through to raw cells. + cs.str_ids.clear(); + } else { + // Low cardinality: intern all rows. + cs.str_ids.resize((size_t)row_count); + // Fill already-sampled rows from pool (intern is idempotent). + for (int r = 0; r < sample_n; ++r) { + const char* sv = cells[(size_t)(r * orig_cols + c)]; + std::string_view svv = sv ? std::string_view(sv) : std::string_view(""); + cs.str_ids[(size_t)r] = st.string_pool.intern(svv); + } + // Intern remaining rows. + for (int r = sample_n; r < row_count; ++r) { + const char* sv = cells[(size_t)(r * orig_cols + c)]; + std::string_view svv = sv ? std::string_view(sv) : std::string_view(""); + cs.str_ids[(size_t)r] = st.string_pool.intern(svv); + } + } + } + } + // Record pool size at end of build so validity check is accurate. + U.snapshot.pool_size_built = (uint32_t)st.string_pool.strings.size(); + } + // Build eff_headers / src_for_eff / eff_types para STAGE 0. std::vector eff_headers(eff_cols); std::vector src_for_eff(eff_cols); @@ -991,7 +1285,10 @@ void render(const char* id, st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); } } - auto visible_rows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + // Issue 0133 — Change 3: use snapshot-aware overload when snapshot is valid. + auto visible_rows = (U.snapshot.last_cells_ptr == cells && !U.snapshot.cols.empty()) + ? compute_visible_rows(cells, row_count, orig_cols, st_tmp, U.snapshot, st.string_pool) + : compute_visible_rows(cells, row_count, orig_cols, st_tmp); int visible_cols = 0; for (int k = 0; k < eff_cols; ++k) if (st.col_visible[k]) ++visible_cols; @@ -1038,7 +1335,13 @@ void render(const char* id, if (!st.col_visible[c]) continue; int src = src_for_eff[c]; if (!first) out += ','; - out += csv_escape(cells[r * orig_cols + src]); + // Issue 0133 — Change 3: use snapshot for orig cols. + char tmp32[32]; + const char* cv = (src < orig_cols && src < (int)U.snapshot.cols.size()) + ? snap_cell(r, src, U.snapshot, st.string_pool, tmp32) + : nullptr; + if (!cv) cv = cells[r * orig_cols + src]; + out += csv_escape(cv); first = false; } out += '\n'; @@ -1091,6 +1394,8 @@ void render(const char* id, for (int r : visible_rows) { for (int c : vcols) { if (c < orig_cols) { + // Raw pointer: materialization copies to string anyway — snapshot + // path offers no benefit here and adds snprintf overhead for Int/Float. const char* p = cells[r * orig_cols + c]; so_main.cell_backing.emplace_back(p ? p : ""); } else { @@ -1161,6 +1466,7 @@ void render(const char* id, } } else { int src = src_for_eff[c]; + // Raw pointer: materialization copies to string anyway. const char* p = cells[r * orig_cols + src]; s0_backing.emplace_back(p ? p : ""); } @@ -1213,7 +1519,10 @@ void render(const char* id, st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); } } - auto vrows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + // Issue 0133 — Change 3: use snapshot-aware filter/sort when available. + auto vrows = (U.snapshot.last_cells_ptr == cells && !U.snapshot.cols.empty()) + ? compute_visible_rows(cells, row_count, orig_cols, st_tmp, U.snapshot, st.string_pool) + : compute_visible_rows(cells, row_count, orig_cols, st_tmp); // Materializar stage0 output: cells (eff_cols) con derived evaluadas. std::vector mat_backing; @@ -1223,10 +1532,9 @@ void render(const char* id, for (int r : vrows) { for (int c = 0; c < eff_cols; ++c) { - const char* p; - std::string buf; if (c < orig_cols) { - p = cells[r * orig_cols + c]; + // Raw pointer: materialization copies to string anyway. + const char* p = cells[r * orig_cols + c]; mat_backing.emplace_back(p ? p : ""); } else { const DerivedColumn& d = stage0.derived[c - orig_cols]; @@ -1249,7 +1557,7 @@ void render(const char* id, lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err)); } } else { - // retipo puro + // retipo puro — raw pointer from orig cells. int src = d.source_col; const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : ""; mat_backing.emplace_back(sp ? sp : ""); diff --git a/modules/data_table/data_table_internal.h b/modules/data_table/data_table_internal.h index f6380b40..5db9ca9c 100644 --- a/modules/data_table/data_table_internal.h +++ b/modules/data_table/data_table_internal.h @@ -4,6 +4,7 @@ // Este header lo incluyen SOLO los .cpp del modulo (data_table.cpp + sus 6 sub-funciones). // // Issue 0107c — split de data_table.cpp (4777 LOC) en 6 sub-funciones del registry. +// Issue 0133 — columnar snapshot + string interning (Changes 1+2). // // Provee: // 1. `UiState` agregador (composicion de sub-states declarados en los .h de @@ -14,6 +15,7 @@ // `effective_type`, `view_mode_label`, `join_strategy_label`, etc. // 4. Forward refs de funciones internas que cruzan sub-funciones (ej. el // draw_header_menu de chips llama draw_color_rule_menu de color_rules). +// 5. `ColumnSnapshot` / `SnapshotCache` — snapshot columnar interno (issue 0133). // // Politica: // - Si un helper se usa SOLO dentro de UNA sub-funcion -> queda `static` en su .cpp. @@ -22,6 +24,63 @@ // // API publica externa = data_table/data_table.h (intacta tras refactor). // API interna del modulo = este header. +// +// --------------------------------------------------------------------------- +// DISEÑO: Snapshot columnar + String Interning (issue 0133) +// --------------------------------------------------------------------------- +// +// MOTIVACION +// El layout de datos de entrada es row-major: `cells[row * cols + col]` +// (punteros a C-strings en el `TableInput` del caller). Acceder a una +// columna entera (para filtrar, ordenar, colorear, calcular stats) requiere +// saltar `cols` posiciones en memoria por fila — mal para cache a 10M+ filas. +// El snapshot convierte a column-major una sola vez por frame y amortiza el +// coste entre filter / sort / color_rules / stats. +// +// SNAPSHOT COLUMNAR — `ColumnSnapshot` / `SnapshotCache` +// - `SnapshotCache` vive en `UiState` (singleton thread_local de este modulo). +// - Contiene un vector de `ColumnSnapshot`, uno por columna efectiva de la +// tabla DESPUES de joins pero ANTES de stages (= stage-0 input). +// - `SnapshotCache::last_cells_ptr` guarda el puntero de `cells` del ultimo +// rebuild. Si el puntero cambia (o es la primera llamada), se reconstruye +// el snapshot completo. Esto sigue exactamente el patron de `stats_last_cells` +// en `State` (data_table_types.h:399). +// - String columns (ColumnType::String / Auto sin numero) almacenan indices +// uint32_t al `StringPool` del `State` correspondiente. +// - Int columns almacenan int64_t parseados una sola vez via `parse_number`. +// - Float columns almacenan double parseados una sola vez via `parse_number`. +// - Si `parse_number` falla en una celda que deberia ser Int o Float, la celda +// se trata como 0 / 0.0. Este comportamiento es consistente con `compare()` +// y el sort actual que ya llaman `parse_number` per-compare. +// +// STRING INTERNING — `StringPool` en `State` +// - `StringPool` vive en `State` (NOT global, NOT singleton) para que cada +// instancia de tabla tenga su propio pool sin interferencia. +// - `intern(sv)` inserta la cadena si no esta y devuelve su indice uint32_t. +// Usa `unordered_map` con la `string_view` apuntando +// al `strings[i]` del vector (el vector se reserva antes del rebuild para +// evitar reallocs que invaliden los string_views del mapa). +// - En datasets tipicos (60-70% de strings repetidos) la reduccion de RAM +// es de 60-70% en la columna interned vs copias planas. +// +// INVARIANTES +// 1. Pointer-identity: si `cells == last_cells_ptr`, el snapshot es valido +// para este frame. Cambio de puntero => rebuild completo. +// 2. row→snapshot_row: el indice de fila en el snapshot es IDENTICO al indice +// de fila del `TableInput` original (no hay reordenacion en el snapshot). +// `TableEvent.row` del caller sigue siendo indice en `TableInput`. +// 3. El snapshot es input de stage-0. Los stages sucesivos (compute_stage) +// operan sobre `StageOutput` materializado y NO consultan el snapshot +// directamente — el snapshot solo alimenta la ruta stage-0 de render(). +// 4. `stats_last_cells` y snapshot son independientes: `stats_last_cells` +// ya existia antes de issue 0133 y permanece como sentinel propio del +// cache de stats. El snapshot tiene su propio `last_cells_ptr`. +// Ambos pueden diferir temporalmente si `stats_cache` invalida por +// filtro (hash de filtros) pero el snapshot sigue valido por puntero. +// 5. `StringPool` se limpia (clear()) en cada rebuild del snapshot para +// mantener coherencia: los indices del snapshot siempre corresponden +// al pool del mismo frame. +// --------------------------------------------------------------------------- #include "core/data_table_types.h" #include "core/auto_detect_type.h" @@ -40,6 +99,28 @@ namespace data_table { +// --------------------------------------------------------------------------- +// ColumnSnapshot — snapshot de una columna en memoria columnar. +// Creado una vez por frame al detectar cambio de puntero en `cells`. +// --------------------------------------------------------------------------- +struct ColumnSnapshot { + ColumnType type; // tipo efectivo inferido (post auto_detect) + std::vector str_ids; // para String/Auto: indices al StringPool + std::vector i64; // para Int: valores parseados + std::vector f64; // para Float: valores parseados +}; + +// --------------------------------------------------------------------------- +// SnapshotCache — vive en UiState (thread_local singleton). +// Un snapshot cubre TODAS las columnas efectivas de la tabla activa +// (post-join, pre-stages). Se invalida por pointer-identity de `cells`. +// --------------------------------------------------------------------------- +struct SnapshotCache { + const char* const* last_cells_ptr = nullptr; // sentinel de invalidacion por ptr + uint32_t pool_size_built = 0; // strings.size() cuando se construyo + std::vector cols; // un entry por columna efectiva +}; + // --------------------------------------------------------------------------- // UiState — singleton thread_local del modulo. Agrupa: // (a) Sub-states declarados en headers de sub-funciones (AskAiState etc.). @@ -120,6 +201,11 @@ struct UiState { // ----- Export path (chips export action) ----- std::string last_export_path; + + // ----- Columnar snapshot (issue 0133, Change 1) ----- + // Invalida cuando cells pointer cambia entre frames. + // Usado en render() stage-0 path para filter/sort/color_rules/stats. + SnapshotCache snapshot; }; // Singleton accessor. Definido en data_table.cpp (entrypoint).