D02-F04-A02 / Complete engineering topic

Survivorship-Bias Guard: Keep Historical Failures in the Test

Build a bitemporal universe guard that retains exited securities, rejects current-roster-only scopes, and separates membership from missing returns.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F04-A02Canonical / Tested / Open
D02 / D02-F04
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

A current roster tells you who remains. It cannot, by itself, tell you who belonged to a universe years ago. If you use today's names to filter an old backtest, failures, mergers, delistings, and other exits can vanish before the first return is calculated.

The fix is not a blanket rule to "include delisted stocks." It is a reproducible point-in-time question:

Which stable security identities belonged to this declared universe at effectiveAt, using only membership evidence available by knownAt?

The guard in this article answers that question and refuses to treat a present-day roster as a historical candidate source.

A historical roster retaining a later exit while a current-only roster drops it.

The visual's central point is that the membership decision happens before any return calculation. A security does not stop being a historical member merely because it later disappears.

A real event that makes the risk visible

An S&P Dow Jones Indices historical comparison identifies Lehman Brothers as an S&P 500 constituent at default. Lehman Brothers' issuer-filed Form 8-K reports that it filed a voluntary Chapter 11 petition on September 15, 2008. A later issuer-filed Form 8-K reports that NYSE Regulation announced immediate suspension of its common stock on September 17, 2008.

These primary-source facts demonstrate the data problem: a name can be present in a historical benchmark and later be absent from a usable current roster. They do not establish the exact index deletion time, a total return, terminal proceeds, provider availability, or the magnitude or direction of survivorship bias. Those claims require separate evidence.

Five questions that must stay separate

Survivorship work becomes unclear when distinct states collapse into one boolean.

QuestionThis guard answers it?Why it is separate
Was the security a universe member at the historical time?YesMembership uses bitemporal interval evidence
Did a current roster retain that historical member?Diagnostic onlyThis is later hindsight and cannot affect the causal set
Was the security still listed?NoListing state is a separate lifecycle
Was it investable or tradable for the strategy?NoLiquidity, float, venue, and mandate rules are separate
Is a complete return or terminal outcome available?NoReturn and terminal-proceeds reconstruction need their own contracts

The guard preserves a historically eligible identity even when its return is missing. The caller must repair or reject that missing return separately; it cannot silently drop the identity.

The two-clock contract

Every definition and membership assertion has two relevant clocks:

  • effective time says when the fact applies;
  • availability time says when the research system could use that version.

For a query (t, k), t = effectiveAt and k = knownAt. A correction that becomes available after k must not rewrite the earlier result, even if it corrects an interval that starts before t.

The implementation first removes wholly unavailable rows and only then validates their deeper fields. That order matters. Appending a malformed future revision cannot make an earlier query fail, change its classifications, or reveal a future identity in the output.

Rendering system map…

The dashed branches cannot feed back into membership classification.

Candidate provenance is part of the algorithm

A caller-supplied list of symbols is dangerous because it may already be filtered to current survivors. This package requires a candidateUniverse with:

  • stable securityIds;
  • construction: historical_membership_ledger;
  • asOf and availableAt timestamps;
  • a sourceId.

The candidate set must include every identity found in the known membership ledger. If the current roster contains A, C, and D while the known historical ledger also contains B, passing only A, C, and D returns incomplete. Calling the construction current_roster returns unsupported.

A guard rejecting a current-only candidate set because it omits a known historical identity.

This check cannot prove the source ledger itself is complete, but it prevents the most direct current-roster substitution from passing unnoticed.

Four membership states

For each stable security identity, select the latest revision available by knownAt for each membership assertion. Then count active intervals that cover effectiveAt.

state(s,t,k)={eligibleCs(t,k)=1ambiguousCs(t,k)>1ineligibleCs(t,k)=0 and known active evidence existsunknownotherwisestate(s,t,k)= \begin{cases} eligible & |C_s(t,k)|=1 \\ ambiguous & |C_s(t,k)|>1 \\ ineligible & |C_s(t,k)|=0\text{ and known active evidence exists} \\ unknown & \text{otherwise} \end{cases}

unknown is not a synonym for ineligible. A cancelled assertion or absent record cannot prove non-membership. Likewise, two overlapping active assertions are not "more certain"; they are ambiguous.

Intervals are half-open. A membership with validTo = 2024-06-01T00:00:00Z is excluded exactly at that instant.

The synthetic worked example

The canonical fixture asks for membership at the final second of 2023 with a knowledge cutoff at noon on January 2, 2024.

SecurityKnown membership evidenceState
SEC:Aopen interval covering the queryeligible
SEC:Brevision one covers the query; a later correction is not yet availableeligible
SEC:Copen interval covering the queryeligible
SEC:Dknown entry starts in February 2024ineligible

The historical eligible set is {A, B, C}. A later synthetic roster is {A, C, D}. It retains two of three historical members:

retention={A,B,C}{A,C,D}3=23=0.666667retention=\frac{|\{A,B,C\}\cap\{A,C,D\}|}{3}=\frac{2}{3}=0.666667

Retention is coverage only. It is not a bias estimate, not proof of return completeness, and not proof that every survivorship problem has been removed.

The synthetic historical roster, later roster, retention ratio, and separate return illustration.

A bounded return illustration

The optional comparator exists only to make one failure mode obvious. It accepts synthetic rows only. Each row must say it is synthetic and observed, declare one common period, name a source, assert the supported complete-period simple-return basis, and be available by the guard's knownAt.

The synthetic returns for A, B, and C are 0.20, -0.80, and 0.10. The historical equal-weighted mean is:

0.200.80+0.103=0.166667\frac{0.20-0.80+0.10}{3}=-0.166667

Filtering through the later roster retains only A and C, whose mean is 0.15. The signed difference is 0.316667.

This one fixture moves upward. Another fixture could move downward or barely move. No universal direction or magnitude follows. If any historical member lacks a return, the comparator returns missing_returns; it does not reconstruct a delisting return, terminal proceeds, or any estimated outcome.

Implementation shape

The core implementations are deliberately explicit:

Python
definition_rows = rows_available_by(universe_definitions, known_at)
membership_rows = rows_available_by(membership_records, known_at)

definition = latest_valid_definition(definition_rows)
selected = latest_revision_per_membership(membership_rows)

if known_ledger_ids - candidate_ids:
    return incomplete("candidate universe omits known identities")

for security_id in sorted(candidate_ids):
    classify_from_covering_intervals(security_id, effective_at, selected)

TypeScript performs the same state transitions and uses the same JSON fixture. Both languages validate calendar dates rather than allowing invalid dates to normalize silently.

What the tests prove

The test suites cover:

  • canonical Python/TypeScript fixture parity;
  • latest revision selection at knownAt;
  • half-open interval boundaries;
  • cancelled evidence becoming unknown;
  • overlapping evidence becoming ambiguous;
  • current-roster-only candidates returning incomplete;
  • malformed future revisions leaving an earlier result unchanged;
  • explicit unsupported base and candidate semantics;
  • non-mutation, strict timestamps, and revision invariants;
  • synthetic return source/status/period/availability requirements;
  • missing returns remaining missing.

Passing these tests proves the implementation follows this declared contract. It does not prove that an external provider's history is complete or that a backtest is economically valid.

Use the interactive lab

Open the Survivorship-Bias Guard playground. Choose the canonical, current-roster failure, unknown, or ambiguous scenario. Step through definition resolution, candidate validation, membership classification, and the non-causal comparison branch. Reset always restores the same initial state.

The lab labels every identifier and return as synthetic. Its initial screen already shows the full scenario preview so the lesson does not depend on animation.

Failure modes to keep visible

  • A current symbol list may omit renamed or exited securities before membership resolution starts.
  • A stable company identity does not replace a stable security identity.
  • A corrected historical interval may be unavailable at the simulated knowledge cutoff.
  • An absent return must not cause an eligible member to disappear.
  • A listing suspension does not, by itself, define an index deletion or a terminal return.
  • A later roster comparison is useful for audit, but never causal input.
  • Coverage can be 100% while prices, returns, or terminal proceeds are still incomplete.

Summary

A survivorship-bias guard is a universe-integrity check, not a performance correction. Declare the base, preserve stable identities, resolve effective and knowledge time separately, retain exited historical members, and make uncertainty explicit. Only after the historical set is resolved should another system evaluate prices, returns, terminal outcomes, or investability.

The prerequisite is Historical Constituent Reconstruction. Continue with IPO Availability Timestamping to prevent a newly listed security's later-known data from leaking backward into an earlier candidate set.

Primary source roles and limitations are recorded in REFERENCES.md. This article is educational and not investment advice.

Asset map

AssetArticle useVideo sceneSourceStatic fallback
Roster contrastOpening intuition1-3visuals/static/article-hero.svgSame SVG
Causal flowTwo-clock contract4visuals/mermaid/calculation-flow.mdText algorithm
State modelFour states5visuals/mermaid/state-lifecycle.mdState table
Worked exampleArithmetic6-7visuals/static/worked-example.svgWorked table
Candidate guardFailure mode8visuals/static/failure-guard.svgFailure paragraph
Guided labInteractive validation6-8visuals/animated/playground.htmlThree SVGs

Causal membership and hindsight diagnostics

Purpose: show that a later roster and return illustration are output-only branches.

Rendering system map…

Interpretation: the dashed branches can quantify omissions or illustrate sample effects, but neither branch can change the causal membership set.

Takeaway: resolve the historical universe before comparing it with who survives later.

Membership classification states

Purpose: distinguish absent evidence from known non-membership and conflicting evidence.

Rendering system map…

Interpretation: cancelled or missing evidence leads to unknown, not ineligible; overlapping assertions lead to ambiguous, not stronger confidence.

Takeaway: preserve uncertainty as a first-class output.

References8 primary sources and evidence notes

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

Accessed 2026-07-22. All package fixtures, returns, identifiers, and performance arithmetic are synthetic. The named S&P 500 event is a bounded evidence pattern, not the fixture and not a claim about historical returns.

R1 - S&P Dow Jones Indices Equity Indices Policies & Practices

  • Organization or authors: S&P Dow Jones Indices
  • Source type: Official index policy and methodology
  • Publication or effective date: Current edition accessed 2026-07-22
  • Version: Edition available at access
  • URL: https://www.spglobal.com/spdji/en/documents/methodologies/methodology-sp-equity-indices-policies-practices.pdf
  • Jurisdiction: Global methodology with index- and market-specific exceptions
  • Supports: Additions and deletions have methodology-specific timing; constituent events are announced; pro-forma files can describe an upcoming roster; recalculations and reposts can occur.
  • Limitations: It does not define this package's data schema, prove a universal announcement lag, or license constituent history for redistribution.

R2 - Lehman Brothers Form 8-K: Chapter 11 filing

  • Organization or authors: Lehman Brothers Holdings Inc., filed with the U.S. Securities and Exchange Commission
  • Source type: Issuer Form 8-K
  • Publication or effective date: Event dated 2008-09-15; filing dated 2008-09-19
  • Version: SEC accession 0001104659-08-059632
  • URL: https://www.sec.gov/Archives/edgar/data/806085/000110465908059632/a08-22764_48k.htm
  • Jurisdiction: United States
  • Supports: Lehman Brothers Holdings Inc. reported that it filed a voluntary Chapter 11 petition on 2008-09-15.
  • Limitations: It does not establish index deletion timing, a return, terminal proceeds, or provider availability.

R3 - Lehman Brothers Form 8-K: NYSE suspension

  • Organization or authors: Lehman Brothers Holdings Inc., filed with the U.S. Securities and Exchange Commission
  • Source type: Issuer Form 8-K
  • Publication or effective date: Event dated 2008-09-17; filing dated 2008-09-23
  • Version: SEC accession 0001104659-08-059900
  • URL: https://www.sec.gov/Archives/edgar/data/806085/000110465908059900/a08-22764_68k.htm
  • Jurisdiction: United States
  • Supports: The issuer reported that NYSE Regulation announced immediate suspension of its common stock on 2008-09-17.
  • Limitations: The filing quotes a closing price, but this package does not use it. It does not establish an index deletion date, return, terminal proceeds, or provider ingestion time.

R4 - S&P 150 comparison

  • Organization or authors: S&P Dow Jones Indices
  • Source type: Official historical comparison document
  • Publication or effective date: Historical document accessed 2026-07-22
  • Version: Available edition
  • URL: https://www.spglobal.com/spdji/en/documents/additional-material/sp-150-comparison.pdf
  • Jurisdiction: United States index history
  • Supports: A footnote identifies Lehman Brothers as an S&P 500 constituent at default.
  • Limitations: It does not supply the exact deletion time, return, terminal value, or data-provider availability used by this package.

R5 - CRSP Research Data Products

  • Organization or authors: Center for Research in Security Prices
  • Source type: Official product description
  • Publication or effective date: Current page accessed 2026-07-22
  • Version: Current web page
  • URL: https://www.crsp.org/research/
  • Jurisdiction: United States research data
  • Supports: Point-in-time research may require data products designed for security histories rather than a current symbol list.
  • Limitations: CRSP is licensed data; this package redistributes no CRSP observations and claims no field-level parity.

R6 - CRSP PERMNO and PERMCO

  • Organization or authors: Center for Research in Security Prices
  • Source type: Official identifier documentation
  • Publication or effective date: Current page accessed 2026-07-22
  • Version: Current web page
  • URL: https://www.crsp.org/research/permno/
  • Jurisdiction: United States research data
  • Supports: Stable security identity across a lifecycle is distinct from a changeable ticker.
  • Limitations: The identifiers and related datasets are proprietary; synthetic SEC:* identifiers are used here.

R7 - CRSP10 US Stock Database Guide

  • Organization or authors: Center for Research in Security Prices
  • Source type: Official product guide
  • Publication or effective date: Edition accessed 2026-07-22
  • Version: Current linked edition
  • URL: https://www.crsp.org/wp-content/uploads/guides/CRSP10_Year_US_Stock_Database_Guide.pdf
  • Jurisdiction: United States research data
  • Supports: Security histories include date-ranged attributes and trading-status fields, illustrating why lifecycle records matter.
  • Limitations: Product-specific fields are not a universal contract and require a license for production use.

R8 - SEC EDGAR access guidance

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official regulator technical guidance
  • Publication or effective date: Current page accessed 2026-07-22
  • Version: Current web page
  • URL: https://www.sec.gov/search-filings/edgar-search-assistance/accessing-edgar-data
  • Jurisdiction: United States
  • Supports: Official filing records carry accession and acceptance-time evidence useful for point-in-time availability systems.
  • Limitations: Filing availability does not establish index membership, terminal return, or market-data usability.
survivorship_bias_guard.ts
/** Bitemporal survivorship-bias guard for D02-F04-A02. */

type Mapping = Record<string, unknown>;
type State = "eligible" | "ineligible" | "unknown" | "ambiguous";

const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;

function mapping(value: unknown, name: string): Mapping {
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be a mapping`);
  return value as Mapping;
}
function textValue(value: unknown, name: string): string {
  if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
  return value;
}
function instant(value: unknown, name: string): number {
  const stringValue = textValue(value, name);
  if (!UTC.test(stringValue)) throw new Error(`${name} must be a strict UTC instant YYYY-MM-DDTHH:mm:ssZ`);
  const parsed = Date.parse(stringValue);
  if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== stringValue.replace("Z", ".000Z")) throw new Error(`${name} is not a calendar-valid instant`);
  return parsed;
}
function positiveInt(value: unknown, name: string): number {
  if (!Number.isInteger(value) || (value as number) < 1) throw new TypeError(`${name} must be a positive integer`);
  return value as number;
}
function uniqueTexts(value: unknown, name: string, allowEmpty = false): string[] {
  if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) throw new TypeError(`${name} must be ${allowEmpty ? "a" : "a non-empty"} list`);
  const checked = value.map((item) => textValue(item, `${name}[]`));
  if (new Set(checked).size !== checked.length) throw new Error(`${name} must not contain duplicates`);
  return checked;
}
function contains(moment: number, start: number, end: number | null): boolean {
  return start <= moment && (end === null || moment < end);
}
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function causallyAvailableRows(raw: unknown, name: string, known: number, requireNonempty: boolean): Mapping[] {
  if (!Array.isArray(raw) || (requireNonempty && raw.length === 0)) throw new TypeError(`${name} must be ${requireNonempty ? "a non-empty" : "a"} list`);
  return raw.map((item, i) => mapping(item, `${name}[${i}]`)).filter((row, i) => instant(row.availableAt, `${name}[${i}].availableAt`) <= known);
}

type Definition = {
  definitionId: string; universeId: string; revision: number; effectiveFrom: string;
  effectiveStart: number; effectiveTo: string | null; effectiveEnd: number | null;
  announcedAt: string; availableAt: string; availableInstant: number; status: string;
  baseType: string; baseId: string; ruleSummary: string; sourceId: string;
};
type Membership = {
  membershipId: string; revision: number; universeId: string; securityId: string;
  validFrom: string; validStart: number; validTo: string | null; validEnd: number | null;
  announcedAt: string; availableAt: string; availableInstant: number; status: string;
  changeType: string; eventId: string; sourceId: string;
};

function validateDefinitions(raw: unknown, universeId: string, definitionId: string): Definition[] {
  if (!Array.isArray(raw)) throw new TypeError("universeDefinitions must be a list");
  const definitions = raw.map((item, i) => {
    const row = mapping(item, `universeDefinitions[${i}]`); const p = `universeDefinitions[${i}]`;
    const status = textValue(row.status, `${p}.status`);
    if (!new Set(["active", "cancelled"]).has(status)) throw new Error(`${p}.status must be active or cancelled`);
    const announcedAt = textValue(row.announcedAt, `${p}.announcedAt`); const announced = instant(announcedAt, `${p}.announcedAt`);
    const availableAt = textValue(row.availableAt, `${p}.availableAt`); const availableInstant = instant(availableAt, `${p}.availableAt`);
    if (availableInstant < announced) throw new Error(`${p}.availableAt cannot precede announcedAt`);
    const effectiveFrom = textValue(row.effectiveFrom, `${p}.effectiveFrom`); const effectiveStart = instant(effectiveFrom, `${p}.effectiveFrom`);
    const effectiveTo = row.effectiveTo === null ? null : textValue(row.effectiveTo, `${p}.effectiveTo`);
    const effectiveEnd = effectiveTo === null ? null : instant(effectiveTo, `${p}.effectiveTo`);
    if (effectiveEnd !== null && effectiveEnd <= effectiveStart) throw new Error(`${p}.effectiveTo must be after effectiveFrom`);
    const result: Definition = {
      definitionId: textValue(row.definitionId, `${p}.definitionId`), universeId: textValue(row.universeId, `${p}.universeId`),
      revision: positiveInt(row.revision, `${p}.revision`), effectiveFrom, effectiveStart, effectiveTo, effectiveEnd,
      announcedAt, availableAt, availableInstant, status, baseType: textValue(row.baseType, `${p}.baseType`),
      baseId: textValue(row.baseId, `${p}.baseId`), ruleSummary: textValue(row.ruleSummary, `${p}.ruleSummary`), sourceId: textValue(row.sourceId, `${p}.sourceId`),
    };
    if (result.universeId !== universeId || result.definitionId !== definitionId) throw new Error(`${p} does not match the requested universe and definition`);
    return result;
  });
  if (definitions.length === 0) return [];
  const ordered = [...definitions].sort((a, b) => a.revision - b.revision);
  if (ordered.some((row, i) => row.revision !== i + 1)) throw new Error("universe definition revisions must be contiguous from 1");
  const immutable = new Set(ordered.map((row) => [row.universeId, row.definitionId, row.baseType, row.baseId, row.effectiveFrom].join("\u0000")));
  if (immutable.size !== 1) throw new Error("universe definition revisions change immutable identity fields");
  if (ordered.some((row, i) => i > 0 && row.availableInstant <= ordered[i - 1].availableInstant)) throw new Error("universe definition availability must increase with revision");
  return definitions;
}

function validateMemberships(raw: unknown, universeId: string): Membership[] {
  if (!Array.isArray(raw)) throw new TypeError("membershipRecords must be a list");
  const records = raw.map((item, i) => {
    const row = mapping(item, `membershipRecords[${i}]`); const p = `membershipRecords[${i}]`;
    const status = textValue(row.status, `${p}.status`); const changeType = textValue(row.changeType, `${p}.changeType`);
    if (!new Set(["active", "cancelled"]).has(status)) throw new Error(`${p}.status must be active or cancelled`);
    if (!new Set(["entry", "exit", "revision", "cancellation"]).has(changeType)) throw new Error(`${p}.changeType is unsupported`);
    if ((status === "cancelled") !== (changeType === "cancellation")) throw new Error(`${p} cancellation status and changeType must agree`);
    const announcedAt = textValue(row.announcedAt, `${p}.announcedAt`); const announced = instant(announcedAt, `${p}.announcedAt`);
    const availableAt = textValue(row.availableAt, `${p}.availableAt`); const availableInstant = instant(availableAt, `${p}.availableAt`);
    if (availableInstant < announced) throw new Error(`${p}.availableAt cannot precede announcedAt`);
    const validFrom = textValue(row.validFrom, `${p}.validFrom`); const validStart = instant(validFrom, `${p}.validFrom`);
    const validTo = row.validTo === null ? null : textValue(row.validTo, `${p}.validTo`); const validEnd = validTo === null ? null : instant(validTo, `${p}.validTo`);
    if (validEnd !== null && validEnd <= validStart) throw new Error(`${p}.validTo must be after validFrom`);
    const result: Membership = {
      membershipId: textValue(row.membershipId, `${p}.membershipId`), revision: positiveInt(row.revision, `${p}.revision`),
      universeId: textValue(row.universeId, `${p}.universeId`), securityId: textValue(row.securityId, `${p}.securityId`),
      validFrom, validStart, validTo, validEnd, announcedAt, availableAt, availableInstant, status, changeType,
      eventId: textValue(row.eventId, `${p}.eventId`), sourceId: textValue(row.sourceId, `${p}.sourceId`),
    };
    if (result.universeId !== universeId) throw new Error(`${p}.universeId does not match universeId`);
    return result;
  });
  for (const membershipId of [...new Set(records.map((row) => row.membershipId))]) {
    const ordered = records.filter((row) => row.membershipId === membershipId).sort((a, b) => a.revision - b.revision);
    if (ordered.some((row, i) => row.revision !== i + 1)) throw new Error(`membership ${membershipId} revisions must be contiguous from 1`);
    const immutable = new Set(ordered.map((row) => [row.universeId, row.securityId, row.validFrom].join("\u0000")));
    if (immutable.size !== 1) throw new Error(`membership ${membershipId} changes immutable identity fields`);
    if (ordered.some((row, i) => i > 0 && row.availableInstant <= ordered[i - 1].availableInstant)) throw new Error(`membership ${membershipId} availability must increase with revision`);
  }
  return records;
}

function emptyResult(universeId: string, definitionId: string, effectiveAt: string, knownAt: string, status: string, diagnostic: string): Mapping {
  return {status, universeId, definitionId, definitionVersion: null, effectiveAt, knownAt, classifications: [], eligibleSecurityIds: [], ineligibleSecurityIds: [], unknownSecurityIds: [], ambiguousSecurityIds: [], membershipEventVersionsUsed: [], diagnostics: [diagnostic], hindsightDiagnostics: null};
}

export function guardSurvivorship(input: unknown): Mapping {
  const data = mapping(input, "data");
  const universeId = textValue(data.universeId, "universeId"); const definitionId = textValue(data.definitionId, "definitionId");
  const effectiveAt = textValue(data.effectiveAt, "effectiveAt"); const effective = instant(effectiveAt, "effectiveAt");
  const knownAt = textValue(data.knownAt, "knownAt"); const known = instant(knownAt, "knownAt");
  const definitions = validateDefinitions(causallyAvailableRows(data.universeDefinitions, "universeDefinitions", known, true), universeId, definitionId);
  const records = validateMemberships(causallyAvailableRows(data.membershipRecords, "membershipRecords", known, false), universeId);
  if (definitions.length === 0) return emptyResult(universeId, definitionId, effectiveAt, knownAt, "unknown", "no universe definition was available by knownAt");
  const definition = definitions.sort((a, b) => b.revision - a.revision)[0];
  if (definition.status === "cancelled" || !contains(effective, definition.effectiveStart, definition.effectiveEnd)) return emptyResult(universeId, definitionId, effectiveAt, knownAt, "unknown", "the selected universe definition was cancelled or not effective at effectiveAt");
  if (definition.baseType !== "named_research_universe") return emptyResult(universeId, definitionId, effectiveAt, knownAt, "unsupported", `unsupported baseType: ${definition.baseType}`);
  const candidateUniverse = mapping(data.candidateUniverse, "candidateUniverse");
  const construction = textValue(candidateUniverse.construction, "candidateUniverse.construction");
  if (construction !== "historical_membership_ledger") return emptyResult(universeId, definitionId, effectiveAt, knownAt, "unsupported", `unsupported candidate-universe construction: ${construction}`);
  const candidateAsOf = textValue(candidateUniverse.asOf, "candidateUniverse.asOf"); const candidateAsOfInstant = instant(candidateAsOf, "candidateUniverse.asOf");
  const candidateAvailableAt = textValue(candidateUniverse.availableAt, "candidateUniverse.availableAt"); const candidateAvailableInstant = instant(candidateAvailableAt, "candidateUniverse.availableAt");
  if (candidateAsOfInstant > candidateAvailableInstant) throw new Error("candidateUniverse.asOf cannot follow candidateUniverse.availableAt");
  if (candidateAvailableInstant > known) return emptyResult(universeId, definitionId, effectiveAt, knownAt, "incomplete", "candidate universe was not available by knownAt");
  const candidateSourceId = textValue(candidateUniverse.sourceId, "candidateUniverse.sourceId");
  const candidates = uniqueTexts(candidateUniverse.securityIds, "candidateUniverse.securityIds");
  const selected: Membership[] = [];
  for (const membershipId of [...new Set(records.map((row) => row.membershipId))].sort()) {
    const eligibleVersions = records.filter((row) => row.membershipId === membershipId).sort((a, b) => b.revision - a.revision);
    if (eligibleVersions.length) selected.push(eligibleVersions[0]);
  }
  const knownLedgerIds = new Set(selected.map((row) => row.securityId)); const candidateSet = new Set(candidates);
  const omittedKnownIds = [...knownLedgerIds].filter((id) => !candidateSet.has(id)).sort();
  if (omittedKnownIds.length) {
    const incomplete = emptyResult(universeId, definitionId, effectiveAt, knownAt, "incomplete", "candidate universe omits identities present in the known membership ledger");
    incomplete.candidateUniverseSourceId = candidateSourceId; incomplete.omittedKnownSecurityIds = omittedKnownIds; return incomplete;
  }
  const usedVersions = new Set<string>();
  const classifications = [...candidates].sort().map((securityId) => {
    const securityRecords = selected.filter((row) => row.securityId === securityId); const active = securityRecords.filter((row) => row.status === "active");
    const covering = active.filter((row) => contains(effective, row.validStart, row.validEnd));
    securityRecords.forEach((row) => usedVersions.add(`${row.membershipId}@${row.revision}`));
    let state: State; let diagnostic: string; let evidenceVersions: string[];
    if (covering.length === 1) { state = "eligible"; diagnostic = "one known active membership interval covers effectiveAt"; evidenceVersions = [`${covering[0].membershipId}@${covering[0].revision}`]; }
    else if (covering.length > 1) { state = "ambiguous"; diagnostic = "multiple known active membership intervals cover effectiveAt"; evidenceVersions = covering.map((row) => `${row.membershipId}@${row.revision}`).sort(); }
    else if (active.length) { state = "ineligible"; diagnostic = "known active membership evidence excludes effectiveAt"; evidenceVersions = active.map((row) => `${row.membershipId}@${row.revision}`).sort(); }
    else { state = "unknown"; diagnostic = "no non-cancelled membership evidence was available by knownAt"; evidenceVersions = securityRecords.map((row) => `${row.membershipId}@${row.revision}`).sort(); }
    return {securityId, state, evidenceVersions, diagnostic};
  });
  const byState = Object.fromEntries((["eligible", "ineligible", "unknown", "ambiguous"] as State[]).map((state) => [state, classifications.filter((row) => row.state === state).map((row) => row.securityId)])) as Record<State, string[]>;
  const status = byState.ambiguous.length ? "ambiguous" : (byState.unknown.length ? "unknown" : "resolved");
  const result: Mapping = {status, universeId, definitionId, definitionVersion: `${definitionId}@${definition.revision}`, effectiveAt, knownAt, candidateUniverseSourceId: candidateSourceId, candidateUniverseAsOf: candidateAsOf, classifications,
    eligibleSecurityIds: byState.eligible, ineligibleSecurityIds: byState.ineligible, unknownSecurityIds: byState.unknown, ambiguousSecurityIds: byState.ambiguous,
    membershipEventVersionsUsed: [...usedVersions].sort(), diagnostics: [], hindsightDiagnostics: null};
  if (data.comparisonRoster !== undefined && data.comparisonRoster !== null) {
    const comparison = mapping(data.comparisonRoster, "comparisonRoster");
    const observedAt = textValue(comparison.observedAt, "comparisonRoster.observedAt"); instant(observedAt, "comparisonRoster.observedAt");
    const availableAt = textValue(comparison.availableAt, "comparisonRoster.availableAt"); const comparisonAvailable = instant(availableAt, "comparisonRoster.availableAt");
    if (instant(observedAt, "comparisonRoster.observedAt") > comparisonAvailable) throw new Error("comparisonRoster.observedAt cannot follow comparisonRoster.availableAt");
    const roster = uniqueTexts(comparison.securityIds, "comparisonRoster.securityIds", true).sort(); const sourceId = textValue(comparison.sourceId, "comparisonRoster.sourceId");
    const historical = new Set(byState.eligible); const current = new Set(roster); const retained = [...historical].filter((id) => current.has(id)).sort();
    result.hindsightDiagnostics = {causal: false, doesNotAlterCausalResult: true, observedAt, availableAt, sourceId, currentRosterSecurityIds: roster,
      historicallyEligibleMissingFromCurrent: [...historical].filter((id) => !current.has(id)).sort(), currentRosterNotHistoricallyEligible: [...current].filter((id) => !historical.has(id)).sort(),
      retainedHistoricalCount: retained.length, historicallyEligibleCount: historical.size, retentionRatio: historical.size ? Number((retained.length / historical.size).toFixed(6)) : null};
  }
  return clone(result);
}

export function compareEqualWeightedReturns(guardResultInput: unknown, returnRowsInput: unknown): Mapping {
  const guardResult = mapping(guardResultInput, "guard_result");
  if (guardResult.status !== "resolved") throw new Error("guard_result must be resolved");
  const hindsight = mapping(guardResult.hindsightDiagnostics, "hindsightDiagnostics");
  if (!Array.isArray(returnRowsInput)) throw new TypeError("return_rows must be a list");
  const values = new Map<string, number>();
  const periods = new Set<string>(); const guardKnown = instant(guardResult.knownAt, "guard_result.knownAt");
  returnRowsInput.forEach((item, i) => {
    const row = mapping(item, `return_rows[${i}]`); const securityId = textValue(row.securityId, `return_rows[${i}].securityId`); const value = row.simpleReturn;
    if (row.isSynthetic !== true) throw new Error(`return_rows[${i}].isSynthetic must be true; this comparator is synthetic-only`);
    if (textValue(row.observationStatus, `return_rows[${i}].observationStatus`) !== "observed") throw new Error(`return_rows[${i}].observationStatus must be observed`);
    if (textValue(row.outcomeBasis, `return_rows[${i}].outcomeBasis`) !== "complete_period_simple_return") throw new Error(`return_rows[${i}].outcomeBasis is unsupported`);
    const periodStart = textValue(row.periodStart, `return_rows[${i}].periodStart`); const start = instant(periodStart, `return_rows[${i}].periodStart`);
    const periodEnd = textValue(row.periodEnd, `return_rows[${i}].periodEnd`); const end = instant(periodEnd, `return_rows[${i}].periodEnd`);
    const available = instant(row.availableAt, `return_rows[${i}].availableAt`); textValue(row.sourceId, `return_rows[${i}].sourceId`);
    if (end <= start || available < end) throw new Error(`return_rows[${i}] has an invalid period or availability order`);
    if (available > guardKnown) throw new Error(`return_rows[${i}] was unavailable by guard_result.knownAt`);
    periods.add(`${periodStart}\u0000${periodEnd}`);
    if (typeof value !== "number" || !Number.isFinite(value) || value < -1) throw new Error(`return_rows[${i}].simpleReturn must be finite and at least -1`);
    if (values.has(securityId)) throw new Error("return_rows must not contain duplicate securityId values"); values.set(securityId, value);
  });
  if (periods.size > 1) throw new Error("return_rows must use one common period");
  const historicalIds = guardResult.eligibleSecurityIds as string[]; const currentSet = new Set(hindsight.currentRosterSecurityIds as string[]);
  const retainedIds = historicalIds.filter((id) => currentSet.has(id)); const missing = historicalIds.filter((id) => !values.has(id)).sort();
  if (missing.length) return {status: "missing_returns", missingReturnSecurityIds: missing};
  if (!retainedIds.length) return {status: "empty_current_subset", missingReturnSecurityIds: []};
  const historicalMean = historicalIds.reduce((sum, id) => sum + values.get(id)!, 0) / historicalIds.length;
  const currentOnlyMean = retainedIds.reduce((sum, id) => sum + values.get(id)!, 0) / retainedIds.length;
  return {status: "calculated", historicalEligibleSecurityIds: historicalIds, currentRosterSubsetSecurityIds: retainedIds,
    historicalEqualWeightedReturn: Number(historicalMean.toFixed(6)), currentRosterSubsetEqualWeightedReturn: Number(currentOnlyMean.toFixed(6)),
    signedDifference: Number((currentOnlyMean - historicalMean).toFixed(6)), interpretation: "synthetic case only; sign and magnitude are not universal",
    terminalOutcomePolicy: "no reconstruction or imputation; supplied synthetic rows assert complete period outcomes"};
}

export const calculate = guardSurvivorship;
Full-height labplaygroundOpen full screen