Model Risk and Ground Truth
Demostatics’ premise is that models turn messy world inputs into numbers, and the four-line business then sells those numbers, advises on them, manages client capital against them and trades its own book on them. This page is about the one question nobody can currently answer: how would anyone know a Demostatics number is right?
Why this page exists
Section titled “Why this page exists”Worker Tier states the design plainly: the ML models and LLMs “are themselves specialized workers”. The data pipeline puts model inference in the middle of the path between a source and a published figure. That makes model output the firm’s core exposure rather than an engineering detail.
The four lines of business load that exposure differently:
| Line | What a wrong number costs |
|---|---|
| 1 — Sell data and risk intelligence | A refund, a churned subscriber, and an accuracy claim you can never make again |
| 2 — Investment advisory | A client acted on it. Under a licence, the firm must be able to reconstruct what it said and why |
| 3 — Discretionary management | Client capital moved on it. Same reconstruction duty, with a loss attached |
| 4 — Proprietary trading | The firm’s own money. No client harm, but no external check either |
Nothing is wrong today because nothing is running. There are no users, no revenue and no deployed instance. Every item on this page is a cost-now-versus-cost-later question, and most of them are contract or schema decisions — the kind that get much more expensive after the first row is published and the first client has parsed it.
There is no ground truth
Section titled “There is no ground truth”Ground truth is different from validation. Validation is a process; ground truth is the measuring instrument that process needs. The platform has neither, but the instrument is the one that has to come first.
No repository contains code that reads an external reference and compares it to a stored value.
There is no fixtures directory, no golden dataset, and no expected column, table or file in
demostatics-web/database/. The only values in the product are seven hand-written strings in
database/seeders/DatabaseItemSeeder.php ('48,210 MW', '62.4 USD/MWh', '0.734', and four
more) plus the hand-written rows in ReportSeeder.php.
Status: Not built, in the strict sense — not badly done, not attempted.
What “ground truth” has to mean concretely
Section titled “What “ground truth” has to mean concretely”Four pieces, per series. None of them requires the worker tier to exist.
| Piece | What it is | Cheapest form |
|---|---|---|
| A reference source | The authority whose figure you accept as correct for this series | A named URL and a fetch cadence, written down per series |
| A sampled comparison | A recurring job that pulls both your figure and the reference for the same period and stores the pair | One table: series, period, our value, reference value, both timestamps |
| An error metric | The statistic you report — mean absolute percentage error, hit rate, whatever the series justifies | One column computed from the pair table |
| A published figure | The number you put in front of a buyer, with its sample size and window | One page, refreshed on a schedule |
Several of the sources the founding document names publish figures that are themselves the authoritative answer for quantities Demostatics intends to derive. That is a gift: it makes the reference free for the series where it exists. Decide the reference per series while writing the minimum viable data set, not afterwards.
The commercial consequence is immediate and does not wait for a licence. The first question an institutional data buyer asks is “how accurate is it, and how do you know?” There is currently no answer and no way to produce one.
Nothing detects that a number is wrong
Section titled “Nothing detects that a number is wrong”There is no reconciliation against a second source, no outlier or bounds check, no
revision-versus-previous-value comparison, and no concept of a data-quality alarm anywhere.
Verified in demostatics-web:
app/Models/DatabaseItem.phpcontains only$fillable(eleven fields) and acasts()method returning onedatetimecast onmeasured_at. No validation, no observer, no mutator.app/Models/Report.phpis the same shape.- There is no ingest endpoint to validate at.
routes/api.phpexposesGET /api/v1/database-itemsand no write path into either table. valueis$table->string('value')->nullable()in the create migration. It holds a display string with the unit baked in.DatabaseItemResourcedocuments the reasoning — casting to a number in the resource “would silently destroy” the units — which is correct, and means the fix is a separate numeric column plus a unit reference, not a cast.- No test asserts anything about value plausibility. A case-insensitive grep for
outlier|anomal|plausib|thresholdacrossapp/,tests/anddatabase/returns one hit, in seeder prose. - The only writer pattern that exists is
updateOrCreatekeyed ontitle, so a row is overwritten in place with no history.
Because the value is a string, a wrong value is not type-detectable. A decimal-place error, a unit swap, or an LLM hallucination in the classification step lands in the table looking exactly like a correct value.
The failure that this permits
Section titled “The failure that this permits”A worker misparses a feed and writes '4,821,000 MW' where the truth was '48,210 MW'. The row
sorts to the top of /database by measured_at. The Database page renders it. The API serves
it. It is served identically to a subscriber, to an advisory call and to the firm’s own trading
desk, because there is exactly one value and no quality flag to distinguish who should trust it.
The first person to notice is a counterparty or a client. At that point the row has been overwritten in place, so the firm cannot show what the number was at the moment the decision was taken, or that it was ever different. A loss the firm cannot reconstruct is materially worse than a loss it can.
What to fix while it is still cheap
Section titled “What to fix while it is still cheap”The ingest endpoint does not exist yet, which is the good news — there is nothing to migrate.
The roadmap Phase 2 already plans metrics, metric_observations, metric_rollups
and a units table behind an ingest endpoint with an Idempotency-Key. Three additions to that
design, all cheap now and expensive later:
- A numeric
valueplus aunit_id, with the display string derived, not stored as the truth. - A plausibility bound per series — minimum, maximum, and maximum step between consecutive observations — checked at ingest, with breaches quarantined rather than published.
- Append-only observations. A revision is a new row, never an overwrite.
updateOrCreateontitlemust not survive into the ingest path.
The composite risk score
Section titled “The composite risk score”The desktop client shows a headline “Composite risk NN.N / 100” with a severity chip. It is the most product-like number the firm currently owns, and it has two problems — one predictable, one sharp.
What it actually computes
Section titled “What it actually computes”Read end to end across bin/demostatics/src/gui/risk.rs, crates/ds-analytics/src/risk_index.rs
and crates/ds-core/src/risk.rs:
per dataset vol = std / max(|mean|, std) * 100 bounded 0..100 % (a coefficient of variation, denominator floored at std so a near-zero mean cannot send it to infinity)
per category bucket every dataset under the top-level ancestor of each of its categories (a dataset in several categories feeds each bucket) mean_vol = mean(vol over the datasets in the bucket) weight = the NUMBER OF DATASETS in the bucket
normalise n = clamp(mean_vol / RISK_SATURATION, 0, 1) RISK_SATURATION = 20.0
compose score = ( Σ n·weight / Σ weight ) * 100 clamped 0..100
band >=80 critical · >=60 high · >=40 medium · >=20 low · else infoStated in one sentence, the headline number is: the dataset-count-weighted mean of per-category average coefficient of variation, saturating at 20 percent, over whichever series this session happened to receive.
Problem one: no methodology, and nothing that would catch it changing
Section titled “Problem one: no methodology, and nothing that would catch it changing”There is no methodology note, no reference and no derivation anywhere. A grep for
coefficient of variation, RISK_SATURATION or methodolog across the .md files of all five
repositories returns nothing outside .claude/agents boilerplate.
Every choice in the pipeline above is unjustified in the sense that no document justifies it:
- The weight is a data-availability artifact. A category counts for more because more series happen to be streaming in it, not because it matters more.
RISK_SATURATION = 20.0carries a docblock describing what it does and no sentence about why 20.- There is no time horizon, no statement of what the risk is of, and no loss it corresponds to. It is a volatility summary presented with the vocabulary of a risk model.
The eight tests behind it — six in ds-core/src/risk.rs, two in ds-analytics/src/risk_index.rs
— assert arithmetic identities: all-max is 100, zero weights is 0, weights of 3 and 1 give 75.
They verify that the formula computes what the formula computes. Nothing asserts that it measures
anything, and nothing would fail if the methodology changed tomorrow.
Problem two: the score depends on who is looking
Section titled “Problem two: the score depends on who is looking”This is the harder one, because it is not fixable by writing a document.
risk_pane composes over cx.datasets() — the set this session actually received — and the
received set is entitlement-filtered. In the reference server, geo_risk_index is pushed only
when entitlements.can(Feature::PremiumRisk) is true (examples/mock-stream-server/src/quic.rs
and main.rs both gate it). That indicator carries volatility: 0.9, the highest of any series
in the generator — the others range from 0.05 to 0.80 — and it belongs to two categories,
info and markets. So it raises both the mean volatility of those buckets and their weight.
A Pro subscriber therefore computes a structurally higher composite risk than an Individual subscriber from the identical world state.
Separately, rate_hint lets a client ask the server to slow the delta cadence
(/api/stream-contract/ documents it as connection-wide). Because the 256-sample history has no
time axis (below), a slower client’s window covers more wall-clock time, which changes its
coefficient of variation and its regression slope. Two clients, two numbers, both labelled
“Composite risk”, nothing in the UI or the contract saying so.
Decide before the number is published anywhere. Retrofitting a defensible methodology after publication means every historical score is a different quantity from every new one, and every screenshot, deck and report that used the old one has to be withdrawn. If the firm ever trades on its own composite score, it has traded on a number that depends on which machine ran it.
Uncertainty has nowhere to live
Section titled “Uncertainty has nowhere to live”Every number the platform delivers is a bare scalar:
| Contract | Fields | Uncertainty field |
|---|---|---|
ds-proto::frames::MetricTick | indicator, categories, value: f64 | none |
DatabaseItemResource | 12 fields — identity, geography, category, value, measured_at | none |
ds-analytics::regression::LinReg | slope, intercept, r2 | no standard errors |
This is distinct from the provenance gap, which is about where a number came from. This is about whether the number is a point estimate at all. A pipeline whose Processing stage is explicitly ML and LLM inference produces distributions, not scalars — a classifier’s confidence, an imputation’s variance, an extraction that may be a guess. Flattening all of it to one number at the contract boundary destroys the information at exactly the point where it is cheapest to keep.
The practical consequence: two subscribers cannot distinguish a figure corroborated by three independent sources from one an LLM inferred from a single news article. Every consumer is forced to treat every number as exact, because the contract gives them no other option.
Provenance is already scheduled for Phase 2 precisely because it is a column set now and a
backfill later. Uncertainty is the same hinge and is not on the list. Once
GET /api/v1/database-items is stable and clients parse it, adding confidence or interval is
a contract version, and every historical observation has an unrecoverable null there — because
nothing ever computed it.
Minimum viable version: a sample_count, a method, and a quality enum with three values. That
is enough to distinguish corroborated from inferred, and it costs three columns today.
Three places the client already overstates confidence
Section titled “Three places the client already overstates confidence”Smaller than the above, same direction, all in the desktop analytics path:
- Auto-fit picks by in-sample R².
best_fit_seriestries six families including cubic and takes the highest R², with no complexity penalty, no adjusted R², no AIC/BIC and no holdout. R² is monotonically non-decreasing in complexity, so the cubic usually wins on noisy data — and the chosen curve is then extrapolated and described in confident prose. - The fit is computed on visually-downsampled data.
indicator_fitruns the fit overds_charts::lttb(history, 64)whenever history exceeds 64 samples. LTTB is a perceptual algorithm that deliberately over-selects extrema; its output is not a statistical sample. The resultingkind · equation · R²string is then handed to the AI assistant asAiIndicator.fit, documented as an “authoritative summary”. - Polynomial fitting has no conditioning guard.
poly_fitsolves the normal equations on raw index powers with a< 1e-12absolute pivot threshold. At the default 256-sample window the cubic system is ill-conditioned rather than singular, so the guard never fires and the solver returns coefficients that have quietly lost significant digits. The failure mode is a wrong number, not an error.
None of these matter while there are no users. All of them become quotable claims the moment there are.
There is no time axis
Section titled “There is no time axis”MetricTick carries no timestamp. The Envelope that wraps it does carry one, and
crates/ds-stream/src/engine.rs never reads it — the Frame::Snapshot and Frame::Delta arms
call apply_tick and discard the envelope. IndicatorState.history is a Vec<f64>, documented
as “bounded recent history (oldest first)”. Ordering is arrival position, not event time.
Everything downstream is computed against that index: mean, sigma, the coefficient of variation
that feeds the risk score, the regression slope labelled per-frame, the fitted equation, the
composite score itself, and the correlation matrix. That is equivalent to a time series only if
ticks arrive at a perfectly uniform rate with no gaps — which the client itself knows is false,
because LiveState maintains a gaps counter for detected sequence skips that no statistic
consults.
The correlation table shows the failure most clearly. analysis.rs computes
pearson(&a[..len], &b[..len]) with len = a.len().min(b.len()) — the shortest prefix of two
front-dropping windows. An indicator that joined the stream late is correlated against an older
window of a long-running one, and the result is printed as r to two decimals.
An analyst on a flaky connection sees +0.87 between two series, screenshots it, and puts it in
a client note. The same panes on a clean connection show something different. Neither number is
wrong given its inputs; the inputs were never the same two time windows. Nothing in the product
could detect this, and no timestamp exists anywhere to reconstruct what was actually compared.
The design was available and was not wired up: schemas/proto/query.proto defines
message Point { int64 ts_ms = 1; double value = 2; }, and ds-transport’s grpc::backfill
client (crates/ds-transport/src/grpc.rs) is called only from the mock server’s own integration
test. Stream Contract v0.2 marks MetricTick Shipped without noting
the absence; that page should say so.
Adding a timestamp to MetricTick while the only consumer is the firm’s own client is a small
change. After a third party implements the contract, it is a version negotiation.
A minimum model risk framework
Section titled “A minimum model risk framework”Not a bank’s policy manual. Seven proportionate practices, sized for one engineer, most of which are a schema column or a written rule rather than a system.
| # | Practice | Concretely, here | Effort |
|---|---|---|---|
| 1 | Version every model and bind the version to the observation | method and method_version on the observation row — already named in the Phase 2 provenance list. The missing part is defining what a version is, who increments it, and which changes force one | days |
| 2 | Measure accuracy against a reference and publish it | Per series: reference source, sample cadence, error metric, current figure. See the ground-truth table above | weeks |
| 3 | Plausibility bounds per series, alarmed on breach | Min, max, and max step between observations. Checked at ingest; breaches quarantined, not published, and surfaced somewhere a human sees | days |
| 4 | Human review before a figure feeds an advisory call or an order | A maker/checker gate on the path from published figure to lines 2, 3 and 4. Does not need software on day one — a checklist and a signature is a control | days |
| 5 | Append-only publication record | What was published, when, under which method version. Revisions are new rows. This is what lets you answer “what did the number say on the day I acted on it” | days |
| 6 | No model output reaches a trade without a provenance chain to a source | A figure with no source id and no method version is not tradeable. Enforce as a written rule now, as a database constraint later | days |
| 7 | Record what the assistant said | Nothing currently persists an assistant exchange, the model identity, or the context that grounded it. local_reply already emits trading ideas; the cloud path streams a model’s text straight to the pane. A disclaimer string is the entire control | weeks |
Two things that make these controls real rather than decorative:
Change control over methodology. RISK_SATURATION, the 20/40/60/80 bands, the 64-sample
LTTB threshold and the 256-sample history default are ordinary constants in ordinary source
files. Changing any of them moves every displayed number with no record that anything moved. A
methodology changelog and a rule that methodology changes bump method_version closes this, and
costs one file plus discipline.
Exports must carry their own metadata. The dispute is never about the database; it is about the CSV a subscriber downloaded eighteen months ago. Desktop exports write a header row and data rows; the web exporter emits filtered rows. Neither records as-of time, build version, filters applied, or sample counts. An export envelope is a handful of extra lines and is what makes practice 5 usable from outside the building.
When each piece is needed
Section titled “When each piece is needed”Severity here follows the gap register: the first two rows are existential, the rest are high or medium.
| Item | Needed by | Why that point |
|---|---|---|
| Ground truth: reference per series, comparison, error metric | Before first customer (line 1) | It is the answer to the first question every data buyer asks, and it cannot be built retroactively |
| A model risk framework at all | Before first customer (line 1) | Without it a wrong model is indistinguishable from a right one from inside the system |
| Uncertainty fields in the output contracts | Now | Three columns today; a contract version and an unrecoverable null column later |
| Numeric value + unit, append-only observations, plausibility bounds | Before the ingest endpoint is written | There is nothing to migrate yet. That stops being true the day Phase 2 lands |
| Risk score: decide world-property or subscription-property | Before the number is published anywhere | Every score published under the old definition is a different quantity from every one after |
| Risk score methodology written down | Before first customer (line 1) | A client or a regulator will ask, and the honest current answer withdraws the number |
| Time axis on observations | Before first customer (line 1) | Small change while the firm owns the only client; a version negotiation afterwards |
| Detection that a produced number is wrong | Before a licence (lines 2 and 3) | A loss plus an unreconstructable file is how a licence gets pulled |
| Human review gate before advice or an order | Before a licence (lines 2 and 3) | This is the control a supervisor expects to see, and it is a checklist before it is software |
| Assistant transcripts, model identity, exportable artifacts | Before a licence (lines 2 and 3) | Client communications may have to be produced on request |
Jurisdiction is undecided and no supervisory regime has been chosen, so nothing above states a legal requirement — it states what the firm cannot answer without. What a specific regulator requires is a question for counsel; see Regulatory Posture.
Where this connects
Section titled “Where this connects”- The reference source for each series is chosen in Minimum Viable Data Set.
- The tier that will produce these numbers is Worker Tier, which is Not built — which is why the schema and contract decisions here are still free.
- The stages a figure passes through are in Data Pipeline.
- Sequencing against everything else is in What To Do Next.