Skip to content

Forecasting Model

Pharmaceutical forecasting domain model: patient flow, addressable population, market share, revenue calculations, and Monte Carlo simulation.

For technical architecture (Jotai state, React Query, component patterns), see ARCHITECTURE.md.

  1. Introduction & Model Overview
  2. Patient Flow Model
  3. Addressable Population Calculations
  4. Market Share Model
  5. Revenue/Sales Calculations
  6. Monte Carlo Simulation
  7. Data Inputs Reference

The Bioloupe forecasting model predicts pharmaceutical sales revenue by modeling the patient journey from disease incidence through treatment and calculating market share capture across multiple therapy lines.

flowchart TB
    A[Annual Incidence] --> B[Addressable Population]
    B --> C[Drug Treated Patients]
    C --> D[Market Share Applied]
    D --> E[New Patients per Therapy Line]
    E --> F[Revenue Calculation]
    F --> G[Total Sales Forecast]
ConceptDescription
IncidenceNew disease cases per year in a population
Addressable PopulationPatients who can potentially receive treatment
Therapy LineSequential treatment stage (1L, 2L, 3L+)
Peak ShareMaximum market share a product can achieve
Loss of Exclusivity (LoE)When patent protection ends and generics enter
Class SharePercentage of patients suitable for the therapy class (adjusts Peak Share)

The model tracks patients through a funnel from initial diagnosis to treatment across multiple therapy lines.

flowchart TD
    subgraph Incidence["Disease Incidence"]
        I[Base Incidence] --> EVO[Incidence Evolution Array<br/>compound growth per year]
    end

    subgraph StageSplit["Stage Split (Solid Tumors Only)"]
        EVO --> |Solid Tumor| ES[Early Stage %]
        EVO --> |Solid Tumor| MS[Metastatic %]
        EVO --> |Hematology| TH[Full Incidence]
        ES --> E2E[+ Early to Early Relapse %]
        ES --> E2M[Early to Met Relapse %]
        E2M --> MS
    end

    subgraph Addressable["Addressable Population (1L)"]
        E2E --> BF1["x Biomarker Factor"]
        BF1 --> EA["Early Addressable x HC%"]
        MS --> BF2["x Biomarker Factor"]
        BF2 --> MA["Met Addressable x HC%"]
        TH --> BF3["x Biomarker Factor"]
        BF3 --> TA["Therapy Addressable x HC%"]
    end

The model handles two main disease categories differently:

CategoryStage Split
Solid TumorsYes — Early Stage + Metastatic
HematologyNo — Full incidence flows to therapy

Disease type is 'Solid Tumor' | 'Hematology' (see DiseaseConfig in src/core/types/types.ts). Specific indications are configured via reference data from the API.

Solid Tumors split into Early Stage and Metastatic populations. Early-stage patients may relapse into the metastatic pool. Hematology cancers are systemic from diagnosis, so all patients flow directly to therapy lines without stage separation.

Formula difference:

Solid Tumor Met Addressable = (Incidence x Met%) + (Incidence x Early% x Relapse%)
Hematology Addressable = Incidence x Healthcare Access%

HA is the scenario-level value from the SummaryPanel, applied once at the top of the patient flow.

flowchart TD
    A[1L Addressable Population] --> |Treatment Rate %| B[1L Drug Treated Patients]
    B --> |Market Share %| C[1L New Patients on Product]

    B --> |Pool minus Retreatment| D[Available for 2L]
    D --> |Transition Rate %| E[2L Drug Treated Patients]
    E --> |Market Share %| F[2L New Patients on Product]

    E --> |Pool minus Retreatment| G[Available for 3L]
    G --> |Transition Rate %| H[3L Drug Treated Patients]
    H --> |Market Share %| I[3L New Patients on Product]

Key Transition Parameters:

  • Drug Treatment Rate: Percentage of eligible patients who receive any treatment (applied at stage entry only — see Important note below)
  • Transition Rate: Percentage of patients who move from one therapy line to the next
  • Retreatment Factor: Patients staying on current therapy (reduces pool for next line)
  • Re-Treatment from Prior Lines Toggle (retreatmentOption, per-line UI): OFF (default) — drug-exposed patients are excluded from subsequent-line pools (pool − newPatients × 1). ON — drug-exposed patients remain in subsequent-line pools and can be captured again by the product’s market share (pool − 0).

Important: Drug Treatment Rate applies at the funnel entry point of each stage group only — the early line, the first metastatic line (Met1), and the first therapy line (hematology 1L). Custom lines participate in their effective stage group (stageCategory ?? "therapy") and are applicable only when they are the first member of that group by array order; in practice that means custom lines appended after a reference line are non-applicable. For non-first lines the UI hides the Drug Treatment Rate field and shows an inline note pointing back at the source line, and the compute/report/MC/Tornado paths skip DTR (factor = 1); transitioned patients are considered eligible. Gated by isDTRApplicable() in src/core/math/forecasting.ts.


Some cancer indications have biomarker-defined sub-populations — subsets of patients who test positive for a specific biomarker. For example, Oropharyngeal Cancer has an HPV Positive sub-population representing ~68% of cases.

When a biomarker filter is active, the model applies a funnel multiplier to each therapy line’s addressable population. The filter is toggleable per line — each line independently forecasts either the biomarker sub-population (toggle ON) or the full population (toggle OFF). Biomarker does not modify incidence — it filters within the patient flow funnel.

biomarkerFactor = (biomarkerPercentage / 100) x (biomarkerTestingRate / 100) [when active]
= 1.0 [when inactive]

Applied in the funnel:

Addressable Population (incidence + relapse -- unchanged by biomarker)
|
x biomarkerFactor <- applied per line (each line's own ON/OFF toggle)
|
x Healthcare Access %
|
x Neo/Adj Factor (if applicable)
|
x Drug Treatment Rate
|
= Drug Treated Patients
  • Biomarker Percentage: prevalence of the biomarker in the disease population (e.g., 68%)
  • Testing Rate: percentage of patients actually tested for this biomarker (e.g., 93%)

Example: HPV Positive, Oropharyngeal Cancer (USA)

Section titled “Example: HPV Positive, Oropharyngeal Cancer (USA)”
ParameterValue
Addressable Population15,004
HPV Positive Prevalence68%
HPV Testing Rate93%
biomarkerFactor0.68 x 0.93 = 0.6324
Filtered Addressable15,004 x 0.6324 = 9,489
  • Only one biomarker can be active per indication at a time. The biomarker definition (prevalence, testing rate, growth) is keyed per stage (bm:{id}:{stage} rows), so each stage’s early/metastatic definition is independent; non-stage-first lines can additionally override the prevalence/testing-rate per line, and every line — including the stage-first line — has its own on/off state
  • Per-line toggle: every therapy line (including the stage-first line) independently includes or excludes the biomarker sub-population. The funnel walks a structural cascade (full population — transition, DTR, neo/adj, custom) and applies each line’s biomarker × transplant scalar only to that line’s own output. The cascade is linear, so lineForecast = structural × biomarker × transplant — all-ON reproduces the legacy single-biomarker numbers exactly
  • Per-line enable on every line (T13, 2026-05-26; superseded 2026-06-21 — per-line enable): the stage-first line (the first SELECTED line of its stage — dynamic, so if the disease-config-first line is deselected, the next selected line takes over) still DEFINES the biomarker type/prevalence/testing-rate for its stage, but no longer force-applies it. Every line — including the stage-first line — honors its own per-line biomarkerEnabled flag and exposes the Apply switch in the card header. While a biomarker is active the card renders on every line — definition mode on the stage-first line and override mode on non-stage-first lines; the per-line on/off Switch lives in the card header on every line (it moved out of InheritedFiltersPanel in T12, which now carries only the transplant + upstream-custom apply Switches). A stored biomarkerEnabled === false on a stage-first line is now HONORED (the prior T13 behavior force-applied and ignored it). No data migration is needed — the flag stays in the persisted model. One data-visible consequence: on a saved model, deselecting the stage-first line to promote a downstream line that carries a stored biomarkerEnabled=false now drops the biomarker on that stage (the old force-apply ignored the flag)
  • Every line (including the stage-first line) defaults to ON when a biomarker is active; toggling a line OFF sets its biomarker factor to 1.0 (no filtering for that line)
  • Backward compatibility: a saved model with no stored per-line flag defaults every line to ON (biomarkerEnabled ?? true)
  • Incidence count is never modified — biomarker is a derived per-line multiplier resolved through biomarkerByLineAtomresolveBiomarkerFactorForLine (the scalar biomarkerFactorAtom is a vestigial fallback)

Key files:

  • Atoms: biomarkerFactorAtom (vestigial scalar fallback), activeBiomarkerAtom in src/core/state/selectors/biomarker.ts; in src/core/state/actions/growth-config.ts: toggleBiomarkerAtom, the cascade aggregator biomarkerByLineAtom, the per-stage definition writers setBiomarkerFieldAtom/resetBiomarkerFieldAtom (keyed by stage), the per-line override writers setLineBiomarkerFieldAtom/resetLineBiomarkerFieldAtom, and the per-line on/off setLineBiomarkerEnabledAtom
  • Type: per-line UnifiedLineData.biomarkerEnabled (optional, default true) in src/core/types/types.ts
  • Computation: each line’s biomarker factor is resolved per-line/per-stage via resolveBiomarkerFactorForLine(lineId, year, biomarkerByLine, fallback), then gated by biomarkerScalarForLine(line, biomarkerFactor) / applyPerLineFilters in src/core/math/forecasting.ts, applied inside calculateLineFunnelForDisplay and calculateLinePatients (the per-line walk shared by Model/Sales/MC/Tornado after the 21c7426 shadow-implementation removal). The gate is now purely per-line (line.biomarkerEnabled === false ? 1 : biomarkerFactor) and no longer takes an isStageFirstLine argument (dropped 2026-06-21). isStageFirstSelectedLine(line, lines) remains the single canonical stage-first check, exported from core/math/forecasting.ts and reused by core/state/selectors/lines.ts (transplant inheritance) and LinePanel.tsx (UI gating)
  • UI: FirstLineExtras component in LinePanel.tsx is mounted on every line and renders the biomarker card when showBiomarker && (isStageFirstLine || isBiomarkerActive) — definition mode on stage-first lines, override mode on non-stage-first lines (which write per-line overrides). The per-line on/off Apply Switch now renders in the card header on every line including stage-first (gated on activeBm, not in InheritedFiltersPanel). isStageFirstLine is selection-aware (via isStageFirstSelectedLine) so the UI gating tracks the same dynamic definer as the math layer

The biomarker prevalence and the biomarker testing rate can each project year-over-year via independent “Expected to change?” toggles. When enabled, the value compounds annually starting from startYear, capped at max:

v(year) = v(year - 1) x (1 + min/100) for year >= startYear (min = annual growth rate %)
= min(v(year), max) clamped each step when min >= 0 (max is a CEILING)
= max(v(year), max) clamped each step when min < 0 (max is a FLOOR; value decays DOWN to it)
= baseValue for year < startYear

The biomarker factor at a given year becomes:

biomarkerFactor(year) = (prevalence(year) / 100) x (testingRate(year) / 100)

Each growth config is shared across early and metastatic first lines where the same biomarker is active (one config per bm:{biomarkerId} instance row — names biomarkerPercentageGrowth and biomarkerTestingRateGrowth). Computed via computeBiomarkerFactor({ year, percentage, percentageGrowth, testingRateBase, testingRateGrowth }) in src/core/math/forecasting.ts.

Preview cards show the as-configured base, not a year projection (manager decision 2026-06-20). This year-over-year growth — for biomarker prevalence/testing rate and for changeable custom variables — applies only to the time-series engines (Model table, Sales, Monte Carlo, Tornado), which walk an explicit year horizon via calculateLinePatients. The on-screen preview/snapshot cards (the “Drug Treated Patients” card, the Patient Flow tree, the slideshow/export, and the Comparison view) are computed by calculateLineFunnelForDisplay / buildLineFunnelMap, which render the base / as-configured value with growth ignored — so the same saved model shows the same preview number regardless of the calendar year. “Current” here means current configuration, not current calendar year. Mechanically, these display funnels call getCustomMultiplier (static) and pass baseValuesOnly: true to resolveBiomarkerFactorForLine / getUpstreamCustomScalar; they take no year argument.


2.1.1 Transplant Eligibility Split (Hematology — AML / B-ALL / MM / DLBCL / FL / Other Hodgkin Lymphoma)

Section titled “2.1.1 Transplant Eligibility Split (Hematology — AML / B-ALL / MM / DLBCL / FL / Other Hodgkin Lymphoma)”

For hematologic malignancies where stem cell transplant is a conditioning step, a therapy line may forecast only a slice of the addressable population: the transplant-eligible cohort, the non-eligible cohort, or some other defined subgroup. The user picks one cohort per line and sets a single percentage applied as a multiplier (mirrors the Neo/Adj split pattern).

transplantSplitFactor = transplantSplitPercent / 100 [when toggle ON for line L]
= 1 [when toggle OFF]
  • When disabled or not allowlisted, factor = 1.0 (no effect).
  • The transplantSplitSetting ("eligible" / "non-eligible" / "other") is a label — does not change the math, but drives the smart default.

When the toggle flips ON (and the user hasn’t manually overridden the percent), the percent auto-fills from the disease-level transplantEligible reference value E:

SettingAuto-fill default
eligibleE (or 50 if no reference)
non-eligible100 − E (or 50 if no reference)
other100
Addressable Population
|
x Healthcare Access
|
x Neo/Adj Factor (early-stage solid tumors only)
|
x Transplant Split Factor <- per line, hematology only
|
x Drug Treatment Rate
|
= Drug Treated Patients

Applied per-line in calculateLinePatients and calculateLineFunnelForDisplay (the per-line walk shared by Model/Sales/MC/Tornado after the 21c7426 shadow-implementation removal) via getTransplantSplitFactor(line) — bundled with the per-line biomarker scalar in applyPerLineFilters. The split is independently toggleable on every therapy line.

  • Allowlist: AML, B-ALL, MM, DLBCL, FL, Other Hodgkin Lymphoma (case-insensitive). Block hidden for all other indications.
  • Toggle: User enables/disables the split per line.
  • Segmented control: Eligible | Non-eligible | Other.
  • Percentage input: Single percentage (0–100%) applied as a multiplier (default = setting-aware as above).
  • No Monte Carlo / Tornado wiring: the percent row hides the “Add to Monte Carlo” toggle.

Key files:

  • Type: UnifiedLineData.transplantSplitEnabled / transplantSplitSetting / transplantSplitPercent in src/core/types/types.ts
  • Computation: getTransplantSplitFactor() + getTransplantSplitDefault() in src/core/math/transplant.ts
  • UI: standalone block in LinePanel.tsx rendered on every therapy line, gated only on isTransplantAllowlisted(indication)
  • Backward compatibility: the linesAtom selector (core/state/selectors/lines.ts) inherits transplant settings from each stage’s first selected line onto downstream lines with no stored transplant row, so saved models open with identical numbers. Inheritance is gated on donor.isSelected — if the stage-first line is deselected, downstream lines stay at defaults (otherwise the donor’s settings would leak into siblings via applyPerLineFilters and scale their output)
  • DOCX: single-emission block in src/features/exports/reporting/collectReportData.ts (mirrors neoAdj)

2.2 Neoadjuvant / Adjuvant Split (Early Stage Solid Tumors Only)

Section titled “2.2 Neoadjuvant / Adjuvant Split (Early Stage Solid Tumors Only)”

For early-stage solid tumors, patients may receive treatment in different settings:

  • Neoadjuvant = treatment before surgery (shrink the tumor, then operate)
  • Adjuvant = treatment after surgery (operate, then treat to prevent recurrence)
  • Other = other treatment settings (e.g. maintenance, palliative)

The user selects one category and sets a single percentage that acts as a simple multiplier on the eligible population.

neoAdjFactor = neoAdjPercent / 100
  • When disabled or not applicable, factor = 1.0 (no effect)
  • The selected category (neoAdjSetting) is a label only — does not affect the math

Default: 100% = factor 1.0 (no reduction)

Addressable Population
|
x Healthcare Access
|
x [Setting] Split <- neoAdjPercent / 100
|
x Drug Treatment Rate
|
= Drug Treated Patients

The neo/adj factor is applied before drug treatment rate (see calculateLineFunnelForDisplay in src/core/math/forecasting.ts). The toggle is per-line — any early-stage line can have it enabled independently. In sales/Monte Carlo calculations, the factor applies to the first line only.

  • Toggle: User enables/disables the split per line
  • Segmented control: Pick one of 3 categories: Neoadjuvant, Adjuvant, or Other
  • Percentage input: Single percentage (0-100%) applied as a multiplier (default 100%)
  • Rollback: Resets percentage to 100%

Key files:

  • Type: UnifiedLineData.neoAdjSetting and neoAdjPercent in src/core/types/types.ts
  • Computation: getNeoAdjFactor() in src/core/math/forecasting.ts
  • UI: Segmented control + single percentage in LinePanel.tsx

2.3 Custom Variables (Addressable Population Multipliers)

Section titled “2.3 Custom Variables (Addressable Population Multipliers)”

Users can define up to MAX_CUSTOM_VARIABLES custom multiplier variables per therapy line (see constants.ts). Each custom variable represents a percentage applied multiplicatively to the addressable population, allowing users to model additional filtering factors not covered by the built-in parameters.

customMultiplier = product(customVar.value / 100) -- product of all custom variable percentages

Applied in the patient funnel (order matches code):

Addressable Population
|
x Healthcare Access %
|
x Neo/Adj Factor (if applicable -- see section 2.2)
|
x Drug Treatment Rate
|
x customMultiplier <- product of all custom variable percentages
|
= Drug Treated Patients

First-line (1L) custom variables are synced to incidenceVars.customVariables for use in Monte Carlo and Tornado simulations. This ensures probabilistic analyses include the custom variable effects.

For 2L+ lines, the system uses findMatchingLineCustomVariable() to locate matching custom variables across lines, enabling consistent filtering across the therapy cascade.

Key files:

  • Computation: getCustomMultiplier() (display paths — year-agnostic) and getCustomMultiplierForYear(vars, year) (live Model/SalesChart — compounds via applyChangeableValue when changeable && startYear !== null) in src/core/math/forecasting.ts
  • Matching: findMatchingLineCustomVariable() in src/core/math/forecasting.ts
  • Constant: MAX_CUSTOM_VARIABLES in src/core/types/constants.ts

Growth Projection (“Expected to change?”)

Section titled “Growth Projection (“Expected to change?”)”

Custom variables already carry {changeable, min, max, startYear} and project year-over-year via applyChangeableValue (src/core/math/forecasting.ts). The same growth mechanism is now wired for built-in Healthcare Access (scenario-level), Drug Treatment Rate (per-line), and Biomarker Testing Rate (per-biomarker) — see §2.1 Testing Rate Over Time and §3 Healthcare Access.


2.4 Line Selection and Cascade Propagation

Section titled “2.4 Line Selection and Cascade Propagation”

The user can deselect any therapy line via the per-line UI toggle. The line stays in the cascade structurally (its position, transitionRate, and treatmentRate still shape the downstream pool), but contributes zero market share, zero new patients, and zero revenue in its own row.

Some line-attached parameters propagate through a deselected line into the downstream pool; others do not. The rule depends on whether the parameter models disease-intrinsic behavior (independent of user choice) or user-chosen subgroup filtering (tied to the line’s existence).

ParameterPropagates through a deselected line?StanceRationale
transitionRate (+ growth)YesADisease evolution — the cascade walks through the line’s position regardless of user selection
treatmentRate (+ growth)YesADrug-treatment-rate growth represents disease-intrinsic access/treatment behavior
customVariablesNoBCustoms are user-chosen subgroup-eligibility filters tied to the specific line; deselecting the line removes its filters from the model. If the user wants the same filter on a downstream line, they re-add it there

Stance A — Growth Propagates (transitionRate, treatmentRate)

Section titled “Stance A — Growth Propagates (transitionRate, treatmentRate)”

Even when an upstream line is deselected, its transitionRate and treatmentRate (and any year-over-year growth on those fields) continue to shape the structural cascade. Only the line’s own market share / new-patient count / revenue zero out via calculateMarketShare’s !isSelected → return 0 short-circuit.

Manager-confirmed 2026-05-24 (audit row #5). Implemented by the in-loop cascade in calculateLinePatients (forecasting.ts), which intentionally iterates every upstream line including deselected ones. See the load-bearing comment around the structural cascade carry that warns against gating growth lookups on isSelected.

When an upstream line is deselected, its customVariables are excluded from the upstream-customs scalar applied to downstream lines. The downstream line sees no contribution from the deselected line’s customs.

If the user wants a custom filter (e.g., “PR-positive 70%”) to apply on a downstream line independently of the upstream line’s selection state, they must add the custom directly to the downstream line.

Manager-confirmed 2026-05-26. Distinct from Stance A above (different reasoning, different field). Implemented via .filter((l) => l.isSelected) before passing upstream lines to getUpstreamCustomScalar at three call sites in forecasting.ts:

SiteFunctionPurpose
~L555calculateLineFunnelForDisplayper-line display scalar (Patient Flow tree, LinePanel)
~L729buildLineFunnelMap.processCustomForStagecustom-line upstream lookup
~L1190applyPerLineFiltersWithCustomsMC / Tornado / Sales engine path

Each site carries a load-bearing in-code comment with the manager-confirmation date and a “DO NOT drop the filter without explicit re-confirmation” warning.

Per-Line Upstream Bypass (independent of selection)

Section titled “Per-Line Upstream Bypass (independent of selection)”

The user can also explicitly bypass an upstream line’s customs on a specific downstream line via the upstreamCustomBypassesByLine map (UI: InheritedFiltersPanel in LinePanel.tsx, see ARCHITECTURE.md “LinePanel Sub-Components”). This is independent of Stance B and gives finer-grained control:

Upstream line stateBypass entry on downstream lineEffect
SelectednoneCustoms propagate normally (Model B default)
Selectedset (Line N+1 bypasses Line N’s variable V)Variable V is excluded from Line N+1 ONLY — Line N+2 still inherits it (Model B: bypass does not propagate further)
DeselectednoneStance B: customs excluded from ALL downstream lines
DeselectedsetSame as deselected-no-bypass — already excluded

Both mechanisms feed getUpstreamCustomScalar in forecasting.ts. The bypass map is keyed by downstreamLineId → Map<${ownerLineId}:${variableId}, boolean> where false means “bypass.” Storage lives in the atoms layer under row name appliesUpstreamCustom:${ownerLineId}:${variableId}; the appliesUpstreamCustom: prefix is stripped at the adapter boundary before reaching the math layer.

The split between Stance A (growth propagates) and Stance B (customs don’t) captures a real semantic distinction:

  • Growth models the disease itself — its incidence trajectory, treatment-rate evolution, biomarker drift over time. The user can’t opt out of disease behavior by unchecking a line; the disease still progresses.
  • Customs model user-chosen filtering of the addressable population — “I only want to forecast the PR-positive subgroup on this line.” Deselecting the line is a stronger statement than bypassing one variable: it removes the line from the forecast entirely, including its filtering intent.

The split is load-bearing — both reviewers in the T8 review loop converged on questioning the customs stance independently, and the manager-confirmed answer is what unblocks future tasks that touch the cascade.

2.5 Custom Variable Inheritance (manager-confirmed 2026-05-28)

Section titled “2.5 Custom Variable Inheritance (manager-confirmed 2026-05-28)”

Domain constraint: early stage is single-reference-line. Solid tumors model early-stage treatment as exactly one reference therapy line. Custom lines are NOT permitted on early stage; the addCustomLine atom enforces this in code (returns "" for an "early" request). Custom-variable filters that apply to early-stage treatment are expressed via customVariables on the early reference line itself.

Two-pool independence. The early and metastatic pools at diagnosis are structurally independent (incidence × earlyStagePercent vs incidence × metStagePercent). A customVariable defined on the early ref line does NOT cascade into the metastatic pool via the funnel — the pools represent different patient cohorts.

Cross-stage copy-paste at creation. When a customVariable is CREATED on the early ref line, the same variable is automatically added as a new customVariable on the first selected metastatic line with a fresh id. The copy is one-way (early → first selected metastatic line only), independent (edit/delete of one side does not affect the other), per-creation-event, and non-retroactive (existing saved models are NOT migrated). The cross-stage copy bypasses MAX_CUSTOM_VARIABLES (a per-line direct-add cap, not a hard invariant): a twin-copy can take the first selected metastatic line past the cap. This is intentional (manager-confirmed) — the cap limits manual additions, not inherited filters.

Within-stage cascade with bypass. Within a stage, a customVariable on line N propagates to lines N+1, N+2, … via the funnel: pool[N+1] = pool[N] × transitionRate × ∏(upstream customs not bypassed at N+1). Upstream customs include both preceding selected reference lines AND preceding selected custom lines (peer-custom propagation). Each downstream line can BYPASS specific upstream customs (toggle in the InheritedFiltersPanel); bypass at line K removes that upstream custom from line K’s scalar product, restoring the patients it filtered.

Hematology. Hematology has no early/met split; therapy lines form a single cascade and customVariables propagate with bypass within it. The cross-stage copy-paste mechanism does not apply.

Scope. This model applies only to per-line customVariables. Other filter categories (biomarker, transplant, DTR, neo/adj, treatmentRate) follow their own documented rules. Indication-scope diseaseCustomFilters are a separate category covered in §2.6 — they are NOT line-attached and the Stance A/B propagation rules do not govern them.


2.6 Disease-Level Custom Filters (indication scope)

Section titled “2.6 Disease-Level Custom Filters (indication scope)”

Named percentage factors applied once to the addressable population for an entire indication — like Healthcare Access. A separate filter category from per-line customVariables: different scope, different storage shape, different propagation rules.

Composition point. A disease filter’s value multiplies the stage addressable in the same step where populationHA is applied (top of funnel): addressable × populationHA × ∏diseaseFilters × …. Values are clamped to [0, 100] via clampDiseaseFilters before the product is taken. On the display path the multiplier is applied inside calculateLineFunnelForDisplay (to the baseAddressable seed via getCustomMultiplier, growth ignored — diseaseFilters is now a required param of LineFunnelDisplayParams); buildLineFunnelMap threads diseaseFilters through and no longer pre-folds the multiplier, except for the custom-line fallbackBase (the no-selected-reference branch), where the three per-stage processCustomForStage calls (hematology/early/metastatic) still fold diseaseMul into their fallbackBase seed by hand. The time-series path (calculateLinePatients) is year-resolved via getCustomMultiplierForYear, so {changeable, startYear} projection works the same way as for line-level customs.

Structural propagation. Because the filter is applied at the top of the funnel, the result propagates to every downstream line through the cascade carry — do NOT re-apply per-line. Disease filters are independent of per-line selection, Stance B, and upstream-custom bypass (§2.4 / §2.5). A disease filter still scales the addressable pool even when an upstream line is deselected or an upstream custom is bypassed.

Storage. One row per (geo, indication) at {line: null, year: null, name: "diseaseCustomFilters", value: Variable[]} — the full filter list lives inside the single row’s array value, not one row per filter. The element shape mirrors line-level customVariables ({id, name, value, changeable, min, max, startYear, origin?, displayName?}); cap is MAX_CUSTOM_VARIABLES. displayName is the producer’s curated display_name (carried by fetchStatisticsReferenceRow.displayNameresolveDiseaseCustomFilters); name remains the identity / math-match / rollback key, and VariableSchema.displayName is declared so z.object() doesn’t strip it on reload.

Guidance bridge. On initIndicationAtom, blank-line reference rows whose name is not a known indication-level builtin (e.g. treatmentRate, incidenceBase, healthcareAccess) are bridged into diseaseCustomFilters with origin: "guidance" and isBuiltIn: false. The bridge does NOT re-fire after reload — once a user has deleted a guidance filter, the empty array (or missing row after full clear) persists; the __initialized__ marker prevents the bridge from re-resurrecting it.

Geo switch. When the user switches geo, disease filters are intentionally NOT cloned from the source geo (the skip-clone branch in selectGeoAtom) — they re-resolve from the reference feed for the new geo (Option A parity with healthcareAccess), so one geo’s edited values cannot bleed into another. The re-resolution is a dual read-path fallback: both the diseaseCustomFilters read selector and the shared getDiseaseCustomFilters SSOT (which the write atoms now route through) end in ?? resolveDiseaseCustomFilters(...), which fires when no instance row exists for the geo. The write-side fallback is what keeps the first edit on a non-carried geo from persisting a wiped set — it re-resolves the geo’s own feed before mutating. On reload of a pre-feature snapshot that carries no diseaseCustomFilters instance row, this same fallback re-derives the admin-seeded filters from the current reference feed rather than restoring saved values — a silent forecast change vs. when saved, accepted as intended (the dual read-path that serves geo-switch parity also serves legacy snapshots; the feature is new enough that no pre-feature snapshot holds user-edited disease filters) and deliberately not separately gated or marked.

Engines (Monte Carlo & Tornado). Each disease filter is exposed as an editable simulation-variable row, not just a static multiplier. deriveDefaultSimulationVariables (core/simulation/) emits one SimulationVariableType.DiseaseFilter row per filter — a calcPercentRange ±10% band, Distribution.Triangular, customVariableId = filter.id, and selected = includeInMonteCarlo ?? true. Monte Carlo randomizes each selected filter per sim (matched to its filter BY id in the per-sim clone); Tornado emits one bar per selected filter (selectedVariables = variables.filter((v) => v.selected)), so deselected filters keep their static value/100 factor. The raw filter value still flows through buildIncidenceVariablesIncidenceVariables.diseaseCustomFilters as the deterministic base / value-carrier (it seeds the MC base case and the Tornado base case). The sections are wired by threading diseaseCustomFilters through MonteCarlo.tsxuseMonteCarloWorker.ts, Tornado.tsx, collectComparisonData.ts (its Tornado sub-call + a deriveDefaultSimulationVariables fallback derive), and features/exports/reporting/slideshow/collectSlideshowData.ts (its Tornado run + the same fallback derive).

Model surfaces. The Model table’s calculateLinePatients caller (getEligibleAndNewPatients) and its driver-chain breakdown (getLineBreakdownSeries, consuming explainLinePatients) thread diseaseFilters, so the displayed Drug Treated Patients column and the breakdown reflect disease-level filters — matching Monte Carlo and Tornado. The filter factor is applied once to the eligible pool inside computeLinePatients and surfaced as its own Disease filter: <name> breakdown row(s), so a filter-active model’s expanded driver rows fully fold to the displayed total — see §2.7.

Reports. Surfaced in per-scenario DOCX, PPTX assumptions, and the Comparison page (DOCX + CSV via collectComparisonData / ComparisonAssumptionsCard). In the Model CSV and the per-scenario DOCX “Model Outputs” table, disease-filter columns are namespaced as Disease filter: <name> (%) with a duplicate-safe (2)/ (3) suffix so same-named filters don’t collide (built in buildModelExportRows, src/features/model/Model.tsx).

Key files:

  • Math: clampDiseaseFilters, the required diseaseFilters param on LineFunnelDisplayParams (applied inside calculateLineFunnelForDisplay; folded by hand only for the custom-line fallbackBase in buildLineFunnelMap), the diseaseFilters param on calculateLinePatients, and the diseaseCustomFilters carry through buildIncidenceVariables — all in src/core/math/forecasting.ts
  • MC/Tornado simulation rows: deriveDefaultSimulationVariables (SimulationVariableType.DiseaseFilter branch, customVariableId = filter.id) in core/simulation/deriveDefaultSimulationVariables.ts; per-sim/per-bar handling in the DiseaseFilter cases of src/core/math/monte-carlo.ts and tornado.ts
  • Section wiring: MonteCarlo.tsxuseMonteCarloWorker.ts, Tornado.tsx, src/features/comparison/collectComparisonData.ts, and src/features/exports/reporting/slideshow/collectSlideshowData.ts all thread diseaseCustomFilters into their MC/Tornado runs (the two collectors also fall back to deriveDefaultSimulationVariables)
  • Atoms (read): diseaseCustomFilters selector inside createConfigAtoms (src/core/state/sections/configuration/atoms.ts), exposed via forecastingAtoms.config.diseaseCustomFilters — ends in ?? resolveDiseaseCustomFilters(...) (dual read-path fallback) when no instance row exists for the geo
  • Atoms (write): addDiseaseCustomFilterAtom, updateDiseaseCustomFilterAtom, removeDiseaseCustomFilterAtom in src/core/state/actions/growth-config.ts, all routing reads through the shared getDiseaseCustomFilters SSOT (same ?? resolveDiseaseCustomFilters(...) fallback) in src/core/state/primitives/instance-rows.ts
  • Per-geo resolve: resolveDiseaseCustomFilters (exact geo+indication, no cross-geo fallback) in core/state/primitives/reference-query.ts
  • Init bridge: initIndicationAtom in core/state/actions/init-flows.ts (blank-line unknown-name → diseaseCustomFilters)
  • Geo switch: selectGeoAtom in core/state/actions/geo-indication-selection.ts (skip-clone branch for diseaseCustomFilters)
  • Export columns: namespaced + deduped Disease filter: <name> (%) columns built in buildModelExportRows (src/features/model/Model.tsx), consumed by the Model CSV and the per-scenario DOCX “Model Outputs” table
  • UI: “Disease Filters” block in src/features/configuration/tree/panels/SummaryPanel.tsx

2.7 Drug Treated Patients Driver Breakdown

Section titled “2.7 Drug Treated Patients Driver Breakdown”

Every row of the Model table can expand into a driver breakdown — a per-line, per-year decomposition of that line’s Drug Treated Patients figure into the ordered factors that produced it. The breakdown answers “why is this number what it is?” by replaying each driver the engine applied, in the order it applied it, ending at the Drug Treated Patients result.

The breakdown is descriptive, not a second computation: it is emitted by the same engine body that computes the number, so it reconciles to the displayed Drug Treated Patients by construction (see below). It is surfaced inline in the Model section only; Monte Carlo, Tornado, and the export pipelines do not render it.

The breakdown is not a flat product of percentages. It is a stateful fold in engine order, mirroring the patient-flow math in §2–§3:

  • a count step seeds the running value (the incidence anchor);
  • a percent step multiplies it (running × value / 100);
  • a countSubtract step subtracts an absolute count (the retreatment pool reduction).

calculateLinePatients (the number) and explainLinePatients (the breakdown) both delegate to one shared engine body, computeLinePatientscalculateLinePatients is a thin wrapper returning just [eligible, newPatients], while explainLinePatients returns the same eligible plus the ordered step list. Because the steps are recorded as the engine applies each factor, folding the emitted steps in emission order reproduces the terminal Drug Treated Patients value. A naive flat product diverges under retreatment-on + sub-100% transition (the countSubtract step happens before a percent step, so order matters); an anti-regression test pins this.

DriverStep = { kind: DriverStepKind; label: string; value: number; display: 'percent' | 'count' | 'countSubtract' }; LineDriverBreakdown = { year; drugTreatedPatients; steps: DriverStep[] }. value is the driver’s own value — a percentage for percent, an absolute count for count / countSubtract.

Rows are emitted in the order the engine applies them. Repeated kinds (retainedOnPriorLine, transition, ownCustom, upstreamCustom) are disambiguated by ordinal (Nth occurrence within the year). transition / ownCustom / upstreamCustom also carry distinct labels, but retainedOnPriorLine repeats with an identical label, so position — not label — is the discriminator.

KindRendered labelDisplayMeaning
addressableAddressable populationcountPre-access stage pool — incidence × effective stage split (including relapse channels) — seeds the fold. ~1/HA larger than the funnel’s post-access “Addressable patients” (see the CONTEXT.md two-senses note)
healthcareAccessHealthcare accesspercentScenario-level healthcare access (§3)
biomarkerBiomarker: <name>percentBiomarker prevalence (see §2.1)
biomarkerTestingRateTesting ratepercentBiomarker testing rate (see §2.1)
transplantTransplant — <Eligible/Non-eligible/Other>percentTransplant split, hematology only
neoAdjNeoadjuvant / Adjuvant / Other settingpercentNeo/adjuvant split, early-stage solid tumors (see §2.2)
drugTreatmentRateDrug treatment ratepercentDrug treatment rate at the stage-entry line
ownCustomCustom: <name>percentThis line’s own custom variable (see §2.3)
upstreamCustomUpstream custom: <name> (<lineName>)percentA non-bypassed upstream line’s custom (see §2.3 / §2.4)
diseaseFilterDisease filter: <displayName ?? name>percentDisease-level custom filter applied once to the eligible pool (§2.6); one row per active filter. Label prefers the producer’s curated displayName and falls back to name (the identity/match key)
retainedOnPriorLineRetained on prior linecountSubtractEligibility-pool reduction (see below); 2L+ only
transitionTransition from <prior line name>percentTransition rate into this line
drugTreatedPatientsDrug Treated PatientscountTerminal result — the existing Model-table row is this anchor; the UI drops the duplicate step
  • addressable folds relapse in. Its count is getAddressableForCategory(computeAddressable(yearIncidence, stageMix), category) — the effective stage pool, so a metastatic line fed by an early→met relapse channel shows more than incidence × configured metastatic %: the pool genuinely receives relapsed early-stage patients on top of de-novo metastatic incidence (§3). The former explicit stageShare percent row (which could exceed 100%) was folded into this count (design D2, 2026-07-13 spec).

  • retainedOnPriorLine is an eligibility-pool subtraction, not a market-share cut. It is the count of already-treated patients held back from this line’s pool before the transition rate applies: (pool − retained) × transitionRate%, where retained = structuralNewPatients × retreatment. It is governed by the per-line “Re-Treatment from Prior Lines” toggle: OFF (default) holds these patients back (large subtraction); ON lets them flow downstream (≈0). It appears on 2L+ lines only and reads 0 for pre-launch / deselected upstream lines (whose market share is 0).

  • Biomarker is split into two rows. Where the line carries separable components, the engine emits Biomarker: <name> (prevalence %) and Testing rate (%) as two percent steps whose product equals the single engine biomarker scalar by construction. A legacy precomputed-scalar fallback (no raw components) degrades to one combined Biomarker: <name> step. The math itself is unchanged — see §2.1.

  • Enabled-at-100% rows still show. biomarker, transplant, and neoAdj rows are gated on the line’s enable flag, not on factor !== 1. An enabled-but-100% filter folds as ×1 (a no-op for reconciliation) but is still rendered, so the user sees that the filter is on; a disabled or not-applicable filter is hidden.

The Model display accessor getLineBreakdownSeries (in src/features/model/Model.tsx) mirrors the getEligibleAndNewPatients parameter bundle exactly — same lines, same year, same diseaseFilters — so the breakdown’s terminal Drug Treated Patients step reconciles to the displayed Drug Treated Patients column, not to a separately-derived figure. Deselected upstream lines still shape the cascade structurally (Stance A growth propagates — see §2.4), so their transition rows continue to contribute even though their own output is zeroed.

Disease-level custom filters (§2.6) are applied once to the eligible pool inside computeLinePatients and are surfaced as their own Disease filter: <displayName ?? name> rows (one per active filter), so a filter-active model’s expanded rows fully fold to the displayed Drug Treated Patients total, exactly like any other driver.

Key files:

  • Math: computeLinePatients (shared engine body), calculateLinePatients (number wrapper), explainLinePatients (breakdown), pushBoundarySteps, types DriverStep / DriverStepKind / LineDriverBreakdown — all in src/core/math/forecasting.ts
  • Display accessor: getLineBreakdownSeries in src/features/model/Model.tsx (mirrors getEligibleAndNewPatients)
  • Reconciliation lockdown: the explainLinePatients — reconciliation (stateful fold) suite in src/core/math/forecasting.test.ts, including the retreatment-on + sub-100%-transition case that asserts the stateful fold reconciles while a naive flat product does not

Per-year incidence is read from the incidenceEvolution array, which is built by compounding the per-year growth rate off the base:

incidence[0] = round(base)
incidence[i] = round(incidence[i-1] x (1 + growthRate / 100))

Example:

  • Base Incidence (year 0): 50,000 patients
  • Growth Rate: 0.5% per year
incidence[6] = round(50,000 x (1 + 0.005)^6) ~= 51,515 patients

The array is built by buildIncidenceEvolution() in src/core/math/incidence-evolution.ts and consumed year-by-year in src/core/math/forecasting.ts via incidenceEvolution[yearIndex]. Growth rates can be edited per transition; see the Evolution Arrays section for storage details. Geo-level variation is applied upstream at cascade time via the per-capita incidenceBase fallback.

Calendar-anchored incidence. The incidence evolution series is re-leveled (“anchored”) to the current calendar year at read time via anchorIncidenceEvolution (anchoredIncidenceEvolutionAtom, anchored to currentYearAtom). Business meaning: the incidence figures every consumer reads — Model, Sales Chart, Monte Carlo, Tornado, Incidence Evolution, Comparison, and the slideshow export — are expressed relative to the current calendar year rather than the year the model was first built, so a saved model’s population counts stay current as time passes. (For the atom wiring, see ARCHITECTURE.md.)

Addressable Population by Disease Category

Section titled “Addressable Population by Disease Category”

Solid tumors split into Early Stage and Metastatic populations with relapse transitions:

Early Addressable:

Early Addressable = Incidence x EarlyStage% x (1 + EarlyToEarlyRelapse%) x HealthcareAccess%

Metastatic Addressable:

Met Addressable = (Incidence x MetStage% + Incidence x EarlyStage% x EarlyToMetRelapse%) x HealthcareAccess%

Code note: computeAddressable() returns raw stage addressable counts. The shared buildLineFunnelMap() helper in src/core/math/forecasting.ts multiplies the population-level Healthcare Access into stage addressable before invoking calculateLineFunnelForDisplay, so the funnel function itself no longer applies HA per line. Both PatientFlowTimeline (the patient-flow tree UI) and LinePanel (the configuration panel) consume that helper, so their addressable/eligible counts cannot drift. The sales path (calculateLinePatients) reads HA from the population row via the populationHealthcareAccess param and applies it once at the first line via applyFilters.

Example (Breast Cancer):

  • Incidence: 50,000
  • Early Stage: 70%
  • Metastatic: 30%
  • Early to Early Relapse: 15%
  • Early to Met Relapse: 10%
  • Healthcare Access: 95%
Early Addressable = 50,000 x 0.70 x (1 + 0.15) x 0.95 = 38,238 patients
Met Addressable = (50,000 x 0.30 + 50,000 x 0.70 x 0.10) x 0.95 = 17,575 patients

Hematology indications do not have stage splits:

Addressable = Incidence x HealthcareAccess%
ParameterDescription
Early Stage %Initial early-stage diagnosis
Localized %Sub-breakdown: localized early stage (optional)
Locally Advanced %Sub-breakdown: locally advanced (III-IVB by default; III-IIIB for lung and breast, optional)
Metastatic %Initial metastatic diagnosis (derived: 100 - Early%)
Unknown Stage %Patients with unknown stage at diagnosis (optional)
Early to Early Relapse %Early patients who relapse but stay early
Early to Met Relapse %Early patients who progress to metastatic

Actual values are sourced per indication from the API reference data.

Note: Early Stage % + Metastatic % + Unknown Stage % = 100%. When Unknown Stage % is not provided, it auto-derives as the residual: max(0, 100 − Early% − Met%).

Early Stage Sub-Variables (Solid Tumors Only)

Section titled “Early Stage Sub-Variables (Solid Tumors Only)”

Some solid tumors have granular staging data that breaks Early Stage into Localized and Locally Advanced (III-IVB) sub-populations. These sub-variables are display-only — the computation layer always uses the combined earlyStagePercent.

Behavior matrix:

Data AvailableParent (Early Stage %)LocalizedLocally AdvancedExample
Both subsRead-only (auto-sum)EditableEditableOropharyngeal: 15.8 + 72.9 = 88.7%
Parent + one subEditableRead-onlyRead-onlyPartial reference data
Parent onlyEditableHiddenHiddenMost indications (today’s behavior)
No dataFallback chainHiddenHiddenMissing geo-specific data

Auto-computation: When both sub-variables are present, the parent is derived:

earlyStagePercent = earlyStageLocalizedPercent + earlyStageLocallyAdvancedPercent
metStagePercent = 100 - earlyStagePercent

Reset behavior: Resetting a sub-variable also resets the parent and metastatic percentage to maintain consistency.

Healthcare access percentages are sourced per geography and indication from the API reference data. The fallback default is DEFAULT_INCIDENCE_DATA.healthcareAccess in src/core/types/constants.ts.

Healthcare Access (scenario-level), Drug Treatment Rate (per-line), progression rate (per-line), and compliance + months of therapy (per-line) can each project year-over-year via the “Expected to change?” toggle. When enabled, the value compounds annually starting from startYear, capped at max:

v(year) = v(year - 1) x (1 + min/100) for year >= startYear (min = annual growth rate %)
= min(v(year), max) clamped each step when min >= 0 (max is a CEILING)
= max(v(year), max) clamped each step when min < 0 (max is a FLOOR; value decays DOWN to it)
= baseValue for year < startYear

Implemented by applyChangeableValue(year, baseValue, config) in src/core/math/forecasting.ts. The growth config is a ChangeableConfig ({changeable, min, max, startYear}) defined in types.ts. The field named min is the annual growth rate (percent), and max is the bound — a ceiling when the rate is non-negative, a floor when the rate is negative.

Read-time growth normalization. A declining variable (negative annual rate) left at the no-op ceiling default (max >= 100 percent / MAX_MONTHS_OF_THERAPY for months-of-therapy) would never decline: the sign-aware max(...) floor would pin it upward to that default. To prevent this, such a variable is floor-normalized to 0 at read time so it actually decays toward zero. This applies to both built-in growth families (via parseGrowthConfig) and custom variables (via calculateIncrementation, which mirrors the same rule since customs bypass parseGrowthConfig). A deliberate floor below the ceiling is preserved; positive/zero rates are untouched. Storage stays raw — the correction happens at read time, so saved models are corrected with no migration.

Engine-level floor cap (negative rate only). A valid floor (max <= baseValue when stored) can later become stale when the host base is lowered below it — e.g., a direct base edit, a custom-variable value arriving lower from a snapshot, or an API-fed reference value. applyChangeableValue defends in the engine itself: when min < 0, the effective floor is min(config.max, baseValue). Idempotent for valid floors (max <= baseValue → no-op); the positive/zero ceiling path is untouched. This complements the UI input-boundary clamp in VariableGrowthConfig (see DESIGN_SYSTEM.md) by covering the snapshot-restore / API-fed / base-edit paths the UI clamp cannot see, and since calculateIncrementation routes through applyChangeableValue, custom variables inherit the same protection.

  • Scenario-level scope (HA): Healthcare Access is a single scenario-level value. It is applied once at the top of the patient flow — multiplied into the stage addressable population before any line-level math runs. Subsequent therapy lines no longer apply HA. The user edits HA in the SummaryPanel (Patient Flow summary), and a single healthcareAccessGrowth row stores the optional “Expected to change?” projection.
  • Per-line scope (TR): Drug Treatment Rate remains per-line, with a per-line growth config; absent rows = growth OFF.
  • Per-line scope (progression rate): The transition rate into each line carries its own per-line growth config. Line 1 has no transition-in, so the toggle surfaces only on downstream lines; a declining rate compounds fewer patients through the funnel each year.
  • Per-line scope (compliance + months of therapy): Both are sales-side inputs (the compliance x months revenue multiplier), so each carries its own per-line growth config evaluated at the projection year inside calculateLineSales (src/core/math/forecasting.ts) — not in calculateLinePatients. The projected value flows from there into Sales, Monte Carlo, and Tornado. The toggles surface inline on the Compliance and Months of Therapy rows in LinePanel. Unit-aware growth cap: VariableGrowthConfig accepts optional maxCeiling / unitSuffix props. Percentage hosts (compliance, HA, treatment/transition rate) use the defaults (0–100, %); Months of Therapy overrides them with maxCeiling=MAX_MONTHS_OF_THERAPY (300) and a mo suffix so the cap is neither mislabeled nor truncated to 100. Toggling growth ON for months seeds DEFAULT_MONTHS_GROWTH_CONFIG (cap 300) so a long duration is not silently clamped down. applyChangeableValue is unit-agnostic (min = annual % growth, max = ceiling in the value’s own unit).
  • Per-line override (HA): To model line-specific access penalties (e.g., access drops at later therapy lines, regional sub-population effects), users add a Custom Variable on the affected line. Custom variables multiply that line’s eligible pool via getCustomMultiplier. Example: a line with HA penalty 85% is modeled by adding a custom variable named e.g. “Late-line access penalty” with value 85.
  • Persistence: Stored as JSON-encoded ChangeableConfig in instanceRowsAtom under companion row names healthcareAccessGrowth ({line: null}), treatmentRateGrowth (per-line), transitionRateGrowth (per-line), complianceGrowth (per-line), and monthsOfTherapyGrowth (per-line).
  • Legacy configs are not migrated. A model saved before the 300-month change keeps its stored monthsOfTherapyGrowth.max (typically 100) verbatim — there is no load-time migration. This is deliberate: auto-bumping the ceiling to 300 would itself change the forecast of any legacy model whose months-growth compounds past 100, and it cannot distinguish a stale default from a user’s intentional 100-month ceiling. Only newly toggled configs receive the 300 default. (Latent edge: a legacy model with a base months value > 100 and growth toggled on would be clamped to 100 — but no such model exists, base durations sit well under 60.)
  • Defaults on toggle ON: rate 0%, max 100%, startYear 2030 (DEFAULT_GROWTH_CONFIG in constants.ts). Months of Therapy is the exception — it seeds DEFAULT_MONTHS_GROWTH_CONFIG (same rate/startYear, cap 300 months) so a long base duration is not clamped down to 100 the moment growth is toggled on.

Peak Share is the absolute maximum share a product reaches over the forecast. It is determined by competitive positioning. The product approaches Peak Share via the Speed-to-Peak uptake curve and may decline before LoE due to market events or competitive dynamics; once LoE hits, share also erodes via the LoE curve. A user-set Peak Share override (via the Custom Input toggle) wins over the Launch Order / Best-in-Class auto-recommendation.

flowchart LR
    subgraph Inputs["Peak Share Inputs"]
        LO[Launch Order<br/>1st to 10th]
        BIC[Best-in-Class?<br/>Yes/No]
        DVC[Delay vs Competition<br/>Quarters]
    end

    subgraph Calculation["Peak Share Calculation"]
        LO --> BASE[Base Share from Matrix]
        BIC --> BONUS[Best-in-Class Bonus]
        DVC --> PENALTY[Delay Penalty]
        BASE --> PS[Peak Share %]
        BONUS --> PS
        PENALTY --> PS
    end

Formulas:

Peak Share (within class):

PeakShare = min(100, max(0, BaseShare[LaunchOrder] + BestInClassBonus - DelayPenalty))

Effective Peak Share (used in market share calculations):

EffectivePeakShare = CustomEffectivePeakShare (if user has toggled Custom Input)
OR PeakShare x (ClassShare / 100) (Bioloupe Guidance -- default)

Where:

  • BaseShare comes from the LAUNCH_ORDERS matrix in constants.ts (indexed by launch order and number of competitors)
  • BestInClassBonus from BEST_IN_CLASS array (indexed by number of competitors)
  • DelayPenalty = DelayQuarters > COMPETITION_THRESHOLD_QUARTERS ? DelayQuarters x COMPETITION_FACTOR_MULTIPLIER : 0 (see constants.ts for current values)
  • ClassShare is the percentage of patients suitable for the therapy class (default 100%)

Example:

  • Launch Order: 2nd to market (with 3 competitors)
  • Best in Class: Yes
  • Delay: 6 quarters behind first entrant
  • Base Share (2nd position, 3 competitors): from LAUNCH_ORDERS
  • Best-in-Class Bonus (3 competitors): from BEST_IN_CLASS
  • Delay Penalty: 6 x COMPETITION_FACTOR_MULTIPLIER

Verify current lookup values in LAUNCH_ORDERS and BEST_IN_CLASS arrays in constants.ts.

Peak Share = 29% + 30% - 3% = 56%

Products don’t achieve peak share immediately. The uptake curve determines how quickly market share ramps up. Peak Share is the ceiling; the uptake curve controls acceleration toward it.

All uptake curves are defined in the UPTAKE_CURVE constant in src/core/types/constants.ts. Curves are named {N} Year {Speed} (e.g., “3 Year Fast”). The number indicates years to reach 100% of peak share.

Note: Curves are named like “3 Year Medium”, “5 Year Fast”, etc. The number indicates years to reach 100% of peak share. A product with 60% peak share using “3 Year Fast” reaches 60% x 60% = 36% in Year 1, 60% x 85% = 51% in Year 2, and the full 60% in Year 3.

flowchart LR
    subgraph PreLaunch["Pre-Launch"]
        PL[Market Share = 0%]
    end

    subgraph Ramp["Ramp Period"]
        PS[Peak Share] --> UC[Uptake Curve Applied]
        UC --> MS1[Growing Share]
    end

    subgraph Mature["Mature Period"]
        MS1 --> PEAK[At Peak Share]
    end

    subgraph PostLoE["Post-LoE"]
        PEAK --> ER[Erosion Curve]
        ER --> FINAL[Declining Share]
    end

    PreLaunch --> Ramp
    Ramp --> Mature
    Mature --> PostLoE

Year N Market Share Formula:

MarketShare[Year] = UptakeCurve[YearOffset] x EffectivePeakShare x (1 - ErosionRate[YearsPostLoE])

Where EffectivePeakShare = CustomEffectivePeakShare (if set) or PeakShare x (ClassShare / 100)

First Year Weighting (for mid-year launches):

WeightedShare = (Uptake[Y1] x (13 - LaunchMonth) + Uptake[Y0] x (LaunchMonth - 1)) / 12
  • Launch date is the first day of the stated month. A launch of 2025-08-01 means Aug 1, 2025; that calendar year contributes 5 months of revenue (Aug–Dec).
  • Year-1 of Speed-to-Peak is the 12-month rolling window starting at the launch datenot the launch calendar year. The WeightedShare formula above prorates the launch calendar year so the rolling-12-month uptake reads correctly across the launch-year / first-full-year boundary.
  • Year-0 (pre-launch row) market share is 0; the forecast horizon shows one pre-launch year of zeros to make the launch transition visible.
  • Per-input clamp, not cross-line: every per-line market-share input is clamped 0–100% on entry, but the cross-line sum (Σ shares across all selected therapy lines) is intentionally not clamped — that surface lets users model competitive scenarios where lines compete for share.

Code-level: offset + 1 uptake indexing is intentional. getUptakeValue(uptakeType, offset + 1) inside eventImpactForLine (src/core/math/forecasting.ts) reads the year-after-launch uptake; the immediately following getUptakeValue(uptakeType, offset) (same function) reads the launch-year value. The pairing implements the rolling-12-month launch-year weighting (see “First Year Weighting” formula above) and is parked-intentional — don’t “fix” to curve[0].

A market event is a one-time, persistent share adjustment that begins at the event’s date. Events are intentionally additive — not multiplicative — because they model discrete competitive or commercial inflections (a new competitor, a label expansion, a guideline change) rather than scaling the existing uptake.

MarketShare[Year] = ( UptakeCurve[YearOffset] x EffectivePeakShare + EventImpact[Year] ) x LoEImpact[Year]
EventImpact[Year] = sum over all events whose startDate ≤ Year of:
event.impactPercent x UptakeCurve[Year − event.year] (same Speed-to-Peak as the line)

Behavior:

  • Events use the same Speed-to-Peak uptake curve as the line they sit on, so a 5pp event under “3 Year Slow” ramps to its full 5pp impact over 3 years.
  • Multiple events stack additively (their impacts sum into a single EventImpact[Year]).
  • Negative impacts are allowed (e.g., a competitor launch with impactPercent: -10 subtracts 10pp).
  • Events are permanent — there is no end date. Once active, an event continues to contribute its impact for the remainder of the forecast.
  • Events are gated by the line’s isSelected flag; deselected lines contribute 0 to both share and event impact.
  • Event impact is added on top of ramped share, then multiplied by LoEImpact, so post-LoE erosion attenuates the event’s contribution alongside the base share.

After LoE, market share erodes due to generic/biosimilar competition. Small molecules face rapid generic erosion; biologics erode more slowly due to biosimilar complexity.

Erosion curves are defined in MOLECULE_SHARE_EROSION and BIOLOGICS_SHARE_EROSION in src/core/types/constants.ts. Index i of the curve is the erosion percentage for the ith calendar year post-LoE (i = 0 is the year before LoE = 0% erosion; i = 1 is the LoE year; i ≥ N plateaus at the last tabulated value).

LoE Year Weighting (for mid-year LoE) — calendar year Y containing the LoE month M is split into a pre-LoE segment (months 1..M−1) and a post-LoE segment (months M..12):

LoEImpact[Y] = 1 - ( Erosion[i] x (13 - M) / 12 // post-LoE segment
+ Erosion[i-1] x (M - 1) / 12 ) // pre-LoE segment

where i = Y - LoEYear + 1 (so for Y = LoEYear, i = 1 → reads Erosion[1] for post-LoE months and Erosion[0] = 0 for pre-LoE months, yielding partial-year erosion). Years prior to LoE return LoEImpact = 1.0.

  • Jan-1 LoE = full calendar year of erosion. A loeDate of 2037-01-01 makes the entire year 2037 post-LoE — Jan 1 is the first post-LoE day, so M = 1 and the formula degenerates to 1 - Erosion[1].
  • Mid-year LoE prorates erosion via the partial-year blend formula above (months 1..M−1 use the prior-year erosion Erosion[i-1]; months M..12 use the LoE-year erosion Erosion[i]).
  • No early erosion before LoE. For any calendar year Y < LoEYear, the LoE pipeline short-circuits to LoEImpact = 1.0 (no share loss).
  • Erosion plateaus past the tabulated curve. The curves in MOLECULE_SHARE_EROSION / BIOLOGICS_SHARE_EROSION are sized to PROJECTION_DATA_LENGTH; for any year past the final tabulated entry the lookup clamps to the last value (no extrapolation). The constants.test.ts drift-guard pins their length to the constant so trailing-year flatlines surface in CI.
  • Past-LoE drugs are out of scope. The LoE date picker disables past calendar years (minYear = currentYear on the MonthPicker) — the model is for products with at least their LoE still ahead.
  • Erosion applies to share, not price. The Net Price Over Time computation has no LoE term; the price formula continues unchanged across the LoE boundary.

flowchart LR
    NP[New Patients] --> CALC((x))
    PRICE[Net Price per Month] --> CALC
    CALC --> CALC2((x))
    COMP[Compliance %] --> CALC2
    CALC2 --> CALC3((x))
    MOT[Months of Therapy] --> CALC3
    CALC3 --> DIV[/ 1,000,000]
    DIV --> SALES[Line Sales $M]

Price typically changes annually from the launch price:

NetPrice[Year] = LaunchPrice x (1 + AnnualPriceChange%)^(Year - LaunchYear)

Net price is independent of LoE. The price formula continues unchanged post-LoE — LoE only erodes share. If the price is meant to drop after generic entry, the user expresses that via the annualNetPriceChange schedule itself, not via the LoE pipeline. Likewise, the launch year sets the baseline (Year ≤ LaunchYear → NetPrice = LaunchPrice); price changes start compounding from year 1 onward.

Example:

  • Launch Price: $15,000/month
  • Annual Price Change: -2%
  • Year of Launch: 2025
YearCalculationNet Price
2025$15,000 x (0.98)^0$15,000
2026$15,000 x (0.98)^1$14,700
2027$15,000 x (0.98)^2$14,406
2030$15,000 x (0.98)^5$13,537
LineSales ($M) = sum(CohortPatients[yearOffset] x NetPrice x Compliance% x MonthsThisYear[yearOffset]) / 1,000,000

MonthsOfTherapy is the total time a typical patient stays on the drug — not a protocol length, not a treatment-cycle length. A value of 24 means the average patient is on the drug for 24 months; revenue from that cohort spans 24 months of billing.

MonthsOfTherapy is distributed across calendar years using cohort logic — each cohort year contributes up to 12 months. For example, a 24-month therapy generates 12 months of revenue in year 0 (from that year’s new patients) and 12 months in year 1 (from the prior year’s cohort). When MonthsOfTherapy <= 12, only one cohort year contributes and the formula reduces to NewPatients x NetPrice x Compliance% x MonthsOfTherapy / 1M.

The cohort look-back loop is bounded by COHORT_YEARS (constants.ts), sized to the number of projected revenue years so durations up to the MAX_MONTHS_OF_THERAPY input cap (300 months = 25 years) bill across all their cohorts. A therapy whose months exceed that cap could never be fully realized anyway: in the last projected year the oldest cohort is the launch-year cohort, so no displayed year can bill more than 25 × 12 months of any single patient’s therapy. Short therapies exit the loop early (the cap costs nothing for them).

Behavior change — COHORT_YEARS 5 → 25. Earlier builds capped the cohort look-back at 5 years (60 months), silently truncating revenue for any therapy longer than that. With the cap now 25 years (300 months), a saved model whose MonthsOfTherapy exceeds 60 bills its full duration and therefore shows higher line revenue than before. This is intended: the old cap under-counted long-therapy cohorts.

Returned values are already in $ millions. calculateLineSales (src/core/math/forecasting.ts), the inner calculateSales closure inside runTornadoAnalysis (src/core/math/tornado.ts), and runTornadoAnalysis itself (same file) all divide by REVENUE_SCALE_DIVISOR (1_000_000, src/core/math/constants.ts) before returning. UI labels, chart legends, and CSV exports that add a “$M” or “(millions)” suffix must NOT re-divide.

Example (1L Therapy):

  • New Patients: 5,000
  • Net Price: $12,000/month
  • Compliance: 85%
  • Months of Therapy: 10
LineSales = 5,000 x $12,000 x 0.85 x 10 / 1,000,000 = $510M
TotalSales = Sum of LineSales across all therapy lines (1L + 2L + 3L...)

Scenario: Oncology product, 2nd line therapy, Year 3 post-launch

InputValue
Addressable Population30,000
Treatment Rate80%
Transition Rate (from 1L)60%
Market Share (Year 3)35%
Net Price$14,000/month
Compliance90%
Months of Therapy8

Calculation:

1. Eligible Pool = 30,000 - 1L_Patients retained
(Assume 15,000 available after 1L retention)
2. 2L Eligible = 15,000 x 60% x 80% = 7,200 patients
3. New Patients = 7,200 x 35% = 2,520 patients
4. Sales = 2,520 x $14,000 x 0.90 x 8 / 1,000,000 = $254M

Monte Carlo simulation quantifies forecast uncertainty by running thousands of scenarios with randomly sampled variable values and aggregating the distribution of outcomes.

The simulation count is configurable and persisted per-scenario. Results are isolated between scenario tabs via independent Jotai stores. For execution architecture (worker lifecycle, dual-path execution, auto-run/stale logic, store pinning), see ARCHITECTURE.md.

flowchart TD
    subgraph Inputs["Variable Definitions"]
        V1["Variable 1<br/>Min: 10 | Mode: 15 | Max: 25"]
        V2["Variable 2<br/>Min: 50 | Mode: 70 | Max: 80"]
        VN["Variable N<br/>Min: 5 | Mode: 8 | Max: 12"]
    end

    subgraph Simulation["Monte Carlo Engine"]
        V1 --> SAMPLE[Sample from Distributions]
        V2 --> SAMPLE
        VN --> SAMPLE
        SAMPLE --> |"1K-100K iterations"| MODEL[Run Full Sales Model]
        MODEL --> COLLECT[Collect Results]
    end

    subgraph Outputs["Analysis Outputs"]
        COLLECT --> DIST[Distribution of Outcomes]
        COLLECT --> PERC["Percentiles<br/>P10 / P50 / P90"]
        COLLECT --> TORNADO[Tornado Chart]
    end

Variables that can be simulated:

CategoryVariables
IncidenceEarly Stage %, De Novo %, Unknown Stage % (when >0), Early-to-Early Relapse %, Early-to-Met Relapse %
TreatmentHealthcare access, Treatment rate — for solid tumors this splits into Early Treatment Rate (first early-stage line) and Metastatic Treatment Rate (first met-stage line) when those stages are present; hematology and early-only / met-only solid tumor scenarios fall back to a single Treatment Rate. See deriveDefaultSimulationVariables.ts.
MarketPeak share (per line), Months of therapy (per line)
PricingLaunch price
CustomCustom multiplier variables (up to MAX_CUSTOM_VARIABLES per line, when changeable)

Note: Transition rate, Compliance, and Annual price change are not currently simulated.

Incidence Evolution: Monte Carlo and Tornado analyses use the evolved incidence values from Custom Inputs. For each simulated year, getYearIncidence(year) applies the growth rates defined in the Incidence Evolution table.

DistributionUse CaseParameters
TriangularMost common — when you have min/max/modeMin, Mode, Max
NormalSymmetric uncertaintyMean, Std Dev
UniformEqual probability across rangeMin, Max

The tornado chart ranks variables by their impact on forecast uncertainty:

For each variable:
1. Hold all other variables at most likely values
2. Run model at variable's minimum value -> Low result
3. Run model at variable's maximum value -> High result
4. Impact = |High - Low|
5. Rank variables by impact (largest at top)

Variables at the top of the tornado have the greatest influence on uncertainty. Focus validation efforts on high-impact variables.

PercentileMeaning
P1010% chance results will be lower (pessimistic)
P50Median outcome (50% above, 50% below)
P9090% chance results will be lower (optimistic)

Key files: src/features/monte-carlo/ (UI), src/core/simulation/ (worker + engine bundle), src/core/state/sections/monte-carlo/atoms.ts


Each indication requires a DiseaseConfig — see src/core/types/types.ts. Includes disease type, incidence, stage mix (for solid tumors), and retreatment factor. Fallback values in STAGE_MIX_DEFAULTS (src/core/types/constants.ts).

The initialization flow is geo-aware: each geography derives its own baseIncidence, growthRate, healthcareAccess, stage mix, and treatment line configuration from the API data. When geo-specific data is unavailable, the fallback chain is: Requested geo -> USA -> EU5 -> Japan -> field-level defaults.

healthcareAccess is the exception: it resolves strictly per-geo (the requested geo’s value, else that geo’s field-level default — USA 95, EU5/Japan 100) and is never sourced from another geo, so one market’s access level cannot leak into another.

When the selected geography lacks its own incidenceBase for a cancer, the fallback cascade sources the value from another geo (USA -> EU5 -> Japan). To preserve epidemiological plausibility (USA’s raw patient count is ~2.8x Japan’s because the USA population is ~2.8x larger), the sourced value is normalized per-capita using each geography’s popBase:

finalBase = round(sourceBase x targetPopBase / sourcePopBase)

Example: Japan has no CRC incidenceBase. Cascade sources USA (154,270). USA popBase = 344.1M; Japan popBase = 124.4M. Result: round(154,270 x 124.4 / 344.1) ~= 55,773.

The derivation is surfaced in the amber fallback banner tooltip at the top of the Configuration section (2-line sub-block showing method and formula).

Degraded path: if either popBase is missing or non-positive (a reference-data defect), the raw source value is copied 1:1 without per-capita normalization (today’s pre-fallback behavior).

Scope: applies to incidenceBase only. incidenceGrowth fallback copies 1:1 — growth rates are already cancer/geo-specific percentages that embed all epidemiological factors.

Stage mix defaults: see STAGE_MIX_DEFAULTS in src/core/types/constants.ts. Line parameters (treatment rate, compliance, months of therapy): each per-line value is seeded from its curated guidance reference row when present (deriveDiseaseConfig in src/core/state/primitives/reference-query.ts), falling back to LINE_DEFAULTS in constants.ts / the DEFAULT object in src/core/state/sections/configuration/atoms.ts (e.g. compliance 85, months of therapy 12) when no guidance row exists.

When stage distribution includes an “Unknown” category, the unknown portion is redistributed proportionally across known stages:

knownTotal = earlyStagePercent + metStagePercent
effectiveEarlyPct = earlyStagePercent / knownTotal
effectiveMetPct = metStagePercent / knownTotal
earlyIncidence = totalIncidence x effectiveEarlyPct
metIncidence = totalIncidence x effectiveMetPct

Example: Incidence=154,270, Early=71%, Met=23%, Unknown=6%

knownTotal = 71 + 23 = 94
effectiveEarly = 71/94 = 75.53% -> 116,523 patients
effectiveMet = 23/94 = 24.47% -> 37,747 patients
Total accounted: 154,270 (100%)

When unknownStagePercent = 0 (default), knownTotal = 100% and the formula reduces to the identity — no change from direct percentage usage.

The UI constraint earlyStagePercent + metStagePercent + unknownStagePercent = 100% is enforced in SummaryPanel.tsx with Metastatic as the editable leaf and Unknown as the read-only residual (shipped in commit 1acc97e):

  • When Early changes (parent, Localized, or Locally Advanced): unknownStagePercent = max(0, 100 - earlyStagePercent - metStagePercent) (Unknown absorbs; Met stays fixed).
  • When Metastatic changes: unknownStagePercent = max(0, 100 - earlyStagePercent - metStagePercent) (Unknown absorbs; Early stays fixed).

The forecast is unchanged from the prior metStagePercent-residual cascade — the normalizer above divides by earlyPct + metPct before consuming, so any algebraically equivalent assignment of unknownStagePercent produces an identical forecast. The Tornado/Monte Carlo constraint solver (see DATA_REFERENCE.md §3 Runtime Behavior) is unchanged: it keeps Met fixed and absorbs into Early.

Projection length is PROJECTION_DATA_LENGTH = 26 in constants.ts1 pre-launch year (zeros) + 25 post-launch years. The pre-launch row anchors the chart at firstSelectedLaunchYear − 1 so the launch transition is visible; the 25 post-launch years are the active forecast horizon. Default visible range is DEFAULT_VISIBLE_LABELS in SalesChart.tsx. Data points are annual.

Launch year flexibility. The launch picker accepts any calendar year — past (already-launched products), present, or future. The LoE picker is the only date input that disables past years; LoE is bound to firstSelectedLaunch + marketExclusivityYears bidirectionally — editing LoE shifts all line launches by the same month delta (see ARCHITECTURE.md §“bidirectional sync (loeDate)”).

Evolution Arrays (incidenceEvolution, netPriceEvolution)

Section titled “Evolution Arrays (incidenceEvolution, netPriceEvolution)”

Both are stored per {geo, indication} in instanceRows as number[] of length PROJECTION_DATA_LENGTH — always arrays, never collapsed to a scalar even when values are identical.

  • Fresh load: derived client-side, not returned by the API.
    • incidenceEvolution = buildIncidenceEvolution(baseIncidence, growthRate, PROJECTION_DATA_LENGTH) (inputs from statistics geoConfig).
    • netPriceEvolution = [0, 0, ...]. When the toggle is enabled, pre-filled with annualNetPriceChange; when disabled, reset to [].
  • Saved model load: both arrive as arrays inside FullSnapshot.instanceRows (GET /api/forecasting/:id).
  • Related scalar: annualNetPriceChange is a separate per-{geo, indication} row (default 2 from DEFAULT_ASSUMPTION_DATA), overridden by the API only on saved-model load.
  • Companion slot — incidenceEditedYears: sorted number[] of transition indices (0-based, between consecutive evolution years) the user has explicitly edited. Hydrated to ReadonlySet<number> on read and threaded through applyGrowthRateChange so non-edited downstream cells snap to referenceRate while user-edited cells are preserved. resetGrowthRateAtYearAtom drops the rolled-back yearIndex from the set; resetAllGrowthRatesAtom clears it entirely. Legacy snapshots without the slot load as an empty Set. Invariant: user-edited is explicit, not derived — the app never infers edits from value comparison.

Common gotchas:

  • The geo fallback chain is: Requested geo -> USA -> EU5 -> Japan -> field-level defaults (per-capita normalization applied to incidenceBase when the cascade fires). healthcareAccess is excluded — it resolves strictly per-geo (requested geo, else that geo’s default) and never cascades across geos.
  • Healthcare access percentages are API-sourced per indication, not hardcoded
  • Unknown stage redistribution preserves total incidence by proportionally scaling early/met percentages
  • Compliance default is defined in Configuration/atoms.ts (not 100% — verify current value there)