The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryPoint-in-Time DatasetA point-in-time dataset can reconstruct what information was valid and available to a specified user or system at a historical decision time.
- PrerequisitePoint-in-Time Issue UniverseA point-in-time issue universe is the eligible security set established using information valid for the session being calculated.
- ImportantAvailability TimeAvailability time is the earliest evidenced instant at which information was usable by a specified system or decision process.
- ImportantData LineageData lineage records where data came from, how it was transformed, and which entities, activities, and agents affected it.
- ImportantEffective TimeEffective time is the instant or interval boundary from which a rule, status, or fact applies in its modeled business domain.
- ImportantLate-Arriving DataLate-arriving data is a record that reaches a processing boundary after the expected time for its event, partition, or decision window.
- ImportantPublication TimePublication time is when a source officially released or disseminated information.
- ImportantRevision LineageAn ordered chain connecting each correction to the exact earlier record it supersedes.
- ImportantUniverse MembershipUniverse membership is an effective-dated assertion that a security belongs to a defined eligible set for a specified interval.
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 byknownAt?
The guard in this article answers that question and refuses to treat a present-day roster as a historical candidate source.
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.
| Question | This guard answers it? | Why it is separate |
|---|---|---|
| Was the security a universe member at the historical time? | Yes | Membership uses bitemporal interval evidence |
| Did a current roster retain that historical member? | Diagnostic only | This is later hindsight and cannot affect the causal set |
| Was the security still listed? | No | Listing state is a separate lifecycle |
| Was it investable or tradable for the strategy? | No | Liquidity, float, venue, and mandate rules are separate |
| Is a complete return or terminal outcome available? | No | Return 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.
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;asOfandavailableAttimestamps;- 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.
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.
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.
| Security | Known membership evidence | State |
|---|---|---|
SEC:A | open interval covering the query | eligible |
SEC:B | revision one covers the query; a later correction is not yet available | eligible |
SEC:C | open interval covering the query | eligible |
SEC:D | known entry starts in February 2024 | ineligible |
The historical eligible set is {A, B, C}. A later synthetic roster is {A, C, D}. It retains two of three historical members:
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.
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:
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:
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
unsupportedbase 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
| Asset | Article use | Video scene | Source | Static fallback |
|---|---|---|---|---|
| Roster contrast | Opening intuition | 1-3 | visuals/static/article-hero.svg | Same SVG |
| Causal flow | Two-clock contract | 4 | visuals/mermaid/calculation-flow.md | Text algorithm |
| State model | Four states | 5 | visuals/mermaid/state-lifecycle.md | State table |
| Worked example | Arithmetic | 6-7 | visuals/static/worked-example.svg | Worked table |
| Candidate guard | Failure mode | 8 | visuals/static/failure-guard.svg | Failure paragraph |
| Guided lab | Interactive validation | 6-8 | visuals/animated/playground.html | Three SVGs |
Rendered from the canonical Mermaid sources linked by this article.
Causal membership and hindsight diagnostics
Purpose: show that a later roster and return illustration are output-only branches.
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.
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 notesExpand the source trail, evidence role, and limitations behind the engineering choices.
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.
Full dependency-light reference implementations in both supported languages.
/** 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;
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.