Library/Model Validation and Backtesting/Classification and Score Validation/Rare-Event Backtest and Confidence Bounds

D40-F05-A10 / Complete engineering topic

Rare-Event Backtest and Confidence Bounds

Backtest a rare binary event with a point estimate, expected count, finite Wilson interval, and explicit sampling assumptions.

Rare-Event Backtest and Confidence Bounds keeps the validation population, convention, evidence, output, and limitation visibleD40 / D40-F05

Backtest a rare binary event with a point estimate, expected count, finite Wilson interval, and explicit sampling assumptions.

Rare-Event Backtest and Confidence Bounds validation path from governed input to auditable diagnostic

Figure 1. Synthetic canonical path. Population, convention, intermediate evidence, output, and misuse boundary remain visible together.

The decision this tutorial makes visible

With rare defaults, frauds, outages, or tail events, zero or a few observations can look reassuring while uncertainty remains wide. A confidence interval keeps that uncertainty visible and prevents zero observed events from becoming zero risk.

The precise question is: Given a matured event count, independent-trial approximation, expected probability, and confidence level, what Wilson bounds and consistency state should be reported?

A practitioner needs to know what the diagnostic does and does not justify. A builder needs a contract that can be reproduced from the same point-in-time inputs in Python, TypeScript, a visual, and a browser lab.

Intuition before notation

The observed rate is one noisy realization. Wilson bounds combine the count, sample size, and confidence level to show which underlying probabilities remain compatible with that realization under the declared approximation.

The result depends on the declared algorithm scope, input clocks, units, equality and rounding policies, and unsupported-state treatment. Change one of those and the output represents a different decision even when its field name is unchanged.

Scope and nearby methods

The canonical variant treats n matured observations as an independent Bernoulli approximation, supports confidence levels 0.90, 0.95, and 0.99, calculates a two-sided Wilson score interval, and classifies the expected probability as inside, below, or above the interval.

VariantDefinitionBest useMain limitation
Canonical Wilson score intervalClosed-form score boundsSparse binomial proportionsApproximate repeated coverage
Clopper-Pearson exact intervalInversion of exact binomial testsConservative finite-sample boundsOften wider
Clustered or time-dependent event modelAccount for dependence, censoring, or exposureRepeated borrowers or time seriesNot this contract

What is sourced, selected, synthetic, and derived

RoleMaterial claimEvidenceBoundary
Sourced factCredit events can be infrequent and long horizons make validation data-limited.Basel Committee BCBS 49Historical supervisory context, not a modern performance threshold.
Sourced methodWilson's score construction provides finite binomial-proportion bounds.Wilson (1927)Depends on the declared sampling approximation.
Implementation choiceUse a two-sided Wilson interval at one of three fixed confidence levels.Frozen contractExact Clopper-Pearson and dependent-event methods are different variants.
Synthetic teaching inputThree events among one thousand trials and expected probability 0.2% are invented.Repository fixtureNot a default, fraud, or loss observation.

The authoritative sources support only the exact facts named in the claim ledger. They do not certify the synthetic numbers in this tutorial. The repository fixture is deliberately invented for auditability, and the displayed output is author-derived under the selected implementation choice.

Formula, symbols, and numerical policy

Plain text
p_hat=k/n; center=(p_hat+z²/(2n))/(1+z²/n); half=z*sqrt(p_hat(1-p_hat)/n+z²/(4n²))/(1+z²/n)
SymbolMeaningUnitPolicy
kobserved matured eventseventsinteger 0..n
neligible trialstrialspositive integer
p_hatobserved event ratefractionk/n
znormal quantilestandard deviationsmapped from allowed confidence level
[L,U]Wilson score intervalfractionfrequentist repeated-procedure interpretation
  • Use IEEE-754 binary64 arithmetic without intermediate rounding.
  • Group equal scores before ROC/PR curve updates unless a topic explicitly declares deterministic rank splitting.
  • Use positive finite weights; report counts and weight sums beside normalized metrics.
  • Round only for presentation and retain null for undefined diagnostics.

Read the formula in the same order as the algorithm. Validate identity, ordering, units, and supported state first. Apply the selected equality and window rules second. Calculate with unrounded numeric values. Round only at the declared presentation boundary, and preserve null as a diagnostic rather than coercing it to zero.

Build the algorithm

  1. Validate count, trials, expected probability, confidence level, horizon, and assumption
  2. Calculate observed rate and expected count
  3. Map confidence level to z
  4. Calculate Wilson center and half-width
  5. Clamp bounds to [0,1]
  6. Classify expected probability relative to the interval

Production-minded operational checklist

  1. Freeze model version, population, score direction, and evaluation cutoff.
  2. Verify outcome maturity and exclude future or revised evidence.
  3. Reconcile record identities, weights, labels, and required slice or time keys.
  4. Calculate the declared metric with visible intermediate denominators.
  5. Review uncertainty and complementary diagnostics before any decision.

The checklist is intentionally strict: an explicit rejection is safer than a plausible output built from stale, malformed, or unsupported state.

Worked synthetic example

The canonical fixture is synthetic teaching data, not an observed control event, customer order, or broker execution. Its primary author-derived output, observed_rate, is 0.003. The complete input and output are in datasets/canonical-input.json and datasets/expected-output.json.

The synthetic backtest observes three events in one thousand matured trials against a frozen 0.2% expectation. The Wilson interval shows the finite set of compatible event probabilities under the independent-Bernoulli approximation.

Counterfactual checkpoint

Observe zero events. Set k=0 while keeping n and the frozen expected probability. The output changes because the point estimate reaches zero but interval uncertainty remains

The structured result retains state and diagnostics in addition to the primary number. That makes the calculation independently reviewable and prevents a partial, null, rejected, or venue-bounded outcome from being mistaken for an unqualified value.

Boundary and counterexample workbook

The playground computes every scenario at 61 deterministic parameter states. The table uses the declared focus step and states whether that focus reproduces the canonical fixture. The full state ledger and compressed transition segments are in datasets/scenario-results.json.

ScenarioReview focusPurposeStatePrimary outputDiagnosticDecision segments
Canonical contractStep 30 · canonical fixtureExact canonical fixture at state 31; nearby states perturb one declared driver.rare-event-evaluatedobserved event rate 0.003000Wilson [0.00102, 0.00878]; inside-interval1
Stronger separationStep 30 · comparison focusMove positives and negatives apart while preserving labels and the evaluation cutoff.rare-event-evaluatedobserved event rate 0.004000Wilson [0.00156, 0.01024]; inside-interval1
Weaker or reversed separationStep 30 · comparison focusCompress and eventually invert score quality without changing outcome maturity.rare-event-evaluatedobserved event rate 0.002857Wilson [0.00097, 0.00837]; inside-interval1
Tie and boundary pressureStep 30 · canonical fixtureQuantize scores or probabilities to expose equality, bin, bucket, and threshold rules.rare-event-evaluatedobserved event rate 0.003000Wilson [0.00102, 0.00878]; inside-interval1
Prevalence and weight shiftStep 30 · comparison focusReweight event and non-event records while preserving identities.rare-event-evaluatedobserved event rate 0.003000Wilson [0.00102, 0.00878]; inside-interval1
Probability sharpness stressStep 30 · comparison focusMove probabilities toward or away from endpoints while preserving score order.rare-event-evaluatedobserved event rate 0.010000Wilson [0.00544, 0.01831]; expected-below-interval1
Low-information comparisonStep 30 · comparison focusCompress scores and probabilities toward the population center.rare-event-evaluatedobserved event rate 0.000000Wilson [0.00000, 0.01378]; inside-interval1

These rows are not backtest observations. They are controlled counterexamples that expose how one driver changes the state, output, or reason code while the rest of the contract stays fixed.

Visualize the boundary

Rare-Event Backtest and Confidence Bounds annotated teaching map

Rare-Event Backtest and Confidence Bounds calculation, rejection, and routing decisions

Decision takeaway: undefined, low-support, and rejected states remain visible rather than being coerced into zero.

Open this SVG at full size, or use the guided playground to compare the seven topic-specific canonical, boundary, policy, and failure scenarios.

The Mermaid flow answers where the selected calculation sits in the processing sequence. The SVG keeps the formula, output, decision boundary, and invariant visible together. The lab lets the reader step through the same structured states without changing the underlying definition.

Implementation walkthrough

The Python and TypeScript references begin with the same validation contract, reject malformed and unsupported state before calculation, preserve declared ordering and rounding policies, and return structured diagnostics rather than one context-free number.

The main implementation branches are:

  • Zero events observed — Report zero point estimate and nonzero upper bound, because Zero observations do not prove zero risk.
  • Expected probability is below lower bound — Flag observed rate higher than expected under the interval test, because The frozen expectation is inconsistent at the selected level.
  • Trials are dependent or labels immature — Reject or route to a dependence/maturity-aware design, because Binomial coverage assumptions are not met.

Neither reference silently fetches data, mutates caller-owned inputs outside the declared engine behavior, guesses hidden state, or substitutes a provider default. Shared JSON fixtures make value, null, state, and reason-code drift visible across languages.

Testing and validation

Definition tests compare every canonical field, reject malformed state, and exercise the material boundary. Family validation recomputes every playground state from the reference function. Independent arithmetic is recorded beside the fixture rather than inferred only from implementation output.

The audit must preserve these invariants:

  • population, positive-label orientation, weights, and cutoff
  • selected tie, integration, bin, bucket, threshold, band, slice, or interval convention
  • intermediate counts and denominators
  • primary metric and comparison baseline
  • undefined, rejected, low-support, and uncertainty diagnostics

Passing the suite proves selected-convention arithmetic and Python/TypeScript parity; it does not establish production fitness or an acceptance threshold.

Failure modes and misuse

  • A metric describes the declared evaluation population; distribution shift, label policy, interventions, and sampling can change its meaning.
  • One aggregate can hide threshold, calibration, segment, temporal, and uncertainty failures.
  • Passing implementation tests proves definition fidelity, not production reliability, fairness, legal compliance, profitability, or causal benefit.
  • Synthetic examples do not estimate real-world model performance or provide investment, lending, fraud, insurance, or regulatory advice.

Debugging order

When a result looks surprising, inspect the state in this order:

  1. Confirm identifiers, scope, side, and decision clock.
  2. Confirm units, ordering, and point-in-time inputs.
  3. Confirm equality, rounding, null, and reset policies.
  4. Recalculate the invariant and declared scenario focus before changing code.

Evidence and historical boundary

Historical decision: not useful. A named production model would add entity, privacy, label-maturity, sampling, policy, licensing, and causal-story risks without teaching the selected metric better than controlled synthetic records. The package therefore makes no claim about any real customer, issuer, fraud event, model approval, or future outcome.

The primary sources are Federal Reserve SR 26-2, Basel credit-risk modelling report, Wilson (1927), Clopper and Pearson (1934). They support the source roles listed in the research ledger, not a redistributable historical observation, a private participant decision, production conformance certification, execution-quality result, profitability claim, or prediction claim.

Choose the validation question first

These methods are complementary layers, not interchangeable scores.

LayerMethodsRequiresDoes not establish
Ranking discriminationROC-AUC · PR-AUC · Gains/Liftscores + matured labelsDoes not validate probability scale or choose a policy
Probability qualityBrier · Log Loss · Reliability/ECEprobabilities + matured outcomesDoes not replace ranking, costs, or support review
Decision policyCost-sensitive thresholdscores + labels + governed costsThe optimum changes with costs, prevalence, and constraints
Monitoring structureScore migration · Slice validationmatched vintages or governed groupsAttrition, taxonomy, and support must remain visible
Sparse evidenceRare-event confidence boundsevent count + trials + sampling modelA point estimate is incomplete without uncertainty

Five complementary classification-validation layers with Rare-Event Backtest and Confidence Bounds highlighted

Method-selection takeaway: start from the decision question and available evidence. A strong rank does not prove calibrated probabilities; calibration does not choose a threshold; an aggregate does not prove slice or temporal stability.

Use the topic glossary to keep score, probability, label maturity, support, convention, and null diagnostics consistent across the family.

Use the guided learning lab

Question to answer: Change events, trials, and confidence to see why zero or few observed events still imply material uncertainty.

  1. Start with the canonical synthetic fixture and read the compact evidence trace.
  2. Select the experiment that exposes the nearest boundary.
  3. Change Observed events and watch the topic-specific stage recompute.
  4. Use Step and Back to connect the intermediate evidence to the primary diagnostic.
  5. Compare the result with this guardrail: This Wilson interval uses an independent Bernoulli approximation; clustering or dependence requires another model.

Open the standalone guided lab

Lab takeaway: A sparse-event point estimate is incomplete without trials, a sampling assumption, and a confidence interval.

Summary and next topic

You can now calculate and audit the selected classification-validation diagnostic. The learning flow is: Slice-Based Validation by Sector, Country, and Regime → Rare-Event Backtest and Confidence Bounds → Model monitoring and time-aware revalidation. Carry the result forward only with its scope, clock, state, and evidence label.

Rare-Event Backtest and Confidence Bounds calculation flow

This flow identifies the selected calculation stages and the structured output.

Rendering system map…

Takeaway: Sparse point estimates are incomplete; confidence bounds and sampling assumptions carry the uncertainty.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

S1 — Revised Guidance on Model Risk Management

  • Organization or authors: Board of Governors of the Federal Reserve System, OCC, and FDIC
  • Source type: Current interagency supervisory guidance
  • Publication or effective date: 2026-04-17
  • Version: SR 26-2
  • URL or DOI: https://www.federalreserve.gov/supervisionreg/srletters/SR2602.htm
  • Accessed: 2026-08-06
  • Jurisdiction: United States banking supervision
  • Supports: Validation and monitoring should assess reliability, limitations, performance deterioration, intended use, and data or model changes using a risk-based approach.
  • Limitations: Does not prescribe a universal metric, threshold, binning rule, or acceptance limit; applicability is supervisory and institution-specific.

S2 — Credit Risk Modelling: Current Practices and Applications

  • Organization or authors: Basel Committee on Banking Supervision
  • Source type: Official international supervisory report
  • Publication or effective date: 1999-04
  • Version: BCBS 49
  • URL or DOI: https://www.bis.org/publ/bcbs49.pdf
  • Accessed: 2026-08-06
  • Jurisdiction: International banking and credit-risk modelling
  • Supports: Rating transition matrices express migration probabilities between grades; credit events are infrequent and long horizons make validation data-limited.
  • Limitations: The report is historical, does not define this package's score bands, and does not validate a modern production model.

S3 — Probable Inference, the Law of Succession, and Statistical Inference

  • Organization or authors: Edwin B. Wilson
  • Source type: Original peer-reviewed research
  • Publication or effective date: 1927-06
  • Version: JASA 22(158), 209-212
  • URL or DOI: https://doi.org/10.1080/01621459.1927.10502953
  • Accessed: 2026-08-06
  • Jurisdiction: Binomial proportion inference
  • Supports: The score-based interval provides finite bounds for a binomial proportion, including sparse-count settings.
  • Limitations: Coverage interpretation assumes the declared sampling model and repeated-use procedure; it is not a posterior probability statement.

S4 — The Use of Confidence or Fiducial Limits Illustrated in the Case of the Binomial

  • Organization or authors: C. J. Clopper and E. S. Pearson
  • Source type: Original peer-reviewed research
  • Publication or effective date: 1934
  • Version: Biometrika 26(4), 404-413
  • URL or DOI: https://doi.org/10.1093/biomet/26.4.404
  • Accessed: 2026-08-06
  • Jurisdiction: Binomial proportion inference
  • Supports: Exact binomial confidence limits are a distinct finite-sample construction.
  • Limitations: The canonical package implements Wilson bounds and names exact Clopper-Pearson only as a nearby alternative.

Evidence boundary

Sources establish metric, statistical, and governance context. They do not verify the synthetic fixture, select a business threshold, or certify a deployed model.

classification-validation.ts
/** Reference implementations for D40-F05 classification and score validation. */

type AnyRecord = Record<string, any>;

function numberValue(value: unknown, name: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite numeric`);
  return value;
}

function integerValue(value: unknown, name: string): number {
  const result = numberValue(value, name);
  if (!Number.isInteger(result)) throw new RangeError(`${name} must be an integer`);
  return result;
}

function records(inputs: AnyRecord, options: { score?: boolean; probability?: boolean } = {}): AnyRecord[] {
  if (!Array.isArray(inputs.records) || inputs.records.length === 0) throw new RangeError("records must be a nonempty array");
  const cutoff = inputs.evaluation_cutoff;
  if (cutoff !== undefined && (typeof cutoff !== "string" || cutoff.length === 0)) throw new TypeError("evaluation_cutoff must be a nonempty ISO-8601 string");
  const seen = new Set<string>();
  return inputs.records.map((raw: unknown, index: number) => {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError(`records[${index}] must be an object`);
    const item = raw as AnyRecord;
    if (typeof item.id !== "string" || item.id.length === 0 || seen.has(item.id)) throw new RangeError("record ids must be unique nonempty strings");
    seen.add(item.id);
    if (item.label !== 0 && item.label !== 1) throw new RangeError("labels must be numeric 0 or 1");
    const weight = numberValue(item.weight ?? 1, `records[${index}].weight`);
    if (weight <= 0) throw new RangeError("weights must be positive");
    const row: AnyRecord = { ...item, label: item.label, weight };
    if (options.score) row.score = numberValue(item.score, `records[${index}].score`);
    if (options.probability) {
      row.probability = numberValue(item.probability, `records[${index}].probability`);
      if (row.probability < 0 || row.probability > 1) throw new RangeError("probabilities must be in [0,1]");
    }
    if (cutoff !== undefined) {
      for (const key of ["score_available_at", "label_available_at"]) {
        const timestamp = item[key];
        if (timestamp !== undefined) {
          if (typeof timestamp !== "string" || timestamp.length === 0) throw new TypeError(`${key} must be a nonempty ISO-8601 string`);
          if (timestamp > cutoff) throw new RangeError(`${key} exceeds evaluation_cutoff`);
        }
      }
    }
    return row;
  });
}

function classWeights(rows: AnyRecord[]): [number, number] {
  let positive = 0, negative = 0;
  for (const row of rows) row.label === 1 ? positive += row.weight : negative += row.weight;
  return [positive, negative];
}

function scoreGroups(rows: AnyRecord[]): Array<[number, AnyRecord[]]> {
  const ordered = [...rows].sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
  const groups: Array<[number, AnyRecord[]]> = [];
  for (const row of ordered) {
    const last = groups.at(-1);
    if (!last || last[0] !== row.score) groups.push([row.score, [row]]);
    else last[1].push(row);
  }
  return groups;
}

export function rocCurveAuc(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { score: true });
  const [positive, negative] = classWeights(rows);
  if (positive <= 0 || negative <= 0) throw new RangeError("ROC requires positive and negative weight");
  let tp = 0, fp = 0;
  const points: AnyRecord[] = [{ threshold: null, true_positive: 0, false_positive: 0, true_negative: negative, false_negative: positive, tpr: 0, fpr: 0 }];
  for (const [threshold, group] of scoreGroups(rows)) {
    for (const row of group) row.label === 1 ? tp += row.weight : fp += row.weight;
    points.push({ threshold, true_positive: tp, false_positive: fp, true_negative: negative - fp, false_negative: positive - tp, tpr: tp / positive, fpr: fp / negative });
  }
  let auc = 0;
  for (let i = 1; i < points.length; i++) auc += (points[i].fpr - points[i - 1].fpr) * (points[i].tpr + points[i - 1].tpr) / 2;
  return { points, roc_auc: auc, positive_weight: positive, negative_weight: negative, tie_group_count: points.length - 1, state: "ranking-evaluated" };
}

export function precisionRecallAuc(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { score: true });
  const [positive, negative] = classWeights(rows);
  if (positive <= 0) throw new RangeError("precision-recall requires positive weight");
  let tp = 0, fp = 0, previousRecall = 0, averagePrecision = 0;
  const points: AnyRecord[] = [{ threshold: null, true_positive: 0, false_positive: 0, precision: 1, recall: 0 }];
  for (const [threshold, group] of scoreGroups(rows)) {
    for (const row of group) row.label === 1 ? tp += row.weight : fp += row.weight;
    const precision = tp / (tp + fp), recall = tp / positive;
    averagePrecision += (recall - previousRecall) * precision;
    previousRecall = recall;
    points.push({ threshold, true_positive: tp, false_positive: fp, precision, recall });
  }
  return { points, pr_auc_average_precision: averagePrecision, baseline_prevalence: positive / (positive + negative), positive_weight: positive, negative_weight: negative, tie_group_count: points.length - 1, integration_rule: "average-precision-right-step", state: "ranking-evaluated" };
}

export function brierScore(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { probability: true });
  const total = rows.reduce((sum, row) => sum + row.weight, 0);
  const eventWeight = rows.reduce((sum, row) => sum + row.weight * row.label, 0);
  const eventRate = eventWeight / total;
  const score = rows.reduce((sum, row) => sum + row.weight * (row.probability - row.label) ** 2, 0) / total;
  const baseline = eventRate * (1 - eventRate);
  return { brier_score: score, event_rate: eventRate, baseline_brier: baseline, brier_skill: baseline === 0 ? null : 1 - score / baseline, weight_sum: total, record_count: rows.length, state: "probability-evaluated" };
}

export function binaryLogLoss(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { probability: true });
  const epsilon = numberValue(inputs.epsilon ?? 1e-15, "epsilon");
  if (epsilon <= 0 || epsilon >= 0.5) throw new RangeError("epsilon must be in (0,0.5)");
  const total = rows.reduce((sum, row) => sum + row.weight, 0);
  const eventWeight = rows.reduce((sum, row) => sum + row.weight * row.label, 0);
  let loss = 0, clippedCount = 0;
  for (const row of rows) {
    const q = Math.min(Math.max(row.probability, epsilon), 1 - epsilon);
    if (q !== row.probability) clippedCount++;
    loss += row.weight * -Math.log(row.label === 1 ? q : 1 - q);
  }
  return { log_loss: loss / total, clipped_count: clippedCount, epsilon, weight_sum: total, event_rate: eventWeight / total, state: "probability-evaluated" };
}

export function reliabilityEce(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { probability: true });
  const binCount = integerValue(inputs.bins ?? 5, "bins");
  if (binCount < 2 || binCount > 20) throw new RangeError("bins must be between 2 and 20");
  if ((inputs.bin_strategy ?? "uniform") !== "uniform") throw new RangeError("only uniform bin_strategy is supported");
  const total = rows.reduce((sum, row) => sum + row.weight, 0);
  const ledgers: AnyRecord[][] = Array.from({ length: binCount }, () => []);
  for (const row of rows) ledgers[Math.min(Math.floor(row.probability * binCount), binCount - 1)].push(row);
  const bins: AnyRecord[] = [];
  let ece = 0, mce = 0, signed = 0;
  for (let index = 0; index < binCount; index++) {
    const members = ledgers[index], lower = index / binCount, upper = (index + 1) / binCount;
    if (members.length === 0) {
      bins.push({ index: index + 1, lower, upper, right_inclusive: index === binCount - 1, record_count: 0, weight_sum: 0, mean_probability: null, event_rate: null, signed_gap: null, absolute_gap: null });
      continue;
    }
    const weight = members.reduce((sum, row) => sum + row.weight, 0);
    const meanProbability = members.reduce((sum, row) => sum + row.weight * row.probability, 0) / weight;
    const eventRate = members.reduce((sum, row) => sum + row.weight * row.label, 0) / weight;
    const signedGap = eventRate - meanProbability, absoluteGap = Math.abs(signedGap);
    ece += weight / total * absoluteGap;
    signed += weight / total * signedGap;
    mce = Math.max(mce, absoluteGap);
    bins.push({ index: index + 1, lower, upper, right_inclusive: index === binCount - 1, record_count: members.length, weight_sum: weight, mean_probability: meanProbability, event_rate: eventRate, signed_gap: signedGap, absolute_gap: absoluteGap });
  }
  return { bins, expected_calibration_error: ece, maximum_calibration_error: mce, signed_calibration_error: signed, bin_count: binCount, weight_sum: total, state: "calibration-evaluated" };
}

export function gainsLift(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { score: true });
  const bucketCount = integerValue(inputs.buckets ?? 10, "buckets");
  if (bucketCount < 2 || bucketCount > rows.length) throw new RangeError("buckets must be between 2 and record count");
  const ordered = [...rows].sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
  const totalWeight = ordered.reduce((sum, row) => sum + row.weight, 0);
  const totalEvents = ordered.reduce((sum, row) => sum + row.weight * row.label, 0);
  if (totalEvents <= 0) throw new RangeError("gains and lift require positive event weight");
  const prevalence = totalEvents / totalWeight;
  const members: Array<Array<[number, AnyRecord]>> = Array.from({ length: bucketCount }, () => []);
  ordered.forEach((row, rank) => members[Math.min(Math.floor(rank * bucketCount / ordered.length), bucketCount - 1)].push([rank + 1, row]));
  let cumulativeWeight = 0, cumulativeEvents = 0;
  const buckets = members.map((bucket, index) => {
    const weight = bucket.reduce((sum, [, row]) => sum + row.weight, 0);
    const events = bucket.reduce((sum, [, row]) => sum + row.weight * row.label, 0);
    cumulativeWeight += weight; cumulativeEvents += events;
    const populationShare = weight / totalWeight, eventCapture = events / totalEvents;
    const cumulativePopulation = cumulativeWeight / totalWeight, cumulativeGain = cumulativeEvents / totalEvents;
    return { bucket: index + 1, rank_start: bucket[0][0], rank_end: bucket.at(-1)![0], record_count: bucket.length, population_weight: weight, event_weight: events, population_share: populationShare, event_capture: eventCapture, bucket_lift: (events / weight) / prevalence, cumulative_population: cumulativePopulation, cumulative_gain: cumulativeGain, cumulative_lift: cumulativeGain / cumulativePopulation };
  });
  return { buckets, top_decile_capture: buckets[0].event_capture, overall_prevalence: prevalence, total_events: totalEvents, total_weight: totalWeight, bucket_count: bucketCount, tie_break: "score-descending-id-ascending", state: "ranking-evaluated" };
}

export function costSensitiveThreshold(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { score: true });
  if (!inputs.costs || typeof inputs.costs !== "object" || Array.isArray(inputs.costs)) throw new TypeError("costs must be an object");
  const names = ["false_positive", "false_negative", "true_positive", "true_negative"];
  const costs: AnyRecord = {};
  for (const name of names) costs[name] = numberValue(inputs.costs[name], `costs.${name}`);
  if (names.some(name => costs[name] < 0) || (costs.false_positive === 0 && costs.false_negative === 0)) throw new RangeError("costs must be nonnegative with a positive misclassification cost");
  const total = rows.reduce((sum, row) => sum + row.weight, 0);
  const thresholds: Array<number | null> = [null, ...[...new Set(rows.map(row => row.score))].sort((a, b) => b - a)];
  const candidates = thresholds.map(threshold => {
    let tp = 0, fp = 0, tn = 0, fn = 0, selected = 0;
    for (const row of rows) {
      const predicted = threshold !== null && row.score >= threshold;
      if (predicted) selected += row.weight;
      if (predicted && row.label === 1) tp += row.weight;
      else if (predicted && row.label === 0) fp += row.weight;
      else if (!predicted && row.label === 0) tn += row.weight;
      else fn += row.weight;
    }
    const expectedCost = (costs.false_positive * fp + costs.false_negative * fn + costs.true_positive * tp + costs.true_negative * tn) / total;
    return { threshold, true_positive: tp, false_positive: fp, true_negative: tn, false_negative: fn, selected_weight: selected, selected_rate: selected / total, expected_cost: expectedCost };
  });
  const optimal = [...candidates].sort((a, b) => a.expected_cost - b.expected_cost || a.selected_weight - b.selected_weight || -((a.threshold ?? Infinity) - (b.threshold ?? Infinity)))[0];
  return { candidates, optimal_threshold: optimal.threshold, optimal_expected_cost: optimal.expected_cost, optimal_selected_rate: optimal.selected_rate, optimal_confusion: { true_positive: optimal.true_positive, false_positive: optimal.false_positive, true_negative: optimal.true_negative, false_negative: optimal.false_negative }, costs, tie_break: "minimum-cost-then-lower-selected-weight-then-higher-threshold", state: "threshold-selected" };
}

function band(score: number, edges: number[]): number {
  let result = 0;
  while (result + 1 < edges.length && score >= edges[result + 1]) result++;
  return Math.min(result, edges.length - 2);
}

export function scoreMigration(inputs: AnyRecord): AnyRecord {
  if (!Array.isArray(inputs.baseline) || inputs.baseline.length === 0 || !Array.isArray(inputs.current) || inputs.current.length === 0) throw new RangeError("baseline and current must be nonempty arrays");
  if (inputs.higher_score_higher_risk !== true) throw new RangeError("canonical orientation requires higher_score_higher_risk=true");
  if (typeof inputs.baseline_observed_at !== "string" || typeof inputs.current_observed_at !== "string" || inputs.baseline_observed_at >= inputs.current_observed_at) throw new RangeError("baseline_observed_at must be before current_observed_at");
  if (!Array.isArray(inputs.band_edges) || inputs.band_edges.length < 3) throw new RangeError("band_edges must contain at least three values");
  const edges = inputs.band_edges.map((value: unknown) => numberValue(value, "band_edges"));
  if (edges[0] !== 0 || edges.at(-1) !== 1 || edges.slice(1).some((value: number, index: number) => edges[index] >= value)) throw new RangeError("band_edges must increase strictly from 0 to 1");
  function snapshot(rawRows: unknown[], name: string): Map<string, number> {
    const result = new Map<string, number>();
    rawRows.forEach((raw, index) => {
      if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError(`${name}[${index}] must be an object`);
      const row = raw as AnyRecord;
      if (typeof row.id !== "string" || row.id.length === 0 || result.has(row.id)) throw new RangeError(`${name} ids must be unique nonempty strings`);
      const score = numberValue(row.score, `${name}[${index}].score`);
      if (score < 0 || score > 1) throw new RangeError("migration scores must be in [0,1]");
      result.set(row.id, score);
    });
    return result;
  }
  const baseline = snapshot(inputs.baseline, "baseline"), current = snapshot(inputs.current, "current");
  if (baseline.size !== current.size || [...baseline.keys()].some(id => !current.has(id))) throw new RangeError("baseline and current must contain identical ids");
  const size = edges.length - 1, matrix = Array.from({ length: size }, () => Array(size).fill(0));
  const migrations: AnyRecord[] = [];
  let stable = 0, improved = 0, worsened = 0, absoluteMove = 0, scoreChange = 0, absoluteScoreChange = 0;
  for (const id of [...baseline.keys()].sort()) {
    const before = baseline.get(id)!, after = current.get(id)!;
    const a = band(before, edges), b = band(after, edges), move = b - a, delta = after - before;
    matrix[a][b]++;
    move === 0 ? stable++ : move < 0 ? improved++ : worsened++;
    absoluteMove += Math.abs(move); scoreChange += delta; absoluteScoreChange += Math.abs(delta);
    migrations.push({ id, baseline_score: before, current_score: after, baseline_band: a + 1, current_band: b + 1, band_move: move, score_change: delta });
  }
  const rowRates = matrix.map(row => { const total = row.reduce((sum, value) => sum + value, 0); return row.map(value => total === 0 ? null : value / total); });
  const baselineCounts = matrix.map(row => row.reduce((sum, value) => sum + value, 0));
  const currentCounts = Array.from({ length: size }, (_, column) => matrix.reduce((sum, row) => sum + row[column], 0));
  const n = baseline.size, baselineShares = baselineCounts.map(value => value / n), currentShares = currentCounts.map(value => value / n);
  const tv = 0.5 * baselineShares.reduce((sum, value, index) => sum + Math.abs(value - currentShares[index]), 0);
  return { band_edges: edges, matrix, row_rates: rowRates, baseline_band_shares: baselineShares, current_band_shares: currentShares, migrations, matched_count: n, stable_count: stable, improved_count: improved, worsened_count: worsened, stable_rate: stable / n, mean_band_move: migrations.reduce((sum, row) => sum + row.band_move, 0) / n, mean_absolute_band_move: absoluteMove / n, mean_score_change: scoreChange / n, mean_absolute_score_change: absoluteScoreChange / n, band_distribution_total_variation: tv, state: "migration-evaluated" };
}

function basicMetrics(rows: AnyRecord[], epsilon: number): AnyRecord {
  const total = rows.reduce((sum, row) => sum + row.weight, 0), [positive, negative] = classWeights(rows);
  const eventRate = positive / total;
  const brier = rows.reduce((sum, row) => sum + row.weight * (row.probability - row.label) ** 2, 0) / total;
  const logloss = rows.reduce((sum, row) => { const q = Math.min(Math.max(row.probability, epsilon), 1 - epsilon); return sum + row.weight * -Math.log(row.label === 1 ? q : 1 - q); }, 0) / total;
  const auc = positive > 0 && negative > 0 ? rocCurveAuc({ records: rows }).roc_auc : null;
  return { record_count: rows.length, weight_sum: total, positive_weight: positive, negative_weight: negative, event_rate: eventRate, brier_score: brier, log_loss: logloss, roc_auc: auc };
}

export function sliceValidation(inputs: AnyRecord): AnyRecord {
  const rows = records(inputs, { score: true, probability: true });
  if (!Array.isArray(inputs.slice_fields) || inputs.slice_fields.length === 0 || new Set(inputs.slice_fields).size !== inputs.slice_fields.length || inputs.slice_fields.some((field: unknown) => typeof field !== "string" || field.length === 0)) throw new RangeError("slice_fields must be unique nonempty strings");
  const fields = inputs.slice_fields as string[], minimum = integerValue(inputs.minimum_support ?? 4, "minimum_support");
  if (minimum < 2) throw new RangeError("minimum_support must be at least 2");
  const epsilon = numberValue(inputs.epsilon ?? 1e-15, "epsilon");
  if (epsilon <= 0 || epsilon >= 0.5) throw new RangeError("epsilon must be in (0,0.5)");
  for (const field of fields) if (rows.some(row => typeof row[field] !== "string" || row[field].length === 0)) throw new RangeError(`slice field ${field} is missing or invalid`);
  const overall = basicMetrics(rows, epsilon), slices: AnyRecord[] = [], eligibleBrier: number[] = [];
  let eligible = 0, flagged = 0;
  for (const field of fields) {
    const values = [...new Set(rows.map(row => row[field] as string))].sort();
    for (const value of values) {
      const members = rows.filter(row => row[field] === value), metrics = basicMetrics(members, epsilon);
      let status = "ok";
      if (members.length < minimum) status = "insufficient-support";
      else if (metrics.positive_weight <= 0 || metrics.negative_weight <= 0) status = "single-class";
      if (status === "ok") { eligible++; eligibleBrier.push(metrics.brier_score); } else flagged++;
      slices.push({ field, value, status, ...metrics, brier_gap_from_overall: metrics.brier_score - overall.brier_score, log_loss_gap_from_overall: metrics.log_loss - overall.log_loss, roc_auc_gap_from_overall: metrics.roc_auc === null ? null : metrics.roc_auc - overall.roc_auc });
    }
  }
  if (eligibleBrier.length === 0) throw new RangeError("no slice meets the canonical support and class requirements");
  return { overall, slices, slice_fields: fields, minimum_support: minimum, worst_slice_brier: Math.max(...eligibleBrier), eligible_slice_count: eligible, flagged_slice_count: flagged, state: "slices-evaluated" };
}

export function rareEventBounds(inputs: AnyRecord): AnyRecord {
  const events = integerValue(inputs.observed_events, "observed_events"), trials = integerValue(inputs.trials, "trials");
  if (trials <= 0 || events < 0 || events > trials) throw new RangeError("require 0 <= observed_events <= trials and trials > 0");
  const expected = numberValue(inputs.expected_probability, "expected_probability");
  if (expected < 0 || expected > 1) throw new RangeError("expected_probability must be in [0,1]");
  const confidence = numberValue(inputs.confidence_level, "confidence_level");
  const zMap = new Map([[0.90, 1.6448536269514722], [0.95, 1.959963984540054], [0.99, 2.5758293035489004]]);
  const z = zMap.get(confidence);
  if (z === undefined) throw new RangeError("confidence_level must be 0.90, 0.95, or 0.99");
  if (inputs.sampling_assumption !== "independent-bernoulli-approximation") throw new RangeError("unsupported sampling_assumption");
  const observed = events / trials, denominator = 1 + z * z / trials;
  const center = (observed + z * z / (2 * trials)) / denominator;
  const half = z * Math.sqrt(observed * (1 - observed) / trials + z * z / (4 * trials * trials)) / denominator;
  const lower = events === 0 ? 0 : Math.max(0, center - half), upper = events === trials ? 1 : Math.min(1, center + half);
  const consistency = expected < lower ? "expected-below-interval" : expected > upper ? "expected-above-interval" : "inside-interval";
  return { observed_events: events, trials, observed_rate: observed, expected_probability: expected, expected_count: trials * expected, confidence_level: confidence, z_value: z, wilson_center: center, wilson_half_width: half, wilson_lower: lower, wilson_upper: upper, consistency, sampling_assumption: "independent-bernoulli-approximation", state: "rare-event-evaluated" };
}

export function calculate(topicId: string, inputs: AnyRecord): AnyRecord {
  if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) throw new TypeError("inputs must be an object");
  const dispatch: Record<string, (value: AnyRecord) => AnyRecord> = {
    "D40-F05-A01": rocCurveAuc,
    "D40-F05-A02": precisionRecallAuc,
    "D40-F05-A03": brierScore,
    "D40-F05-A04": binaryLogLoss,
    "D40-F05-A05": reliabilityEce,
    "D40-F05-A06": gainsLift,
    "D40-F05-A07": costSensitiveThreshold,
    "D40-F05-A08": scoreMigration,
    "D40-F05-A09": sliceValidation,
    "D40-F05-A10": rareEventBounds,
  };
  const implementation = dispatch[topicId];
  if (!implementation) throw new RangeError(`unsupported topic_id: ${topicId}`);
  return implementation(inputs);
}
Full-height labplaygroundOpen full screen