From cdcceefd8577bbbb940bd1c4af4a3c64a42962a5 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 30 Jul 2026 17:39:12 -0500 Subject: [PATCH] feat(pivot): roll-forward preferred-label bindings (beginning/ending balances) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit periodStart*/periodEnd* preferred-label roles carry a binding semantic, not just a label: the same concept appears under two presentation arcs (beginning balance at the top of the network, ending at the bottom), and the beginning row binds the INSTANT fact at each duration column's start date — dated the day before the start per the XBRL/SEC convention, with the exact start date as fallback. The presentation tree now carries arcs (child + label + binding + negation) instead of bare child ids, the walk dedups per (element, binding) so both roll-forward rows emit in arc order, and cell/combo lookups are binding-aware. Standalone opening-instant columns are suppressed in roll-forward sections — their data renders as the beginning-balance row inside the duration columns (previously openings were dropped as sparse noise and only ending balances survived). The per-arc label plumbing (0.3.2's first-arc-wins map) is subsumed by the arc-carrying tree, which also fixes labels when one concept has different labels under different arcs. Verified on the NVDA 10-K equity statement: beginning balances 22,101 / 42,978 / 79,327 chain into ending 42,978 / 79,327 / 157,293 across FY24-FY26, with component sub-rows and negated withholding rows matching the as-filed statement. --- src/pivot.ts | 195 +++++++++++++++++++++++++++------------ test/rollforward.test.ts | 157 +++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 59 deletions(-) create mode 100644 test/rollforward.test.ts diff --git a/src/pivot.ts b/src/pivot.ts index c0e1c89..2ff8a83 100644 --- a/src/pivot.ts +++ b/src/pivot.ts @@ -73,6 +73,20 @@ function memberKey(d: DimensionQualifier | null | undefined): string { return d?.member ?? d?.typedValue ?? DOMAIN } +/** + * The ISO date one day before `date`. XBRL dates a beginning-of-period instant + * as the prior period's end (FY2026 runs 2025-01-27 → 2026-01-25; its opening + * balance is the instant 2025-01-26), so a `periodStart*` row binds the instant + * at the duration's start date minus one day (with the exact start date as a + * fallback for non-SEC conventions). + */ +function isoDayBefore(date: string): string { + const t = new Date(`${date}T00:00:00Z`) + if (Number.isNaN(t.getTime())) return date + t.setUTCDate(t.getUTCDate() - 1) + return t.toISOString().slice(0, 10) +} + /** * A fact's coordinate along a chosen set of axes, as a stable signature. A fact * with no member on an axis contributes the domain-total marker, so the @@ -152,17 +166,45 @@ function dimensionInScope(scope: PresentationScope, d: DimensionQualifier): bool // ── Presentation tree ─────────────────────────────────────────────────────── +/** + * One presentation arc as the row walk consumes it: the child plus the arc's + * preferred-label choice. `binding` is the roll-forward semantic — a + * `periodStart*` / `periodEnd*` role means this arc's row binds the instant + * fact at the START (respectively end) of each duration column, so the same + * concept legitimately appears twice (beginning and ending balance). + */ +interface PresArcRef { + child: string + label: string | null + binding: 'start' | 'end' | null + negated: boolean +} + interface PresentationTree { - /** Ordered children per parent (by arc order). */ - childrenOf: Map + /** Ordered child arcs per parent (by arc order). */ + childrenOf: Map /** Presentation roots (a `from` that is never a `to`), in filing sequence. */ roots: string[] /** Pre-order position of each element — the member/row ordering key. */ indexOf: Map } +function arcRef(a: { + child: string + preferredLabel?: string | null + preferredLabelRole?: string | null +}): PresArcRef { + const role = a.preferredLabelRole ?? '' + return { + child: a.child, + label: a.preferredLabel ?? null, + binding: /periodStart/i.test(role) ? 'start' : /periodEnd/i.test(role) ? 'end' : null, + negated: /negated/i.test(role), + } +} + /** - * Index one structure's presentation arcs: ordered children per parent, the + * Index one structure's presentation arcs: ordered child arcs per parent, the * roots, and each element's pre-order position (used to order dimension members). * The row walk itself (in `buildPivot`) is a hybrid — abstract headers emit * before their children, concrete concepts after — so a subtotal renders after @@ -170,22 +212,22 @@ interface PresentationTree { * (us-gaap) or as the presentation parent of its components (rs-gaap). */ function presentationTree(model: NormalizedReport, structureId: string): PresentationTree { - const children = new Map>() + const children = new Map>() const froms = new Set() const tos = new Set() for (const assoc of model.presAssociations) { if (assoc.structure !== structureId) continue const kids = children.get(assoc.parent) ?? [] - kids.push([assoc.order, assoc.child]) + kids.push([assoc.order, arcRef(assoc)]) children.set(assoc.parent, kids) froms.add(assoc.parent) tos.add(assoc.child) } - const childrenOf = new Map() + const childrenOf = new Map() for (const [parent, kids] of children) { childrenOf.set( parent, - kids.sort((a, b) => a[0] - b[0]).map(([, child]) => child) + kids.sort((a, b) => a[0] - b[0]).map(([, arc]) => arc) ) } const rootKey = (root: string): number => { @@ -204,7 +246,7 @@ function presentationTree(model: NormalizedReport, structureId: string): Present seen.add(node) const ln = localName(node) if (!indexOf.has(ln)) indexOf.set(ln, indexOf.size) - for (const child of childrenOf.get(node) ?? []) pre(child) + for (const arc of childrenOf.get(node) ?? []) pre(arc.child) } for (const root of roots) pre(root) return { childrenOf, roots, indexOf } @@ -561,24 +603,6 @@ export function buildPivot( ? presentationTree(model, structure.id) : { childrenOf: new Map(), roots: [], indexOf: new Map() } - // The filer's per-arc label choice from the presentation network: the label - // string that names the row and — for `negated*` roles — a display-sign flip - // (`Less short-term portion (999)` for a fact tagged +999). First arc to a - // child wins within a structure. - const preferredByChild = new Map() - if (structure) { - for (const a of model.presAssociations) { - if (a.structure !== structure.id) continue - if (!a.preferredLabel && !a.preferredLabelRole) continue - if (!preferredByChild.has(a.child)) { - preferredByChild.set(a.child, { - label: a.preferredLabel ?? null, - negated: (a.preferredLabelRole ?? '').includes('negated'), - }) - } - } - } - const rowDimAxes = dimAxes(config.rows) const colDimAxes = dimAxes(config.columns) const allowedAxes = new Set([...rowDimAxes, ...colDimAxes, ...dimAxes(config.slicers)]) @@ -624,6 +648,20 @@ export function buildPivot( if (periodOnColumns && config.dropSparsePeriods !== false) { periods = pruneSparsePeriods(model, tableFacts, periods) } + // A roll-forward's opening instants render as beginning-balance rows *inside* + // the duration columns (periodStart binding below); a standalone column for + // the same instant would duplicate that data, so drop it. + const hasRollForward = [...tree.childrenOf.values()].some((arcs) => + arcs.some((a) => a.binding !== null) + ) + if (periodOnColumns && hasRollForward) { + const startKeys = new Set( + periods + .filter((p) => p.type === 'duration' && p.startDate) + .flatMap((p) => [isoDayBefore(p.startDate as string), p.startDate as string]) + ) + periods = periods.filter((p) => !(p.type === 'instant' && startKeys.has(p.end))) + } const colCombos = memberCombos(tableFacts, colDimAxes, tree.indexOf, true) // Column coordinates that actually carry a fact. The member-combo union (and @@ -676,28 +714,52 @@ export function buildPivot( } const factfulConcepts = new Set(tableFacts.map((f) => f.element)) + + // The period end-date(s) a row binds against in a column. Default (and + // `periodEnd`) rows read the column's own end; a `periodStart` row reads the + // instant at the column duration's start — dated the day before the start + // (the XBRL/SEC convention), with the exact start date as fallback. + const periodKeysFor = (col: PivotColumn, binding: 'start' | 'end' | null): string[] => { + if (binding !== 'start') return [col.period?.end ?? ''] + const start = col.period?.startDate + return start ? [isoDayBefore(start), start] : [] + } + + const lookupFact = ( + element: string, + rowSig: string, + col: PivotColumn, + binding: 'start' | 'end' | null + ): Fact | undefined => { + const colSig = coordinate(new Map(col.members.map((m) => [m.axis, m])), colDimAxes) + for (const end of periodKeysFor(col, binding)) { + const fact = factIndex.get(`${element}␟${rowSig}␟${end}␟${colSig}`) + if (fact) return fact + } + return undefined + } + // Domain total last on rows too: member rows list first, then the concept's // own (memberless) total below and outdented — the accounting "components then // total" layout, so the total reads as the sum of the members above it. const rowCombos = memberCombos(tableFacts, rowDimAxes, tree.indexOf, true) - const combosForConcept = (element: string): MemberCombo[] => { - if (rowDimAxes.length === 0) return rowCombos // single domain combo - return rowCombos.filter((combo) => - columns.some((col) => - factIndex.has( - `${element}␟${combo.sig}␟${col.period?.end ?? ''}␟${coordinate( - new Map(col.members.map((m) => [m.axis, m])), - colDimAxes - )}` - ) - ) - ) + const combosForConcept = (element: string, binding: 'start' | 'end' | null): MemberCombo[] => { + const occupied = (combo: MemberCombo): boolean => + columns.some((col) => lookupFact(element, combo.sig, col, binding) !== undefined) + if (rowDimAxes.length === 0) { + // A start/end row with no bindable fact anywhere must not render at all. + return binding === null || occupied(rowCombos[0]) ? rowCombos : [] + } + return rowCombos.filter(occupied) } - const cellsFor = (element: string, rowSig: string): PivotRow['cells'] => + const cellsFor = ( + element: string, + rowSig: string, + binding: 'start' | 'end' | null + ): PivotRow['cells'] => columns.map((col) => { - const colSig = coordinate(new Map(col.members.map((m) => [m.axis, m])), colDimAxes) - const fact = factIndex.get(`${element}␟${rowSig}␟${col.period?.end ?? ''}␟${colSig}`) + const fact = lookupFact(element, rowSig, col, binding) return { value: fact?.value ?? null, fact: fact ?? null, @@ -715,21 +777,24 @@ export function buildPivot( if (cached !== undefined) return cached hasFactMemo.set(id, false) // guard against cycles let has = factfulConcepts.has(id) - for (const child of tree.childrenOf.get(id) ?? []) { - if (subtreeHasFact(child)) has = true + for (const arc of tree.childrenOf.get(id) ?? []) { + if (subtreeHasFact(arc.child)) has = true } hasFactMemo.set(id, has) return has } const rows: PivotRow[] = [] - const emitConcept = (id: string, depth: number): void => { + const emitConcept = (id: string, depth: number, arc: PresArcRef | null): void => { const el = elementOf(model, id) - const combos = combosForConcept(id) + const binding = arc?.binding ?? null + const combos = combosForConcept(id, binding) if (!combos.length) return - const pref = preferredByChild.get(id) - const rowLabel = pref?.label ?? undefined - const negated = pref?.negated || undefined + const rowLabel = arc?.label ?? undefined + const negated = arc?.negated || undefined + // A roll-forward concept renders once per arc (beginning / ending balance); + // the binding suffix keeps those rows' keys distinct. + const variant = binding ? `${id}␟rf:${binding}` : id // With dimensions on rows, a concept's own total row heads its indented member // breakdown and supplies the concept name. When the concept has *no* // consolidated total (only per-member facts — common in detail disclosures), @@ -737,7 +802,7 @@ export function buildPivot( // labels (`CREM Loan · Mortgages` with no idea what is being measured). if (combos.every((c) => c.members.length > 0)) { rows.push({ - key: id, + key: variant, element: el, depth, header: false, @@ -753,13 +818,13 @@ export function buildPivot( // concept's own total row, identical to the undimensioned case. Only member // sub-rows carry the dimensional signature in their key. rows.push({ - key: combo.members.length ? `${id}␟${combo.sig}` : id, + key: combo.members.length ? `${variant}␟${combo.sig}` : variant, element: el, depth: depth + (combo.members.length ? 1 : 0), header: false, isSubtotal: subtotals.has(id), members: combo.members, - cells: cellsFor(id, combo.sig), + cells: cellsFor(id, combo.sig, binding), label: rowLabel, negated, }) @@ -770,11 +835,21 @@ export function buildPivot( // `[Roll Up]` header sits above its section); a concrete concept emits *after* // its children (so a subtotal lands below its components) — correct whether the // subtotal is an abstract's last child (us-gaap) or the parent itself (rs-gaap). + // + // Dedup is per (element, binding): a roll-forward concept appears under TWO + // arcs — beginning balance at the top of the network, ending balance at the + // bottom — and each emits its own row; every other repeat of an element is a + // genuine duplicate and drops. const seenInTree = new Set() - const walk = (id: string, depth: number): void => { - if (seenInTree.has(id) || !subtreeHasFact(id)) return - seenInTree.add(id) + const walkedConcepts = new Set() + const walk = (arc: PresArcRef, depth: number): void => { + const id = arc.child + if (!subtreeHasFact(id)) return const el = elementOf(model, id) + const dedupKey = el.abstract ? id : `${id}␟${arc.binding ?? ''}` + if (seenInTree.has(dedupKey)) return + seenInTree.add(dedupKey) + walkedConcepts.add(id) if (el.abstract) { // Hypercube scaffolding ([Table]/[Line Items]) is not a heading — hide its // row and keep its children at the current depth so the tree stays flat. @@ -788,21 +863,23 @@ export function buildPivot( isSubtotal: false, members: [], cells: [], - label: preferredByChild.get(id)?.label ?? undefined, + label: arc.label ?? undefined, }) } const childDepth = scaffold ? depth : depth + 1 for (const child of tree.childrenOf.get(id) ?? []) walk(child, childDepth) } else { for (const child of tree.childrenOf.get(id) ?? []) walk(child, depth + 1) - emitConcept(id, depth) + emitConcept(id, depth, arc) } } - for (const root of tree.roots) walk(root, 0) + for (const root of tree.roots) { + walk({ child: root, label: null, binding: null, negated: false }, 0) + } // Concepts with facts but absent from the presentation network — append after. for (const id of factfulConcepts) { - if (seenInTree.has(id) || elementOf(model, id).abstract) continue - emitConcept(id, 0) + if (walkedConcepts.has(id) || elementOf(model, id).abstract) continue + emitConcept(id, 0, null) } const title = ib.structureId diff --git a/test/rollforward.test.ts b/test/rollforward.test.ts new file mode 100644 index 0000000..a30e488 --- /dev/null +++ b/test/rollforward.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import type { Fact, NormalizedReport } from '../src/model' +import { buildPivots } from '../src/pivot' + +// The equity roll-forward shape: the SAME concept appears under two +// presentation arcs — beginning balance (periodStartLabel, top of the network) +// and ending balance (periodEndLabel, bottom) — and each binds a different +// instant inside the duration column: the opening instant is dated the day +// before the duration starts (XBRL/SEC convention), the closing instant shares +// the duration's end date. Standalone opening-instant columns are suppressed — +// their data renders as the beginning-balance row. +const ABS = 'us-gaap:StatementOfStockholdersEquityAbstract' +const SE = 'us-gaap:StockholdersEquity' +const NI = 'us-gaap:NetIncomeLoss' +const START_ROLE = 'http://www.xbrl.org/2003/role/periodStartLabel' +const END_ROLE = 'http://www.xbrl.org/2003/role/periodEndLabel' + +const fact = (id: string, element: string, period: string, value: number): Fact => ({ + id, + element, + period, + unit: 'u', + entity: 'e', + factSet: 'fs', + value, + decimals: '0', +}) + +function report(): NormalizedReport { + return { + reportId: 'r', + reportIri: null, + entity: { id: 'e', name: 'Co', legalName: null, country: null }, + informationBlocks: [ + { id: 's', blockType: '', factSet: 'fs', label: 'Equity', structureId: 's' }, + ], + structures: [{ id: 's', blockType: '', roleUri: null, structureName: 'Equity', order: 0 }], + facts: [ + // FY2026: 2025-01-27 → 2026-01-25; opening instant 2025-01-26. + fact('se-open', SE, 'i-open', 100), + fact('se-close', SE, 'i-close', 150), + fact('ni', NI, 'd-fy', 50), + ], + elements: { + [ABS]: { + id: ABS, + qname: ABS, + label: 'Statement of Stockholders Equity', + balance: null, + periodType: null, + abstract: true, + monetary: false, + }, + [SE]: { + id: SE, + qname: SE, + label: 'Stockholders Equity', + balance: 'credit', + periodType: 'instant', + abstract: false, + monetary: true, + numericKind: 'monetary', + }, + [NI]: { + id: NI, + qname: NI, + label: 'Net Income (Loss)', + balance: 'credit', + periodType: 'duration', + abstract: false, + monetary: true, + numericKind: 'monetary', + }, + }, + periods: { + 'i-open': { + id: 'i-open', + type: 'instant', + instant: '2025-01-26', + startDate: null, + endDate: null, + end: '2025-01-26', + }, + 'i-close': { + id: 'i-close', + type: 'instant', + instant: '2026-01-25', + startDate: null, + endDate: null, + end: '2026-01-25', + }, + 'd-fy': { + id: 'd-fy', + type: 'duration', + instant: null, + startDate: '2025-01-27', + endDate: '2026-01-25', + end: '2026-01-25', + }, + }, + units: { u: { id: 'u', measure: 'iso4217:USD', label: 'USD', symbol: '$' } }, + calcAssociations: [], + presAssociations: [ + { + parent: ABS, + child: SE, + order: 1, + role: null, + structure: 's', + preferredLabel: 'Beginning balances', + preferredLabelRole: START_ROLE, + }, + { parent: ABS, child: NI, order: 2, role: null, structure: 's' }, + { + parent: ABS, + child: SE, + order: 3, + role: null, + structure: 's', + preferredLabel: 'Ending balances', + preferredLabelRole: END_ROLE, + }, + ], + } +} + +describe('roll-forward preferred-label bindings', () => { + it('renders beginning and ending balance rows for the same concept, in arc order', () => { + const table = buildPivots(report())[0] + const se = table.rows.filter((r) => r.element.id === SE) + expect(se.map((r) => r.label)).toEqual(['Beginning balances', 'Ending balances']) + const labels = table.rows.filter((r) => !r.header).map((r) => r.label ?? r.element.label) + expect(labels).toEqual(['Beginning balances', 'Net Income (Loss)', 'Ending balances']) + }) + + it('binds the opening instant (start − 1 day) into the duration column', () => { + const table = buildPivots(report())[0] + expect(table.columns).toHaveLength(1) + expect(table.columns[0].period?.end).toBe('2026-01-25') + const [beginning, ending] = table.rows.filter((r) => r.element.id === SE) + expect(beginning.cells[0]?.value).toBe(100) + expect(beginning.cells[0]?.fact?.id).toBe('se-open') + expect(ending.cells[0]?.value).toBe(150) + expect(ending.cells[0]?.fact?.id).toBe('se-close') + }) + + it('suppresses the standalone opening-instant column', () => { + const table = buildPivots(report())[0] + expect(table.columns.map((c) => c.period?.end)).not.toContain('2025-01-26') + }) + + it('keeps the roll-forward rows keyed distinctly', () => { + const table = buildPivots(report())[0] + const keys = table.rows.map((r) => r.key) + expect(new Set(keys).size).toBe(keys.length) + }) +})