Skip to content

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?

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:

LineWhat a wrong number costs
1 — Sell data and risk intelligenceA refund, a churned subscriber, and an accuracy claim you can never make again
2 — Investment advisoryA client acted on it. Under a licence, the firm must be able to reconstruct what it said and why
3 — Discretionary managementClient capital moved on it. Same reconstruction duty, with a loss attached
4 — Proprietary tradingThe 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.

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.

PieceWhat it isCheapest form
A reference sourceThe authority whose figure you accept as correct for this seriesA named URL and a fetch cadence, written down per series
A sampled comparisonA recurring job that pulls both your figure and the reference for the same period and stores the pairOne table: series, period, our value, reference value, both timestamps
An error metricThe statistic you report — mean absolute percentage error, hit rate, whatever the series justifiesOne column computed from the pair table
A published figureThe number you put in front of a buyer, with its sample size and windowOne 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.

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.php contains only $fillable (eleven fields) and a casts() method returning one datetime cast on measured_at. No validation, no observer, no mutator. app/Models/Report.php is the same shape.
  • There is no ingest endpoint to validate at. routes/api.php exposes GET /api/v1/database-items and no write path into either table.
  • value is $table->string('value')->nullable() in the create migration. It holds a display string with the unit baked in. DatabaseItemResource documents 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|threshold across app/, tests/ and database/ returns one hit, in seeder prose.
  • The only writer pattern that exists is updateOrCreate keyed on title, 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.

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.

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:

  1. A numeric value plus a unit_id, with the display string derived, not stored as the truth.
  2. A plausibility bound per series — minimum, maximum, and maximum step between consecutive observations — checked at ingest, with breaches quarantined rather than published.
  3. Append-only observations. A revision is a new row, never an overwrite. updateOrCreate on title must not survive into the ingest path.

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.

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 info

Stated 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.0 carries 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.

Every number the platform delivers is a bare scalar:

ContractFieldsUncertainty field
ds-proto::frames::MetricTickindicator, categories, value: f64none
DatabaseItemResource12 fields — identity, geography, category, value, measured_atnone
ds-analytics::regression::LinRegslope, intercept, r2no 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_series tries 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_fit runs the fit over ds_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 resulting kind · equation · R² string is then handed to the AI assistant as AiIndicator.fit, documented as an “authoritative summary”.
  • Polynomial fitting has no conditioning guard. poly_fit solves the normal equations on raw index powers with a < 1e-12 absolute 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.

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.

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.

#PracticeConcretely, hereEffort
1Version every model and bind the version to the observationmethod 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 onedays
2Measure accuracy against a reference and publish itPer series: reference source, sample cadence, error metric, current figure. See the ground-truth table aboveweeks
3Plausibility bounds per series, alarmed on breachMin, max, and max step between observations. Checked at ingest; breaches quarantined, not published, and surfaced somewhere a human seesdays
4Human review before a figure feeds an advisory call or an orderA 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 controldays
5Append-only publication recordWhat 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
6No model output reaches a trade without a provenance chain to a sourceA figure with no source id and no method version is not tradeable. Enforce as a written rule now, as a database constraint laterdays
7Record what the assistant saidNothing 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 controlweeks

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.

Severity here follows the gap register: the first two rows are existential, the rest are high or medium.

ItemNeeded byWhy that point
Ground truth: reference per series, comparison, error metricBefore 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 allBefore first customer (line 1)Without it a wrong model is indistinguishable from a right one from inside the system
Uncertainty fields in the output contractsNowThree columns today; a contract version and an unrecoverable null column later
Numeric value + unit, append-only observations, plausibility boundsBefore the ingest endpoint is writtenThere is nothing to migrate yet. That stops being true the day Phase 2 lands
Risk score: decide world-property or subscription-propertyBefore the number is published anywhereEvery score published under the old definition is a different quantity from every one after
Risk score methodology written downBefore first customer (line 1)A client or a regulator will ask, and the honest current answer withdraws the number
Time axis on observationsBefore first customer (line 1)Small change while the firm owns the only client; a version negotiation afterwards
Detection that a produced number is wrongBefore 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 orderBefore 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 artifactsBefore 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.

  • 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.