Skip to content

fix(xbrl): read fraction items as their quotient, not joined digits (#1766) - #1803

Open
rqms40 wants to merge 11 commits into
dgunning:mainfrom
rqms40:fix/xbrl-fraction-items
Open

rqms40 wants to merge 11 commits into
dgunning:mainfrom
rqms40:fix/xbrl-fraction-items

Conversation

@rqms40

@rqms40 rqms40 commented Oct 24, 2026 •

Copy link
Copy Markdown

What this changes

Before: an xbrli:fractionItemType fact with numerator 1 and denominator 3 was parsed as value='13', numeric_value=13.0, because the instance parser joined the children's text. After: the fact is the quotient with decimals='INF'. Terminating quotients are stored exactly as decimal text (9007199254740993/1 stays 9007199254740993, and 1/2**6200 keeps all 4,334 digits). The quotient is computed with one division at sig(n) + 4*sig(d) + 1 digits; if that is inexact (recurring, such as one third) it is rounded half-even to 50 significant digits. Operands must match the xsd:decimal lexical form (ASCII digits, no exponent or underscore) and be at most 10,000 characters; anything else keeps its slash text with numeric_value=None. The division ignores the caller's decimal context. Non-fraction facts are unchanged.

Fixes #1766

Verification

  • Ground truth: Apple Inc. FY2023 Form 10-K, accession 0000320193-23-000106, instance aapl-20230930_htm.xml: us-gaap:Assets, context c-22 (instant 2023-09-30) = 352583000000. SEC full-text search for fractionItemType and xbrli:numerator returned no hits, so no real filing with a fraction item was found. This is an ordinary-fact control, and the fraction cases use synthetic fixtures.
  • Regression test in tests/issues/regression/test_issue_1766.py; its docstring links XBRL fraction items are parsed as their numerator and denominator digits joined (1/3 reads as 13) #1766. 17 tests pass (including exponent, 1_0, non-ASCII digit, 10,000/10,001-character boundary, and 10**60/3 fallback cases), also with PYTHONINTMAXSTRDIGITS=0.
  • Cassettes: tests/cassettes/test_apple_fy2023_10k_ordinary_assets_are_unchanged.yaml was recorded from live SEC and not edited afterwards. Recorded on: 2026-10-24 (SEC response date 03:33:24 GMT)
  • Silence check: not changed. A bad fraction (1/0, 0/0, abc/3) keeps its text with numeric_value=None, the same as an existing non-numeric fact, and the tests assert that.
  • Docs: no documented example is touched.
  • Changelog: changelog.d/1766.fixed.md, changelog.d/1766-xsd-decimal.fixed.md, changelog.d/1766-operand-length.fixed.md

Other checks: regression provenance OK (556), cassettes OK (272), lint shows no new findings. test-fast was last run on 2f7d1574, with 49963 passed and 3 failed. The 3 failures are textsearch highlight tests that fail under NO_COLOR and pass with color enabled. It was not re-run after the later commits, which touch only edgar/xbrl/parsers/instance.py, the regression test and the changelog fragments. XBRL parser tests (tests/xbrl/test_xbrl_parser.py, test_jj77_instance_fact_identity.py) pass at 9898334.

6.0

  • This PR adds a deprecation, a 6.0 warning, or a "removed in 6.0" note, and docs/upgrade/6.0.md says what users see now and what to do instead

Not a 6.0 change.

AI assistance

  • None
  • Assisted. Tool and model: Cursor (implementation), GPT-6.1 Sol via Codex (code review)
dgunning#1766)

Root cause: a fractionItemType fact has no text of its own. The instance
parser fell back to "".join(element.itertext()), and the XML parser drops
blank text, so the whitespace between numerator and denominator disappeared
and 1/3 became "13". float() then stored 13.0, and the missing decimals
ranked as precision 0. XBRL 2.1 §5.1.1.3.2 defines the value as the
quotient and §4.6.6 defines the precision as INF.

Change: a fact with direct xbrli:numerator and xbrli:denominator children
stores the quotient and sets decimals to INF. A zero denominator, a
non-decimal, a nil fact, or a quotient that does not fit in a float leaves
numeric_value unset and keeps the two parts separated by "/", the same way
float() leaves any other non-numeric text unset. Nested text that is not a
fraction is still joined.

Verification: inline instance, no SEC filing. No US-GAAP concept uses
fractionItemType, and the issue's sample of 80 SEC instances had none, so
the ground truth is the spec's 1/3. tests/issues/regression/test_issue_1766.py
fails before the fix (value '13', numeric_value 13.0) and passes after
(value '0.3333333333333333', numeric_value == 1/3, decimals 'INF').
hatch run test-fast: 3 failed, 49959 passed, 9 skipped, 3419 deselected,
1 xfailed. The three failures are tests/search/test_textsearch.py highlight
assertions, which require ANSI color; this environment sets NO_COLOR=1,
TERM=dumb and FORCE_COLOR=0. The same three pass with color enabled. They
do not touch instance parsing.
Root cause: the quotient was formatted from float(), so
9007199254740993/1 was stored as 9007199254740992 and
1.0000000000000001/1 as 1. The division used the caller's decimal
context, so prec=2 rounded it and an Inexact trap aborted the
instance parse. Operand .text stops at a comment or processing
instruction, so 1<!--c-->2 was read as 1.

Change: value is the Decimal quotient in fixed-point form, and
float() is used only for numeric_value. A float that overflows,
underflows to zero, or is non-finite leaves numeric_value unset
while value stays the exact decimal. The division runs in a local
context (precision max(50, the operands' significant digits), traps
cleared). Each operand is the element's full character content.

Verification: regression tests cover the two float-mantissa cases,
a caller context of prec=2 with the Inexact trap enabled, and
1<!--c-->2 / 4 == 3. Apple Inc. FY2023 10-K, accession
0000320193-23-000106, instance aapl-20230930_htm.xml, has no
fraction item (no numerator element). Ordinary control, read from
that instance: us-gaap:Assets, context c-22, instant 2023-09-30,
352583000000 (id f-172, decimals -6); context c-23, instant
2022-09-24, 352755000000. Cassette recorded from live SEC and not
edited.

hatch run pytest tests/issues/regression/test_issue_1766.py: 11 passed.
hatch run check-regression-provenance: 556 regression file(s).
hatch run check-cassettes: 272 YAML file(s), no disallowed tags.
hatch run test-fast: 3 failed, 49963 passed, 9 skipped, 3419
deselected, 1 xfailed. The three failures are the text-search
highlight tests under NO_COLOR; the same tests pass with color
enabled. hatch run lint: 1061 findings, the same three pre-existing
instance.py findings (N812, two S110) and no new ones.
…xact (GH dgunning#1766)

Root cause: localcontext() copied the caller's rounding and exponent
limits, so ROUND_UP changed the last digit of 1/3, Emax=2 overflowed
1000/1, and Emin=-9 turned 10^-100 into zero. Precision was also
capped near 50 significant digits, which truncates the terminating
quotient 1/2**100 (70 digits) while still marking it INF. decimals
was set to INF only after float() succeeded, so 10**400/1 and
1/10**400 kept decimals None.

Change: divide in an explicit Context (ROUND_HALF_EVEN, Emin/Emax at
the decimal limits, traps cleared). A denominator of 2^a*5^b is stored
at its exact digit count. A non-terminating quotient stays half-even
at 50 significant digits, so 1/3 is fifty 3s. decimals is INF whenever
the quotient is a finite decimal, including when it does not fit in a
float.

Verification: the caller-context test now sets ROUND_UP, Emax=2 and
Emin=-9. 1/2**100 is the 70-digit expansion of 5**100/10**100.
10**400/1 and 1/10**400 keep decimals INF. hatch run pytest
tests/issues/regression/test_issue_1766.py: 12 passed.
check-regression-provenance: 556. check-cassettes: 272.
hatch run lint: 1061 findings, the same three pre-existing instance.py
findings (N812, two S110).
…ning#1766)

Root cause: the significand's digit count used len(str(coefficient)).
Under the default 4300-digit int-to-str limit, 1/2**6200 (5**6200 has
4334 digits) raises ValueError, and that handler stored slash text
with no numeric value.

Change: the count is floor(log10(n))+1 from bit_length, corrected
against a power of ten, so the int is never converted to a string.
The changelog no longer calls a recurring one third exact: terminating
quotients are exact, recurring ones are half-even to 50 significant
digits, and decimals stays INF.

Verification: tests/issues/regression/test_issue_1766.py 13 passed,
including 1/2**6200 while str(5**6200) still raises under the default
limit. check-regression-provenance 556. check-cassettes 272. lint
1061, the same three pre-existing instance.py findings.
…nning#1766)

The 1/2**6200 regression assumed the process still had Python's default
4300-digit limit. PYTHONINTMAXSTRDIGITS=0 turns that limit off, so
str(5**6200) would succeed and the test would fail for the wrong
reason. The test now sets the limit to 4300 and restores the previous
value. The fraction docstring no longer says rounding clears
numeric_value: that field is unset only on overflow or a nonzero
underflow, and the exact text stays in value.

Verification: tests/issues/regression/test_issue_1766.py 13 passed,
and the same 13 passed with PYTHONINTMAXSTRDIGITS=0. hatch run lint:
1061 findings, the same three pre-existing instance.py findings.
@arhancanli

Copy link
Copy Markdown

One edge case in _exact_terminating_digits, with timings. I copied the PR's helper functions out of the diff and ran them on Python 3.9, which has the same decimal module behaviour.

_exact_terminating_digits(Decimal("1"), Decimal("3"))              # None, 0.0 s
_exact_terminating_digits(Decimal("1E+300000"), Decimal("3"))      # None, 0.05 s
_exact_terminating_digits(Decimal("1"), Decimal("1E-300000"))      # 1, 81.7 s

When the shift (numerator_exp - denominator_exp) is negative, the code runs denominator_coeff *= 10 ** (-shift), then gcd and % loops on that integer. The cost grows with the exponent rather than with the number of digits in the text. 1E-300000 is 9 characters, so a 9-character denominator in a filing's instance document ties up the parser for over a minute, and 1E-3000000 would take far longer. 1E-300000 is not a valid xsd:decimal (the lexical space has no exponent), which is why the numbers in real filings never hit this. But Decimal() accepts it, and so it accepts 1_0 and non-ASCII digits too, so the Decimal(num_text) call is the real gate.

A check against the decimal lexical form before any arithmetic would close this. For example, re.fullmatch(r'[+-]?(\d+(\.\d*)?|\.\d+)', text) on both operands, falling through to the existing "keep the slash form, numeric_value=None" path. A test with <xbrli:denominator>1E-300000</xbrli:denominator> asserting it returns fast and keeps 1/1E-300000 would pin it.

Smaller point: the regression test file is 413 lines, and none of the cases I could see in the diff cover an operand with an exponent or an underscore. Those are the inputs where Decimal() and the XBRL lexical space disagree.

…1766)

Root cause: Decimal() accepts an exponent, underscores, and non-ASCII
digits, which the xsd:decimal lexical space does not include. A negative
exponent then makes _exact_terminating_digits scale a coefficient by
10**n, so the cost follows the exponent rather than the text.
_exact_terminating_digits(Decimal('1'), Decimal('1E-300000')) takes
about 82 seconds.

Change: both operands are checked against the xsd:decimal production,
ASCII digits only, before any Decimal arithmetic. Surrounding whitespace
is still stripped, matching the operand text that is stored, so
"  2 " / " 4 " stays one half. A mismatch keeps the slash form and
leaves numeric_value unset.

Verification: hatch run pytest tests/issues/regression/test_issue_1766.py
14 passed in 0.18s, including 1/1E-300000, an exponent numerator, 1_0,
and an Arabic-Indic digit. hatch run lint: 1061 findings, the same
three pre-existing instance.py findings (N812, two S110).
@arhancanli

Copy link
Copy Markdown

Read the new commit against the case I timed: fullmatch on [0-9] only, run before any Decimal arithmetic, rejects 1E-300000, 1_0 and the Arabic-Indic digit, and the strip keeps " 2 " / " 4 " as one half. That closes the exponent route.

One input still scales with text length rather than value: a long plain-decimal denominator such as 0. followed by 300,000 zeros and a 1 is a valid xsd:decimal, so it passes the regex, and 10 ** (-shift) is then taken on a 300,000-digit scale. Did you time one of those with the same helper? If it is slow, a cap on operand length (real filings never need more than a few dozen digits) before the arithmetic would bound it, and the test could pin the cap with that input.

@dgunning dgunning left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The bug is real (1/3 read as 13) and the main change is in the right place. I'd like two changes before merge, plus a note on CI.

1. Check that each operand is an xsd:decimal before calling Decimal(). @arhancanli's finding reproduces at the PR head: a denominator of 1E-20000 takes 0.16 s and 1E-60000 takes 1.47 s, so the time grows with the square of the exponent. Decimal() also accepts 1_0 (read as 10) and Arabic-Indic digits (١/2 gives 0.5), and neither is valid in an instance document. A gate like re.fullmatch(r'[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)', text) on both operands, falling through to the existing slash-form path, closes all three. Please add tests for an exponent, an underscore and a non-ASCII digit.

2. With that gate in place, most of the exactness code can go. Once the operands have no exponent, a terminating n/d has at most sig(n) + 4*sig(d) + 1 significant digits, since log2(d) < 3.33 * digits(d). So you can divide once at that precision and read the context's Inexact flag. If it's set, the quotient recurs, and you divide again at your current 50-digit rule. This replaces _coefficient, _decimal_digit_count and _exact_terminating_digits (about 75 lines). I compared the two versions on 40,006 cases (random integers and decimals over denominators of the form 2^a*5^b, random values, 3*2^k and 10^k, plus 1/2**6200 and 9007199254740993/1) and got 0 differences:

ctx = Context(prec=max(50, sig(n) + 4 * sig(d) + 1), rounding=ROUND_HALF_EVEN,
              Emin=MIN_EMIN, Emax=MAX_EMAX, traps=[])
q = ctx.divide(N, D)
if ctx.flags[Inexact]:          # recurring: round to the 50-digit rule
    q = _decimal_context(max(50, sig(n) + sig(d))).divide(N, D)

CI: the one test-fast (3.13) failure, tests/documents/test_markdown.py::test_obscure_filing_to_markdown, has nothing to do with this change. It passes at your head commit locally and on main, so the next push should clear it.

Parse time is fine. A 21,393-fact instance (GBDC 10-Q fixture) takes 15.3 s on this branch and 16.1 s on main, which is within noise.

)

Root cause: a plain decimal is legal xsd:decimal, so the lexical gate
admits 0. followed by 300,000 zeros and a 1. Decimal stores that as
coefficient 1 and exponent -300001, and _exact_terminating_digits then
scales a coefficient by 10**300001. Parsing that fraction took 29.4
seconds and stored a 300,002-digit integer.

Change: drop _coefficient, _decimal_digit_count and
_exact_terminating_digits. Divide once at sig(n) + 4*sig(d) + 1 digits,
since log2(d) < 3.33 * digits(d). If the context's Inexact flag is set,
divide again at the 50-digit rule. The lexical pattern is the
maintainer's non-capturing form. Operands longer than 10000 characters
keep the slash form. A cap of 100 would reject 2**6200 (1867 digits)
and 10**400, which still have to divide exactly.

Verification: the 300,000-zero denominator was 29.37s before the cap
and 0.0016s after, value 1/0.000...001 with numeric_value None.
hatch run pytest tests/issues/regression/test_issue_1766.py: 15 passed
in 0.23s. hatch run pytest tests/xbrl/test_xbrl_parser.py
tests/issues/regression/test_jj77_instance_fact_identity.py -k 'not xoxo':
14 passed, 1 deselected, in 15.30s. hatch run ruff check
edgar/xbrl/parsers/instance.py: the same three pre-existing findings
(N812, two S110).
…unning#1766)

The 1/3 case uses 50 digits on both passes, so deleting the Inexact
fallback would still pass. 10**60/3 recurs: sig(n)+sig(d) is 62 and the
first pass is 66 digits. Half-even, the stored text is sixty 3s, a
point, and two 3s. The cap is checked at 10000 and 10001 characters on
both operands. The 300,000-zero denominator is spied, so a capped
operand never reaches Decimal. A one-second clock assert stays beside
the spy.

Verification: hatch run pytest tests/issues/regression/test_issue_1766.py
17 passed in 0.24s. hatch run ruff check
tests/issues/regression/test_issue_1766.py: all checks passed.
@rqms40

rqms40 commented Oct 25, 2026

Copy link
Copy Markdown
Author

Thanks both. dgunning: point 1 is in (79c3ac1), with the gate matching your pattern and tests for an exponent, 1_0 and an Arabic-Indic digit. Point 2 is in 9898334: _coefficient, _decimal_digit_count and _exact_terminating_digits are gone, replaced by your single division at sig(n) + 4*sig(d) + 1 with the Inexact fallback to the 50-digit rule.

arhancanli: good call on long plain decimals. 1 / 0. + 300,000 zeros + 1 took 29.4 s before. The single division alone brings it to 2.5 ms, and operands over 10,000 characters now keep the slash form with numeric_value=None (100 would have rejected values the existing tests divide exactly, like 2**6200). Tests (264f4d0) pin the 10,000/10,001 boundary on both operands, check with a spy that capped operands never reach Decimal, and add 10**60 / 3 so the recurring fallback is actually exercised. 17 tests pass.

@arhancanli

Copy link
Copy Markdown

Read the new commit. The _is_xsd_decimal gate runs before any Decimal arithmetic, the 10,000-character cap sits right after it, and I checked the precision bound. sig(n) + 4*sig(d) + 1 is enough for a terminating quotient: the longest tail comes from a denominator with a factor 2^k, which adds k digits, and k is at most about 3.33 per significant digit of d. The Inexact flag then separates the recurring case cleanly. I didn't re-time it myself, so the 2.5 ms is your number.

One case I'd want a test for: the operands pass the gate, the quotient is exact, but it is too small or large for a float. For example 1 over 1 followed by 9,999 zeros gives an exact 1E-9999, and float() returns 0.0. Your numeric == 0.0 and quotient != 0 branch returns value, None, True, so it looks handled. Does any of the 17 tests cover it? That is the input where value and numeric_value have to disagree on purpose, and where a later refactor of numeric_value handling would break it without anyone noticing.

…#1766)

1/10**9999 is an exact decimal, the digit 1 in the 9,999th place, and
float() of it is 0.0. A 9,999-digit numerator over 1 is exact and
float() of it is inf on Python 3.12 (it does not raise). Both operands
stay within the 10,000-character cap. The parser already returns the
exact text, numeric_value None and decimals INF for both: underflow
through numeric == 0.0 and quotient != 0, overflow through the
non-finite check. No parser change.

Verification: pytest tests/issues/regression/test_issue_1766.py
19 passed in 0.25s. pytest tests/xbrl/test_xbrl_parser.py
tests/issues/regression/test_jj77_instance_fact_identity.py
15 passed in 16.83s. ruff check on the test file: all checks passed.
check-regression-provenance: 556. check-regression-skips: 556.
@arhancanli

Copy link
Copy Markdown

Read c61c6e7 (not run). Both tests cover the case I asked about: 1 over 1 plus 9,999 zeros keeps the exact decimal with numeric_value unset and float(fact.value) == 0.0 pinned, and the overflow test handles the OverflowError-or-inf split across Python builds.

One band is between those two and the normal range: a quotient like 1 over 10**320 is exact and inside the cap, and float() returns a subnormal (about 1e-320), nonzero but with only a few significant bits. Your numeric == 0.0 and quotient != 0 branch does not fire, so numeric_value is set to a number that has lost most of its precision while value stays exact. Is that intended, or should the unset rule also cover subnormals? Either way a test at 10**320 would pin which.

…ing#1766)

1/10**320 is the subnormal 1e-320 (hex 0x0.00000000007e8p-1022), finite
and nonzero, so numeric_value keeps that float. 1/10**400 underflows to
0.0 and -1/10**400 underflows to signed zero; both keep the exact decimal
with numeric_value unset. That is the existing out-of-range rule: unset
only for a non-finite float or a nonzero quotient that collapses to 0.0.
A subnormal is the same kind of rounded float a value past the mantissa
already gets.

Split the division and the float conversion out of _fraction_item.
CodeFactor flags that method as Complex Method at complexity 16. After
the split, radon reports 13 for _fraction_item, 7 for _fraction_quotient
and 4 for _fraction_numeric_value.

Verification: the new test passed against the parser at c61c6e7 (1 passed
in 0.12s) and again after the split. hatch run pytest
tests/issues/regression/test_issue_1766.py: 20 passed in 0.20s.
hatch run pytest tests/xbrl/test_xbrl_parser.py
tests/issues/regression/test_jj77_instance_fact_identity.py
tests/issues/regression/test_issue_1766.py: 35 passed in 16.02s.
hatch run test-fast: 49887 passed, 88 failed, 9 skipped, 3419 deselected,
1 xfailed, 88 rerun in 1510.56s. The 88 failures are share-scale captions,
two Schedule 13D rich-table rows, three text-search highlights, and the
VCR null-reason test. None are in test_issue_1766.py.
hatch run ruff check tests/issues/regression/test_issue_1766.py: all
checks passed. ruff check edgar/xbrl/parsers/instance.py: the same three
pre-existing findings (N812, two S110). check-regression-provenance and
check-regression-skips: 556.
…unning#1766)

A one-second bound fails when the machine is busy, even if the parser
is right. The exponent 1E-300000, the underscore 1_0, the Arabic-Indic
digit, and the 300,000-zero denominator are spied instead. Each is
absent from Decimal's constructor arguments. The fixture's 1/3 is
present, so an empty spy cannot pass the check.

Verification: hatch run pytest tests/issues/regression/test_issue_1766.py
20 passed in 0.35s. hatch run pytest tests/xbrl/test_xbrl_parser.py
tests/issues/regression/test_jj77_instance_fact_identity.py
15 passed in 15.40s. hatch run ruff check
tests/issues/regression/test_issue_1766.py: all checks passed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants