Skip to content

arb: add arb_get_simplest_fmpq#2673

Open
edgarcosta wants to merge 1 commit into
flintlib:mainfrom
edgarcosta:worktree-arb-simplest-fmpq
Open

arb: add arb_get_simplest_fmpq#2673
edgarcosta wants to merge 1 commit into
flintlib:mainfrom
edgarcosta:worktree-arb-simplest-fmpq

Conversation

@edgarcosta
Copy link
Copy Markdown
Member

@edgarcosta edgarcosta commented May 11, 2026

Adds a new arb function:

int arb_get_simplest_fmpq(fmpq_t res, const arb_t b);

Sets res to the rational p/q in lowest terms with smallest positive
denominator q lying in the closed real interval represented by b.
Among rationals with the smallest denominator, returns the one with
smallest absolute numerator (0/1 if 0 lies in b).

This is the FLINT counterpart to Sage's
RealIntervalFieldElement.simplest_rational(): the same well-known
Stern-Brocot / continued-fraction characterisation of the simplest
rational in an interval, exposed at the arb level so other FLINT
callers (and Sage itself, via a follow-up wrapper) can use it without
going through Python.

Where it fits in the arb API

  • arb_get_unique_fmpz: unique integer in a ball, or fail.
  • arb_get_interval_fmpz_2exp: dyadic endpoints, caller does the rest.
  • arb_get_simplest_fmpq (new): rational analogue of the first;
    unambiguously returns the simplest rational in the ball.

Algorithm

A thin wrapper around the existing fmpq_simplest_between(mid, l, r)
(HGCD-based Stern-Brocot / continued-fraction engine, due to Daniel
Schultz, in src/fmpq/simplest_between.c):

  1. Reject non-finite b.
  2. Fast path for exact balls (arb_is_exact(b), equivalently
    r = 0 in the [n·2^e ± r] representation): the midpoint is the
    only element of the interval, so arf_get_fmpq(res, arb_midref(b))
    is the answer.
  3. Otherwise extract lo, hi as exact fmpq_t (dyadic endpoints via
    arb_get_lbound_arf / arb_get_ubound_arf + arf_get_fmpq).
  4. If the interval crosses zero, return 0/1 directly.
  5. If the interval is fully negative, negate-and-swap, call
    fmpq_simplest_between, then negate the result.
  6. Otherwise (fully positive), call fmpq_simplest_between directly.

Steps 4 and 5 are necessary because fmpq_simplest_between breaks
denominator ties by smallest value (returns ceil(l) in the
integer-contains case), whereas this function's contract is smallest
absolute numerator. They agree on positive intervals; the sign and
zero-crossing cases need explicit handling.

Reusing fmpq_simplest_between

An earlier draft of this PR re-implemented the iterative Stern-Brocot
descent from scratch. After noticing fmpq_simplest_between already
provides the HGCD-based engine, the body collapsed from ~150 lines of
hand-rolled CF descent to ~50 lines of wrapper, and pi at 50000-bit
precision went from 34 ms per call to under 2 ms (~18×). All 13 tests
unchanged; semantics identical.

Microbench

-O2, x86_64 raptorlake, single call timed via clock_gettime.

Non-exact balls:

prec ball around 355/113 ball around π (answer's denom bits)
53 0.031 ms 0.004 ms (27)
1000 0.152 ms 0.026 ms (500)
10000 0.043 ms 0.152 ms (4999)
50000 0.254 ms 1.005 ms (24999)

Exact balls (fast path applies: arf_get_fmpq of the midpoint,
no fmpq_simplest_between call), averaged over 1000 calls:

mantissa bits per-call time
53 9 ns
1000 35 ns
10000 140 ns
50000 481 ns

At 50000-bit precision the exact-ball path is about 2000× cheaper than
the non-exact path on the same input size.

Tests

src/arb/test/t-get_simplest_fmpq.c covers 13 cases:

  1. Exact zero ball → 0/1
  2. Ball straddling 0 with radius 0.5 → 0/1
  3. Exact integer 7 → 7/1
  4. Ball [2.3, 3.7] containing 3 → 3/1
  5. Invalid (NaN) ball → returns 0
  6. Exact 1.53/2
  7. Tight ball around 355/113 at prec 53 → 355/113
  8. Negative ball around -355/113-355/113
  9. Exact 355/113 at prec 50000355/113
  10. Ball around π at prec 50000 → succeeds, denominator < 2^25000
  11. arb_zero_pm_inf → returns 0
  12. Random fuzz (1000 × multiplier balls): returned fmpq must be
    arb_contains_fmpq of the ball
  13. Theoretical-bound coverage (200 × multiplier random p/q with
    q <= 1000): the best-approximation bound
    |r/s - p/q| >= 1/(s*q) implies that a ball of radius
    < 1/((q-1)*q) around p/q has p/q as its unique simplest
    rational. The test builds such balls at prec 100 (radius ~ 2^-90,
    well inside the bound) and asserts the algorithm recovers p/q
    exactly.

Verified locally:

  • make check MOD=arb clean.
  • make check MOD=arb ARGS=arb_get_simplest_fmpq passes in 0.04 s; the
    50000-bit tests complete in a few seconds total.
  • FLINT_TEST_MULTIPLIER=10 still under a second.
  • Valgrind clean (valgrind --leak-check=full, 0 errors, 0 leaks).

Test plan

Verified locally on x86_64 Linux (raptorlake, GCC):

  • make check MOD=arb clean (full arb suite, 4.98 s wall).
  • make check MOD=arb ARGS=arb_get_simplest_fmpq PASS in 0.01 s
    reported test time, 0.36 s wall (default FLINT_TEST_MULTIPLIER=1).
  • Same test at FLINT_TEST_MULTIPLIER=10 PASS in 0.39 s wall
    (Test 12 runs 10 000 iters and Test 13 runs 2 000 iters).
  • Valgrind on the new test:
    valgrind --leak-check=full build/arb/test/main arb_get_simplest_fmpq
    PASS in 1.24 s wall. 0 errors, 0 leaks, 14 388 allocs / 14 388
    frees, 3.3 MB total heap.
  • make valgrind MOD=arb (full arb suite under valgrind) exits 0
    in ~392 s wall (verified on prior implementation; this change only
    shrinks the new test's footprint, so still clean).

@fredrik-johansson
Copy link
Copy Markdown
Collaborator

No objections to having this function with this API, but doesn't the implementation duplicate fmpq_simplest_between? How does the performance compare?

@fredrik-johansson
Copy link
Copy Markdown
Collaborator

BTW, this function probably ought to have a fast path for input of the form $[n 2^e \pm r]$ where $r &lt; 2^e$.

@edgarcosta
Copy link
Copy Markdown
Member Author

Thanks!!!! fmpq_simplest_between is what I have been looking for this whole time. I will rewrite it!

@edgarcosta edgarcosta force-pushed the worktree-arb-simplest-fmpq branch from 631e6e1 to cd5f9f0 Compare May 12, 2026 17:44
@edgarcosta
Copy link
Copy Markdown
Member Author

Rewrote in cd5f9f0 (force-pushed). Two changes:

1. Wrapping fmpq_simplest_between. The function body now just
handles arb_is_finite, exact-dyadic endpoint extraction, and the
zero-crossing / fully-negative cases (needed because
fmpq_simplest_between breaks denominator ties by smallest value
i.e. ceil(l), whereas this contract is smallest absolute numerator).
For positive intervals it delegates directly. Body collapsed from
~150 lines of hand-rolled CF descent to ~75 lines of wrapper.

Performance, single per-call timing under -O2, x86_64 raptorlake:

prec 355/113 (was → now) π (was → now)
53 0.045 ms → 0.031 ms 0.002 ms → 0.004 ms
1000 0.132 ms → 0.152 ms 0.075 ms → 0.026 ms
10000 0.007 ms → 0.043 ms 1.872 ms → 0.152 ms (~12×)
50000 0.108 ms → 0.254 ms 34.241 ms → 1.005 ms (~34×)

The rational-target rows are noise (both terminate in [3; 7, 16]);
the irrational case is where HGCD pays off, ~34× faster at 50000-bit
precision.

2. Fast path for [n·2^e ± r] with r < 2^e. Added the r = 0
case (arb_is_exact(b)): returns arf_get_fmpq(res, arb_midref(b))
without calling fmpq_simplest_between at all. Cost averaged over
1000 calls:

mantissa bits per-call exact-ball time
53 9 ns
1000 35 ns
10000 140 ns
50000 481 ns

At 50000-bit precision that is ~2000× cheaper than the non-exact
path on the same input. For the more general 0 < r < 2^e case, the
midpoint is not always the answer (e.g. [5/8 ± 0.1] contains 2/3),
so it does not reduce to a one-step return; that case still falls
through to fmpq_simplest_between. Happy to add a tighter general
fast path if you have a specific shape in mind.

PR body has been updated to match.

@fredrik-johansson
Copy link
Copy Markdown
Collaborator

You can use arb_contains_zero to check for intervals containing 0 before converting to endpoints. This predicate is exact, and should be faster.

The fmpq_cmp(lo, hi) > 0 check should always fail; what is its purpose?

Set res to the rational p/q in lowest terms with smallest positive
denominator q lying in the closed real interval represented by b.
Among rationals with the smallest denominator, returns the one with
smallest absolute numerator (0/1 if 0 lies in b).

Exact balls (radius 0) take a fast path returning the midpoint
directly. Otherwise the implementation extracts the exact dyadic
endpoints of b as fmpq_t and delegates to fmpq_simplest_between,
handling the zero-crossing and fully-negative cases explicitly so
the tie-break agrees with the contract (smallest absolute numerator,
not smallest value).
@edgarcosta edgarcosta force-pushed the worktree-arb-simplest-fmpq branch from cd5f9f0 to c7bc389 Compare May 12, 2026 21:19
@edgarcosta
Copy link
Copy Markdown
Member Author

  • arb_contains_zero(b) now handles the zero-crossing case before any endpoint extraction. Non-finite and exact balls still take their own fast paths above it, so the conversion only runs for strictly positive/negative non-exact balls.
  • The fmpq_cmp(lo, hi) > 0 check was indeed unreachable given that arb_get_lbound_arf <= arb_get_ubound_arf is guaranteed by arb for finite balls; removed, along with the goto cleanup it was guarding.
  • Sign reduction now uses arb_is_negative(b) (also exact on the arf/mag pair) instead of fmpq_sgn(hi) < 0.

Body is down to ~60 lines. Valgrind on the new test dropped from 1.32 s to 0.91 s (the fuzz iterations that hit a zero-crossing ball no longer allocate). Per-call timings are within noise of the previous numbers.

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.

2 participants