Skip to content

viz smart: dictionary-declared pipeline funnel (#4222) - #4281

Merged
jqnatividad merged 15 commits into
masterfrom
viz-smart-pipeline-funnel
Jul 26, 2026
Merged

viz smart: dictionary-declared pipeline funnel (#4222)#4281
jqnatividad merged 15 commits into
masterfrom
viz-smart-pipeline-funnel

Conversation

@jqnatividad

@jqnatividad jqnatividad commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #4222 — the last of its three asks. Asks 1 and 2 (the Lorenz inequality caveat) shipped in #4280.

This PR was substantially redesigned mid-flight. It began as a column-name vocabulary plus a row-wise containment gate. It now derives the funnel from an explicit data-dictionary declaration, with containment kept as a disclosure rather than a gate. The reasoning is below, because the earlier design's own headline finding is what argued against it.

Problem

The pipeline in the NYC Capital Projects data was 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 — 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:

how does viz smart recognise that a set of measures forms a pipeline? … This has the same shape as the unit-heterogeneity problem above — it is semantics, not a statistic — so it deserves the same explicit treatment rather than a guessed proxy.

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_total is 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 in x-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 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. Relationship inference is gated on --infer-content-type, which --dictionary infer already 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:

// stages as COLUMNS — upstream first. NB: the OPPOSITE direction from "ordered", which ascends.
{"kind":"pipeline","members":["totalplannedcommit","commit_total","spent_total"]}

// stages as ROW VALUES of one category column — the shape smart previously declined to guess at
{"kind":"pipeline","members":["stage","revenue"],"stage_column":"stage",
 "stages":["Impression","Click","Lead","Conversion"],"value_column":"revenue"}

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 Funnel trace added in dathere/plotly (upstream PR plotly/plotly.rs#432); Cargo.toml:475 pins the stacked deps/webdriver-downloader-0.17+funnel branch, 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

  • 255 viz unit + 352 test_viz + 179 describegpt unit + 86 describegpt integration + 39 synthesize. Clippy clean (2 pre-existing warnings).
  • End-to-end on the motivating dataset: CPDB draws, subtitle reports 46% — matching the share recorded on the issue, so the measurement is unchanged and only the gate is gone.
  • Dictionary-only confirmed: all 20 gallery corpus CSVs produce zero funnels without a dictionary, including the perfectly-nested fixture that used to produce one.
  • jsonschema::meta::validate passes with relationships in root x-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::members has no serde default (unlike anchor) and the dictionary deserializes in one pass, so a pipeline entry lacking members would hard-error synthesize --dictionary rather than degrade. members is therefore synthesized for the row encoding rather than taken from the LLM.

Known limits

  • The LLM can mis-declare a partition as a pipeline — a status column on a one-row-per-entity table. The prompt guards against it explicitly ("check the grain sentence 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.
  • Row-encoded funnels use the disjoint reading (counts are rows at each stage). A cumulative pipeline — one row per entity, column = furthest stage reached — would need a reverse-cumulative sum that is only valid under an assumption the data cannot confirm. A future stage_semantics key can lift this.
  • Legacy flat {"fields":[…]} dictionaries are unsupported for pipelines (JSON Schema only); they have nowhere to put a row-encoded declaration.
  • viz smart on a plain CSV will never show a funnel again. That is the intended trade: no false positives, at the cost of requiring a dictionary.

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>
@codacy-production

codacy-production Bot commented Jul 25, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 6 complexity

Metric Results
Complexity 6

View in Codacy

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.

jqnatividad and others added 6 commits July 26, 2026 00:35
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>
jqnatividad and others added 6 commits July 26, 2026 09:14
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>
@jqnatividad jqnatividad changed the title viz smart: surface planned → committed → spent as a funnel (#4222) viz smart: dictionary-declared pipeline funnel (#4222) Jul 26, 2026
jqnatividad and others added 2 commits July 26, 2026 11:27
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
jqnatividad merged commit de09e94 into master Jul 26, 2026
17 checks passed
@jqnatividad
jqnatividad deleted the viz-smart-pipeline-funnel branch July 26, 2026 08:10
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>
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.

viz smart: guard inequality (Gini/Lorenz) framing for heterogeneous-unit tables; label zero-run as pipeline stage

1 participant