D02-F03-A03 / Complete engineering topic

Share-Class Relationship Mapping: Same Issuer Does Not Mean Same Security

A production-minded guide to Share-Class Relationship Mapping: Same Issuer Does Not Mean Same Security.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F03-A03Canonical / Tested / Open
D02 / D02-F03
Key concepts

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

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 identity layers connected without collapsing them

Six identities, not one ticker row

A useful graph separates:

  1. the issuer—the legal organization;
  2. the legal instrument or series;
  3. the share class—the rights-bearing class;
  4. the listing—the market admission;
  5. the trading line—the currency, settlement, and venue-specific tradable line; and
  6. 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.

Rendering system map…

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:

  • effectiveFrom and effectiveTo say when the relationship is legally or operationally true;
  • observedAt says when the source event was observed; and
  • availableAt says when the system could actually use the revision.

For assertion (a) and knowledge time (k), select the highest revision available by (k):

ra(k)=arg maxr{r.revisionNumberr.assertionId=a, r.availableAtk}.r_a(k)=\operatorname*{arg\,max}_{r}\{r.revisionNumber \mid r.assertionId=a,\ r.availableAt\le k\}.

Then keep the edge only when the selected revision is active at valid time (v):

r.effectiveFromv<r.effectiveTo,r.effectiveFrom\le v<r.effectiveTo,

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:

QL:R=nL/dLnR/dR=nLdRdLnR.Q_{L:R}=\frac{n_L/d_L}{n_R/d_R}=\frac{n_Ld_R}{d_Ln_R}.

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:

TermFounder ClassPublic ClassResolver result
Voting rights10 votes/share1 vote/share10:1
Economic rights1 distribution unit/share1 distribution unit/share1:1; explicit terms equal
Conversion1 Founder → 1 Public on qualifying transferNo reverse assertionOne-way, conditional
Class fungibilityNo class-to-class assertionNo class-to-class assertionNot established
Price convertibilityNo price relationship in contractNo price relationship in contractNot established

Exact synthetic comparison and guarded conclusions

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:

Plain text
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:

Plain text
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 guard separates sourced relationships from unsafe inference

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:

  1. validate entity and endpoint types;
  2. validate timestamps, revisions, lineage, units, and conditions;
  3. select the latest revision available at knownAt;
  4. apply the valid interval at validAt;
  5. detect conflicts and unsupported clauses;
  6. derive the issuer inventory without inferring equivalence; and
  7. 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_ESTABLISHED and UNKNOWN;
  • 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

AssetArticle placementVideo sceneSourceStatic fallback
Identity layersSix identities3visuals/static/article-hero.svgSame SVG
Bitemporal flowTwo clocks4visuals/mermaid/calculation-flow.mdArticle equations
Worked comparisonSynthetic example5visuals/static/worked-example.svgArticle table
Failure guardConflict and inference8visuals/static/failure-guard.svgGuardrail list
Guided labExploration4–8visuals/animated/playground.htmlWorked-example SVG

Primary-source roles and limitations are documented in ../REFERENCES.md.

Bitemporal relationship resolution

This flow shows why validation, knowledge-time selection, valid-time selection, and guarded comparison are separate decisions.

Rendering system map…

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.

Rendering system map…

Takeaway: “available” is not the same as “effective,” and “effective” is not the same as “safe to compare.”

References4 primary sources and evidence notes

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 statementClassificationEvidence
Alphabet A/B/C voting rights in the bounded FY2025 exampleSourced factR1
Identity exists at venue, composite, and share-class levelsSourced specification conceptR2
Six-node entity modelImplementation choiceR2 motivates separation but does not mandate this schema
Bitemporal latest-available revision ruleImplementation choiceExplicitly documented and tested in this package
Synthetic 10:1 votes and 1:1 economic rightsPedagogical fixturetests/shared_fixture.json; not an Alphabet claim
Equal price, fungibility, or economics from common issuerRejected inferenceNot established without explicit sourced relationships
share_class_relationship_mapping.ts
/** 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 },
  };
}
Full-height labplaygroundOpen full screen