An interactive report as data: prose, the figures it quotes, and the claims it rests on.
A story is a JSON document, not a component. It says what to compute, what to say about it, and what would have to be true for the saying to hold. resolveStory runs it against a real frame and returns everything a host needs to render — and nothing about how.
Which means the numbers in the finished report are computed from the data every time it is read, and a claim the data no longer supports says so, next to the paragraph that made it.
import { resolveStory } from '@gestaltbi/storybook';
const resolved = resolveStory(story, rows, {
columnDirectory: directory, // from @gestaltbi/stream
opContext: context, // needed only by pivot and correlate panels
locale: 'it',
currency: 'EUR',
});
resolved.status // 'pass' | 'warn' | 'fail' | 'none'
resolved.chapters // prose with the numbers already in it, panels, verdicts
The output carries no colours, no components and no markup. A host renders ResolvedStory however
it renders anything else, which is why the same story reads the same in a browser, a PDF job or a test.
Figures, and the {{token}} rule
Prose never contains a number. It contains a token naming a figure the chapter declares, and the
resolver substitutes the value it computed from the frame.
{
"id": "volume",
"title": "How much moved",
"figures": [
{ "id": "qty", "label": "Units sold", "measure": "sold_quant", "reduce": "sum", "format": "integer" }
],
"prose": ["Across the period the business shifted {{qty}} units."],
"takeaway": "Volume is the place to start."
}
This is the whole discipline of the format. A sentence written this way stays true when the filter
moves, when next month lands, and when somebody points it at a different period — because the author
committed to what to measure, never to what it came to.
reduce
what it takes
sum / avg / min / max
over every row in the frame
first / last
in the order of the story’s date column
delta
last minus first
growth
last over first, minus one
count
rows — the one reduce that needs no measure
Panels
What is drawn beside the prose. Five kinds, each resolved to plain data.
kind
renders
figures
the chapter’s own figures as metric cards
series
one row per period, one line or bar per measure
pivot
a cross-tab — dimensions down the side, dimensions across the top
format says how the cells should be printed — a rate rendered raw is 0.1881408827463219, which
is correct and unreadable. The host formats; the story only says what with, the same division of
labour as FigureSpec.format.
A report that can be contradicted
A narrative that its own data cannot argue with is marketing. Chapters carry
checks from @gestaltbi/stream; they run every time the story
is resolved, and a failure is reported next to the paragraph that made the claim.
A chapter's status is the worst verdict in it, and the story's is the worst of its chapters —
none when it asserts nothing at all. In the published Everpix report two checks fail on purpose:
revenue never covered AWS, and the accrual margin was never positive. That is the report proving its
own thesis rather than asserting it.
Fitting a story to a dataset
A story is written against one dataset's vocabulary. Pointing it at another does not throw — figures
come back absent, checks skip — but the result is a page of em dashes, which reads like a bug rather
than a mismatch. Ask first.
import { missingColumns } from '@gestaltbi/storybook';
const missing = missingColumns(story, declaredColumns);
if (missing.length) {
// "This story was written for a different dataset", and name them.
}
requiredColumns walks figures, every panel kind and every check, so it sees a column named in a
pivot's denominator as readily as one in a paragraph.
Where stories come from
This package ships none. A story belongs with the data it reads, so a GestaltBI config repo keeps
story.json beside processing.json and owns its narrative outright — see
Kickstarter Intelligence for a hand-written one.
@gestaltbi/inference writes them too: composeStory asks a
model for this format, grounds it against the dataset profile, and hands back a Story ready to
resolve. The model chooses what to compute and never learns what it came to — which is the same
{{token}} rule, enforced from the other side.
Reference
Extracted from src/index.ts when this page was built, so it
cannot drift from what the package actually exports.
function formatFigure(value: number | null, format: FigureFormat = 'number', o: FormatOptions = {}): string
Turn a number into the string a sentence can carry.
Deliberately conservative: a story is read, not audited, so figures round to what a person would say out loud. The unrounded value stays on the figure for anything that needs it.
A story is written against one dataset's vocabulary. Pointing it at another one does not throw — figures come back absent and checks skip — but the result is a report full of em dashes, which reads like a bug. A host can ask this first and say "this story was written for a different dataset" instead.
function resolveStory(story: Story, rows: any[], o: ResolveOptions = {}): ResolvedStory
Run a story against a frame.
The output is everything a host needs to render and nothing about how: no colours, no components, no markup. A narrative is a sequence of claims, the figures behind them, and the verdicts on whether the data still supports them — which is the part that survives the data changing underneath.
One idea: what it says, the numbers it quotes, what is drawn beside it, and the claims it rests on.
id: string
title: string
prose: string[]
Paragraphs. {{figureId}} is replaced with the formatted figure.
takeaway?: string
The one line the reader should leave with.
figures?: FigureSpec[]
Numbers this chapter computes; quotable from prose and takeaway.
panel?: Panel
checks?: Check[]
The claims the chapter rests on.
A narrative that cannot be contradicted by its own data is marketing. These run every time the story is resolved, and a failure is reported next to the paragraph that made the claim.
Everything needed to render one and nothing about how: the chapters in order, the process they read, and the column carrying time when the structure does not say.
id: string
title: string
subtitle?: string
standfirst?: string[]
Opening paragraphs, before the first chapter.
source?: string
Process name the chapters read from. The host resolves it.
date?: string
Column carrying time, when the structure does not say.