viz smart: dictionary-declared pipeline funnel (#4222) - #4281
Merged
Conversation
The last of the three asks in #4222. A pipeline was previously visible only as `% zeros` annotations scattered across three separate box titles: 47% of projects have nothing committed, 55% nothing spent. Read one at a time those are three unrelated data-quality notes; read together they are a funding pipeline, which is the actionable governance signal. The funnel states it once. Detection requires BOTH a vocabulary match and row-wise containment, and neither gate is redundant. The vocabulary supplies the ORDER -- direction is semantics and no statistic recovers it -- while containment verifies that the order the names claim is the order the data has. Names alone would read `township` as a shipping stage. Containment alone cannot tell a pipeline from a partition: total/male/female population nests perfectly and would render as leakage, the worst failure mode for the admin data qsv is usually pointed at. Matching is by SUBSTRING, which needs justifying given `is_intensive_measure`'s scar tissue (where "ratio" matched the whole `-ration` word family). The motivating column is `totalplannedcommit`: one all-lowercase run, so `field_name_tokens` yields a single token and token-equality against "planned" misses entirely. What makes substrings safe here is the conjunction -- a spurious "ship" inside `township` cannot produce a panel unless a second column independently matches a different rank of the same family AND the two are nested in that order. `is_intensive_measure` had no second gate; this does. `totalplannedcommit` also contains "commit" at position 12. If that won it would collide with `commit_total` and the funnel would be ambiguous or ordered backwards -- on the exact dataset the issue names. Lowest character position wins, because English puts the stage modifier first. One false-positive class survives the conjunction and needs its own guard: complements. `unspent_balance` contains "spent" and is near-perfectly nested inside `planned`, so both gates pass and it would render as the Spent stage, inverting the panel. Hence FUNNEL_NON_STAGE_MARKERS. Bars are summed amounts; rows-reached rides in the hover. At Gini 0.95 "42% of dollars committed" and "45% of projects committed anything" are both true and read as a contradiction, so neither is ever shown alone -- the same hazard `lorenz_caveat` exists to defuse. Stage-to-stage conversion is plotly's own `textinfo`, computed from the bar values, so it cannot drift from the bars. Totals are summed over the listwise-complete join and therefore do NOT match `stats.sum`. Following `lorenz_caveat`, the subtitle discloses the denominator unconditionally rather than only when it looks bad. Order is never sorted by value. When a downstream total outruns its predecessor -- plausible for capital projects, where a few change orders dominate a Gini-0.95 column -- the inverted band IS the finding, and the subtitle names it. Uses the Funnel trace added in dathere/plotly (upstream PR plotly/plotly.rs#432); Cargo.toml now pins the stacked `deps/webdriver-downloader-0.17+funnel` branch. Funnel is a CARTESIAN trace, so unlike Sankey it composes with the typed subplot grid and static export -- verified by rendering a funnel to PNG, not just assumed. Note a funnel draws index 0 at the TOP, the opposite of a plain category axis; feeding it the lollipop's reversed arrays renders the funnel upside down, widening toward the bottom. 7 integration + 3 unit tests. The two negative tests would pass vacuously against pre-fix code (no funnel existed), so they were confirmed against a deliberately loosened build instead -- containment gate disabled and negation guard emptied -- and both failed there, so each gate is doing real work. Swept all 18 gallery corpus datasets: zero funnels, no false positives. Gallery not regenerated: no example CSV produces a funnel, so the only diff would be timestamp churn. Closes #4222. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 6 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Picks up the funnel-specific `FunnelHoverInfo` enum, which replaces the generic `HoverInfo` on the Funnel trace and carries the `percent initial` / `percent previous` / `percent total` flags the generic one has no room for. No qsv change needed: the funnel panel sets `hover_template` plus a pre-rendered `hover_text_array` rather than `hover_info`, so the swapped field is untouched. Verified rather than assumed -- full viz suite re-run against the new rev. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two defects, both in the rescue path that carries a wholly-zero stage the correlation pre-filter drops (`cardinality > 1`). Containment was SKIPPED across a rescued stage. The gate bailed on any pair where either side had no data vector, so `planned` -> all-zero `commit` -> non-nested `spent` skipped BOTH pairs and drew a funnel whose actual planned/spent relationship violates containment outright. A zero stage is now treated as the vector of zeros it is: a zero DOWNSTREAM is trivially contained in any non-negative upstream, while a zero UPSTREAM contains only zeros, so a positive downstream means a later stage recorded activity its predecessor never did -- exactly the nesting claim a funnel makes, so it is measured and can fail rather than being waved through. The subtitle's violation shares go through the same helper, so the number shown agrees with the gate that ran. The rescue also applied only type and map-column checks, skipping the route, aggregation, intensive-measure and identifier-name filters the main pass uses. An all-zero `spent_pct` or `orders_id` could therefore enter as a funnel amount stage that the main pass would have rejected. Both paths now share one `eligible_stage` closure; the duplication was the bug, so the fix is to have one copy rather than to patch the second. Two regression tests, both confirmed failing against 7736213. The existing all-zero-stage test still passes, so the headline nothing-spent-yet case is intact. Addresses roborev 3832 (2x Medium). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rescued all-zero-upstream case was measured against the global FUNNEL_CONTAINMENT_MIN, so a downstream stage active in up to a tenth of rows still passed the gate and rendered. That tolerance exists for one specific real-world failure mode: genuine change orders on a POPULATED upstream stage, where a few rows legitimately overrun their predecessor. An all-zero upstream is a different kind of claim -- the column recorded nothing anywhere -- so "90% contained" would mean up to 10% of rows show a later stage running while its predecessor never happened. That is not an overrun, it contradicts the nesting the funnel asserts, and one positive downstream value is enough to reject the pipeline. Implemented as `stage_pair_passes`, kept deliberately separate from `stage_containment`: the gate is strict, while the measurement stays honest so the subtitle can still report the true violation share instead of a flat 0%. One regression test -- 5% sparse spend under an all-zero commit, comfortably inside the old gate -- confirmed failing against 4042d59. The existing all-zero-stage test still passes, so the nothing-spent-yet case is intact. Addresses roborev 3838 (Medium). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`viz smart` can now detect a pipeline, but only the column-shaped kind (planned/committed/spent as three columns), and its doc comment explicitly declares the other shape out of scope: a pipeline encoded as ROWS, one row per stage. `viz funnel` is that shape. The two are complements rather than duplicates, and the split falls out of who knows what. The smart panel has to INFER both the stage order and the nesting, which is why it needs a vocabulary, a containment gate and a negation guard. Here the user has already answered both by naming the column and ordering the rows, so nothing is inferred: stage order is first-appearance order in the file, and no containment check applies, because an explicit `viz funnel` is a request rather than a detection. Input shape mirrors `viz pie` exactly -- `--x` = stage column, optional `--y` = value column, counting occurrences when omitted -- so it aggregates several rows per stage (the gallery example sums three acquisition channels per stage). Stages are never sorted by value, for the same reason the smart panel doesn't: a stage that outruns its predecessor is a finding, and sorting would hide it. A negative stage total is rejected outright rather than drawn. No axis titles. A funnel is cartesian, but plotly renders no visible value axis for it -- each band carries its own value and conversion label -- so an axis title would be dead config that never appears. Gallery gains a 46th figure beside `pie (donut)`, with `signup_funnel.csv` as a purpose-built fixture (force-added: `.gitignore` ignores `*.csv*` repo-wide and all 18 sibling gallery inputs are tracked the same way). The other `smart_*.html` artifacts were left untouched -- regenerating them produces only timestamp churn plus the known base64/window-id nondeterminism, and no existing gallery dataset yields a funnel. 3 tests (file order preserved, counting mode, negative rejected). Help regenerated via `--generate-help-md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Checking whether any gallery dataset triggers the funnel panel turned up a
shipped false positive instead. `nyc_311` carries `X Coordinate (State Plane)`
-- "State Plane" contains a bare "plan" -- and on a table that also holds a
genuine spend column that renders as:
Pipeline funnel: X Coordinate (State Plane) -> spent_total
Reproduced end to end before fixing, and gone after.
This is the `ratio`-inside-`duration` bug again, in the very code whose comment
claimed the vocabulary/containment conjunction prevents it. The conjunction is
real but weaker than that comment implied: it demands a second column at a
different rank, and any table with a real `spent` column hands that over for
free. So a greedy word is dangerous even WITH the conjunction, and the comment
now says so, citing this case rather than reasoning in the abstract.
Bare "plan" is dropped. It also matches "plant", "floorplan" and "airplane",
while adding almost nothing: "planned" already covers the glued
`totalplannedcommit` case that substring matching exists for, and budget /
appropriat / authoriz / estimated / proposed cover the stage otherwise.
The remaining greedy words keep their coverage but get their collisions named
in FUNNEL_NON_STAGE_MARKERS: a review or overview count is not an impressions
stage, a contractor count is not a commitment, a wholesale price is not a
conversion, a lead time is not a lead, and replaced units are not placed
orders.
One unit test over the collision set, confirmed failing when grafted onto the
pre-fix vocabulary, plus assertions that the genuine stage words still resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the dataset the whole #4222 review series was filed against -- 12,587 capital projects, sourced from Checkbook NYC via NYC Open Data -- as a `--smarter` gallery figure, and it earns its place twice. What it shows. Its money columns are both extremely concentrated (Gini 0.93/0.96) and heavily zero-inflated, so the Lorenz panels read "flat run = 60% zeros, not small values". No existing gallery figure exercised that clause: cms_medicare has no zero inflation, so it only ever showed the unit caveat. This is the case the caveat was written for, at 0.96 rather than 0.65. What it does NOT show, deliberately: a pipeline funnel. The columns are named planned / committed / spent and match the stage vocabulary, but the values are not nested -- spent totals $81.0B against committed $28.4B (2.9x), and spent exceeds committed on 46% of rows. The official column descriptions explain why: `totalplannedcommit` is allocated in the Capital Commitment Plan while `commit_total` and `spent_total` are sums within the City's budget -- three independent aggregates on different bases, not stages of one nesting. So the containment gate declines and says so: viz smart: pipeline funnel skipped -- commit_total is not contained in totalplannedcommit (80% of rows) That is the gate doing exactly the job it exists for, on the dataset whose issue proposed the funnel in the first place. The vocabulary says pipeline and the data says otherwise, which is the strongest argument available for not having built detection on names alone. The threshold was deliberately NOT relaxed to force the panel: doing so would assert a nesting the source data contradicts. `nyc_capital_projects.csv` is force-added, as `.gitignore` ignores `*.csv*` repo-wide and all sibling gallery inputs are tracked the same way. The other `smart_*.html` artifacts are untouched -- regenerating them yields only timestamp churn plus the known base64/window-id nondeterminism. An unrelated windows-sys bump that a non-`--locked` build had written into Cargo.lock was reverted rather than carried along. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The budget funnel vocabulary uses truncated stems -- `authoriz`, `obligat`, `disburs`, `encumber` -- so one word covers a whole family. typos 1.48.0 ships a dictionary entry for `appropriat` -> `appropriate`, which fails CI on a stem that is deliberate. Its siblings pass only because the dictionary happens to carry no entry for them, so rewriting just this one to full words would leave five stems and one odd pair, and a future reader would have to do archaeology to learn the reason was a spell checker. Record the exception in the config, where the other false positives already live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
describegpt has always asked the LLM for a top-level `relationships` array, and `parse_llm_relationships` has always validated it -- but `format_dictionary_jsonschema` dropped it on the floor, and JSON Schema is the format `viz smart --dictionary` consumes. The inference was being requested and discarded on exactly viz's code path (relationships are gated on --infer-content-type, which `--dictionary infer` already passes). Add a `pipeline` kind: a process whose stages narrow monotonically, in either of two encodings. Stages as COLUMNS uses `members` alone, listed upstream-first -- the opposite direction from `ordered`, which ascends, so the two must not be conflated. Stages as ROW VALUES of one category column additionally carries `stage_column`, an ordered `stages` array, and an optional `value_column` to sum. `members` is SYNTHESIZED for the row encoding rather than taken from the LLM. `SynthRelationship::members` has no serde default -- unlike `anchor` -- and the dictionary deserializes in a single pass, so an entry lacking it does not degrade: it hard-errors `synthesize --dictionary`. A test pins that invariant. An unusable `value_column` drops the whole entry rather than falling back to counting rows, since silently turning a declared measure funnel into a row-count funnel changes what the chart claims. The prompt carries a partition guard, because this is the one hazard neither the containment disclosure nor the complement markers can catch: a row-encoded pipeline requires a subject that passes THROUGH the stages over time, so a status column on a one-row-per-entity table is a partition, and a funnel of it would be a lie. Relationships ride in the top-level `x-qsv` rather than at the schema root, for the same reason `grain` does -- it keeps the document a valid draft 2020-12 schema, and a dictionary built without the flag stays byte-identical. Verified against the same `jsonschema::meta::validate` describegpt applies before writing. viz does not read these yet; that follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hardcoded stage vocabulary and the containment GATE with an explicit dictionary declaration (`x-qsv.relationships`, `kind: "pipeline"`). Issue #4222 framed the question correctly for its sibling asks: which columns are stages, and in which direction, is SEMANTICS, not a statistic -- "so it deserves explicit treatment rather than a guessed proxy". Asks 1 and 2 were resolved that way (no gate, unconditional caveat, no false negatives). Ask 3 shipped with a guessed proxy instead: a word list to guess with, and row-wise containment as a gate to catch its own bad guesses. This brings ask 3 in line. Deleted: FUNNEL_FAMILIES, FunnelFamily, FunnelStage, FunnelHit, stage_match, stage_containment, stage_pair_passes, FUNNEL_CONTAINMENT_MIN. With no dictionary, `viz smart` no longer draws a funnel at all. Containment is still MEASURED but never refuses. A declared pipeline whose stages do not nest now draws with the violation share named in the subtitle, which is more informative than silence. The motivating NYC CPDB dataset -- three aggregates on different accounting bases -- renders for the first time, subtitle reading "Spent exceeds Committed in 46% of rows", matching the share recorded on the issue. FUNNEL_COMPLEMENT_MARKERS survives as a WARNING rather than a veto: a complement column nests PERFECTLY, so it reports 0% violations while rendering a funnel that means the opposite of the truth -- the one bad declaration disclosure cannot catch. The old list's COLLISION half (review, contractor, wholesale, lead_time, replaced) is deleted with the vocabulary it defended; those words only mattered against greedy substring matching, so warning on a declared column would be a false alarm. Row-encoded pipelines are now supported -- stages as VALUES of one category column, the shape the smart path previously declined to guess at. Safe because it is declared. PanelKind::Funnel gains `shape`, because `reached`/`n_complete` are numerically identical between the encodings while meaning different things: reusing the column wording for a row funnel would assert a meaningless 100% complete-case rate. The funnel takes its OWN data pass rather than piggybacking the correlation read. That read excludes near-unique columns, so a declared money column could silently vanish, and force-including one would perturb the Pearson matrix and shrink the listwise join for every other correlation panel. It also fixes a live bug: the complete-case denominator was diluted by columns unrelated to the funnel. Tests: the four `no_funnel` negatives would now pass for the wrong reason (no dictionary, no funnel), so they are replaced by one honest baseline -- viz_smart_no_funnel_without_a_dictionary, which asserts the nested table that DOES draw with a dictionary draws nothing without one. 255 viz unit + 352 test_viz integration, clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stem lived in FUNNEL_FAMILIES, which the dictionary-driven funnel rework deleted. Verified against the same typos 1.48.0 CI pins: the repo is clean without the exception. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `viz smart` funnel panel was never documented for users -- the overview-panels list in the help never mentioned it, and everything about it lived in rustdoc. Now that it is dictionary-driven, and therefore something a user must author or edit to get at all, that gap matters. Adds the panel to the smart overview-panels list, stating plainly that a funnel is drawn ONLY from an explicit declaration and never guessed, that declared order is authoritative and never re-sorted by size, and that containment is measured and disclosed but never refuses. Documents both `kind: "pipeline"` encodings under --dictionary with worked JSON, and the relationships array under describegpt's --infer-content-type, where it belongs alongside gauge_range. Also corrects two doc comments the rework falsified: `build_funnel_chart` claimed the row-shaped pipeline is one "the smart path explicitly declines to guess at" (smart now charts it when declared), and PanelKind::Funnel still described the deleted FUNNEL_FAMILIES vocabulary and its two gates. docs/help/*.md regenerated via `qsv --generate-help-md`, never hand-edited. No gallery regeneration: none of the 5 committed dictionaries declares a relationships array (they predate the emitter fix), and no committed smart dashboard ever carried a funnel panel, so no figure gains or loses one. The single funnel in gallery.html is the standalone `viz funnel` figure, untouched by this work. Regenerating would produce only timestamp churn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only the four `--dictionary infer` figures changed materially; the other twelve
dashboards differed solely by the `Compiled:` timestamp and the known
base64-stats/`qsv_dict_<hash>` noise, so they are reverted rather than committed.
No figure gains a funnel panel, which is the correct outcome: none of the gallery
datasets is a pipeline, and detection is now dictionary-only. The single funnel in
gallery.html remains the standalone `viz funnel` figure.
Verified the relationship chain end-to-end against a live LLM rather than assuming
it, since "no relationships emitted" and "emitter broken" are indistinguishable from
the gallery alone:
- the model declares relationships where they exist and does NOT invent pipelines --
sales_sample got `correlated`, world_cities got `joint` + `correlated`
- on an unambiguous nested table it emits
{"kind":"pipeline","members":["planned_amt","committed_amt","spent_amt"]},
which survives into the JSON Schema root x-qsv and renders as a funnel with bands
top-first and totals strictly decreasing (87.9M -> 54.5M -> 30.2M)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
roborev 3848 flagged a "Total Earthquake Magnitude" KPI (1407.1) in the regenerated geospatial dashboard. Richter magnitude is logarithmic, so its sum has no meaning. Fixed at the source rather than in the HTML. `magnitude` was simply missing from `is_intensive_measure`'s INTENSIVE_TOKENS, a list that already carries elevation, altitude, density and temperature -- the same non-additive class. The new dictionary tags it `role: measure` with `concept: unknown`, which routes to Sum; magnitude carries no rate/percent tell in its name, so an LLM reads it as a plain amount. This guard is exactly the backstop for that, and it had a gap. Also adds `depth`, which the review did not flag: the same dashboard rendered "Total Epicenter Depth (km)", and elevation/altitude were already listed, so its absence was an inconsistency rather than a decision. The fix is general, not a patch on one figure -- it independently corrected smart_world_events.html, whose magnitude KPI was also summed (1311.7 -> 4.9686). "Total Felt Report Count" correctly stays a total. The review's second finding (installClusterUi's 500ms tick) is NOT fixed; it is recorded on the job as a false positive. Its premise is wrong -- the page does carry a Clusters/Points toggle, so clustering is enabled at runtime, which is the case the tick exists to cover -- and the suggested fix would leave cluster bubbles unpainted when a user toggles clustering on later. The code is also pre-existing (#4266), not introduced here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last open item from the funnel work: the gallery demonstrated the standalone `viz funnel` but had nothing showing the smart panel. That gap existed because no reachable dataset had genuinely NESTED stage columns -- a constraint the redesign removed. Containment is now disclosed rather than enforced, so CPDB qualifies precisely BECAUSE its stages do not nest, which makes it the more instructive demo. Adds a curated `nyc_capital_projects_dict.schema.json` (the same hand-authored pattern as allegheny_dogs_dict / sales_kpi_dict, so gallery regeneration stays LLM-free) declaring the pipeline and giving the bands their Planned / Committed / Spent titles. Column descriptions are NYC's own wording, which is what explains the non-nesting. The old caption is REPLACED, not amended -- it actively taught the removed design: "Note what is absent: no pipeline funnel ... the containment gate declines and says so on stderr rather than drawing a funnel the data does not support." There is no containment gate any more, and the panel now draws. The new caption reads the subtitle instead: Spent totals 2.9x Committed because the three are independent aggregates on different bases, and the funnel names it -- "Spent exceeds Committed in 46% of rows" -- rather than refusing. Worth recording: gemma-4-26b-a4b-qat declared this pipeline UNPROMPTED on the raw CSV, in the correct upstream-first order, alongside a correct `joint` grouping for the two agency columns. So `--dictionary infer` alone produces the funnel here; the curated dictionary is for label quality and reproducibility, not to make it work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jqnatividad
added a commit
that referenced
this pull request
Jul 27, 2026
qsv --update-mcp-skills picks up the new flag in qsv-viz.json, plus previously-unregenerated accurate drift from the funnel/pipeline work (#4281/#4282): the funnel subcommand in the viz enum and the pipeline relationship docs in the viz + describegpt skills. Skills CHANGELOG Unreleased note added; skill count unchanged at 55. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jqnatividad
added a commit
that referenced
this pull request
Jul 27, 2026
…4283) (#4284) * viz smart: DataTables data viewer drawer with Explore/Preview link (#4283) Add an interactive data viewer to `viz smart` HTML dashboards: next to the rowcount in the metadata frontmatter, an "(Explore)" link (all rows embedded) or "(Preview)" link (first N rows) opens the underlying table in a bottom drawer, mirroring the Data Dictionary drawer mechanics. - New `--preview-threshold <n>` flag (default 50,000; 0 disables): at or under the threshold every row embeds and the link reads "(Explore)"; above it only the first <n> rows embed and the link reads "(Preview)". - Vendored DataTables 3.0.0 + DateTime 2.0.0 + SearchBuilder 2.0.0 (vanilla, no jQuery; MIT) in src/cmd/assets/, embedded gzip+base64 by default and inflated lazily on first drawer open (zero page-load cost); plain inline under QSV_VIZ_NO_COMPRESS; version-pinned cdn.datatables.net tags with Subresource Integrity under QSV_VIZ_CDN (unit test recomputes sha384 of the vendored files against the pinned hashes, like plotly's). - DataTable has global search, a per-column filter input header row, and a SearchBuilder whose per-column conditions derive from the stats cache: Integer/Float -> num, Date/DateTime -> date, else string - so numeric columns get numeric conditions, dates get Before/After/Between, and low-cardinality categoricals get auto-populated value dropdowns without client-side type sniffing on a partial preview. - Rows embed as a JSON array-of-arrays payload (gzip+base64 when >= 4 KiB and compression is on, else plain with &/</> \u-escaped so no cell can smuggle a </script>); cells render via DataTable.render.text() so cell values are data, not markup. - Bottom drawer coexists with the dictionary side drawer (different axes, z-index 1090 vs 1100), pushes the fixed logo/theme widgets up, closes on Esc, and dispatches a resize so plotly re-fits. DataTables' native dark palette follows the qsv theme toggle by mirroring body.qsv-dark onto html.dark. - HTML-only: image exports never pay the extra data pass. - gen_gallery.py caps gallery smart runs at --preview-threshold 500 (its payloads are plain under QSV_VIZ_NO_COMPRESS; the 50k default would have added ~6MB to smart_nyc311.html alone - now +317KB, ~757KB across the 13 regenerated pages). - 2 unit + 9 integration tests; 3 existing panel-scoped tests now pin --preview-threshold 0 (the viewer legitimately embeds all raw columns, including panel-excluded ones), and the metadata test tracks the new Rows cell markup. Full viz suite: 363 passed. Closes #4283 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz: regenerate MCP skill JSONs for --preview-threshold (#4283) qsv --update-mcp-skills picks up the new flag in qsv-viz.json, plus previously-unregenerated accurate drift from the funnel/pipeline work (#4281/#4282): the funnel subcommand in the viz enum and the pipeline relationship docs in the viz + describegpt skills. Skills CHANGELOG Unreleased note added; skill count unchanged at 55. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: data viewer drawer UX refinements (#4283) Six UX tweaks to the DataTables data viewer drawer: - Stacking: the data drawer now paints ABOVE the dictionary drawer when both are open (z-index 1120 vs 1100). - SearchBuilder rides in a Buttons popover: a "Filter (n)" button on the same controls row as the page-length selector and the global search box (language.searchBuilder.button + layout.topStart), replacing the permanent top pane and its whitespace. Esc with the popover open closes only the popover; a bare Esc closes the drawer. - Focus management: opening the drawer moves focus into it (tabindex="-1"); closing returns focus to the Explore/Preview link in the metadata table at the top of the dashboard. - Responsive extension replaces scrollX: columns that do not fit collapse into an expandable child-row control per row (nyc311's 41 columns show 7 + expander); the per-column filter input row tracks Responsive's column visibility via the responsive-resize event. - Resizable drawer: a grip bar on the top edge drags the height between 20vh and 90vh via the --qsv-data-h CSS var, so the body margin and the fixed logo/theme widgets follow; releasing re-fits plotly and re-balances the table columns. - Fixed header/footer: only the table's own layout row scrolls (flex column + overflow:auto) with a sticky two-row thead (title row at top:0, filter row offset by the measured title-row height), keeping the controls row above and the info/paging bar below always visible. The FixedHeader extension was deliberately NOT used - it tracks window scroll only and is inert inside the fixed-position drawer; native sticky delivers the same UX and stays correct under drawer resize. Vendored bundle regenerated with Buttons 4.0.0 + Responsive 4.0.0 added (combo dt-3.0.0/b-4.0.0/date-2.0.0/r-4.0.0/sb-2.0.0; new SRI hashes; unit-test code map extended). Gallery regenerated. Full viz suite: 363 passed. Browser-verified: popover + condition count, responsive collapse + filter-row sync, sticky scrolling, grip resize, focus round-trip, both drawers stacked, light/dark, console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: re-clamp the persisted data drawer height against the viewport (#4283) roborev #3864 (Low): the grip persists --qsv-data-h as absolute px, so a later window resize or device rotation could leave the drawer outside the intended 20vh-90vh bounds. All consumers (drawer height, body margin, fixed logo/theme offsets) now read one body-scoped `--qsv-data-h-eff: clamp(20vh, var(--qsv-data-h, min(55vh, 560px)), 90vh)` so the effective height tracks the CURRENT viewport and the offsets can never disagree. Browser-verified both bounds; gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * typos: add *.js and *.ts to whitelist * viz smart: reveal the data drawer in embedded (gallery iframe) pages (#4283) In the gallery, smart dashboards are embedded in auto-sized iframes that never scroll themselves (the parent page does), so position:fixed pins the data drawer to the bottom of the FULL iframe height - often far below the visible viewport. Clicking Explore/Preview appeared to do nothing, and scrollIntoView cannot fix it: it is a no-op on fixed-position elements. New revealBottom()/revealTop() helpers scroll the PARENT window directly via window.frameElement geometry: opening aligns the iframe's bottom edge (where the drawer lives) with the parent viewport (re-aligned once more after the open transition + the gallery's iframe auto-grow settle); closing scrolls back to this dashboard's top where focus lands on the Explore/Preview link. Same-origin only - frameElement access throws in cross-origin/file:// embeddings and we quietly skip, which is the prior behavior. Standalone pages never scroll (self === top guard). Browser-verified in the served gallery: open scrolls the drawer fully into view, close returns to the dashboard top; standalone pages unchanged. Gallery regenerated; full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: data drawer reveal rides the gallery's postMessage channel (#4283) The frameElement-based reveal only worked for same-origin embeddings; a file://-opened gallery has opaque frames where every direct parent-window access throws, so clicking Explore/Preview still appeared to do nothing — the same constraint that made the gallery's iframe auto-sizing use postMessage in the first place. The reveal now posts {qsvVizReveal:"bottom"|"top"} to the parent (works cross-origin), with the direct frameElement scroll kept as a fallback for same-origin embedders without the listener. Gallery side: the resize listener honors the reveal messages (matching the sender iframe by e.source, like height reports); the jump stabilizer treats a reveal as reader takeover so it can no longer snap the view back and undo the reveal after a TOC jump; and pointerdown joins the takeover-forwarding events, since a click inside a dashboard is as much reader engagement as a scroll. Browser-verified end-to-end in the served gallery, including the adversarial flow (hash-jump arms the stabilizer, then a real click on Explore): open reveals the drawer, close returns to the dashboard top. Message path exercised in isolation (posted from the iframe context). Gallery regenerated; full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: skip the drawer's delayed re-align after a quick close (#4283) roborev #3868 (Medium): the 450ms settle re-align in show() fired even if the drawer was closed within the window — over the postMessage path this scrolled the parent back to the iframe bottom right after qsvCloseData() had revealed the dashboard top. The delayed callback now re-checks that the drawer still carries the `open` class first. Gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz gallery: iframes shrink back when the data drawer closes (#4283) The gallery's height reporter posted documentElement.scrollHeight, which is max(content, viewport) — once the drawer's reserved body margin had grown an iframe, scrollHeight could never report smaller than the iframe's own height, so closing the drawer left the reserved space as permanent whitespace (a one-way ratchet). The reporter now posts a content-derived height (body border-box + body vertical margins, which is exactly what the drawer's reserved space rides on; fixed-position chrome deliberately excluded), so the iframe grows on open AND shrinks back on close, and the report can never echo the viewport back into itself. Also: the drawer's post-open re-align is now a short series (450ms / 1.2s / 2.2s, still gated on the drawer being open) — on a still-settling gallery, other iframes growing above the target cancel an in-flight smooth scroll (the same churn the jump stabilizer fights), and a single re-align could land before the churn ended, leaving the drawer opened but out of view. Once the drawer region is visible the parent's reveal handler is a no-op, so the series converges and never fights the reader. Browser-verified on a fresh gallery load: click Explore -> iframe grows 2931->3663 and the drawer lands exactly at the viewport bottom; close -> iframe shrinks back to 2931 with the dashboard top in view. Full viz suite 363 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * viz smart: reader input cancels drawer re-aligns; reveals scroll instant (#4283) roborev #3870 (Medium): the 450ms/1.2s/2.2s reveal re-align series only checked that the drawer was still open, so a reader who scrolled back up to inspect the dashboard (drawer left open) was yanked back down by a pending re-align. A revealSeq counter now invalidates the series on any reader input (wheel/touchstart/keydown/pointerdown, capture+passive); each delayed callback requires the drawer open AND the sequence unchanged. The arming click's own pointerdown fires before show() captures its sequence number, so it never cancels the series it starts. Verifying this surfaced the root cause of the reveal flakiness seen across sessions: the gallery page sets scroll-behavior:smooth, and on an iframe-heavy load a smooth scrollTo animation can be canceled at its first frame by concurrent layout work and never arrive — programmatic scrolls appeared to no-op while user scrolling worked. The jump stabilizer's snap() documents the identical lesson ("must be instant"). All reveal scrolls (viz.rs same-origin fallbacks + the gallery reveal listener) now use behavior:"instant"; "auto" would inherit the page's smooth CSS. Browser-verified: fresh-load click reveals deterministically in <800ms; wheel-up during the reveal window is respected (no yank-back for 3s+); close still shrinks the iframe and returns to the dashboard top. Full viz suite 363 passed; gallery regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4222 — the last of its three asks. Asks 1 and 2 (the Lorenz inequality caveat) shipped in #4280.
Problem
The pipeline in the NYC Capital Projects data was visible only as
% zerosannotations scattered across three separate box titles: 47% of projects have nothing committed, 55% nothing spent. Read one at a time those are three unrelated data-quality notes. Read together they are a funding pipeline — the actionable governance signal. The funnel states it once.Why the first design was wrong
Issue #4222 framed the question correctly for its sibling asks:
Asks 1 and 2 were resolved exactly that way: no gate, an unconditional caveat, no false negatives. Ask 3 initially shipped with the guessed proxy that reasoning rejects — a hardcoded word list to guess with (
FUNNEL_FAMILIES: budget / marketing / fulfillment), and row-wise containment as a gate to catch its own bad guesses.The evidence against it came from the motivating dataset itself. Real CPDB is not row-wise nested —
spent_totalis 2.9×commit_total, violating containment on 46.3% of rows — because the three columns are independent aggregates on different accounting bases, which the dataset's own column descriptions explain. The old design's response was to draw nothing at all. But conceptually those three columns are planned → committed → spent. Refusing to chart them was a false negative produced by a statistical proxy standing in for a semantic question.What it does now
A funnel is drawn only when the dictionary declares one, via a
kind: "pipeline"entry inx-qsv.relationships. Nothing is guessed.The enabling discovery: describegpt already inferred this and viz never saw it. The dictionary prompt has always asked for a top-level
relationshipsarray, andparse_llm_relationshipshas always validated it — butformat_dictionary_jsonschemadropped it on the floor, and JSON Schema is the formatviz smart --dictionaryconsumes. Relationship inference is gated on--infer-content-type, which--dictionary inferalready passes. So the LLM was being asked for this on exactly viz's code path, and the answer was discarded at emission.Two encodings, both hand-editable in the saved
<stem>.schema.json:Containment is measured, never enforced. A declared pipeline whose stages don't nest is drawn with the violation share named in the subtitle. CPDB now renders for the first time, reading "Spent exceeds Committed in 46% of rows · Spent total exceeds Committed — overruns, not leakage" — the same 46% the old design used to refuse on. More informative than silence.
Declared order is authoritative and is never re-sorted by size, so a stage that outruns its predecessor stays visible instead of being quietly reordered away.
The one guard that survived, and why
FUNNEL_COMPLEMENT_MARKERS(unspent,remaining,balance, …) outlived the vocabulary it was born in, repurposed from a veto into a warning.This is the single bad declaration that disclosure cannot catch. A complement column nests perfectly inside its predecessor — it reports 0% violations — while rendering a funnel that means the opposite of the truth. Every other bad declaration shows up as a violation share the subtitle can name; this one does not.
The old list's collision half (
review,contractor,wholesale,lead_time,replaced) was deleted with the vocabulary. Those words existed solely as antidotes to greedy substring matching; warning on a column a user explicitly declared would be a pure false alarm.Deliberate cost: a dedicated data pass
The funnel no longer piggybacks the correlation read. That read excludes near-unique columns, so a declared money column could silently vanish from it — and force-including one would perturb the Pearson matrix and shrink the listwise join for every other correlation-derived panel.
It also fixes a live bug: the complete-case denominator was previously diluted by columns unrelated to the funnel. It now covers exactly the declared stages, and a test pins it.
Plotly dependency
Unchanged. Uses the
Funneltrace added in dathere/plotly (upstream PR plotly/plotly.rs#432);Cargo.toml:475pins the stackeddeps/webdriver-downloader-0.17+funnelbranch, so qsv is not blocked on the upstream merge.Funnel is a cartesian trace, so unlike Sankey it composes with the typed subplot grid and static export — verified by rendering to PNG, not assumed. Gotcha for future readers: a funnel draws index 0 at the top, the opposite of a plain category axis.
Verification
test_viz+ 179 describegpt unit + 86 describegpt integration + 39 synthesize. Clippy clean (2 pre-existing warnings).jsonschema::meta::validatepasses withrelationshipsin rootx-qsv— asserted in a test, not assumed.The vacuous-test trap, handled explicitly
Under dictionary-only, the four old
!contains("Pipeline funnel:")negatives all still pass — for an entirely different reason than they were written for (no dictionary, no funnel). They were green while testing nothing. They are replaced by one honest baseline,viz_smart_no_funnel_without_a_dictionary, which asserts that the nested table that does draw with a dictionary draws nothing without one — and which would have failed against the old code, since the vocabulary charted that exact table.A cross-command invariant is also pinned:
SynthRelationship::membershas no serde default (unlikeanchor) and the dictionary deserializes in one pass, so apipelineentry lackingmemberswould hard-errorsynthesize --dictionaryrather than degrade.membersis therefore synthesized for the row encoding rather than taken from the LLM.Known limits
grainsentence you are about to write"), but the guard depends on the model obeying. Editing the saved schema is the fix, which is why the declaration lives in a hand-editable file.stage_semanticskey can lift this.{"fields":[…]}dictionaries are unsupported for pipelines (JSON Schema only); they have nowhere to put a row-encoded declaration.viz smarton a plain CSV will never show a funnel again. That is the intended trade: no false positives, at the cost of requiring a dictionary.