Skip to content

feat(f39): multi-facet exploration workspace - #49

Merged
soldforaloss merged 7 commits into
mainfrom
feat/f39-facets
Aug 4, 2026
Merged

feat(f39): multi-facet exploration workspace#49
soldforaloss merged 7 commits into
mainfrom
feat/f39-facets

Conversation

@soldforaloss

Copy link
Copy Markdown
Owner

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.

  • Facet kinds — per-column value counts (top-N with a search box for
    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.
  • Cross-filtered faceted search — the row view is the AND across
    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.
  • Panel management — reorder, pin, collapse, remove; copy a facet's
    values + counts to the clipboard.
  • Persistence & conversion — save the facet configuration inside a named
    view (restored with the view); one-step convert-to-filter into the
    standard filter builder (one-way; facets with no filter equivalent are
    reported).
  • Indexed docs — counts on a very large indexed document may be estimated
    from a leading sample (marked sampled); the applied row filter is always
    exact.
  • Non-destructive — never dirties the document; integrates with the
    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:

  • Rust engine: 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 persisted
    inside named views).
  • Frontend: FacetsPanel.tsx, lib/facets.ts, store wiring in
    store/useStore.ts, types.ts, lib/tauri.ts, lib/commandDefs.ts,
    lib/views.ts, Toolbar.tsx, App.tsx mount.
  • Docs: 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):

  • Rust: cargo test --lib = 829 passed / 0 failed (incl. 22 new
    facets module tests). cargo fmt --check, cargo clippy -D warnings clean.
  • Frontend: vitest = 458 passed / 45 files (incl. the 15 new
    src/lib/facets.test.ts tests). ESLint, prettier --check, tsc --noEmit,
    vite build all clean.

Acceptance mapping

Acceptance criterion Where
Multiple facet panels active simultaneously facets.rs FacetResultSet / FacetsPanel.tsx
Value / histogram / boolean / nullability / semantic facet kinds FacetKind in facets.rs + types.ts
Row-level status facets (diagnostics, validation, dup, bookmarked/flagged/tagged) StatusInput in facets.rs
AND across panels, OR (include/exclude) within a panel apply_facets matching in facets.rs
Cross-filtered counts (counts vs all other facets) compute_facets per-facet population in facets.rs
Reorder / pin / collapse / remove / copy FacetsPanel.tsx, useStore.ts
Save facet config in a named view; restore with the view settings.rs, project.rs, views.ts
Convert active facets to the standard filter builder (one-way, report gaps) convert_facets_to_filter / FacetConversion
Estimated (sampled) counts on large indexed docs; applied filter exact indexed-scan path in facets.rs (self-review finding #2 fix)
Non-destructive (never dirties); visible-row export respects facet filter interactive row-view pipeline; apply_facets mirrors set_filter

STACKED

This PR is stacked on feat/f35-db (its base), which in turn stacks down
the chain to main. Review only the delta against feat/f35-db — the
five commits listed on this branch. The rebase folded all conflict
resolutions (invoke_handler union, use imports, Db*/Excel*/Facet* type
unions, CHANGELOG ordering) into the feature commits; chore(f39): integrate onto chain is an empty marker recording the integration point. GitHub's
default diff against main will also show the parent features' changes — that
is expected for a stacked PR and is not part of this review.

🤖 Generated with Claude Code

soldforaloss and others added 5 commits July 18, 2026 01:55
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/store/useStore.ts
},
// 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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/store/useStore.ts
Comment on lines +7062 to +7063
set((s) => ({
facets: { ...s.facets, results, applied: active, loading: false, error: null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src-tauri/src/facets.rs Outdated
Comment on lines +1451 to +1455
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3213 to +3214
let rows = crate::facets::matching_rows(&doc, &config, &inputs)?;
doc.set_filter(rows)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

soldforaloss and others added 2 commits July 18, 2026 04:40
…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
@soldforaloss
soldforaloss changed the base branch from feat/f35-db to main August 4, 2026 20:00
@soldforaloss
soldforaloss merged commit 0a0b8f4 into main Aug 4, 2026
2 checks passed
@soldforaloss
soldforaloss deleted the feat/f39-facets branch August 4, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant