// Sanity floors for the canonical Stammbaum fixture. Owned by the fixture // directory rather than the capture script so the contract lives next to the // JSON it constrains and is reachable from unit tests. // // Calibrated against the May-2026 canonical dataset (62 nodes, 5 generations, // 28 SPOUSE_OF edges, 1 multi-spouse person). The point of these gates is // catching a silently empty backend or a structurally regressed snapshot, not // strict size validation; raise them only if the canonical graph grows // substantially. export const MIN_NODES = 50; export const MIN_GENERATIONS = 5; export const MIN_SPOUSE_OF_EDGES = 1; export const MIN_MULTI_SPOUSE_PERSONS = 1; type Edge = { relationType?: string; personId?: string; relatedPersonId?: string }; type Node = { id?: string; generation?: number | null }; export type NetworkShape = { nodes?: Node[]; edges?: Edge[] }; export type FixtureStats = { nodes: number; edges: number; spouseEdges: number; generations: number[]; multiSpousePersons: number; }; export function validateFixture(network: NetworkShape): FixtureStats { const nodes = Array.isArray(network.nodes) ? network.nodes : []; const edges = Array.isArray(network.edges) ? network.edges : []; const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF'); const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null)); const multiSpousePersons = countMultiSpousePersons(spouseEdges); const failures: string[] = []; if (nodes.length < MIN_NODES) { failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`); } if (generations.size < MIN_GENERATIONS) { failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`); } if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) { failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`); } if (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) { // buildLayout.test.ts asserts the multi-spouse property on this // fixture; a recapture that loses every multi-spouse person would // silently make that test vacuous. Fail loudly instead. failures.push( `expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` + `got ${multiSpousePersons}` ); } if (failures.length > 0) { throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`); } return { nodes: nodes.length, edges: edges.length, spouseEdges: spouseEdges.length, generations: [...generations].sort((a, b) => (a as number) - (b as number)) as number[], multiSpousePersons }; } function countMultiSpousePersons(spouseEdges: Edge[]): number { const partners = new Map>(); for (const e of spouseEdges) { if (!e.personId || !e.relatedPersonId) continue; addPartner(partners, e.personId, e.relatedPersonId); addPartner(partners, e.relatedPersonId, e.personId); } let count = 0; for (const set of partners.values()) { if (set.size >= 2) count += 1; } return count; } function addPartner(map: Map>, key: string, value: string) { const s = map.get(key); if (s) s.add(value); else map.set(key, new Set([value])); }