The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryShare ClassA share class is a category of an issuer's equity with a defined set of rights, restrictions, or economic terms.
- PrerequisiteFinancial InstrumentA financial instrument is a contract or arrangement that creates a financial asset for one party and a corresponding liability or equity interest for another.
- PrerequisiteIssuerAn issuer is the legal entity or other organization responsible for creating and honoring a financial instrument.
- PrerequisiteSecurityA security is a financial instrument representing an investment or financing interest that can be identified and transferred under applicable rules.
- 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.
- ImportantInstrument CurrencyInstrument currency is the currency in which a financial instrument's contractual amounts or reference terms are denominated.
- ImportantListingA listing is the effective-dated relationship that makes a security available under specified terms on a trading venue.
- ImportantSecurity MasterA security master is a governed reference-data system that organizes instruments, issuers, identifiers, listings, currencies, statuses, and effective dates.
- ImportantStable Security IdentifierA stable security identifier is a governed key intended to preserve instrument identity across changes in mutable labels such as ticker or name.
- ImportantTicker SymbolA ticker symbol is a short, human-readable code used to refer to a security or listing within a particular market context.
A portfolio says it owns “Alpha.” The voting dashboard says 10 votes per share. The dividend model assumes one distribution unit. The router sees two venue lines. Are these all the same thing?
Not yet. A common issuer only tells you who issued the claims. It does not tell you whether the claims have equal votes, equal economics, reciprocal conversion, the same liquidation rank, or interchangeable market prices. Share-Class Relationship Mapping replaces that shortcut with a sourced, effective-dated relationship graph.
What you will build
You will build a resolver that can answer: “At this legal-validity time, using only evidence available by this knowledge time, what relationships were established between two share classes?” The output retains legal identities, trading identities, ratios, conditions, revisions, cancellations, source locators, conflicts, and unknown terms.
The key safety property is simple: missing evidence stays missing. A similar ticker, a class label, or a shared issuer never becomes a synthetic right.
Six identities, not one ticker row
A useful graph separates:
- the issuer—the legal organization;
- the legal instrument or series;
- the share class—the rights-bearing class;
- the listing—the market admission;
- the trading line—the currency, settlement, and venue-specific tradable line; and
- the venue.
The FIGI 1.2 specification likewise distinguishes venue-level, composite, and share-class identifier layers. That supports the architectural lesson that identity has levels. It does not mean an identifier standard supplies charter rights.
One class can have multiple listings and trading lines. Multiple classes can belong to instruments from one issuer. A line-level fungibility bridge can exist while two legal classes remain non-fungible.
The arrows are typed assertions, not database conveniences. Reverse directions are not automatic.
Two clocks prevent hindsight
Every assertion carries a valid interval and an information timeline:
effectiveFromandeffectiveTosay when the relationship is legally or operationally true;observedAtsays when the source event was observed; andavailableAtsays when the system could actually use the revision.
For assertion (a) and knowledge time (k), select the highest revision available by (k):
Then keep the edge only when the selected revision is active at valid time (v):
where a missing end means open-ended validity.
This distinction matters when a charter amendment is published after an analysis date. The canonical synthetic fixture contains a future revision that raises Founder Class voting rights from 10 to 20. At the canonical knowledge time, that revision is unavailable, so revision 1 remains the only admissible fact.
Store rights as exact typed terms
The graph uses different relationship types for voting rights, economic rights, dividend preference, conversion, exchange, redemption, participation, seniority, and fungibility. Each ratio preserves numerator, denominator, and unit. Currency amounts preserve currency and per-share denominator. Conditions remain strings linked to the assertion rather than disappearing into a boolean.
If the left class has (n_L/d_L) units per share and the right class has (n_R/d_R), compare them with:
Reduce with the greatest common divisor. Do not compare mismatched units. If the right-side numerator is zero, the ratio is undefined rather than infinite-looking prose.
Synthetic worked example
The canonical graph describes a fictional issuer. Founder Class and Public Class share the same common-equity instrument. The sourced synthetic terms are:
| Term | Founder Class | Public Class | Resolver result |
|---|---|---|---|
| Voting rights | 10 votes/share | 1 vote/share | 10:1 |
| Economic rights | 1 distribution unit/share | 1 distribution unit/share | 1:1; explicit terms equal |
| Conversion | 1 Founder → 1 Public on qualifying transfer | No reverse assertion | One-way, conditional |
| Class fungibility | No class-to-class assertion | No class-to-class assertion | Not established |
| Price convertibility | No price relationship in contract | No price relationship in contract | Not established |
The Public Class also has two listings and two trading lines. A source explicitly marks those lines as fungible when a settlement bridge is open and the beneficial owner is unchanged. That operational relationship does not promote itself to class-level fungibility.
Same issuer is an inventory result, not an equivalence result
The resolver derives same-issuer classes only through two explicit links:
share class → CLASS_OF → legal instrument → ISSUED_BY → issuer
The returned class IDs help inventory the issuer’s capital structure. They do not form a mathematical equivalence class. Each legal right still requires its own assertion.
This is the exact failure in a shortcut such as:
if left.issuerId == right.issuerId:
use one price and one rights record
The shared issuer is true. The conclusion is unsupported.
Directionality and symmetry are different
Conversion and seniority are directional. If Founder converts to Public, the reverse conversion exists only when another source says so. If Preferred is senior to Public for liquidation, Public is not “senior in reverse.”
Fungibility is symmetric in this reference contract, but it is accepted only when the assertion says symmetric: true and retains operational conditions. The contract rejects a one-sided fungibility edge because that would be internally inconsistent.
Revisions, cancellations, conflicts, and unsupported language
An assertion keeps a stable ID while its revision number increases. Type and endpoints cannot change inside that identity. A cancellation remains visible in diagnostics but is removed from the resolved graph.
Two active assertions with the same typed endpoints and different terms produce CONFLICT. A clause the reference implementation cannot model appears in unsupportedTerms and produces INCOMPLETE. Neither condition is “fixed” by taking the latest row or averaging ratios.
A bounded real filing example
Alphabet’s fiscal-year 2025 Form 10-K, signed February 4, 2026, states that Class A has one vote per share, Class B has ten votes per share, and Class C has no voting rights. That is enough to show why three class nodes under one issuer need separate voting assertions.
It is not enough, by itself, to manufacture historical effective dates, listing continuity, price interchangeability, provider mappings, or operational fungibility. Those questions require the controlling document version, precise provisions, exchange records, and an effective-dated ingestion trail. The package therefore keeps the calculation synthetic and uses the SEC filing only as a bounded evidence pattern.
Implementation walkthrough
The Python and TypeScript implementations follow the same sequence:
- validate entity and endpoint types;
- validate timestamps, revisions, lineage, units, and conditions;
- select the latest revision available at
knownAt; - apply the valid interval at
validAt; - detect conflicts and unsupported clauses;
- derive the issuer inventory without inferring equivalence; and
- compare only explicit rights.
Exact integer arithmetic is used until a reduced result can be returned safely. Both languages reject an irreducibly unsafe derived ratio, and both accept large intermediate products that reduce to a safe exact ratio.
Display labels are deliberately absent from resolver output. A current name can leak future information into a historical query; names need their own bitemporal model if they are required.
Test the claims, not just the happy path
The shared fixture and tests verify:
- 10:1 voting and 1:1 economics from explicit synthetic terms;
- future revisions do not leak before availability;
- expired and cancelled assertions disappear from the graph but remain auditable;
- missing economics returns
NOT_ESTABLISHEDandUNKNOWN; - conversion direction is preserved;
- trading-line fungibility does not imply class fungibility;
- many-to-many listing links remain separate;
- conflicting sources and unsupported clauses are visible;
- invalid timestamps, endpoint types, intervals, source locators, denominators, and revision changes are rejected; and
- Python and TypeScript agree at exact safe-integer boundaries.
Passing these tests proves the reference implementation matches its declared contract. It does not prove a vendor feed is complete or a legal interpretation is correct.
Explore the graph step by step
Open the guided relationship lab. Choose the canonical, missing-economics, future-revision, or conflict scenario. Use Step to advance exactly one evidence transition and Reset to return to the same initial state. The graph, ratio panel, guidance, and audit table update together.
The useful comparison is not “which line looks smoother?” It is “which conclusions remain established after we constrain evidence by type and time?”
Practical guardrails
- Preserve the controlling document and provision locator.
- Treat a zero right as data; treat a missing right as unknown.
- Store ratios with units and denominators.
- Keep conditional conversion and redemption language visible.
- Scope seniority to the relevant waterfall.
- Do not elevate venue-line fungibility to legal-class equivalence.
- Never infer price convertibility from a 1:1 legal ratio.
- Add bitemporal names instead of returning current labels in historical output.
- Route unsupported legal language to review.
Summary
Share-Class Relationship Mapping is an evidence discipline. Separate the issuer, instrument, class, listing, trading line, and venue. Resolve each assertion on valid and knowledge time. Compare only explicit, unit-compatible rights. Keep direction, conditions, revisions, cancellations, conflicts, and unknown clauses visible.
Continue with Merger Predecessor/Successor Mapping to apply the same identity discipline across a legal succession event.
Asset map
| Asset | Article placement | Video scene | Source | Static fallback |
|---|---|---|---|---|
| Identity layers | Six identities | 3 | visuals/static/article-hero.svg | Same SVG |
| Bitemporal flow | Two clocks | 4 | visuals/mermaid/calculation-flow.md | Article equations |
| Worked comparison | Synthetic example | 5 | visuals/static/worked-example.svg | Article table |
| Failure guard | Conflict and inference | 8 | visuals/static/failure-guard.svg | Guardrail list |
| Guided lab | Exploration | 4–8 | visuals/animated/playground.html | Worked-example SVG |
Primary-source roles and limitations are documented in ../REFERENCES.md.
Rendered from the canonical Mermaid sources linked by this article.
Bitemporal relationship resolution
This flow shows why validation, knowledge-time selection, valid-time selection, and guarded comparison are separate decisions.
Takeaway: a relationship must pass both clocks and the evidence guard before it can support a comparison.
Assertion and resolution lifecycle
This state diagram separates source-revision lifecycle from the resolver’s final evidence state.
Takeaway: “available” is not the same as “effective,” and “effective” is not the same as “safe to compare.”
References4 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. The implementation fixture is fully synthetic. No vendor security-master data, market prices, or proprietary identifier mappings are redistributed.
R1 — Alphabet Inc. 2025 Annual Report on Form 10-K
- Organization or authors: Alphabet Inc.; filed with the U.S. Securities and Exchange Commission
- Source type: Issuer-filed regulatory report
- Reporting date: Year ended December 31, 2025
- Signed date: February 4, 2026
- Version: SEC accession
0001652044-26-000018 - URL: https://www.sec.gov/Archives/edgar/data/1652044/000165204426000018/goog-20251231.htm
- Jurisdiction: United States; Delaware corporation and SEC reporting context
- Supports: The bounded example that Class A has one vote per share, Class B ten votes per share, and Class C no voting rights in the filing’s reporting context
- Limitations: Used only as an evidence pattern. The package does not derive historical effective dates, provider mapping, listing continuity, fungibility, market-price equivalence, or a numeric real-world calculation from this report.
R2 — Financial Instrument Global Identifier Specification, version 1.2
- Organization or authors: Object Management Group
- Source type: Open normative identifier specification
- Publication date: March 2026
- Version: 1.2
- URL: https://www.omg.org/spec/FIGI/1.2/PDF
- Jurisdiction: Global identifier framework
- Supports: Separate venue-level, Composite Global Identifier, and Share Class Global Identifier layers; hierarchy among those identifier types
- Limitations: Identifier hierarchy does not establish charter rights, issuer legal identity, economic equivalence, conversion, or fungibility. FIGI-to-licensed-identifier mappings may have redistribution restrictions.
R3 — ISO 6166:2021, Financial services — International securities identification number
- Organization or authors: International Organization for Standardization
- Source type: International identifier standard
- Publication date: 2021
- Version: Edition 8
- URL: https://www.iso.org/standard/78502.html
- Jurisdiction: Global
- Supports: Scope and role of the International Securities Identification Number for financial and referential instruments
- Limitations: Full text is licensed. An identifier does not itself encode voting rights, preferences, conversion conditions, or a bitemporal relationship history.
R4 — Alphabet Inc. Amended and Restated Certificate of Incorporation
- Organization or authors: Alphabet Inc.
- Source type: Issuer-published governing instrument
- Publication or effective date: Document version published by the issuer; effective provisions must be checked for the target date
- Version: Issuer-hosted current copy accessed 2026-07-22
- URL: https://abc.xyz/investor/board-and-governance/certificate-of-incorporation/
- Jurisdiction: Delaware, United States
- Supports: Why a production relationship graph needs controlling charter provisions and condition-bearing conversion rules rather than labels alone
- Limitations: The package does not transcribe the charter into its fixture. A production legal mapping must capture the exact document version, filing/effective date, provision locator, and qualified interpretation.
Evidence classification
| Package statement | Classification | Evidence |
|---|---|---|
| Alphabet A/B/C voting rights in the bounded FY2025 example | Sourced fact | R1 |
| Identity exists at venue, composite, and share-class levels | Sourced specification concept | R2 |
| Six-node entity model | Implementation choice | R2 motivates separation but does not mandate this schema |
| Bitemporal latest-available revision rule | Implementation choice | Explicitly documented and tested in this package |
| Synthetic 10:1 votes and 1:1 economic rights | Pedagogical fixture | tests/shared_fixture.json; not an Alphabet claim |
| Equal price, fungibility, or economics from common issuer | Rejected inference | Not established without explicit sourced relationships |
Full dependency-light reference implementations in both supported languages.
/** Bitemporal share-class relationship resolver. */
type RecordValue = Record<string, any>;
const ENTITY_TYPES = new Set(["ISSUER", "LEGAL_INSTRUMENT", "SHARE_CLASS", "LISTING", "TRADING_LINE", "VENUE"]);
const RELATIONSHIP_TYPES = new Set([
"ISSUED_BY", "CLASS_OF", "LISTED_AS", "TRADING_LINE_OF", "TRADES_ON",
"VOTING_RIGHTS", "ECONOMIC_RIGHTS", "DIVIDEND_PREFERENCE", "CONVERTS_TO",
"EXCHANGES_TO", "REDEEMABLE_FOR", "PARTICIPATION_RIGHTS", "SENIOR_TO", "FUNGIBLE_WITH",
]);
const RATIO_RELATIONSHIPS = new Set(["VOTING_RIGHTS", "ECONOMIC_RIGHTS", "CONVERTS_TO", "EXCHANGES_TO", "FUNGIBLE_WITH"]);
const ENDPOINTS: Record<string, [Set<string>, Set<string>]> = {
ISSUED_BY: [new Set(["LEGAL_INSTRUMENT"]), new Set(["ISSUER"])],
CLASS_OF: [new Set(["SHARE_CLASS"]), new Set(["LEGAL_INSTRUMENT"])],
LISTED_AS: [new Set(["SHARE_CLASS"]), new Set(["LISTING"])],
TRADING_LINE_OF: [new Set(["TRADING_LINE"]), new Set(["LISTING"])],
TRADES_ON: [new Set(["TRADING_LINE"]), new Set(["VENUE"])],
VOTING_RIGHTS: [new Set(["SHARE_CLASS"]), new Set(["ISSUER"])],
ECONOMIC_RIGHTS: [new Set(["SHARE_CLASS"]), new Set(["ISSUER", "LEGAL_INSTRUMENT"])],
DIVIDEND_PREFERENCE: [new Set(["SHARE_CLASS"]), new Set(["ISSUER", "LEGAL_INSTRUMENT"])],
CONVERTS_TO: [new Set(["SHARE_CLASS"]), new Set(["SHARE_CLASS"])],
EXCHANGES_TO: [new Set(["SHARE_CLASS"]), new Set(["SHARE_CLASS"])],
REDEEMABLE_FOR: [new Set(["SHARE_CLASS"]), new Set(["ISSUER", "LEGAL_INSTRUMENT"])],
PARTICIPATION_RIGHTS: [new Set(["SHARE_CLASS"]), new Set(["ISSUER", "LEGAL_INSTRUMENT"])],
SENIOR_TO: [new Set(["SHARE_CLASS"]), new Set(["SHARE_CLASS"])],
FUNGIBLE_WITH: [new Set(["SHARE_CLASS", "TRADING_LINE"]), new Set(["SHARE_CLASS", "TRADING_LINE"])],
};
function text(value: unknown, name: string): string {
if (typeof value !== "string" || value.trim() === "") throw new RangeError(`${name} must be a non-empty string`);
return value;
}
function instant(value: unknown, name: string): number {
const raw = text(value, name);
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(raw)) throw new RangeError(`${name} must be an RFC 3339 UTC instant ending in Z`);
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) throw new RangeError(`${name} is not a valid timestamp`);
const normalized = new Date(parsed).toISOString();
const expected = raw.includes(".") ? raw.replace(/\.(\d)Z$/, ".$100Z").replace(/\.(\d{2})Z$/, ".$10Z") : raw.replace("Z", ".000Z");
if (normalized !== expected) throw new RangeError(`${name} is not a valid calendar timestamp`);
return parsed;
}
function safeInt(value: unknown, name: string, allowZero = false): number {
if (!Number.isSafeInteger(value) || (allowZero ? Number(value) < 0 : Number(value) <= 0)) {
throw new RangeError(`${name} must be a ${allowZero ? "non-negative" : "positive"} safe integer`);
}
return Number(value);
}
function ratio(terms: unknown, name: string, allowZero: boolean): [number, number, string] {
if (!terms || typeof terms !== "object" || Array.isArray(terms)) throw new TypeError(`${name}.terms must be an object`);
const item = terms as RecordValue;
return [safeInt(item.numerator, `${name}.terms.numerator`, allowZero), safeInt(item.denominator, `${name}.terms.denominator`), text(item.unit, `${name}.terms.unit`)];
}
function validateSource(source: unknown, name: string): void {
if (!source || typeof source !== "object" || Array.isArray(source)) throw new TypeError(`${name}.source must be an object`);
const item = source as RecordValue;
for (const field of ["sourceId", "sourceType", "documentDate", "locator"]) text(item[field], `${name}.source.${field}`);
}
function validateTerms(relation: RecordValue, name: string): void {
const terms = relation.terms;
if (!terms || typeof terms !== "object" || Array.isArray(terms)) throw new TypeError(`${name}.terms must be an object`);
const type = relation.relationshipType;
if (RATIO_RELATIONSHIPS.has(type)) ratio(terms, name, type === "VOTING_RIGHTS");
if (["CONVERTS_TO", "EXCHANGES_TO", "FUNGIBLE_WITH"].includes(type)) {
if (!Array.isArray(terms.conditions) || terms.conditions.some((item: unknown) => typeof item !== "string" || item === "")) throw new RangeError(`${name}.terms.conditions must be a string list`);
}
if (type === "FUNGIBLE_WITH" && terms.symmetric !== true) throw new RangeError(`${name}.terms.symmetric must be true`);
if (type === "DIVIDEND_PREFERENCE") {
text(terms.currency, `${name}.terms.currency`); safeInt(terms.minorUnits, `${name}.terms.minorUnits`, true); safeInt(terms.perShares, `${name}.terms.perShares`);
if (typeof terms.cumulative !== "boolean") throw new TypeError(`${name}.terms.cumulative must be boolean`);
}
if (type === "REDEEMABLE_FOR") {
text(terms.currency, `${name}.terms.currency`); safeInt(terms.minorUnits, `${name}.terms.minorUnits`, true); safeInt(terms.perShares, `${name}.terms.perShares`);
if (typeof terms.holderOption !== "boolean") throw new TypeError(`${name}.terms.holderOption must be boolean`);
}
const unsupported = relation.unsupportedTerms ?? [];
if (!Array.isArray(unsupported) || unsupported.some((item: unknown) => typeof item !== "string" || item === "")) throw new RangeError(`${name}.unsupportedTerms must be a string list`);
}
function gcd(a: bigint, b: bigint): bigint { let left = a < 0n ? -a : a; let right = b < 0n ? -b : b; while (right) [left, right] = [right, left % right]; return left; }
function reduced(numerator: number, denominator: number): { numerator: number; denominator: number } {
const divisor = gcd(BigInt(numerator), BigInt(denominator));
const n = BigInt(numerator) / divisor; const d = BigInt(denominator) / divisor;
if (n > BigInt(Number.MAX_SAFE_INTEGER) || d > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("derived ratio exceeds the safe-integer boundary");
return { numerator: Number(n), denominator: Number(d) };
}
function divideRatios(left: [number, number, string] | null, right: [number, number, string] | null): RecordValue {
if (!left || !right) return { status: "NOT_ESTABLISHED" };
if (left[2] !== right[2]) return { status: "INCOMPARABLE_UNITS", leftUnit: left[2], rightUnit: right[2] };
if (right[0] === 0) return { status: "UNDEFINED_ZERO_DENOMINATOR", unit: left[2] };
const numerator = BigInt(left[0]) * BigInt(right[1]); const denominator = BigInt(left[1]) * BigInt(right[0]); const divisor = gcd(numerator, denominator);
const n = numerator / divisor; const d = denominator / divisor;
if (n > BigInt(Number.MAX_SAFE_INTEGER) || d > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("derived ratio exceeds the safe-integer boundary");
return { status: "ESTABLISHED", numerator: Number(n), denominator: Number(d), unit: left[2] };
}
export function calculate(data: RecordValue): RecordValue {
if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("data must be an object");
const query = data.query;
if (!query || typeof query !== "object" || Array.isArray(query)) throw new TypeError("query must be an object");
const validAtText = text(query.validAt, "query.validAt"); const knownAtText = text(query.knownAt, "query.knownAt");
const validAt = instant(validAtText, "query.validAt"); const knownAt = instant(knownAtText, "query.knownAt");
const rootId = text(query.rootEntityId, "query.rootEntityId"); const compareIds = query.compareShareClassIds;
if (!Array.isArray(compareIds) || compareIds.length !== 2 || compareIds.some((item: unknown) => typeof item !== "string" || item === "")) throw new RangeError("query.compareShareClassIds must contain exactly two IDs");
if (!Array.isArray(data.entities) || data.entities.length === 0) throw new RangeError("entities must be a non-empty list");
const entityById = new Map<string, RecordValue>();
data.entities.forEach((entity: unknown, index: number) => {
const name = `entities[${index}]`; if (!entity || typeof entity !== "object" || Array.isArray(entity)) throw new TypeError(`${name} must be an object`);
const item = entity as RecordValue; const entityId = text(item.entityId, `${name}.entityId`); const entityType = text(item.entityType, `${name}.entityType`);
if (!ENTITY_TYPES.has(entityType)) throw new RangeError(`${name}.entityType is unsupported`); if (entityById.has(entityId)) throw new RangeError(`duplicate entityId ${entityId}`); entityById.set(entityId, item);
});
if (!entityById.has(rootId)) throw new RangeError("rootEntityId is unknown"); if (entityById.get(rootId)!.entityType !== "SHARE_CLASS") throw new RangeError("rootEntityId must identify a SHARE_CLASS");
for (const id of compareIds) if (!entityById.has(id) || entityById.get(id)!.entityType !== "SHARE_CLASS") throw new RangeError("compareShareClassIds must identify SHARE_CLASS entities");
if (!Array.isArray(data.relationships) || data.relationships.length === 0) throw new RangeError("relationships must be a non-empty list");
const grouped = new Map<string, RecordValue[]>(); const immutable = new Map<string, string>(); const seen = new Set<string>();
data.relationships.forEach((relation: unknown, index: number) => {
const name = `relationships[${index}]`; if (!relation || typeof relation !== "object" || Array.isArray(relation)) throw new TypeError(`${name} must be an object`); const item = relation as RecordValue;
const assertionId = text(item.assertionId, `${name}.assertionId`); const revisionNumber = safeInt(item.revisionNumber, `${name}.revisionNumber`); const revisionKey = `${assertionId}\0${revisionNumber}`;
if (seen.has(revisionKey)) throw new RangeError(`duplicate revision ${assertionId}/${revisionNumber}`); seen.add(revisionKey);
const relationshipType = text(item.relationshipType, `${name}.relationshipType`); if (!RELATIONSHIP_TYPES.has(relationshipType)) throw new RangeError(`${name}.relationshipType is unsupported`);
const sourceId = text(item.sourceEntityId, `${name}.sourceEntityId`); const targetId = text(item.targetEntityId, `${name}.targetEntityId`); if (sourceId === targetId) throw new RangeError(`${name} cannot be self-referential`);
if (!entityById.has(sourceId) || !entityById.has(targetId)) throw new RangeError(`${name} refers to an unknown entity`);
const [sourceTypes, targetTypes] = ENDPOINTS[relationshipType]; if (!sourceTypes.has(entityById.get(sourceId)!.entityType) || !targetTypes.has(entityById.get(targetId)!.entityType)) throw new RangeError(`${name} has invalid endpoint types for ${relationshipType}`);
const signature = `${relationshipType}\0${sourceId}\0${targetId}`; if (immutable.has(assertionId) && immutable.get(assertionId) !== signature) throw new RangeError(`${assertionId} changes type or endpoints across revisions`); immutable.set(assertionId, signature);
const observed = instant(item.observedAt, `${name}.observedAt`); const available = instant(item.availableAt, `${name}.availableAt`); if (available < observed) throw new RangeError(`${name}.availableAt precedes observedAt`);
const effectiveFrom = instant(item.effectiveFrom, `${name}.effectiveFrom`); if (item.effectiveTo !== null && item.effectiveTo !== undefined && instant(item.effectiveTo, `${name}.effectiveTo`) <= effectiveFrom) throw new RangeError(`${name}.effectiveTo must follow effectiveFrom`);
if (!new Set(["ACTIVE", "CANCELLED"]).has(item.status)) throw new RangeError(`${name}.status must be ACTIVE or CANCELLED`); validateSource(item.source, name); validateTerms(item, name);
const group = grouped.get(assertionId) ?? []; group.push(item); grouped.set(assertionId, group);
});
const selected: RecordValue[] = []; const selectedRevision: Record<string, number> = {}; const cancelled: string[] = []; let futureCount = 0;
for (const assertionId of [...grouped.keys()].sort()) {
const group = grouped.get(assertionId)!.slice().sort((a, b) => a.revisionNumber - b.revisionNumber);
for (let index = 1; index < group.length; index++) {
if (group[index].revisionNumber <= group[index - 1].revisionNumber) throw new RangeError(`${assertionId} revisions are not increasing`);
if (instant(group[index].availableAt, "availableAt") <= instant(group[index - 1].availableAt, "availableAt")) throw new RangeError(`${assertionId} availability must increase with revision number`);
}
const available = group.filter(item => instant(item.availableAt, "availableAt") <= knownAt); futureCount += group.length - available.length; if (!available.length) continue;
const latest = available[available.length - 1]; selectedRevision[assertionId] = latest.revisionNumber; if (latest.status === "CANCELLED") { cancelled.push(assertionId); continue; }
const start = instant(latest.effectiveFrom, "effectiveFrom"); const end = latest.effectiveTo == null ? null : instant(latest.effectiveTo, "effectiveTo"); if (start <= validAt && (end === null || validAt < end)) selected.push(latest);
}
selected.sort((a, b) => [a.relationshipType, a.sourceEntityId, a.targetEntityId, a.assertionId].join("\0").localeCompare([b.relationshipType, b.sourceEntityId, b.targetEntityId, b.assertionId].join("\0")));
const conflictGroups = new Map<string, RecordValue[]>(); for (const item of selected) { const key = [item.relationshipType, item.sourceEntityId, item.targetEntityId].join("\0"); const group = conflictGroups.get(key) ?? []; group.push(item); conflictGroups.set(key, group); }
const conflicts: RecordValue[] = [];
for (const key of [...conflictGroups.keys()].sort()) { const relations = conflictGroups.get(key)!; const signatures = new Set(relations.map(item => JSON.stringify(item.terms, Object.keys(item.terms).sort()))); if (relations.length > 1 && signatures.size > 1) { const [relationshipType, sourceEntityId, targetEntityId] = key.split("\0"); conflicts.push({ relationshipType, sourceEntityId, targetEntityId, assertionIds: relations.map(item => item.assertionId).sort() }); } }
const unsupported = selected.filter(item => (item.unsupportedTerms ?? []).length).map(item => ({ assertionId: item.assertionId, terms: [...item.unsupportedTerms] })); const state = conflicts.length ? "CONFLICT" : unsupported.length ? "INCOMPLETE" : "RESOLVED";
const edges = (relationshipType: string, sourceId?: string) => selected.filter(item => item.relationshipType === relationshipType && (sourceId === undefined || item.sourceEntityId === sourceId));
const legalIds = new Set(edges("CLASS_OF", rootId).map(item => item.targetEntityId)); const issuerIds = new Set(edges("ISSUED_BY").filter(item => legalIds.has(item.sourceEntityId)).map(item => item.targetEntityId));
const issuerLegalIds = new Set(edges("ISSUED_BY").filter(item => issuerIds.has(item.targetEntityId)).map(item => item.sourceEntityId)); const sameIssuerClasses = [...new Set(edges("CLASS_OF").filter(item => issuerLegalIds.has(item.targetEntityId)).map(item => item.sourceEntityId))].sort();
const [leftId, rightId] = compareIds as [string, string];
const rights = (relationshipType: string, classId: string): [number, number, string] | null => { const found = edges(relationshipType, classId); return found.length === 1 ? ratio(found[0].terms, found[0].assertionId, relationshipType === "VOTING_RIGHTS") : null; };
const leftEconomic = edges("ECONOMIC_RIGHTS", leftId), rightEconomic = edges("ECONOMIC_RIGHTS", rightId); let equalEconomics: boolean | string = "UNKNOWN";
if (leftEconomic.length === 1 && rightEconomic.length === 1 && !(leftEconomic[0].unsupportedTerms ?? []).length && !(rightEconomic[0].unsupportedTerms ?? []).length) equalEconomics = JSON.stringify(leftEconomic[0].terms) === JSON.stringify(rightEconomic[0].terms);
const conversionEdges = selected.filter(item => ["CONVERTS_TO", "EXCHANGES_TO"].includes(item.relationshipType) && item.sourceEntityId === leftId && item.targetEntityId === rightId);
const conversion = conversionEdges.length === 1 ? { status: "ESTABLISHED", direction: `${leftId}->${rightId}`, relationshipType: conversionEdges[0].relationshipType, ratio: reduced(conversionEdges[0].terms.numerator, conversionEdges[0].terms.denominator), unit: conversionEdges[0].terms.unit, conditions: [...conversionEdges[0].terms.conditions] } : { status: "NOT_ESTABLISHED" };
const fungibleEdges = edges("FUNGIBLE_WITH").filter(item => new Set([item.sourceEntityId, item.targetEntityId]).size === 2 && [item.sourceEntityId, item.targetEntityId].includes(leftId) && [item.sourceEntityId, item.targetEntityId].includes(rightId));
const fungibility = fungibleEdges.length === 1 ? { status: "ESTABLISHED", conditions: [...fungibleEdges[0].terms.conditions] } : { status: "NOT_ESTABLISHED" };
const resolvedRelationships = selected.map(item => ({ assertionId: item.assertionId, revisionNumber: item.revisionNumber, relationshipType: item.relationshipType, sourceEntityId: item.sourceEntityId, targetEntityId: item.targetEntityId, effectiveFrom: item.effectiveFrom, effectiveTo: item.effectiveTo ?? null, observedAt: item.observedAt, availableAt: item.availableAt, source: structuredClone(item.source), terms: structuredClone(item.terms), unsupportedTerms: [...(item.unsupportedTerms ?? [])] }));
return {
query: { rootEntityId: rootId, validAt: validAtText, knownAt: knownAtText }, root: { entityId: rootId, entityType: entityById.get(rootId)!.entityType }, state,
sameIssuerShareClassIds: sameIssuerClasses,
comparison: { leftShareClassId: leftId, rightShareClassId: rightId, votingRightsRatioLeftToRight: divideRatios(rights("VOTING_RIGHTS", leftId), rights("VOTING_RIGHTS", rightId)), economicRightsRatioLeftToRight: divideRatios(rights("ECONOMIC_RIGHTS", leftId), rights("ECONOMIC_RIGHTS", rightId)), equalEconomics, conversion, fungibility, priceConvertibility: "NOT_ESTABLISHED" },
resolvedRelationships,
diagnostics: { selectedRevisionByAssertion: selectedRevision, excludedFutureRevisionCount: futureCount, cancelledAssertionIds: cancelled, conflicts, unsupportedTerms: unsupported },
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.