A SQL result contains everything needed to choose its own chart: how many rows it has, which columns are measures, and whether the grouping key is a time. Deciding from that is a rendering decision, not a reasoning one — so it needs no model call, adds no latency, and gives the same answer every time.
An assistant that hands back a grid of rows has done the hard part and stopped one step early. "Orders by region last quarter" is a question about relative size, and relative size is a thing eyes read off bar lengths in a moment and off a column of numbers slowly, if at all. So every answer here arrives drawn as something. The interesting question is who decides what.
The model should not choose the chart
It is the obvious design and it is the wrong one. The result set has already come back — columns, rows, types — which means the choice is fully determined by data the application is holding. Sending that to a model to ask what to draw buys nothing and costs three things: a round trip of latency on every answer, tokens on a decision with no reasoning in it, and non-determinism, so the same query charts differently on Tuesday.
Worse, it is a decision a model is not especially good at. "Is this a time series" is a regex over the grouping column. "Are there too many categories for a bar chart" is a row count. These are rules, and rules are what code is for.
The result set already says what it is
Classify each column as a measure or a dimension, then read the shape. Almost every case falls out of three facts: how many rows came back, how many measures there are, and whether the dimension is temporal.
- One row is a number, not a chart. A one-bar bar chart and a two-slice pie are both a value wearing a costume. It renders as a stat — large type, the measure named underneath.
- A temporal dimension is a line. Dates, and ordinal names like `month`, `quarter`, `week`, `bucket`. Time has an order, and a line is the mark that shows an order.
- Everything else is a magnitude comparison, which is a bar. Sorted by value, because comparing lengths against a common baseline is the thing bars are for, and an unordered category has no row order worth preserving.
- No measures at all means no chart. Identifiers and prose belong in a table, and drawing them would be decoration.
That last one matters more than it looks. Returning "nothing" has to be an available answer, or the rules start reaching for a chart on results that do not have one.
The case that catches everyone
`SELECT year, count(*) FROM orders GROUP BY year` types both columns as numeric, so a naive classifier calls both of them measures and plots the year as if it were a quantity. The grouping key is recoverable: it is the column with a distinct value on every row and an ordinal-looking name.
Every threshold is a legibility limit
The numeric constants in this logic are not tuning knobs — each is the point at which a mark stops being readable, and each is worth naming.
const MAX_BARS = 20 // beyond this a bar chart stops being readable
const MAX_SERIES = 4 // past four, direct labels collide
const MAX_SLICES = 6 // part-to-whole at a glance onlyPast twenty bars the labels are unreadable, so the chart shows the top twenty by value and says so on the card — "Top 20 of 340 rows shown" — while the table view keeps all of them. A donut is offered only for a single measure, at most six slices, all positive; part-to-whole reading collapses past that, and a negative slice is not a share of anything.
Stacking answers a different question from grouping
Grouped bars compare measures against each other; a stack shows what each category is made of and how the totals compare. Both are correct, they are just answers to different questions, so a result with several measures offers both and you switch in a click.
The exception is the one that makes stacking arithmetically wrong rather than merely busy: when one measure is the sum of the others. `SELECT approved, pending, rejected, COUNT(*) AS total … GROUP BY service` is an ordinary query whose last column is the first three added up. Stack it and every bar is drawn at twice its real height, with an axis running to twice the real maximum.
So the check runs on every row — a column that happens to match on one row is not a total — and where it holds, the stacked view is withheld. Grouped bars and the table survive it, so nothing is lost but the one view that would lie.
The table is always there, and so is the SQL
Every card carries a table view, whatever else it offers, for two independent reasons. Nothing should be reachable only by hovering a mark — that is an accessibility floor, not a preference. And a chart is an interpretation, so the numbers it was drawn from have to stay one click away, next to the query that produced them and the row count and execution time. Copy the whole result as CSV from the same row of controls.
The views a card offers vary with the data; their order never does. Whichever forms a result supports appear in a fixed sequence and the rest are simply absent, so the switcher never rearranges itself between two answers and your hand learns where things are.
What this costs and what it buys
It costs a few hundred lines of rules with a comment on each explaining the legibility limit it encodes. It buys an answer that arrives drawn, with no extra model call, no added latency, and the same output for the same query every time — and a set of decisions anyone can read, disagree with, and change in one place.
