# In-product SKILL: charting + analytics recipes (v1, 2026-07-16) — the visualization half of the # tool registry. Teaches the small model WHICH form answers WHICH comparison (Zelazny: the form # follows the comparison) and HOW to reach it: run_semantic_query -> transform_result (optional, # governed) -> make_chart | make_table. The model NEVER computes derived numbers itself and NEVER # emits HTML/vega — it picks a kind; the platform owns the pixels and every chart drills to its # exact rows (result_id). # # This file versions TOGETHER with harness/tools.py (CHART_KINDS + transform ops) — a kind or op # named here must exist there, and vice versa. key: charting label: Charts, tables, and analytics transforms kinds: [line, bar, area, scatter, map, pie, donut, stacked_bar, grouped_bar, ranked_bar, stacked_pct, combo, yoy_bars, waterfall, pareto, histogram, heatmap, treemap, funnel, bullet, bubble, sparkline] transforms: "the full 60-op analytics library (table calcs, stats, business templates, re-query windows) lives in analytics.skill.yml — this file covers the viz forms they feed" recipes: - ask: "top [N] [customers|products|states] as a chart" plan: > run_semantic_query(group_by=[dim], sort=-measure, limit=high) -> transform_result([{op: top_n, by: measure, n: N, other: false}]) -> make_chart(kind=ranked_bar, x=dim, y=measure) guard: > for a RANKING use other:false (a giant 'Other' bar crushes the scale — the platform captions what was cut); use other:true when the chart claims a share of the whole (donut, stacked). Never truncate without the transform. - ask: "how concentrated is [revenue|margin]? / do a few customers carry the book?" plan: > run_semantic_query(group_by=[dim], sort=-measure, limit=high) -> make_chart(kind=pareto, x=dim, y=measure) # platform draws bars + cumulative-% line guard: "pareto reads concentration; for a plain ranking use ranked_bar instead" - ask: "share/mix of [revenue] by [category|BU] — part-to-whole" plan: > run_semantic_query(group_by=[dim]) -> if >6 groups: transform_result top_n -> make_chart(kind=donut|pie, x=dim, y=measure). Mix OVER TIME: group_by dim + grain -> make_chart(kind=stacked_bar, x=period, y=measure, series=dim); if the question is about SHARES not levels -> stacked_pct. Hierarchical share -> treemap. guard: "pie/donut only <=6 slices; percentages must come from the rows, never estimated" - ask: "this year vs last year [by month] — YoY comparison" plan: > run_semantic_query(grain=month, date_from/date_to = this year) -> transform_result([{op: yoy}]) -> make_chart(kind=yoy_bars, x=period, y=revenue) guard: > yoy re-runs the SAME governed query shifted -1 year; needs explicit date_from/date_to. The platform prints the YoY % on the chart — never hand-compute deltas. - ask: "what drove the change / bridge [revenue] from X to Y" plan: > get a result whose rows are SIGNED contributions (e.g. yoy transform then compute per-group delta is NOT available — query each component) -> make_chart(kind=waterfall, x=label, y=amount). The platform appends the Total bar. guard: "steps must sum to the change being explained; if they don't, say what's missing" - ask: "distribution — how are [order values|customer sizes] spread?" plan: > run_semantic_query(group_by=[entity dim], limit=high) -> make_chart(kind=histogram, x=measure) | or transform_result([{op: bin, of: measure, bins: 12}]) -> make_chart(kind=bar, x=bucket, y=count) when you want the buckets as rows guard: "histogram bins per-entity values — group by the entity first, never bin a trend" - ask: "is [X] related to [Y]? (two measures per entity)" plan: > run_semantic_query(group_by=[entity], measures=[X, Y]) -> make_chart(kind=scatter, x=X, y=Y); a third measure sizes the dots -> kind=bubble, size=Z guard: "state that correlation is visual, not causal; outliers get named in the answer" - ask: "level AND rate together (GM$ + GM%, revenue + orders)" plan: > run_semantic_query(grain=month, measures=[level, rate]) -> make_chart(kind=combo, x=period, y=level, y2=rate) guard: "y = the dollar level (bars, left); y2 = the rate/count (line, right)" - ask: "smoothed trend / running total / share columns" plan: > transform_result ops: moving_average{of, window} (adds _maN) · running_total{of} · share_of_total{of} · rank{by} — then chart the derived result (e.g. line of the _ma3 column, or make_table with the share column) guard: "the first window-1 moving-average points are None by design — say so if asked" - ask: "pipeline/stage conversion; actual vs target" plan: > funnel: rows in stage order -> make_chart(kind=funnel, x=stage, y=value). target: make_chart(kind=bullet, x=label, y=actual, y2=target) guard: "funnels need a real stage sequence; never reorder stages by size" - ask: "same mini-chart per [BU|category] — compare shapes side by side" plan: > run_semantic_query(grain=month, group_by=[dim]) -> make_chart(kind=line, x=period, y=measure, facet=dim) guard: "facet shares scales across panels so shapes compare honestly" - ask: "show me the numbers / a list / exact figures" plan: > run_semantic_query(...) -> make_table(result_id, columns=[...], title=?) guard: > make_table renders house-formatted with a totals row — prefer it over pasting rows into the answer text whenever there are more than ~5 rows or 3 columns context: > Every chart/table keeps its result_id and semantic query — the platform renders a "Data behind" drill for each one, and saved views re-run query + transforms live. If a form needs a column the result lacks, transform first (the error message names valid columns). When two forms could work, pick the simpler; never decorate.