Skip to content

Nearest[list, x]: the closest element(s), with all ties returned - #53

Open
msollami wants to merge 1 commit into
stblake:mainfrom
msollami:feat/nearest
Open

Nearest[list, x]: the closest element(s), with all ties returned#53
msollami wants to merge 1 commit into
stblake:mainfrom
msollami:feat/nearest

Conversation

@msollami

@msollami msollami commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the two-argument Nearest[list, x] — the element(s) of list at minimum Abs[element - x], returned as a List in original order, with all tied elements included.

Nearest[{1, 5, 10}, 3] is {1, 5}, not {1}.

Changes

New: src/list/nearest.{c,h}, registered in src/list/list_init.c with Protected and a docstring.

AlgorithmMinimalBy's two-pass shape (src/sort.c:663-716): find the minimum, then collect every distance equal to it. Deliberately not the RankedMin quickselect, whose comparator carries an original-index tiebreak (src/sort.c:932) that exists to make ties impossible and would return a single element. Input order among ties falls out of the ascending collect pass, so there is no tie logic in the file.

Distance composes the existing internal_subtract and internal_abs the way comparisons.c:313-314 already does; no distance helper was added. Since Abs of a complex difference is its modulus, Nearest[{3 + 4 I, 1}, 0] is {1} with no extra code.

Distances are ordered by numeric value, not by expr_compare. Canonical order is wrong here in both directions, and each direction breaks the all-ties guarantee:

  • It settles a value tie between different ExprTypes on the type enum (src/sort.c:376), so Nearest[{0, 2.0}, 1] would answer {0} — distances 1 and 1.0 are equal but Integer sorts before Real — dropping a tied element from the one function whose contract is to return them all.
  • For atoms not both integer-like it compares get_numeric_value() doubles (src/sort.c:372-377), so Nearest[{1/3, 1/3 + 1/10^18}, 0] would report a tie between distances that differ exactly.

Subtracting instead is exact where it must be and inexact only where the input already was: 1 - 1.0 is 0.0, a genuine tie, while 1/3 - (1/3 + 1/10^18) is the exact Rational[-1, 10^18]. nearest_sign reports undecidable separately from zero, which expr_numeric_sign cannot — it returns a bare 0 for both and recognises neither MPFR nor a bigint-component Rational.

One deliberate divergence from MinimalBy: every distance must be a real number, or the call stays unevaluated. MinimalBy[{1, a, 3}, Abs[# - 2] &] answers {1, 3}, dropping the symbolic element because expr_compare orders symbols after all numbers — a plausible wrong answer. Gating on the distance rather than the element covers a symbolic element, a symbolic target and a non-real complex in one check.

Known limitations, pinned by test rows rather than left silent

Both flip the day the underlying behaviour changes, so they surface as a table diff:

  • A symbolic real such as Pi declines rather than being numericalized (ranked_numeric_key would).
  • A rational with a bigint component declines, for a reason outside this file: builtin_abs does not evaluate one. Abs[1/1000] is 1/1000 but Abs[1/10^25] is Abs[1/10^25], and Sign has the same gap — so the distance arrives unevaluated and the gate rejects it. Worth fixing in builtin_abs, where every caller benefits; not done here to keep this diff to one builtin.

Scope

Two-argument form only. The n-nearest, radius, rule, all-pairs and NearestTo operator forms and the DistanceFunction option are deliberately excluded.

A packed list is materialised on the way in, since Nearest is not on pack.c's AWARE list. A visible NDArray is not a List and stays unevaluated rather than being silently truncated.

Testing

29 acceptance rows in tests/test_list.c::test_nearest, covering ties (input order, mixed exact/inexact, exact rational, duplicates, symmetric), unique-nearest, empty lists, the eight unevaluated-gate cases, packed input and attributes.

  • list_tests passes; 17 list/array/sort/packing binaries pass with no regression
  • make check-c99, make check-packed-aware, make check-array-exactness pass
  • Leak-free under a differential leaks run: 0 bytes at both 200 and 20 000 iterations across the success, gate-bail, mixed-type-tie and decline paths
  • Every example in the docs verified against the built binary

Docs

docs/spec/builtins/lists-and-iteration.md gains a ## Nearest entry; docs/spec/changelog/2026-08-03.md records the rationale.

Adds the two-argument Nearest: the element(s) of list at minimum
Abs[element - x], as a List in original order.

All tied elements are returned, which is what fixes the algorithm.
Nearest[{1, 5, 10}, 3] is {1, 5}, not {1}. The shape is MinimalBy's
(src/sort.c:663-716) — find the minimum, then collect every distance
equal to it — rather than the RankedMin quickselect, whose comparator
carries an original-index tiebreak (src/sort.c:932) that exists to make
ties impossible and would return a single element. Input order among
ties falls out of the ascending collect pass, so there is no tie logic
in the file.

Distance composes the existing internal_subtract and internal_abs the
way comparisons.c:313-314 already does; no distance helper was added.
Abs of a complex difference is its modulus, so Nearest[{3 + 4 I, 1}, 0]
is {1} with no extra code.

Distances are ordered by numeric value, not by expr_compare. Canonical
order is wrong here in both directions, and each direction breaks the
all-ties guarantee. It settles a value tie between different ExprTypes
on the type enum (src/sort.c:376), so Nearest[{0, 2.0}, 1] would answer
{0} — distances 1 and 1.0 are equal but Integer sorts before Real —
dropping a tied element from the one function whose contract is to
return them all. And for atoms that are not both integer-like it
compares get_numeric_value() doubles (src/sort.c:372-377), so
Nearest[{1/3, 1/3 + 1/10^18}, 0] would report a tie between distances
that differ exactly. Subtracting is exact where it must be and inexact
only where the input already was: 1 - 1.0 is 0.0, a genuine tie, while
1/3 - (1/3 + 1/10^18) is the exact Rational[-1, 10^18]. nearest_sign
reports undecidable separately from zero, which expr_numeric_sign
cannot — it returns a bare 0 for both and recognises neither MPFR nor a
bigint-component Rational.

Nearest diverges from MinimalBy in one respect, deliberately: every
distance must be a real number or the call stays unevaluated.
MinimalBy[{1, a, 3}, Abs[# - 2] &] answers {1, 3}, dropping the symbolic
element because expr_compare orders symbols after all numbers — a
plausible wrong answer. Gating on the distance rather than the element
covers a symbolic element, a symbolic target and a non-real complex in
one check.

Two limitations are pinned by test rows rather than left silent. A
symbolic real such as Pi declines instead of being numericalized. A
rational with a bigint component declines for a reason outside this
file: builtin_abs does not evaluate one (Abs[1/1000] is 1/1000,
Abs[1/10^25] is Abs[1/10^25], and Sign has the same gap), so the
distance arrives unevaluated and the gate rejects it. Both rows flip the
day the underlying behaviour changes.

Only the two-argument form lands here; the n-nearest, radius, rule,
all-pairs and NearestTo forms and the DistanceFunction option are
separate. A packed list is materialised on the way in since Nearest is
not on pack.c's AWARE list; a visible NDArray is not a List and stays
unevaluated rather than being silently truncated.

New: src/list/nearest.{c,h}, registered in list_init.c with Protected.
30 acceptance rows in tests/test_list.c::test_nearest. make check-c99,
check-packed-aware and check-array-exactness pass; leak-free under a
differential leaks run (0 bytes at 200 and 20000 iterations across the
success, gate-bail, mixed-type-tie and decline paths).
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