Figure correctness checklist
This skill makes one plot trustworthy and readable. It deliberately has no
house aesthetic — frame, font family, and sizes are all parameters of
apply_figure_style(), which must run before the first plotting call.
Multi-panel assembly is figure-composer's job; deciding what each figure in
a paper should argue is paper-narrative's.
Loading the Python helpers only defines them; it does not select a matplotlib
backend or apply a style. Call apply_figure_style(...) explicitly for plotting.
The optional figure_style_self_check() applies the defaults and checks font
wiring when called; it is not a load-time check.
Two tiers of rule live below. Hard rules — everything under Tell the
truth, Never do, and Prove the render, plus any rule stating a
perceptual or factual invariant (semantic-zero centring, colour-vision
safety, leader-line anchoring) — apply to every plot with no override.
Everything else is a default: deviate when you have a deliberate reason, not
by accident.
Tell the truth about the data
- Excluded means excluded. A row the source data marks excluded either
disappears from the plot or appears as a clearly distinct open/hatched
marker named in the key — and it never contaminates a summary statistic
drawn next to included rows.
- Peers must be comparable. Arms measured under different N, budget,
initialisation, or protocol don't sit side by side as if equivalent. Facet
them apart or mark the label, and state the difference once in the caption.
- The figure can't contradict itself. Before saving, trace every
categorical label, threshold, and title back to the rule that defines it
and check each plotted row satisfies it. A row that contradicts its label
means the figure is wrong.
- A sentence-title is a claim — test it. Check the claim against every
category on the axis. One counterexample means qualifying ("on 3 of 4
pairs") or demoting the title to a description.
- State n and what's held fixed. Any summary mark comes with
n and the
unit of replication; any small-multiple that fixes a variable names the
fixed value — in-panel, or in the caption when the label budget is tight.
- Context structure comes from references. A tree, ordering, or topology
drawn as background (scale bar, category strip) uses an established
reference. Infer it from the plotted data only when the structure is
itself the finding.
- One claim, one number. Each quantitative claim (accuracy, runtime,
count) has a single canonical value reused identically in every panel,
caption, and the abstract — with a definition of what it measures.
Say less, and say it in the right place
The panel shows the pattern; the caption carries the context. Design for a
general scientific reader, not for yourself.
- Floor. Every visually distinct mark must be identifiable from the
figure alone. Deleting a label may only ever leave the reader asking "why
is that there?" — never "what is that?". Comparators are named for what
they are ("no joint training", "prior method"), not a role word
("baseline"). Gloss any term a general scientist can't parse.
- Ceiling. Per panel: title, axis labels, ticks, series identity
(labelled once per row of small multiples), and at most 2–3 narrative
annotations. More than ~6 strings beyond axes/ticks means over budget.
Identity labels are floor, not budget.
- Caption material: n=, held-fixed values, abbreviation expansions,
exclusion rationale, non-comparability footnotes, methods caveats.
- Titles state takeaways. "Robust to gene dropout" works; "Fewer genes"
doesn't — read it aloud and if a listener would ask "fewer genes what?",
rewrite. A row of small multiples varying one thing gets one row header,
not per-panel titles.
- Numbers on marks: headline only. Print the one value a reader would
quote; the axis serves the rest.
- Tie-break: delete and re-read. If the message survives without the
label, the label stays gone.
Axes and scales
- Limits clear the data by at least a marker radius on every side —
ax.margins(0.04) — and no mark or text touches a spine.
- Data using under 40% of an axis calls for a break or a data-floor start
with an explicit non-zero tick. Nothing may be drawn inside a break gap:
the gap has no coordinates.
- Log ticks read as
10²/1k/10k/100k, never raw exponents. Filled bars on
a log value axis are banned outright — bar length would encode the ratio
to an arbitrary floor. Points with a median tick replace them.
- In a row/column of small multiples, tick labels appear once (leftmost or
bottommost); interior panels keep tick marks only. Panels sharing y and
differing only in x abut (
wspace≤0.06) under one row header.
- A panel's data envelope fills ≥75% of its rectangle; dead bands mean
reshaping the grid, not padding the panel.
- When better-is-up/down isn't obvious from the axis label, put an upright
"higher = better" cue in the margin — once per row, never per panel, never
caption-only, and never rotated with rotated text (
goodness_arrow).
- The full-width figure must fit the venue's double-column width at 300 dpi,
and adding a schematic or label never squeezes the data panels narrower.
Colour
- A colour is a binding. Once an entity gets a colour, every mark for
that entity — line, fill, marker, text, heatmap row — reuses it exactly.
Colour is the cross-reference; nobody should read a legend twice.
- Few hues, one dominant. Use the minimum hue count. A focal series is
saturated and heavy; comparators desaturate and thin (
focal_palette).
The focal hue may not collide with any categorical palette in the same
figure, and the focal series must stay identifiable even at zero width or
full overlap — outline, marker, or tinted band.
- Nested categories: outer level chooses the hue family, inner level
samples within it.
- Continuous data: perceptually uniform sequential map; single-hue ramp
for rank/size; diverging map for signed values, centred on the semantic
zero (0, 1.0, median) — never the data midpoint.
- Colour-vision safety. No red/green binary. Every binary pair survives
a deuteranopia simulation. One alarm hue is reserved for
error/anomaly/perturbation and never doubles as a series colour.
- Two palettes ⇒ two legends, each adjacent to the first panel using its
palette.
Type
- Panel titles are plain-language sentences, regular weight, left-aligned;
metric names live on the axis.
- Three sizes, mapped to roles: base for titles/axis labels/series
identity, one step down for legends/annotations, one more for ticks
(
apply_figure_style(sizes=(8,7,6))). Panel letters alone break the rule
(bold, larger). A label that doesn't fit gets a layout fix or a shorter
string, never a fourth size.
- Species, genes, and variables that convention italicises are italicised;
abbreviations inherit the style and expand once on first use.
- Large numbers wear magnitude suffixes —
4.2B, 120 kb — not comma
grouping.
- On-mark values: ≤2 significant figures, unless rounding would collapse two
distinct rows, in which case show the separating digit. Text on a fill
needs 4.5:1 contrast or it moves outside the mark.
- No codebase identifiers as labels: readable name first, code in
parentheses or the caption.
- Panel letters: bold, top-left, outside the axes box; case per venue
(
panel_letter(ax, 'a', case=...)).
Match the chart to the data
- Category × number: show the distribution. Small n → jittered strip
with median tick (
strip_with_median); large n → box/violin; mean-as-
message → bar with raw points or interval (bar_with_points), not both.
errorbar='ci95' is the t-interval, valid at small n. A missing category
is marked n.d./—/hatched ghost — an empty slot reads as zero — and a
true zero gets a visible stub.
- One observation per category: lollipop (dot plus thin stem to the
semantic zero), value beside the dot.
- Series over a continuum: mean line with markers, raw runs as thin
translucent traces behind it, series named by text at the line's right end
(
end_of_line_labels) rather than a legend box. Per-bin summary glyphs
are unmistakable-for-raw, identical across series, and drawn under the raw
points.
- Overlapping distributions: stacked panels with shared x, or a
ridgeline; overlay only when separation is obvious.
- Matrices: under ~200 cells, print every value; state the threshold in
the colourbar label.
- Embedding scatters (UMAP/t-SNE/PCA): no ticks or tick labels, a corner
arrow pair for axes, clusters labelled by thin leaders into whitespace.
- Prediction vs. observation: adjacent tracks, identical x and colours,
alignment carries the comparison; target regions as translucent spans in
the legend.
- Insets connect visibly to their source region: box plus connectors, or
a wedge.
- Named-point scatters direct-label at least max, min, and every flagged
point via thin leaders — and after rendering, confirm each leader ends
within a marker radius of its row.
Composition
- Show what is being measured before the result — plain title, labelled
schematic, or panel order — and any schematic reuses the exact words and
glyphs of the data panels.
- A multi-panel figure exists to make one sentence true. Panels that neither
state, support, nor bound that sentence move to the supplement.
- Legends are frameless, sit in natural whitespace or become direct labels,
read swatch-first left-aligned, and resolve every distinct glyph.
- Grouped small multiples take one spanning header per group, not repeated
titles.
- Across a paper, Figure 1 renders the pitch as data (scope, not
architecture); later figures carry mechanism, evidence, robustness,
application. Panels are judged against the paper's pitch and move between
figures when the story requires (
paper-narrative runs that review).
- Between revision rounds, a passing panel is left alone — decorating a
clean panel is a regression.
Never do
Each of these is a correctness failure:
- red vs. green as an opposing pair;
- filled bars on a log value axis;
- a diverging map centred on the data midpoint, or a colourbar whose ticks
skip the semantic centre;
- an axis title that repeats the tick labels;
- direction-of-goodness explained only in the caption;
- a "reference" line at a value that is one of the plotted points;
- an excluded row inside a plotted summary;
- a leader line whose nearest mark is not its target.
Prove the render
Run both checks after fig.savefig(...) and before presenting the file.
1. Collision scan. Assert no visible text box overlaps another or a
spine (a tick label touching its own spine doesn't count), and every text
box sits inside fig.bbox:
rend = fig.canvas.get_renderer()
labels = [(t, t.get_window_extent(rend)) for t in fig.findobj(mpl.text.Text)
if t.get_text().strip() and t.get_visible()]
frames = [(s, s.get_window_extent(rend)) for ax in fig.axes
for s in ax.spines.values() if s.get_visible()]
own_ticks = {ax: set(ax.get_xticklabels(which='both') + ax.get_yticklabels(which='both'))
for ax in fig.axes}
hits = [(a, b) for i, (a, ba) in enumerate(labels)
for b, bb in labels[i+1:] if ba.overlaps(bb)]
hits += [(t, s) for t, bt in labels for s, bs in frames
if bt.overlaps(bs) and t not in own_ticks[s.axes]]
assert not hits
Move, shorten, or stagger until the scan is clean, re-saving each time.
2. Visual pass. Geometry can't see a low-contrast label, crossing
leaders, or two confusable series colours. Crop each panel to its own file
and inspect every crop with Wisp's view_image tool:
from PIL import Image
fig.savefig("figure.png")
for letter, box in panel_crops(fig).items():
Image.open("figure.png").crop(box).save(f"figure-{letter}.png")
Leave Python, then view_image each crop asking: every glyph legible
against its background? smallest element still has a stroke or stub? leaders
uncrossed? any two series colours confusable? legend beside what it keys?
A visual defect that passed the collision scan is still a defect.
3. R output. Prefer explicit ggsave(filename, plot = p, dpi = 300, bg = "white", ...) over the active device; for base graphics open
png(..., bg = "white", res = 300), draw, and always dev.off(). Then
assert the file exists and is non-empty and inspect it — a "successful" R
call with a missing, zero-byte, or blank file is a failed render.
Defaults when unsure: fewer hues, direct labels over legends, raw data over
summaries, and name the measurement before showing its result.
1---2name: figure-style3description: Correctness and legibility checklist for publication figures, plus a matplotlib sidecar. Load before plotting anything and call `apply_figure_style()` (role-mapped font ladder, outward ticks, frameless legends, 300-dpi saves, CJK-safe fonts). Covers data fidelity, label budgets, axis/colour/type rules, chart choice by data shape, composition, and a mandatory render-then-inspect QA pass (bbox collisions + per-panel visual crops). Helpers: focal_palette, bar_with_points, strip_with_median, end_of_line_labels, panel_letter, set_frame, panel_crops. Multi-panel assembly lives in figure-composer; whole-paper figure ordering in paper-narrative.4license: Apache-2.05---6
7# Figure correctness checklist
8
9This skill makes one plot trustworthy and readable. It deliberately has no
10house aesthetic — frame, font family, and sizes are all parameters of
11`apply_figure_style()`, which must run before the first plotting call.
12Multi-panel assembly is `figure-composer`'s job; deciding what each figure in
13a paper should argue is `paper-narrative`'s.
14
15Loading the Python helpers only defines them; it does not select a matplotlib
16backend or apply a style. Call `apply_figure_style(...)` explicitly for plotting.
17The optional `figure_style_self_check()` applies the defaults and checks font
18wiring when called; it is not a load-time check.
19
20Two tiers of rule live below. **Hard rules** — everything under *Tell the
21truth*, *Never do*, and *Prove the render*, plus any rule stating a
22perceptual or factual invariant (semantic-zero centring, colour-vision
23safety, leader-line anchoring) — apply to every plot with no override.
24Everything else is a default: deviate when you have a deliberate reason, not
25by accident.
26
27## Tell the truth about the data
28
29- **Excluded means excluded.** A row the source data marks excluded either
30 disappears from the plot or appears as a clearly distinct open/hatched
31 marker named in the key — and it never contaminates a summary statistic
32 drawn next to included rows.
33- **Peers must be comparable.** Arms measured under different N, budget,
34 initialisation, or protocol don't sit side by side as if equivalent. Facet
35 them apart or mark the label, and state the difference once in the caption.
36- **The figure can't contradict itself.** Before saving, trace every
37 categorical label, threshold, and title back to the rule that defines it
38 and check each plotted row satisfies it. A row that contradicts its label
39 means the figure is wrong.
40- **A sentence-title is a claim — test it.** Check the claim against every
41 category on the axis. One counterexample means qualifying ("on 3 of 4
42 pairs") or demoting the title to a description.
43- **State n and what's held fixed.** Any summary mark comes with `n` and the
44 unit of replication; any small-multiple that fixes a variable names the
45 fixed value — in-panel, or in the caption when the label budget is tight.
46- **Context structure comes from references.** A tree, ordering, or topology
47 drawn as background (scale bar, category strip) uses an established
48 reference. Infer it from the plotted data only when the structure is
49 itself the finding.
50- **One claim, one number.** Each quantitative claim (accuracy, runtime,
51 count) has a single canonical value reused identically in every panel,
52 caption, and the abstract — with a definition of what it measures.
53
54## Say less, and say it in the right place
55
56The panel shows the pattern; the caption carries the context. Design for a
57general scientific reader, not for yourself.
58
59- **Floor.** Every visually distinct mark must be identifiable from the
60 figure alone. Deleting a label may only ever leave the reader asking "why
61 is that there?" — never "what is that?". Comparators are named for what
62 they are ("no joint training", "prior method"), not a role word
63 ("baseline"). Gloss any term a general scientist can't parse.
64- **Ceiling.** Per panel: title, axis labels, ticks, series identity
65 (labelled once per row of small multiples), and at most 2–3 narrative
66 annotations. More than ~6 strings beyond axes/ticks means over budget.
67 Identity labels are floor, not budget.
68- **Caption material:** n=, held-fixed values, abbreviation expansions,
69 exclusion rationale, non-comparability footnotes, methods caveats.
70- **Titles state takeaways.** "Robust to gene dropout" works; "Fewer genes"
71 doesn't — read it aloud and if a listener would ask "fewer genes *what*?",
72 rewrite. A row of small multiples varying one thing gets one row header,
73 not per-panel titles.
74- **Numbers on marks: headline only.** Print the one value a reader would
75 quote; the axis serves the rest.
76- **Tie-break: delete and re-read.** If the message survives without the
77 label, the label stays gone.
78
79## Axes and scales
80
81- Limits clear the data by at least a marker radius on every side —
82 `ax.margins(0.04)` — and no mark or text touches a spine.
83- Data using under 40% of an axis calls for a break or a data-floor start
84 with an explicit non-zero tick. Nothing may be drawn inside a break gap:
85 the gap has no coordinates.
86- Log ticks read as `10²`/`1k/10k/100k`, never raw exponents. Filled bars on
87 a log value axis are banned outright — bar length would encode the ratio
88 to an arbitrary floor. Points with a median tick replace them.
89- In a row/column of small multiples, tick *labels* appear once (leftmost or
90 bottommost); interior panels keep tick marks only. Panels sharing y and
91 differing only in x abut (`wspace≤0.06`) under one row header.
92- A panel's data envelope fills ≥75% of its rectangle; dead bands mean
93 reshaping the grid, not padding the panel.
94- When better-is-up/down isn't obvious from the axis label, put an upright
95 "higher = better" cue in the margin — once per row, never per panel, never
96 caption-only, and never rotated with rotated text (`goodness_arrow`).
97- The full-width figure must fit the venue's double-column width at 300 dpi,
98 and adding a schematic or label never squeezes the data panels narrower.
99
100## Colour
101
102- **A colour is a binding.** Once an entity gets a colour, every mark for
103 that entity — line, fill, marker, text, heatmap row — reuses it exactly.
104 Colour is the cross-reference; nobody should read a legend twice.
105- **Few hues, one dominant.** Use the minimum hue count. A focal series is
106 saturated and heavy; comparators desaturate and thin (`focal_palette`).
107 The focal hue may not collide with any categorical palette in the same
108 figure, and the focal series must stay identifiable even at zero width or
109 full overlap — outline, marker, or tinted band.
110- **Nested categories:** outer level chooses the hue family, inner level
111 samples within it.
112- **Continuous data:** perceptually uniform sequential map; single-hue ramp
113 for rank/size; diverging map for signed values, centred on the *semantic*
114 zero (0, 1.0, median) — never the data midpoint.
115- **Colour-vision safety.** No red/green binary. Every binary pair survives
116 a deuteranopia simulation. One alarm hue is reserved for
117 error/anomaly/perturbation and never doubles as a series colour.
118- **Two palettes ⇒ two legends,** each adjacent to the first panel using its
119 palette.
120
121## Type
122
123- Panel titles are plain-language sentences, regular weight, left-aligned;
124 metric names live on the axis.
125- **Three sizes, mapped to roles**: base for titles/axis labels/series
126 identity, one step down for legends/annotations, one more for ticks
127 (`apply_figure_style(sizes=(8,7,6))`). Panel letters alone break the rule
128 (bold, larger). A label that doesn't fit gets a layout fix or a shorter
129 string, never a fourth size.
130- Species, genes, and variables that convention italicises are italicised;
131 abbreviations inherit the style and expand once on first use.
132- Large numbers wear magnitude suffixes — `4.2B`, `120 kb` — not comma
133 grouping.
134- On-mark values: ≤2 significant figures, unless rounding would collapse two
135 distinct rows, in which case show the separating digit. Text on a fill
136 needs 4.5:1 contrast or it moves outside the mark.
137- No codebase identifiers as labels: readable name first, code in
138 parentheses or the caption.
139- Panel letters: bold, top-left, outside the axes box; case per venue
140 (`panel_letter(ax, 'a', case=...)`).
141
142## Match the chart to the data
143
144- **Category × number:** show the distribution. Small n → jittered strip
145 with median tick (`strip_with_median`); large n → box/violin; mean-as-
146 message → bar with raw points *or* interval (`bar_with_points`), not both.
147 `errorbar='ci95'` is the t-interval, valid at small n. A missing category
148 is marked `n.d.`/`—`/hatched ghost — an empty slot reads as zero — and a
149 true zero gets a visible stub.
150- **One observation per category:** lollipop (dot plus thin stem to the
151 semantic zero), value beside the dot.
152- **Series over a continuum:** mean line with markers, raw runs as thin
153 translucent traces behind it, series named by text at the line's right end
154 (`end_of_line_labels`) rather than a legend box. Per-bin summary glyphs
155 are unmistakable-for-raw, identical across series, and drawn under the raw
156 points.
157- **Overlapping distributions:** stacked panels with shared x, or a
158 ridgeline; overlay only when separation is obvious.
159- **Matrices:** under ~200 cells, print every value; state the threshold in
160 the colourbar label.
161- **Embedding scatters** (UMAP/t-SNE/PCA): no ticks or tick labels, a corner
162 arrow pair for axes, clusters labelled by thin leaders into whitespace.
163- **Prediction vs. observation:** adjacent tracks, identical x and colours,
164 alignment carries the comparison; target regions as translucent spans in
165 the legend.
166- **Insets** connect visibly to their source region: box plus connectors, or
167 a wedge.
168- **Named-point scatters** direct-label at least max, min, and every flagged
169 point via thin leaders — and after rendering, confirm each leader ends
170 within a marker radius of its row.
171
172## Composition
173
174- Show what is being measured before the result — plain title, labelled
175 schematic, or panel order — and any schematic reuses the exact words and
176 glyphs of the data panels.
177- A multi-panel figure exists to make one sentence true. Panels that neither
178 state, support, nor bound that sentence move to the supplement.
179- Legends are frameless, sit in natural whitespace or become direct labels,
180 read swatch-first left-aligned, and resolve every distinct glyph.
181- Grouped small multiples take one spanning header per group, not repeated
182 titles.
183- Across a paper, Figure 1 renders the pitch as data (scope, not
184 architecture); later figures carry mechanism, evidence, robustness,
185 application. Panels are judged against the paper's pitch and move between
186 figures when the story requires (`paper-narrative` runs that review).
187- Between revision rounds, a passing panel is left alone — decorating a
188 clean panel is a regression.
189
190## Never do
191
192Each of these is a correctness failure:
193
194- red vs. green as an opposing pair;
195- filled bars on a log value axis;
196- a diverging map centred on the data midpoint, or a colourbar whose ticks
197 skip the semantic centre;
198- an axis title that repeats the tick labels;
199- direction-of-goodness explained only in the caption;
200- a "reference" line at a value that is one of the plotted points;
201- an excluded row inside a plotted summary;
202- a leader line whose nearest mark is not its target.
203
204## Prove the render
205
206Run both checks after `fig.savefig(...)` and before presenting the file.
207
208**1. Collision scan.** Assert no visible text box overlaps another or a
209spine (a tick label touching its own spine doesn't count), and every text
210box sits inside `fig.bbox`:
211
212```python
213rend = fig.canvas.get_renderer()
214labels = [(t, t.get_window_extent(rend)) for t in fig.findobj(mpl.text.Text)
215 if t.get_text().strip() and t.get_visible()]
216frames = [(s, s.get_window_extent(rend)) for ax in fig.axes
217 for s in ax.spines.values() if s.get_visible()]
218own_ticks = {ax: set(ax.get_xticklabels(which='both') + ax.get_yticklabels(which='both'))
219 for ax in fig.axes}
220hits = [(a, b) for i, (a, ba) in enumerate(labels)
221 for b, bb in labels[i+1:] if ba.overlaps(bb)]
222hits += [(t, s) for t, bt in labels for s, bs in frames
223 if bt.overlaps(bs) and t not in own_ticks[s.axes]]
224assert not hits
225```
226
227Move, shorten, or stagger until the scan is clean, re-saving each time.
228
229**2. Visual pass.** Geometry can't see a low-contrast label, crossing
230leaders, or two confusable series colours. Crop each panel to its own file
231and inspect every crop with Wisp's `view_image` tool:
232
233```python
234from PIL import Image
235
236fig.savefig("figure.png")
237for letter, box in panel_crops(fig).items():
238 Image.open("figure.png").crop(box).save(f"figure-{letter}.png")
239```
240
241Leave Python, then `view_image` each crop asking: every glyph legible
242against its background? smallest element still has a stroke or stub? leaders
243uncrossed? any two series colours confusable? legend beside what it keys?
244A visual defect that passed the collision scan is still a defect.
245
246**3. R output.** Prefer explicit `ggsave(filename, plot = p, dpi = 300,
247bg = "white", ...)` over the active device; for base graphics open
248`png(..., bg = "white", res = 300)`, draw, and always `dev.off()`. Then
249assert the file exists and is non-empty and inspect it — a "successful" R
250call with a missing, zero-byte, or blank file is a failed render.
251
252---
253*Defaults when unsure: fewer hues, direct labels over legends, raw data over
254summaries, and name the measurement before showing its result.*