Forecasting Must Have Variables
Forecasting Model — Must-Have Variables
Section titled “Forecasting Model — Must-Have Variables”Question answered: What data must exist for a disease/indication before the forecasting model can produce a correct forecast — for Solid Tumor and for Hematology?
Use this for data gathering. Every claim below was verified against code in both repos (bioloupe-data-gov and bioloupe-forecasting). The bioloupe-data-gov readiness gate was re-verified on 2026-06-05 — the displayName / type-value hardening (see Gate alignment) — and again on 2026-06-16, when the per-line category requirement was dropped from the gate because the forecasting client derives category structurally (see category is client-derived, not gated), and again on 2026-06-17, when the per-line displayName requirement was dropped because the client falls back to a non-blank derived line name whenever no curated row is stored, so a missing displayName can no longer blank a line — though a stored displayName stays an optional curated label (see displayName is not gated, but stays a curated label); the bioloupe-forecasting engine behavior was verified on 2026-05-28; the Tier-1 treatmentRate rule was aligned to the model on 2026-05-29. Code is the source of truth, not the prose. file:line evidence is cited throughout so it can be re-checked.
⚠️ Read this first — the gate now enforces a correct forecast
Section titled “⚠️ Read this first — the gate now enforces a correct forecast”The server’s ReadinessChecker is the single gate deciding which diseases the forecasting tool may show and forecast. It enforces every field the engine needs for a correct forecast — not just the ones that make a disease appear:
- the 3 common disease-level variables,
- the type-specific stage / funnel variables, and
- a
typevalue that is actually recognized ("Solid Tumor"or"Hematology").
If any is missing — or the type value is unrecognized — the disease is hidden from the forecasting tool’s indication dropdown. Safe: the user never sees it. This closes the previously-documented gap where a disease could pass readiness yet silently forecast empty or structurally wrong.
categoryis no longer in this list. It was gate-enforced from 2026-06-05 until 2026-06-16, when it was dropped: the forecasting client derives each line’scategorystructurally from the line id (deriveLineCategory), so a storedcategoryrow is redundant and a missing/wrong one can no longer break a forecast. Seecategoryis client-derived, not gated.
displayNameis no longer in this list either. It was gate-enforced from 2026-06-05 until 2026-06-17, when it was dropped: the forecasting client falls back to a name derived from the line id (deriveLineDisplayName) whenever no curated row is stored, so a missingdisplayNamerow can no longer blank a line or empty a forecast. A storeddisplayNamerow is still honored as a curated label — it is simply no longer required. SeedisplayNameis not gated, but stays a curated label.
One residual gap remains, and it is NOT in this repo: loading a saved ForecastingModel / scenario after its underlying data has degraded restores the stale indication without re-checking readiness. That path lives entirely in the bioloupe-forecasting client — see Recommended hardening and the self-contained handoff docs/forecasting-saved-model-readiness-followup.md.
The gate-enforced must-haves
Section titled “The gate-enforced must-haves”Single source of truth: ForecastingStatistics::ReadinessChecker::KEY_DATA_REQUIREMENTS (app/services/forecasting_statistics/readiness_checker.rb:25-66). There is no client-side copy of this list: the admin readiness banner renders the server’s computed missing/score from GET /api/forecasting/statistics/readiness (app/javascript/bundles/Statistics/components/MissingDataBanner.jsx), and the forecasting client gates purely by consuming the rows GET /api/forecasting/statistics returns. So this Ruby constant is the one place the gate is defined.
A disease is “ready” only when its guidance rows (is_guidance: true), resolved through the forecasting client’s geo fallback cascade (USA → EU5 → Japan), cover the full checklist for its type. The counts (12 / 7) are simply the checklist lengths and are the same denominators the admin banner shows (e.g. 9/12 essentials).
Common — required for both types (3, all disease-level)
Section titled “Common — required for both types (3, all disease-level)”| Variable | Label | Meaning |
|---|---|---|
type | Disease Type | Not data-entered — resolved from the forecasting_indications table (disease_type column) by indication label (ForecastingIndication.type_for_label). An unclassified label fails the gate; a classified one resolves to "Solid Tumor" or "Hematology". See the type-classification rule. |
incidenceBase | Incidence Base | New cases per year (count). |
incidenceGrowth | Incidence Growth Rate | Annual incidence growth, as a percentage (e.g. 1.2 = 1.2%/yr). |
typeis classified, not gathered (Decision C, 2026-06-16). It is no longer a storedForecastingStatisticrow. The server resolves it from theforecasting_indicationstable (disease_typecolumn, viaForecastingIndication.type_for_label) and the public/statisticsfeed synthesizes thetyperow per ready indication; operators neither enter nor curate it (it was removed from the admin form). Sotypestill counts as a requirement — an unclassified label is not ready — but it is satisfied by the taxonomy classification, mirroring howcategoryis client-derived.
Solid Tumor — 12 total (3 common + 9 type-specific)
Section titled “Solid Tumor — 12 total (3 common + 9 type-specific)”Solid tumors are modeled with a stage split (early vs. metastatic) plus relapse dynamics, feeding a 5-line funnel (early, met1, met2, met3, met4). (forecasting.ts:748,769-773 — isHematology === false runs the early+metastatic passes.)
Disease-level (7 = 3 common + 4 stage): type, incidenceBase, incidenceGrowth, earlyStagePercent, metStagePercent, earlyToEarlyRelapse, earlyToMetRelapse
Line-level (5 = 3 transition + 2 DTR):
| Variable | Line | Label | Gated on |
|---|---|---|---|
transitionRate | met2 | 1L → 2L Transition Rate | presence |
transitionRate | met3 | 2L → 3L Transition Rate | presence |
transitionRate | met4 | 3L → 4L Transition Rate | presence |
treatmentRate | early | Early Stage Treatment Rate | presence |
treatmentRate | met1 | Met 1L Treatment Rate | presence |
treatmentRate(DTR) is required only on the first line of each stage (early+met1). The model applies DTR only at stage entry points and skips it onmet2/met3/met4(factor = 1) — see why.Neither
displayNamenorcategoryis gated.categoryis fully client-derived from the line id (deriveLineCategory), so no stored row is needed.displayNamefalls back to a name derived from the line id (deriveLineDisplayName) when none is stored, so it too is never required — but a storeddisplayNameis still honored as a curated label (seedisplayNameis not gated, but stays a curated label).
Hematology — 7 total (3 common + 4 type-specific)
Section titled “Hematology — 7 total (3 common + 4 type-specific)”Hematology is modeled without a stage split (no early/met segmentation, no relapse) — a 4-line funnel (line1–line4). (forecasting.ts:769-770 — isHematology === true runs only the therapy pass.)
Disease-level (3): the common set only.
Line-level (4 = 3 transition + 1 DTR):
| Variable | Line | Label | Gated on |
|---|---|---|---|
transitionRate | line2 | 1L → 2L Transition Rate | presence |
transitionRate | line3 | 2L → 3L Transition Rate | presence |
transitionRate | line4 | 3L → 4L Transition Rate | presence |
treatmentRate | line1 | 1st Line Treatment Rate | presence |
treatmentRate(DTR) is required only on the first therapy line (line1) — the model skips DTR online2/line3/line4. Same rule as solid tumors. NeithercategorynordisplayNameis gated for heme (nor for any type): the client derivesline{N}→"therapy"(deriveLineCategory) structurally, and falls back to a derived line name (deriveLineDisplayName→"1st Line"/"2nd Line"/ …) when no curateddisplayNameis stored — so neither is required, though a storeddisplayNameis still honored as a curated label.
Per-disease exception — Multiple Myeloma carries a 5th line. MM is the only indication gated beyond the 4-line heme norm: it adds a
transitionRateonline5(4L → 5L Transition Rate, presence), taking its gate total to 8 (7 + 1). Every other hematology disease stays ready online1–line4and is never gated on a 5th line (it may still render one if aline5row exists). Single source:DISEASE_SPECIFIC_REQUIREMENTS(readiness_checker.rb);line5’sdisplayNameis not gated either. SeeCONTEXT.md— “line5(4L → 5L, Multiple Myeloma only)”.
Why the first line needs no transition rate — and why DTR is first-line-only
Section titled “Why the first line needs no transition rate — and why DTR is first-line-only”Two different rules, both grounded in the funnel math:
transitionRateis inbound — the fraction of the prior line’s pool that flows into this line, stored on the destination line. The first line of each funnel is a stage entry point (fed by the incidence/stage split, not a prior line), so it has no inbound transition. The first line usesbaseAddressablewith no transition factor; later lines multiply bytransitionRate/100— on the display path incalculateLineFunnelForDisplay(its first-line/later transition branch) and on the time-series path incomputeLinePatients’s cascade carry, both inbioloupe-forecasting’sforecasting.ts. → required only onmet2/met3/met4(solid) andline2/line3/line4(heme). The producer admin enforces this with a destination-keyed transition picker (1L → 2L …, no first-line option) plus a model guard rejecting atransitionRateonline1/early/met1; seeCONTEXT.md.treatmentRate(DTR) is applied only at the first line of each stage — DTR has meaning where patients enter the funnel from an addressable pool; on later lines they’re already under treatment, so the model hides DTR in the UI and skips it in computation (factor = 1). Predicate:isDTRApplicable; the gated DTR factor (trFactor) is computed on the display path incalculateLineFunnelForDisplay, and the same predicate gates the DTR step on the time-series path incomputeLinePatients— both inbioloupe-forecasting’sforecasting.ts. This was the deliberate 2026-04-16 “hide DTR on non-first lines” change. → required only onearly+met1(solid) andline1(heme).
Gate alignment (2026-05-29): the readiness gate originally required
treatmentRateon every line — over-requiring the later-line DTRs the model discards. It was corrected to require DTR only on first-of-stage lines, matchingisDTRApplicable. That took the Tier-1 counts to 12 (solid) / 7 (heme), down from 15 / 10.
Gate alignment (2026-06-05): the gate was hardened to enforce the fields the engine silently needs for a correct forecast: a per-line
displayName(presence) and a per-solid-linecategory(value-checked, resolve-first through the cascade), plus validation that thetypevalue is inRECOGNIZED_TYPES. This took the counts to 22 (solid) / 11 (heme), up from 12 / 7.Gate alignment (2026-06-16): the per-solid-line
categoryrequirement was removed. The forecasting client derives each line’scategorystructurally from the line id (deriveLineCategory:early→early,met{N}→metastatic, else→therapy), so a storedcategoryrow is redundant and value-gating it gated data the client never reads. This took solid tumor back to 17 (heme unchanged at 11). Zero regression: every indication already carrying forecasting data stays ready, andcategoryis now guaranteed correct by derivation.Gate alignment (2026-06-17): the per-line
displayNamerequirement was removed. The forecasting client falls back to a name derived from the line id (deriveLineDisplayName:early→"Early Stage",met{N}→"Metastatic Line {N}",line{N}→"{ordinal} Line") whenever no curated row is stored, so an absentdisplayNamecan no longer blank a line — the gate was presence-checking a field the client can always supply a non-blank fallback for, so requiring it added no safety. This took solid tumor to 12 and hematology to 7 (from 17 / 11). Zero regression: the derived name is non-blank for every line id, so no line is dropped and no forecast goes empty. Note this differs fromcategory: a storeddisplayNameis not redundant — curated rows carry clinically meaningful labels the generic fallback cannot reproduce (e.g. SCLC Limited Stage/Extensive Stage, GBM Newly-Diagnosed/Recurrent) — so they remain in the data as an optional curated override; they are just no longer gated.
Why displayName and category are not gated
Section titled “Why displayName and category are not gated”Both displayName and category were briefly gate-enforced (2026-06-05) and then dropped — category on 2026-06-16, displayName on 2026-06-17. They differ in why: category is structurally derived (uniform per line id), so a stored row is genuinely redundant; displayName is derived only as a fallback, so a stored row is never required but stays a meaningful curated label. They are called out here because neither reason is obvious from the requirement list.
displayName is not gated, but stays a curated label
Section titled “displayName is not gated, but stays a curated label”- When no curated row is stored, the client derives a line’s display name from its id:
deriveLineDisplayName(bioloupe-forecastingline-metadata.ts:48-58) returnsearly→"Early Stage",met{N}→"Metastatic Line {N}",line{N}→"{ordinal} Line", and the raw id for anything else — non-blank for every funnel line id. - The disease-config path uses a stored
displayNamerow only as an override-with-fallback:deriveDiseaseConfigtakes the curated row when present and falls back toderiveLineDisplayName(lineId)when none is stored (reference-query.ts:443-444). A line therefore always carries a non-blankdisplayName. - The instance/snapshot path seeds each line’s
lineNamerow from that config value (init-flows.ts:466-467), and the line materializer drops only lines whose name is blank (selectors/lines.ts:150-151—if (!lineName) continue;). Because the derived name is never blank, no line is dropped. - Why it’s no longer gated (2026-06-17): the engine can no longer end up with a nameless line —
deriveLineDisplayNameguarantees a non-blank fallback — so the silent empty-forecast failure the gate once guarded against cannot occur, with or without a stored row. Removing the gate is zero-regression and lets indications without curateddisplayNamerows enter the feed. A storeddisplayNamestill wins over the derived fallback (reference-query.ts:443-444), and curated rows carry clinically meaningful reference labels the generic fallback cannot reproduce (e.g. SCLC Limited Stage/Extensive Stage, GBM Newly-Diagnosed/Recurrent with a line-id offset, prostate mCRPC, Merkel IO regimen, melanoma Adjuvant) — so they stay in the data as an optional curated override, not deleted. Operators are never required to enter adisplayName. (Runtime end-user line renaming applies only to user-added custom lines,LinePanel.tsx:582-599; reference lines are read-only, so these curated rows are the sole source of those clinical labels.)
category is client-derived, not gated
Section titled “category is client-derived, not gated”- The client derives each line’s
categorystructurally from its id:deriveLineCategory(bioloupe-forecastingline-metadata.ts:34-38) returnsearly→"early",met{N}→"metastatic", anything else (line{N})→"therapy".categoryis uniform per line id across all diseases, so the derivation is behavior-preserving for real producer data. - The disease-config path derives it unconditionally —
deriveDiseaseConfigcallsderiveLineCategory(lineId)and no longer reads a stored row (reference-query.ts:438-440,447, comment: “drops the dependency on a stored category row”). The instance/snapshot path uses a storedcategoryrow only as an override-with-fallback (lines.ts:169-174:(stored category) ?? deriveLineCategory(id)), so a snapshot lacking the row still resolves correctly (bug #10). - The funnel builder still filters lines by exact category:
forecasting.ts:755—lines.filter((l) => l.category === category), running only theearlyandmetastaticpasses for a solid tumor (forecasting.ts:771-773), with addressable routingearly→earlyAddressable,metastatic→metAddressable,therapy→totalAddressable(forecasting.ts:688-704). The difference is that the right value is now guaranteed by derivation rather than by a gated stored value. - Why it’s no longer gated (2026-06-16): because the client always derives a correct
category, a stored row is redundant — the old gate value-checked data the client never reads. Removing it is zero-regression (the derivation matches what correct data carried) and lets indications that lack curatedcategoryrows enter the feed correctly. Hematology was never gated oncategory(its singletherapypass legitimately draws fromtotalAddressable); this change makes solid tumors consistent with that.
Solid Tumor vs. Hematology at a glance
Section titled “Solid Tumor vs. Hematology at a glance”| Solid Tumor | Hematology | |
|---|---|---|
| Common disease-level | type, incidenceBase, incidenceGrowth | type, incidenceBase, incidenceGrowth |
| Stage variables | earlyStagePercent, metStagePercent, earlyToEarlyRelapse, earlyToMetRelapse | none (no stage split) |
| Therapy lines | 5 (early, met1–met4) | 4 (line1–line4) |
transitionRate (gate) | met2, met3, met4 | line2, line3, line4 |
treatmentRate (gate) | early, met1 (first-of-stage only) | line1 (first line only) |
displayName per line | not gated — curated label, derived fallback (deriveLineDisplayName) | not gated — curated label, derived fallback (deriveLineDisplayName) |
category per line | client-derived, not gated (early→early, met{N}→metastatic) | client-derived, not gated (line{N}→therapy) |
| Gate total | 12 | 7 |
| Per-disease extra | none | Multiple Myeloma only: +transitionRate on line5 (4L → 5L) → MM gate total 8 |
How the system itself classifies a model: a scenario with stage rows (earlyStagePercent) is solidTumor, otherwise hematology (app/services/forecasting_model_summary.rb:29,36).
What happens when a variable is absent (engine defaults)
Section titled “What happens when a variable is absent (engine defaults)”Verified in bioloupe-forecasting deriveDiseaseConfig (in reference-query.ts) and the math engine. Every gated field below is gate-enforced, so its absence (or, for type, a wrong value) hides the disease instead of letting the engine run on the default — the right column is what would happen if the gate were bypassed. The exceptions are displayName and category, which are no longer gated: category is structurally derived, and displayName falls back to a derived name when no curated row is stored (see their rows).
| Field | If missing, the model… | Severity (now gate-blocked) |
|---|---|---|
type | returns null — no config built (the only return null in deriveDiseaseConfig — the if (!type) return null guard); an unrecognized value also fails the gate | Hidden by gate (would hard-stop — Configuration card spins forever, Configuration.tsx:154-165) |
incidenceBase | defaults to 0 (the incidenceBase resolver in deriveDiseaseConfig) → entire funnel is zero | Hidden by gate (would be a silent zero forecast) |
incidenceGrowth | defaults to 0.5%/yr (DEFAULT_INCIDENCE_GROWTH_RATE, constants.ts:110; applied in deriveDiseaseConfig) | Hidden by gate (would invent an assumption) |
earlyStagePercent (solid) | the stage-mix block is skipped (the incidenceConfig.stageMix block in deriveDiseaseConfig) → no early/met segmentation | Hidden by gate (would break the solid funnel) |
metStagePercent (solid) | defaults to 0 (the metStagePercent ?? 0 default in deriveDiseaseConfig) | Hidden by gate (would treat all incidence as early) |
earlyToEarlyRelapse / earlyToMetRelapse | default to 0 (the ?? 0 defaults in deriveDiseaseConfig’s stageMix block) | Hidden by gate (would zero out relapse flow) |
transitionRate (per line) | defaults to 100% (the transitionRate ?? 100 default in deriveDiseaseConfig) | Hidden by gate (would advance everyone) |
treatmentRate (first-of-stage line) | defaults to 100% (the treatmentRate ?? 100 default in reference-query.ts); on later lines the value is ignored (factor = 1, isDTRApplicable) | Hidden by gate (would treat everyone at stage entry) |
displayName (per line) | stored curated row wins; falls back to a name derived from the line id (deriveLineDisplayName) when absent → non-blank either way (applied in deriveDiseaseConfig) | Not gated — the derived fallback is non-blank, and a curated row remains an optional override (line-metadata.ts:48-58) |
category (per line) | client-derived from the line id (deriveLineCategory) → correct value with or without a stored row | Not gated — derivation is behavior-preserving (line-metadata.ts:34-38) |
Only type blocks engine execution outright; everything else “runs” but fabricates the answer from defaults. The gate covers the gated fields above — including the value of type; displayName and category need no gate because the client always resolves both (a derived category, and at least a non-blank derived fallback for displayName). The only silent-garbage path left is a saved-model reload (see below).
Why
incidenceGrowthstays gate-required even though it defaults: unlike later-linetreatmentRate(whose value the model discards),incidenceGrowthis consumed — the 0.5%/yr fallback silently substitutes a made-up growth rate across a multi-year projection. Keeping it required forces a real, sourced value. It is the one field that is both gate-required and safely-defaulted; that is intentional, not a waste-mismatch like the later-line DTRs that were removed.
Rules that decide whether a row counts
Section titled “Rules that decide whether a row counts”- Geo = USA is the cascade root, not a hard floor. Readiness starts at
geo = 'USA'(ReadinessChecker::GEO_FOR_READINESS,readiness_checker.rb:14) but resolves each field through the client’s geo fallback cascade (USA → EU5 → Japan,GEO_FALLBACK_CASCADE:18; applied incascade_scope:169-171). So a field present only under EU5 or Japan does count toward readiness — it still feeds the USA forecast, exactly as the client resolves it. (Real example: OPC, whosemet3/met4transition rates are EU5-sourced, is ready.) EU5/Japan data is therefore not merely optional. - Canonical rows only (
is_guidance: true). A disease with only alternative rows is not ready (readiness_checker.rb:171; testT5). - Existence, not recency. Only presence is checked;
yearis ignored (metadata). A value from any year satisfies the requirement. (categoryis no longer a value-checked exception — it was dropped from the gate on 2026-06-16; seecategoryis client-derived, not gated.) typeis classified from the label; an unclassified label fails the gate.typeis not a stored row — it is the solid/heme classification read from theforecasting_indicationstable (disease_typecolumn) keyed by the indication label (ForecastingIndication.type_for_label), checked againstRECOGNIZED_TYPES(['Solid Tumor', 'Hematology'],readiness_checker.rb:23) inmissing_for(readiness_checker.rb:143,147). A label outside the classification resolves the type-specific checklist to empty and fails the commontyperequirement, so the disease is hidden — it can no longer slip through “ready on the 3 common variables alone.” A stray storedtyperow is ignored, and the controller’stype_unknownflag andreadyagree by construction (statistics_controller.rb:59-60).- The API enforces the gate (with one carve-out).
GET /api/forecasting/statisticsreturns rows only forReadinessChecker-ready diseases (statistics_controller.rb:11) — plus population-marker rows (popBase/popGrowth) atindication = '', the only names allowed to pass at a blank indication (statistics_controller.rb:15-17); any other name atindication = ''is excluded (enforced by the model validationpopulation_markers_only_at_blank_indicationand the public-feed scope). The admin endpointGET /api/forecasting/admin/statisticsreturns everything regardless of readiness. - Computed live. There is no cached
forecasting_readycolumn — readiness is recomputed per request (db/structure.sqlis the authoritative schema; no such column exists).
Is the gate actually wired? (“does an incomplete indication stay hidden?”)
Section titled “Is the gate actually wired? (“does an incomplete indication stay hidden?”)”Mostly — the dropdown is genuinely gated; one residual path (saved-model reload) can still bypass it.
- Implemented (dropdown path): client fetches the gated endpoint (
useStatistics.ts:47), derives the indication list purely from those rows (indicationsAtom,selectors/geo-indication.ts:27-30), and the dropdown renders only that list (Forecasting.tsx:146-149,380-387). No hardcoded list, no URL/deep-link override, and the client computes no independent readiness — gating is purely a consequence of the server returning only ready rows. So an unready disease cannot appear in a fresh dropdown selection. ✅ - Residual leak (saved models / scenarios): loading a saved
ForecastingModel(or duplicating a scenario) restores the savedactiveIndicationandinstanceRowsverbatim (model-atoms.ts:119-121), validating only structural shape (FullSnapshotSchema) — never current readiness. If a disease that was ready at save time later loses required data, reloading resurrects the unready indication: the Model/Sales/Monte-Carlo sections render from stale snapshot rows while the Configuration card spins forever (livetypeis gone). This is the one remaining hole and lives in thebioloupe-forecastingclient — full diagnosis + fix direction indocs/forecasting-saved-model-readiness-followup.md. ⚠️
What is genuinely optional (safely defaulted or feature-gated)
Section titled “What is genuinely optional (safely defaulted or feature-gated)”| Not required | Why |
|---|---|
Biomarkers (biomarkerName / biomarkerPercentage / biomarkerTestingRate, bm:<slug> rows) | Disease-specific add-ons; not every cancer has them. |
Population (popBase, popGrowth) | Used only for per-capita normalization when a geo fallback fires; degrades to a raw copy if absent (reference-query.ts:331-362). Not read by the core forecast. |
healthcareAccess | Defaults to 95% (reference-query.ts:365). |
unknownStagePercent | Auto-derived as max(0, 100 − early − met) when missing/0 (reference-query.ts:395-398). |
Stage sub-splits (earlyStageLocalizedPercent, earlyStageLocallyAdvancedPercent) | Optional refinement; if both present, summed into earlyStagePercent (reference-query.ts:384-387). |
transplantEligible / transplant flow | Opt-in feature behind per-line UI toggles + a hardcoded indication allowlist; absence is a no-op (transplant.ts:54-59). |
monthsOfTherapy / compliance (per-line) | Sales-layer guidance — validated when present (monthsOfTherapy 0..600, compliance 0..100; forecasting_statistic.rb) but not gate-required. They affect the revenue layer only, not patient flow; the client seeds each per-line from a curated guidance reference row, falling back to 12 / 85 when absent (reference-query.ts deriveDiseaseConfig). |
Pricing (forecasting_pricings) | The patient-flow forecast runs without pricing (forecasting.ts:1151); only the revenue layer needs it. Out of scope for “the model runs.” |
displayName (all lines, both types) | The client derives a non-blank fallback name from the line id (deriveLineDisplayName); a stored row is an optional curated override, never required and not gated (dropped 2026-06-17). |
category (all lines, both types) | The client derives it structurally from the line id (deriveLineCategory); a stored row is redundant, so it is neither required nor gated (dropped 2026-06-16). |
Both
displayNameandcategoryare listed above because neither is a value an operator must supply —categoryis always derived, anddisplayNamefalls back to a derived name when absent. A storeddisplayNamerow acts as an optional curated override (and carries clinical labels the fallback cannot reproduce).
Recommended hardening (to make “ready” actually mean “forecastable”)
Section titled “Recommended hardening (to make “ready” actually mean “forecastable”)”The type-value validation is implemented in the gate (2026-06-05). The category and displayName requirements were added then too but removed on 2026-06-16 and 2026-06-17 respectively: category is structurally derived (its stored row is redundant), and displayName always has a non-blank derived fallback (so gating its presence added no safety, though a stored curated row is still honored). One gap remains, and it lives in the bioloupe-forecasting client, not here:
- Re-validate saved models on load: after restoring a scenario, check
activeIndicationagainst the current ready set (or callGET /api/forecasting/statistics/readiness) and clear/flag it if no longer ready, instead of silently restoring stale rows. Self-contained diagnosis, anchors, and fix direction:docs/forecasting-saved-model-readiness-followup.md.
Source of truth (verify / edit here)
Section titled “Source of truth (verify / edit here)”| Concern | Location |
|---|---|
| Gate-enforced must-have list | app/services/forecasting_statistics/readiness_checker.rb:25-66 (KEY_DATA_REQUIREMENTS) |
| Admin banner (server-driven, no client mirror) | app/javascript/bundles/Statistics/components/MissingDataBanner.jsx → fetches GET /api/forecasting/statistics/readiness |
| Recognized types | ReadinessChecker::RECOGNIZED_TYPES = ['Solid Tumor', 'Hematology'] (readiness_checker.rb:23) |
type classification (solid/heme by label) | readiness_checker.rb:143,147 (in missing_for) + the forecasting_indications table (disease_type column), via ForecastingIndication.type_for_label / .type_map (app/models/forecasting_indication.rb) |
category derivation (client) | bioloupe-forecasting → src/features/reports/forecasting/atoms/primitives/line-metadata.ts:34-38 (deriveLineCategory); applied in deriveDiseaseConfig + linesAtom (selectors/lines.ts) |
displayName derivation (client) | bioloupe-forecasting → src/features/reports/forecasting/atoms/primitives/line-metadata.ts:48-58 (deriveLineDisplayName); applied in deriveDiseaseConfig, seeded into the snapshot at actions/init-flows.ts:466-467 |
| Readiness tests | test/services/forecasting_statistics/readiness_checker_test.rb |
| Server gate / carve-out | app/controllers/api/forecasting/statistics_controller.rb:10,20 |
| Config builder + engine defaults | bioloupe-forecasting → deriveDiseaseConfig in src/features/reports/forecasting/atoms/primitives/reference-query.ts |
| Engine: line drop on blank name (never fires for derived names) | bioloupe-forecasting → src/features/reports/forecasting/atoms/selectors/lines.ts:150-151 |
| Engine: category → addressable routing | bioloupe-forecasting → src/features/forecasting/math/forecasting.ts:688-704,748-774 |
DTR applied first-of-stage only (why later-line treatmentRate isn’t gated) | bioloupe-forecasting → isDTRApplicable in forecasting.ts (gated trFactor factor in calculateLineFunnelForDisplay) |
| Dropdown gating chain | bioloupe-forecasting → useStatistics.ts:47 → selectors/geo-indication.ts:27-30 → Forecasting.tsx:146-149 |
| Saved-model bypass (hole c, follow-up) | bioloupe-forecasting → model-atoms.ts:119-121; see docs/forecasting-saved-model-readiness-followup.md |
| All variable names + labels | ForecastingStatistic::VARIABLE_NAMES (app/models/forecasting_statistic.rb:31) |
| Canonical disease/indication labels + acronyms | the forecasting_indications taxonomy table (indication column), seeded from SeedForecastingIndicationsThor::PAIRS (lib/tasks/one_off/seed_forecasting_indications.thor); full label → acronym list rendered in docs/forecasting-disease-acronyms.md |
| Design rationale (transition/line conventions) | CONTEXT.md (project glossary — transition-rate convention + Multiple Myeloma-only line5) |
Maintenance note:
KEY_DATA_REQUIREMENTS(readiness_checker.rb) is the single source of truth. The admin banner renders the server’s computedmissing/scorefromGET /api/forecasting/statistics/readiness, and the forecasting client gates only by consuming the rowsGET /api/forecasting/statisticsreturns — neither keeps an independent requirement list, so a gate change needs no client edit.