Skip to content

Commit fc6e3ba

Browse files
authored
Merge pull request #17 from RoboFinSystems/bugfix/strip-xbrl-role-suffixes
fix: strip XBRL role suffixes from labels on the holon/store path
2 parents daccc1c + 0f8d616 commit fc6e3ba

5 files changed

Lines changed: 108 additions & 18 deletions

File tree

src/adapters/sec.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* 5. Aggregations must `ORDER BY <alias>` (not `s.number`); `end` is reserved, so
3232
* alias every `p.end_date AS end_date`.
3333
*/
34-
import { humanize, qname as toQname } from '../constants'
34+
import { humanize, stripRoleSuffix, qname as toQname } from '../constants'
3535
import { currencySymbolFor } from '../format'
3636
import type {
3737
CalcAssociation,
@@ -254,19 +254,6 @@ const STANDARD_LABEL_ROLE = 'http://www.xbrl.org/2003/role/label'
254254
/** Terse label role — the fallback when a concept has no standard label. */
255255
const TERSE_LABEL_ROLE = 'http://www.xbrl.org/2003/role/terseLabel'
256256

257-
/**
258-
* XBRL standard labels tag structural elements with a bracketed role suffix —
259-
* `Land [Member]`, `Operating Expenses [Abstract]`, and likewise `[Axis]`,
260-
* `[Table]`, `[Line Items]`, `[Domain]`. That tag is metadata, not part of the
261-
* display name, so strip a trailing one. If stripping would empty the label
262-
* (a label that is only the tag), keep the original.
263-
*/
264-
const ROLE_SUFFIX_RE = /\s*\[(?:Abstract|Axis|Member|Table|Line Items|Domain)\]\s*$/
265-
function stripRoleSuffix(label: string): string {
266-
const stripped = label.replace(ROLE_SUFFIX_RE, '').trim()
267-
return stripped || label
268-
}
269-
270257
/** Build the report's element-URI → (role → value) label dictionary from LABELS_Q rows. */
271258
function buildReportLabels(rows: Array<Record<string, unknown>>): ReportLabels {
272259
const byElement = new Map<string, Map<string, string>>()

src/adapters/store.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* traversal. The only per-source code is how the store gets filled.
1010
*/
1111
import { DataFactory, Store } from 'n3'
12-
import { IRI, humanize, qname } from '../constants'
12+
import { IRI, humanize, qname, stripRoleSuffix } from '../constants'
1313
import type {
1414
CalcAssociation,
1515
DimensionQualifier,
@@ -82,10 +82,14 @@ export function parseStore(store: Store): NormalizedReport {
8282
const balance = firstValue(id, IRI.balance)
8383
const periodType = firstValue(id, IRI.periodType)
8484
const itemType = firstValue(id, IRI.itemType) ?? undefined
85+
// A holon's prefLabel is the filing's XBRL standard label, which tags
86+
// structural concepts with their role (`Common Stock [Member]`,
87+
// `Statement [Table]`) — strip the tag for display, as the SEC adapter does.
88+
const prefLabel = firstValue(id, IRI.prefLabel)
8589
elements[id] = {
8690
id,
8791
qname: qname(id),
88-
label: firstValue(id, IRI.prefLabel) ?? humanize(id),
92+
label: prefLabel ? stripRoleSuffix(prefLabel) : humanize(id),
8993
balance: balance === 'debit' || balance === 'credit' ? balance : null,
9094
periodType: periodType === 'instant' || periodType === 'duration' ? periodType : null,
9195
abstract: firstValue(id, IRI.abstract) === 'true',
@@ -182,6 +186,7 @@ export function parseStore(store: Store): NormalizedReport {
182186
// ── Information Blocks ──
183187
const informationBlocks: InformationBlock[] = []
184188
for (const id of subjectsOfType(IRI.InformationBlock)) {
189+
const blockLabel = firstValue(id, IRI.prefLabel)
185190
informationBlocks.push({
186191
id,
187192
blockType: firstValue(id, IRI.blockType) ?? '',
@@ -190,7 +195,7 @@ export function parseStore(store: Store): NormalizedReport {
190195
// block-type classification, so structureId is how row order is found.
191196
structureId: firstValue(id, IRI.structure) ?? undefined,
192197
factSet: firstValue(id, IRI.factSet),
193-
label: firstValue(id, IRI.prefLabel),
198+
label: blockLabel ? stripRoleSuffix(blockLabel) : null,
194199
})
195200
}
196201

src/constants.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ export function humanize(iri: string): string {
113113
return local.replace(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
114114
}
115115

116+
/**
117+
* XBRL standard labels tag structural elements with a bracketed role suffix —
118+
* `Land [Member]`, `Operating Expenses [Abstract]`, and likewise `[Axis]`,
119+
* `[Table]`, `[Line Items]`, `[Domain]`, `[Roll Forward]`, `[Text Block]`. That
120+
* tag is metadata, not part of the display name, so strip a trailing one. If
121+
* stripping would empty the label (a label that is only the tag), keep the
122+
* original.
123+
*/
124+
const ROLE_SUFFIX_RE =
125+
/\s*\[(?:Abstract|Axis|Member|Table|Line Items|Domain|Roll Forward|Roll Up|Text Block|Policy Text Block|Table Text Block|Extensible List|Extensible Enumeration|Flag)\]\s*$/i
126+
export function stripRoleSuffix(label: string): string {
127+
const stripped = label.replace(ROLE_SUFFIX_RE, '').trim()
128+
return stripped || label
129+
}
130+
116131
/**
117132
* A large text-block / policy fact whose HTML (or plain-text) body is too big to
118133
* store inline, so the platform externalizes it to the public CDN and the fact's

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@
1111
*/
1212

1313
// Model + reconstruction + formatting
14-
export { BLOCK_ORDER, BLOCK_TITLES, NS, humanize, isExternalFactUrl, qname } from './constants'
14+
export {
15+
BLOCK_ORDER,
16+
BLOCK_TITLES,
17+
NS,
18+
humanize,
19+
isExternalFactUrl,
20+
qname,
21+
stripRoleSuffix,
22+
} from './constants'
1523
export * from './format'
1624
export * from './model'
1725
export {

test/store.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,79 @@ describe('holon store adapter', () => {
129129
expect(fact?.factSets).toEqual(expect.arrayContaining(['http://ex/fsNotes', 'http://ex/fsBS']))
130130
expect(fact?.factSets).toHaveLength(2)
131131
})
132+
133+
it('strips the XBRL role tag from element and dimension labels', async () => {
134+
// A holon's prefLabel is the filing's standard label, which tags structural
135+
// concepts with their role — `[Abstract]`, `[Table]`, `[Line Items]`,
136+
// `[Roll Forward]`, `[Member]`. These are metadata, not display names.
137+
const doc = {
138+
'@graph': [
139+
{
140+
'@id': 'http://ex/hdr',
141+
'@type': `${RS}Element`,
142+
[`${SKOS}prefLabel`]: "Statement of Stockholders' Equity [Abstract]",
143+
},
144+
{
145+
'@id': 'http://ex/tbl',
146+
'@type': `${RS}Element`,
147+
[`${SKOS}prefLabel`]: 'Statement [Table]',
148+
},
149+
{
150+
'@id': 'http://ex/li',
151+
'@type': `${RS}Element`,
152+
[`${SKOS}prefLabel`]: 'Statement [Line Items]',
153+
},
154+
{
155+
'@id': 'http://ex/rf',
156+
'@type': `${RS}Element`,
157+
[`${SKOS}prefLabel`]: 'Increase (Decrease) in Stockholders’ Equity [Roll Forward]',
158+
},
159+
{
160+
'@id': 'http://ex/fd',
161+
'@type': `${RS}Fact`,
162+
[`${RS}element`]: { '@id': 'http://ex/rf' },
163+
[`${RS}period`]: { '@id': 'http://ex/p' },
164+
[`${RS}numericValue`]: 60,
165+
[`${RS}dimension`]: { '@id': 'http://ex/d1' },
166+
},
167+
{
168+
'@id': 'http://ex/d1',
169+
'@type': `${RS}Dimension`,
170+
[`${RS}axis`]: { '@id': 'http://ex/axis' },
171+
[`${RS}member`]: { '@id': 'http://ex/mem' },
172+
[`${RS}isExplicit`]: true,
173+
},
174+
{
175+
'@id': 'http://ex/axis',
176+
'@type': `${RS}Element`,
177+
[`${SKOS}prefLabel`]: 'Equity Components [Axis]',
178+
},
179+
{
180+
'@id': 'http://ex/mem',
181+
'@type': `${RS}Element`,
182+
[`${SKOS}prefLabel`]: 'Common Stock [Member]',
183+
},
184+
],
185+
}
186+
const model = await parseJsonld(doc)
187+
expect(model.elements['http://ex/hdr']?.label).toBe("Statement of Stockholders' Equity")
188+
expect(model.elements['http://ex/tbl']?.label).toBe('Statement')
189+
expect(model.elements['http://ex/li']?.label).toBe('Statement')
190+
expect(model.elements['http://ex/rf']?.label).toBe(
191+
'Increase (Decrease) in Stockholders’ Equity'
192+
)
193+
const dim = model.facts.find((f) => f.id === 'http://ex/fd')?.dimensions?.[0]
194+
expect(dim?.axisLabel).toBe('Equity Components')
195+
expect(dim?.memberLabel).toBe('Common Stock')
196+
})
197+
198+
it('keeps a label that is only a role tag rather than emptying it', async () => {
199+
const doc = {
200+
'@graph': [
201+
{ '@id': 'http://ex/bare', '@type': `${RS}Element`, [`${SKOS}prefLabel`]: '[Abstract]' },
202+
],
203+
}
204+
const model = await parseJsonld(doc)
205+
expect(model.elements['http://ex/bare']?.label).toBe('[Abstract]')
206+
})
132207
})

0 commit comments

Comments
 (0)