Skip to content

test(analysis): measure the numeric boundaries instead of assuming them - #52

Merged
hyperpolymath merged 1 commit into
mainfrom
feat/numeric-boundaries
Sep 22, 2026
Merged

hyperpolymath merged 1 commit into
mainfrom
feat/numeric-boundaries

Conversation

@hyperpolymath

@hyperpolymath hyperpolymath commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Refs #1 — second numeric-integrity step, following #49. This one closes the issue's
boundary-audit criterion: "audit CSV, JSON, database, browser, R and external-tool
boundaries for precision loss, integer range and serialization compatibility."

An audit that restates the contract is not an audit, so every boundary is crossed
with a real partner
— node for the browser, Rscript for R, DuckDB for the database.
The probe throughout is 9007199254740993 (2^53+1), the smallest integer Float64
cannot hold.

Boundary What was measured
Browser In a real JS engine that count as a JSON number arrives as 9007199254740992 — one short, unmarked; the encoded string crosses byte for byte
R R has no integer type wider than 32 bits (as.integer(2^31) is NA), every number is a double, and neither format() nor as.character() renders that count back to its digits
Database BIGINT carries the count exactly, DOUBLE silently changes it; past BIGINT a BIGINT column refuses (2^64 raises) instead of rounding; HUGEINT is exact to 2^127; beyond that the string form transports it unchanged
CSV A CSV is text, so the boundary is the parser's: "9.007199254740992e15" and "9007199254740993.0" are refused by name

The round trip is now executable

parse_exact_count and parse_exact_rational are the inverses of to_storage: a
whole-number literal, and nothing else. This matters because a value that has passed
through a float cannot be inspected for the damage — so the string form is the only
representation allowed to claim to be a count. The negative controls assert that the
float form of that literal really is a different number, so the refusals are not
ceremony.

Skips are loud

Where a partner is absent the testset is named — browser (node absent — the JavaScript boundary is untested here) — so a missing boundary check shows in the
summary rather than hiding inside a clean run. #30 is what a silently skipped check
costs.

One note on the repository's own lint

config/ci/lint_source.jl scans for Capitalised.name as a Julia module reference, and
read a qualified JSON.parse inside my JavaScript text as a module named JSON the
file never imports. I wrote the JS with destructuring (const { parse } = JSON) rather
than teaching the gate an exception for prose it cannot see into — the gate is right to
be suspicious of that pattern, and the fix costs one line of idiomatic JavaScript.

Verification

45 boundary assertions alongside the existing 144 numeric-policy tests, both run
against the real module here. check-format, check-spdx and lint_source.jl clean.
Not claimed: anything about methods — the method catalogue still awaits your approval,
per the issue's own ordering.

Branch rebuilt. When I opened this I had branched from the still-open gate branch, so
this PR briefly carried the gate fix as well. That fix has since merged as #51
(0ef5eee ci(tools): retry pinned downloads and name a TLS failure as one), so I rebuilt
this branch on current main and force-pushed with a lease. This PR now carries one
commit and three files
, all numeric: src/analysis/numeric_policy.jl,
test/unit/test_numeric_boundaries.jl, test/runtests.jl.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6097fb66-ad9f-40d9-923e-e778a3dfdb3b

📥 Commits

Reviewing files that changed from the base of the PR and between 0ef5eee and 6af6cb6.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • scripts/ci/fetch_pinned.sh
  • src/analysis/numeric_policy.jl
  • test/runtests.jl
  • test/unit/test_install_pins.jl
  • test/unit/test_numeric_boundaries.jl
 ______________________________________
< I am an equal opportunity nitpicker. >
 --------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • 🔴 Error generating docstrings - (🔄 Check to retry)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Second numeric-integrity step for #1, whose criteria ask for an audit of
the CSV, JSON, database, browser, R and external-tool boundaries for
precision loss, integer range and serialization compatibility. An audit
that only restates the contract is not an audit, so each boundary here is
crossed with a real partner: node for the browser, Rscript for R, DuckDB
for the database.

Measured, with the probe being 9007199254740993 (2^53+1), the smallest
integer Float64 cannot hold:

- Browser. In a real JavaScript engine, that count as a JSON NUMBER
  arrives as 9007199254740992 -- one short, unmarked -- while the encoded
  STRING crosses byte for byte. This is the whole reason
  encode_json_number exists, now demonstrated rather than asserted.
- R. R has no integer type wider than 32 bits (as.integer(2^31) is NA),
  every number is a double, and neither format() nor as.character() can
  render that count back to its digits. A count that has been through R
  is therefore unproven, and the contract's diagnosis -- send and read it
  as a string -- is asserted by round trip through Rscript, since what R
  never converts, R cannot corrupt.
- Database. A BIGINT column carries the count exactly and a DOUBLE column
  silently changes it, so the column type is part of the contract. Past
  BIGINT a BIGINT column refuses (2^64 raises) rather than rounding,
  HUGEINT is exact to 2^127, and beyond that the string form transports
  the value unchanged.
- CSV. A CSV file is text, so the boundary is the parser's; asserted
  through the round-trip testset, where "9.007199254740992e15" and
  "9007199254740993.0" are refused by name.

Closes the round trip as an executable contract: parse_exact_count and
parse_exact_rational are the inverses of to_storage, accepting a
whole-number literal and nothing else. A value that has passed through a
float cannot be inspected for the damage, so the string form is the only
one allowed to claim to be a count -- and the negative control asserts
that the float form of that literal really is a different number.

Skips, where they happen, are loud and named ("browser (node absent --
the JavaScript boundary is untested here)") rather than a quiet pass, and
the JavaScript is written with destructuring because the repository's own
lint_source.jl reads a qualified `JSON.parse` inside test prose as a Julia
module reference -- writing the JS idiomatically is cheaper than teaching
the gate an exception.

Tests: 45 boundary assertions alongside the existing 144, both run under
the real module. Refs #1
@hyperpolymath
hyperpolymath force-pushed the feat/numeric-boundaries branch from 6af6cb6 to 79e2333 Compare September 22, 2026 10:03
@hyperpolymath
hyperpolymath merged commit 387fff5 into main Sep 22, 2026
@hyperpolymath
hyperpolymath deleted the feat/numeric-boundaries branch September 22, 2026 10:03
@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown

❌ Failed to create Coding Agent finishing-touch task. Please try again.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown

❌ Failed to create Coding Agent finishing-touch task. Please try again.

hyperpolymath added a commit that referenced this pull request Sep 22, 2026
Refs #1 — **the first method under the approved catalogue**, and the
first held to
conditions that were published while it did not yet exist
(`docs/statistics/method-conditions/exact-descriptive-summaries.md`,
merged in #56).

Counts and proportions per sample and per group, carried exactly. No
inference is
claimed, and the module has no vocabulary for one — no p-value, no
interval, no model,
no comparison.

## The distinction the code exists to keep

A single number cannot tell two different facts apart:

| | |
| --- | --- |
| A feature with **no reads**, in a sample that has reads | relative
abundance of **exactly 0** — a value |
| A sample with **no reads at all** | **no** relative abundances —
carried as `nothing`, never `0` |

That second case is the one `Execution.jl` writes `0.0` for today. The
exact layer
reports it as undefined; the legacy path is deliberately **left alone**,
because changing
it would alter results that saved analyses were computed from.

## Decisions worth naming

- **Float input is accepted only when finite, integral and within
2^53**, and the result
is marked `approximate` and says so in its warnings. Beyond 2^53 a float
cannot say
which integer it holds — the boundary audit (#52) showed exactly what
that costs.
- **An ordinary or high-precision policy is refused by name.** Higher
precision is not
exactness, and a caller who asked for exactness must not receive
Float64s that look
  identical until somebody checks.
- **Group summaries aggregate counts, not proportions.** A mean of
proportions weights a
shallow sample like a deep one, which is a different (and usually
unintended)
  statement.
- **Storage and display stay separate.** Stored proportions are strings
that parse back
through `parse_exact_rational` to the identical rational; a rounded
decimal is never
  what gets written down.

## Making the code match the conditions fixed two things underneath

Rather than adjust the document to fit the code, the code changed —
which surfaced two
display defects in the numeric layer:

1. `to_display` printed a rendering labelled **"6dp"** followed by ~80
digits;
`round(x; digits)` on a BigFloat keeps its 256-bit significand, so the
label was right
   and the rendering was not.
2. It rendered exact rationals as Julia's **`2//3`** — syntax leaking
into text a person
reads, and not the form they could copy out. Display now agrees with
`to_storage`.

The rounding is now done in **integer arithmetic** (scale, `divrem`,
half away from zero)
rather than by formatting a BigFloat. That keeps a value which is
exactly not a float
away from float arithmetic and from whatever rounding rule the float
libraries use, it
renders correctly past Float64 (a rational with a 2^200-scale integer
part prints its
digits, with no exponent notation in text meant to be read), and it
needs **nothing
beyond Base** — after a first attempt added a stdlib dependency the
package did not
declare and could not precompile.

## Evidence (all four the conditions require)

| Required | Delivered |
| --- | --- |
| Hand-derived known answers | totals, `2/3`, `1/3`, `1//5 + 2//5 + 2//5
== 1` exactly, `2^53+1` as a count |
| Independent reference | compared value by value against Python
`fractions.Fraction`; skips **loudly by name** if `python3` is absent |
| Negative controls | zero-total → undefined (not 0, not NaN), float
claiming exactness refused, negative count refused, budget overrun
raises |
| Unchanged when unselected | a guard fails **by name** if the pipeline
starts calling this module |

Plus display tests for the cases a float-based renderer gets wrong:
signs (`-2/3` must not
print `--`), true ties (`1/8` at 2dp rounds away from zero in *both*
directions), and
values beyond Float64.

**Mutation-tested**, because a test that cannot fail is decoration:
making a zero-total
sample report `0` fails four assertions; wiring the pipeline to this
module fails the
guard. Removing the `:widen` flag does **not** fail them — counts are
already `BigInt` by
then, so the flag is defensive rather than load-bearing — and the test
says so out loud
rather than implying a mechanism test it does not perform.

## Also in this branch: the gate that let the dependency bug through

The first push failed CI precompilation with `Package MetaManifold does
not have Printf
in its dependencies` — a bug this repo already has a lint check for.
That check had two
false negatives and the bug walked through both: it exempted a list of
"stdlibs needing
no `[deps]` entry" (Printf among them — there is no such class of name;
only `Base`,
`Core` and `Main` are bound without a declaration), and it examined only
the first name
in a comma list, so `using JSON3, YAML` never checked YAML.

Rewritten to read the AST rather than pattern-match, with a **self-test
that replays the
input which defeated it** — an undeclared stdlib, and a name after the
first in a comma
list — alongside the inverses that must *not* be reported.
Mutation-checked: adding
Printf to the always-bound set makes the self-test fail and name the
reason.

**Tests**: 97 new assertions; the numeric policy (144) and boundary (45)
suites still
pass. `check-format`, `check-spdx`, `lint_source.jl` clean. Nothing is
wired into the UI
yet — that is a separate step and a separate decision.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant