feat(f39): multi-facet exploration workspace - #49
Conversation
Multi-facet exploration backend (F39): a cross-filtering facet engine over documents, integrated with the existing row-view/filter pipeline. - New src-tauri/src/facets.rs: the ten facet types (text top-N + search, numeric histogram + range, date range, boolean, blank/null/invalid via the F31 classify semantics, semantic, and the four row-level status facets — diagnostics, validation, duplicate, annotation); a single streaming pass that recomputes every facet's bucket counts against the population filtered by all the OTHER facets (AND across panels, OR within, per-value include/exclude); an exact row-view producer (matching_rows) that composes with the F12 view sort, scoped export and visible-row export; the one-way facets->filter-builder conversion; and strictly bounded DTOs (top-N + search, never a full value dump, with a distinct-value cap that flags truncation). - Status facets consume the existing analysis outputs through StatusInput adapters (from_marks / from_diagnostics / from_validation / from_duplicates) so the command layer stays trivial; the annotation facet is wired to real F40 marks. - lib.rs: register the module (public, like job/highlight). - settings.rs: extend the F12 NamedView payload with an optional FacetConfig, so a saved view restores facets + selections. Faceting is non-destructive and never dirties the document; counts may be estimated from a leading sample on large indexed documents (flagged), while the applied filter is always exact. 18 new Rust unit tests (cross-filter count matrix, include/exclude determinism, one-facet-clear retention, bounded payloads, non-destructive guarantee, view round-trip, conversion correctness, annotation-facet integration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the F39 facet engine to a dockable FacetsPanel and the command surface. - Commands (compute_facets / apply_facets / convert_facets_to_filter) that build FacetInputs from the live analysis caches: annotations resolved for real (F40 mark_index), diagnostics/validation/duplicate from their caches (duplicate via a remembered dedup spec). apply_facets drives the grid row view through the existing filter pipeline (view-only, never dirties); convert emits the one-way filter-builder tree. - FacetsPanel: per-type cards (value list + search + counts, CSS histogram + range, status chips), include/exclude toggle, estimated-count badge, drag reorder, pin, collapse, remove, copy values+counts, add-facet picker. - Store slice with debounced live cross-filter sync; F12 view save/restore of the facet config; convert-to-filter surfaces the built filter in FilterDialog; commandDefs + toolbar toggle; types.ts + tauri.ts wrappers. - Vitest for selection reducers, count formatting, conversion mapping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the Multi-facet exploration bullet to the README "Navigate & analyze" features group. The consolidated CHANGELOG [Unreleased] entry landed with the UI stage. Full gates (npm lint/format/typecheck/vitest/build; cargo fmt --check, clippy -D warnings, cargo test --lib = 708 passed) verified green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Status facets now flag `sampled` when a truncated (indexed) scan under-counts them, so the UI estimate mark surfaces (was: only value facets ORed in `scan_sampled`). `compute` split into a testable `compute_scan(scan_end)` core. - Stale diagnostics/cross-val cache entries that reference a column removed by a later structural edit now degrade that one category to empty instead of failing the whole compute_facets/apply_facets call, mirroring the F42 highlight engine (`if let Ok`, `.unwrap_or_default`). - Text facet "always-shown selected" buckets are bounded by MAX_TEXT_VALUES so a huge/hand-edited saved-view selection can never produce an unbounded FacetResult payload (filtering stays exact). Rejected finding #1 (no JobCtx wired): the apply/matching_rows path deliberately mirrors the shipped synchronous set_filter / filter::matching_rows interactive row-view pipeline (no ctx there either); the job-registry invariant governs batch/disk ops, not interactive faceting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebase feat/f39-facets onto feat/f35-db. Resolve additive-union conflicts across lib.rs (invoke_handler: keep the F32/F34/F35 excel/db handlers alongside the F39 compute_facets/apply_facets/convert_facets_to_filter), commands.rs (separate `use crate::excel` and `use crate::facets` imports), tauri.ts / useStore.ts type-import blocks (Db* + Excel* + Facet* unions), and CHANGELOG.md (keep all bullets in chain order, with the F39 Multi-facet entry placed after the F32/F34/F35 entries and before F40). All resolutions folded into the replayed feature commits; this marker records the integration point. Full gates green against the chain: cargo test --lib = 829 passed, vitest = 458 passed / 45 files, fmt/clippy/lint/typecheck/build all clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b654f7f67
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }, | ||
| // F39: the facet config is per-document (columns by stable ID); keep the | ||
| // panel open across switches but drop the previous doc's facets/results. | ||
| facets: { ...initialFacets, open: s.facets.open }, |
There was a problem hiding this comment.
Preserve facet state when switching tabs
When a document has an active facet selection, apply_facets has already installed a backend row filter for that document; this tab-switch path drops the facet config/results/applied flag instead of snapshotting them per document. Switching away from a facet-filtered tab and back leaves the grid still filtered (meta.filtered comes from the backend), but the Facets panel is empty and applied is false, so the user can no longer see or clear the facet selections that are driving the row view.
Useful? React with 👍 / 👎.
| set((s) => ({ | ||
| facets: { ...s.facets, results, applied: active, loading: false, error: null }, |
There was a problem hiding this comment.
Guard facet syncs against stale responses
If a facet sync for selection A is in flight and the user changes the selection to B, a second sync can start while the first applyFacets/computeFacets is still running; this path only checks that the active document is unchanged. A slower response for A can therefore call reloadDoc and write results after B, leaving the grid/counts filtered by an older config while the UI checkboxes show the newer config. Use a request token or compare the current config before applying the response.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cae7ee1. syncFacets now claims a monotonic request token (facetSyncSeq) before its first await and re-checks it after both the applyFacets and computeFacets awaits (in addition to the existing active-document check). A slower response for an older selection bails before reloadDoc and before writing results/applied, so it can no longer clobber a newer selection's state; a superseded sync that rejects also stays silent instead of overwriting loading/error. Regression test: src/store/useStore.test.ts ("drops a slower response for an older selection instead of clobbering the newer one") starts sync A, starts sync B, resolves B fully, then resolves A's stale applyFacets and asserts A performed no reload, wrote no results, and never called computeFacets.
| if let Some(lo) = min { | ||
| nodes.push(condition(col, FilterOp::Lt, lo, false)); | ||
| } | ||
| if let Some(hi) = max { | ||
| nodes.push(condition(col, FilterOp::Gt, hi, false)); |
There was a problem hiding this comment.
Do not convert exclude ranges as exact filters
For exclude-mode number/date facets, the facet predicate keeps rows whose cell is outside the range or cannot be parsed (value(...).is_some_and(...) is false, then negated). The converted filter here is only < min OR > max, and the standard filter engine's numeric/typed comparisons never match blank, null, or invalid cells, so converting an exclude range silently drops those rows instead of reporting the facet as lossy.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cae7ee1. Confirmed: the exclude predicate negates parses && in_range, so it keeps blank/null/invalid cells, while the emitted < min OR > max filter could only ever match parseable out-of-range cells — the conversion silently dropped the unparseable rows. Since the filter builder has no "is not a valid number/date" op, there is no faithful equivalent; convert_facet now returns an error for exclude-mode number/date ranges so the facet lands in FacetConversion::dropped (surfaced by the existing UI messaging) instead of emitting a semantics-inverting filter. Test: facets::tests::exclude_range_is_reported_lossy_not_inverted builds a column with a blank cell, asserts the facet keeps [below-range, blank], and asserts conversion emits no filter nodes and reports the facet as dropped.
| let rows = crate::facets::matching_rows(&doc, &config, &inputs)?; | ||
| doc.set_filter(rows)?; |
There was a problem hiding this comment.
Avoid applying unresolved active facets as all-row filters
When a selected facet references a deleted column or a status input that is unavailable, config.any_active() is true but matching_rows treats the unresolved facet as pass-all. This then replaces any existing row view with an all-rows filter and marks the document as filtered even though the facet result says it is unresolved and should not narrow anything; applying a saved view with a missing facet is a concrete way to hit this.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cae7ee1. Added facets::narrowing_rows, which returns None when no built facet actually narrows (i.e. every facet with an active selection resolved to Eval::Unresolved — deleted column or unavailable status input). apply_facets now clears the facet-driven filter in that case instead of installing an all-rows row view that falsely marks the document filtered. When at least one active facet resolves, behavior is unchanged: resolved facets narrow and unresolved ones pass all rows. Tests: facets::tests::narrowing_rows_skips_apply_when_only_unresolved_facets_are_active (any_active true, missing column -> None) and facets::tests::narrowing_rows_applies_resolved_and_ignores_unresolved (resolved city facet still narrows to its rows alongside an active-but-unresolved facet).
…sion, unresolved-facet no-op apply Addresses the three P2 findings from the Codex review of PR #49: - useStore.syncFacets: add a monotonic request token claimed before any await and re-checked after each one, so an out-of-order response for an older facet selection can never reloadDoc/write results over a newer one (previously only the active-document id was checked). A superseded sync that errors also stays silent instead of stealing loading/error state. Covered by a vitest that resolves sync B fully, then lets the stale sync A return and asserts it bailed before reload and compute. - facets::convert_facet: an exclude-mode number/date range no longer converts to `< min OR > max`. The facet predicate keeps rows whose cell is blank/null/invalid (it negates `parses && in range`), while the filter engine's typed comparisons never match such cells, so the converted filter silently dropped them. There is no filter op for "not a valid number/date", so the conversion now reports the facet as dropped/lossy instead of inverting semantics. - apply_facets: an active facet that is unresolved (deleted column, unavailable status input) no longer degrades to an all-rows filter. New facets::narrowing_rows returns None when nothing actually narrows (any_active true but every active facet unresolved) and apply_facets clears the facet-driven view instead of installing an all-rows filter that falsely marks the document filtered; resolved facets still narrow with unresolved ones passing through. Gates: npm lint/format/typecheck/test (459) /build, cargo fmt, clippy -D warnings, cargo test --lib (832) all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/store/useStore.test.ts
Summary
F39 adds a multi-facet exploration workspace — the multi-dimensional
extension of the single-column explorer. Open several facet panels at once
(toolbar → "Facets", palette → "Toggle facets") and cross-filter the grid
row view interactively, non-destructively.
high-cardinality columns), numeric/date histogram with range selection,
boolean (true/false), nullability (blank / null-token / invalid /
value), and semantic type; plus row-level status facets:
diagnostics, validation (cross-column rules + advisory schema issues),
duplicate-group membership, and bookmarked/flagged/tagged.
panels and the OR (per-facet include/exclude toggle) among selected
values within one panel; every facet's counts are recomputed against the
population filtered by all the other facets, so counts always reflect the
current cross-filter.
values + counts to the clipboard.
view (restored with the view); one-step convert-to-filter into the
standard filter builder (one-way; facets with no filter equivalent are
reported).
from a leading sample (marked
sampled); the applied row filter is alwaysexact.
existing row-view/filter pipeline, so visible-row export respects the facet
filter.
Changes (stacked delta vs
feat/f35-db, purely additive)17 files changed, +4077, 0 deletions:
src-tauri/src/facets.rs(facet compute/apply/convert core,status-facet inputs, sampled-count marking on indexed scans),
commands.rs(
compute_facets/apply_facets/convert_facets_to_filter),lib.rs(handler registration),
settings.rs+project.rs(facet config persistedinside named views).
FacetsPanel.tsx,lib/facets.ts, store wiring instore/useStore.ts,types.ts,lib/tauri.ts,lib/commandDefs.ts,lib/views.ts,Toolbar.tsx,App.tsxmount.CHANGELOG.md(Unreleased → Added, F39 entry after F35, before F40),README.md(Multi-facet bullet after Column explorer).Tests
Full gates green against the chain (foreground,
run-gates.ps1):cargo test --lib= 829 passed / 0 failed (incl. 22 newfacetsmodule tests).cargo fmt --check,cargo clippy -D warningsclean.src/lib/facets.test.tstests). ESLint,prettier --check,tsc --noEmit,vite buildall clean.Acceptance mapping
facets.rsFacetResultSet /FacetsPanel.tsxFacetKindinfacets.rs+types.tsStatusInputinfacets.rsapply_facetsmatching infacets.rscompute_facetsper-facet population infacets.rsFacetsPanel.tsx,useStore.tssettings.rs,project.rs,views.tsconvert_facets_to_filter/FacetConversionsampled) counts on large indexed docs; applied filter exactfacets.rs(self-review finding #2 fix)apply_facetsmirrorsset_filterSTACKED
This PR is stacked on
feat/f35-db(its base), which in turn stacks downthe chain to
main. Review only the delta againstfeat/f35-db— thefive commits listed on this branch. The rebase folded all conflict
resolutions (invoke_handler union,
useimports, Db*/Excel*/Facet* typeunions, CHANGELOG ordering) into the feature commits;
chore(f39): integrate onto chainis an empty marker recording the integration point. GitHub'sdefault diff against
mainwill also show the parent features' changes — thatis expected for a stacked PR and is not part of this review.
🤖 Generated with Claude Code