Hand suggestCharts() a hundred rows and it'll happily pick the right chart — for the hundred rows. The production dataset is a million rows. The chart it picked is wrong for the data the user will actually see. The library knows nothing about the gap because it only sees what's been passed to it, and the rows it's been passed lied about scale.
Scale-aware suggestions close that gap. You declare the scale of the production dataset — row count, cardinality, completeness — and the engine returns recommendations biased toward what works at the declared scale rather than the sample size. No profiling, no measurement, no statistics on the sample standing in for the population. Pure declaration, applied through the same composable bias layer thatAudienceProfile uses for chart literacy.
Why we don't measure scale
The same argument that ruled out measuring audience familiarity rules out measuring scale. Three reasons:
- Samples lie. A hundred-row sample of a million-row table picks a bar chart. The million wants a heatmap. The library can't see the discrepancy.
- Forward-looking. "We're shipping a dashboard that will run on a windowed view of the last 30 days, ~50k rows" — that's what to design for, not the seed fixture in the unit tests.
- Standardization. Your shop's definition of "small" might be 10k rows. Whoever's writing the chart-recommendation engine has no way to know that. Declarative overrides are the only honest answer.
Semiotic's contract becomes describe your data, we'll suggest for it. Profile the sample if you want — profileData still does — but nothing in the suggestion engine pretends the sample is the population unless you say so.
The same data, three declared scales
Below: a six-row sample of region × product revenue, evaluated three times with only the declared scale changing. At tiny scale the engine recommends a gauge — single-value summaries are the honest part-to-whole at that magnitude. At medium scale a treemap takes over because the hierarchy structure is what's worth browsing. At huge scale the hierarchy decays in favor of an aggregated stacked bar, since at that density browsing is no longer competitive with weighted comparison.
declared: tiny (≤3)
BigNumber · 5.80/5
Strong fit for part-to-whole (5/5)
declared: medium (~140)
Treemap · 5.40/5
Strong fit for part-to-whole (5/5)
declared: huge (>5k)
semiotic.recipe.waffle.v0 · 5.25/5
Strong fit for part-to-whole (5/5)
Each card is the same call —suggestCharts(rows, { intent: "part-to-whole", rawInput: hierarchy, scale: { rows: "tiny" | "medium" | "huge" } })— only the declared rows band changes. The engine doesn't see different rows; it sees a different declaration about what those rows represent.fits() evaluates against the declared row count too, so charts can be excluded entirely at the wrong scale — not just downranked.
Two profiles, not one
The first design decision: scale and quality are separate schemas. Both are optional. Both compose with AudienceProfile. Neither tries to cover the other.
Scale answers "how big?" — rows, cardinality, fields, growth mode. Quality answers "how clean?" — completeness, outlier prevalence, type heterogeneity. The temptation to merge them was strong: both are descriptions of the dataset, both come from the same place organizationally, both bias the same suggestion score. The reason they're split:they affect chart choice differently. Scale changes which chart wins; quality changes which treatment of the chart wins. A missing-data dataset doesn't need a smaller chart — it needs a broken-line variant, or a "missing" annotation channel, or a quality caveat attached to the recommendation. That's a structural concern, not a size one, and squashing them into one knob makes both schemas worse.
The graduation surface
The second design decision: what shape the response takes. The natural impulse is to wrap suggestions in a new shape —{ tiny: [...], small: [...], large: [...] } — but that's redundant with what's already there. Every suggestion already knows which scale it was scored at. Exposing that as a scaleRange tag on the existing Suggestion shape gives callers everything they need, and grouping into tiers becomes a derived view.
The grouped surface that is exposed —suggestChartsGrouped() — is sugar over running the engine once per band. The use case is the "now → at 10× → at 100×" narrative card. You'd build it by hand from the tagged primitives if you needed something custom; the grouped surface is the shortcut for the common case.
Breakpoints aren't global
The third design decision: where do the breakpoints live? The instinct says "global config" — one place to define what tiny / small / medium / large / huge mean, and every chart inherits. The instinct is wrong. Heatmap is great at 100×100. Bar chart fails at 20 bars. A heatmap can be a sweet-spot chart at the same row count where a bar chart is past its limits. A global breakpoint loses that.
So breakpoints live on the capability descriptors. Each chart declares its own sweet-spot band via scaleFit. The default thresholds — 3 / 25 / 250 / 5,000 — are the abstraction that lets callers group, but the actual scoring is per-chart. The defaults come from Miller (1956) on category comprehension limits, Cleveland-McGill (1984) on position-encoded perception, Munzner (2014) on quantitative encoding density, and Few (2009) on dashboard cognitive load. They're starting points; they're overridable per profile.
The single-value gap
Doing this honestly surfaced a coverage gap. At declared row counts of 1–3, the right recommendation is often not a chart. A revenue total wants a BigNumber with a delta and trend context, not a bar chart with one bar. The catalog's only single-value chart today is GaugeChart, and gauges only work when the value has a meaningful min and max. Most single values don't.
Rather than ship a half-built BigNumber just to fill the tier, the roadmap sketches a SingleValueFrame — a peer to XYFrame,OrdinalFrame, NetworkFrame, and GeoFrame — that would own the rendering primitives for the single-value family (focal value + adornments, threshold zones, format cascade, density modes, streaming KPIs). Whether the frame abstraction earns its infrastructure cost for a 4–6-HOC family is an empirical question the next six months will answer; the alternative is plain React components and accepting the duplication. Both are explicitly open. The scale work doesn't pretend the gap is closed.
When to declare scale, and when not
- Reach for it when the data you pass to the suggestion engine is a sample, a stub, or a forward projection of a dataset that will be materially larger in production.
- Reach for it when the recommendation needs to be honest about what the user will actually see, not what fits the demo data.
- Reach for it when your shop has opinions about what scale means — a BI environment where 10k rows is "small," a streaming dashboard where 100k rows arrive per minute, an embedded analytics tool that needs to ship the same chart across customers with 100× variance in volume.
- Skip it when the data you pass is the data you'll render. The engine's measurement of the sample is the right answer when the sample is the population.
Wiring
import { suggestCharts, suggestChartsGrouped } from "semiotic/ai" import type { DataScaleProfile, DataQualityProfile } from "semiotic/ai" const scale: DataScaleProfile = { rows: 50_000, cardinality: { region: 12, status: "low" }, growth: "windowed", } const quality: DataQualityProfile = { completeness: { revenue: 0.98, cohort: 0.62 }, } // Single recommendation at the declared scale. const [top] = suggestCharts(sampleRows, { intent: "trend", scale, quality }) // All five tiers for a "now → at 10×" narrative. const grouped = suggestChartsGrouped(sampleRows, { intent: "trend", scale }) const today = grouped[grouped.effective.rowBand][0] const tomorrow = grouped.huge[0]Where this same pattern shows up
Sample-vs-population mismatch is everywhere in software, and most of the time the answer is the same:let the caller describe what's coming, and don't infer it from what's currently in hand.
- Database query planners use declared cardinality hints when statistics on the actual table are stale or expensive to gather.
- Browser responsive design works because authors declare breakpoints in advance, not because the browser measures user behavior and adjusts. Media queries are the right metaphor: the data has breakpoints, just like a layout does.
- Load testing declares expected throughput rather than measuring it from a 10-second smoke test.
- Build systems ship
--release flags because the dev profile of an application isn't the production profile.
In each case, the system isn't dumber for not measuring — it's more honest for accepting that the answer to "what's the scale of this thing?" lives outside the immediately observable code.
The interactive surface lives at /intelligence/scale; the schema reference sits next to AudienceProfile in semiotic/ai.