Skip to content

FindClusters in n dimensions, runnable reference pages, and the distance functions - #56

Merged
stblake merged 7 commits into
stblake:mainfrom
msollami:feat/findclusters-ndim
Aug 13, 2026
Merged

FindClusters in n dimensions, runnable reference pages, and the distance functions#56
stblake merged 7 commits into
stblake:mainfrom
msollami:feat/findclusters-ndim

Conversation

@msollami

Copy link
Copy Markdown
Collaborator

Summary

Five commits, rebased onto current main. Three separable pieces of work:

  1. FindClusters beyond one dimension — vectors, colours and strings — plus seven distance functions, none of which existed.
  2. Reference pages gain Algorithm, Performance, See also and Options sections, all derived from content the repo already had and was silently discarding.
  3. The notebook can open a reference page as a runnable notebook and look up the symbol under the cursor.

Changes

Kernel

  • FindClusters accepts equal-length numeric vectors, colours (RGBColor, GrayLevel, Hue, CMYKColor) and strings. One dimension is unchanged by construction: the structure is now a spanning tree with exact edge weights, and on a line the MST is the sorted adjacency chain, so the general code reduces to the original algorithm there. The 132 existing acceptance rows were the gate and stayed green; 40 were added.
  • Exactness survives in n dimensions by ranking on squared Euclidean distance — rational for rational input, monotone in the true distance, so no root is taken.
  • A colour needs no colour-specific code: RGBColor[r, g, b] is a compound expression carrying numeric arguments, the same shape as a 3-vector. Point heads are nonetheless an explicit list, because Rational and Complex are also compound — reading any compound head as a point would cluster 1/2 by its numerator and denominator.
  • New: EuclideanDistance, SquaredEuclideanDistance, ManhattanDistance, CosineDistance, EditDistance, HammingDistance. Names["*Distance*"] previously returned only GraphDistance, while DistanceFunction accepted these names as inert symbols — which is why the docs could truthfully claim all the choices behaved alike.

Two performance fixes, both found by measuring

  • Explicit cluster count was quadratic. FindClusters[data, 10] at n=100,000 took 2.24 s against 0.087 s for Automatic on identical data, growing 4× per doubling. The union-find had no path compression, so few cuts left one long chain and every find walked it. Automatic hid it because many cuts leave many short components. 2.24 s → 0.083 s, linear again.

  • Exact Prim allocated an Expr per distance even when the input was already machine-precision, where exact arithmetic preserves nothing. Machine-precision points now take a double Prim; Rational/bigint/MPFR keep the exact builder.

    2-D points before after
    500 95 ms 0.62 ms
    1000 398 ms 2.0 ms
    2000 1.49 s 6.8 ms

    219×, lifting the machine ceiling from 2,000 to 20,000 points (0.81 s there). Two builders for one definition is a standing risk, so a test clusters the same points both ways — as integers (machine) and divided by a constant (exact) — and requires matching partitions, including over 400 random points.

Documentation generator

Two silent bugs in the example pipeline:

  • Graphics results were discarded. A Plot result arrives as {"type":"plot"}, not "expr", so the verifier recorded nothing and judged every graphics example unevaluated. Plot's page was left with the one example that trivially reproduces itself, Plot[Sin[x], {x, a, b}] — a signature, not a worked example.
  • Multi-line examples were truncated at the line break, then fed to the binary as a syntax error and dropped.

Together: 1792 → 1900 verified examples.

The Features miner stopped at the first wrapped bullet. The spec files hard-wrap near 80 columns, so this truncated 168 of 305 sections — 3,351 lines, often mid-sentence. TrigFactor kept 2 lines of 42.

New sections, each derived from something that already existed: Algorithm (197 pages, from the implementation's own header comment), Performance (65, measured by the new tools/docs_perf.py plus benchmark-suite comparison), See also (467, from the spec's own groupings), Options & behaviour (60, spec prose that never reached a page). Examples are grouped with counts; graphics examples save their figure (18 MB → 1.6 MB after rounding and a 150 KB per-figure cap).

Notebook

  • Cmd/Ctrl+click or Cmd+I on a symbol opens its reference page as a notebook: prose cells, foldable section cells, and every example a runnable code cell seeded with its recorded output. Graphics examples arrive drawn.
  • ?pat* returns the Names[...] list instead of printing, so Length[?Find*] is 7.
  • Fixes: light mode was broken app-wide (:root held the light palette while App.svelte assumed dark); reference pages were centre-aligned; two-finger pan died over a card being edited; closing a full-screen notebook left a dangling focusedId.

Testing

Full suite, both trees built fresh and run from tests/build:

suites failing
base 412 9
this branch 412 9

No new failures. The 9 are pre-existing (compiledfunction, eigen, iter, lapack_builtin, mateigen_direct, ndarray_linalg, simplify, singularvaluedecomposition, zero_test; two are segfaults).

Re-verified after the rebase onto current main: list_tests green, make check-c99 passes, exactness and the 219× both hold. Distance values cross-checked against wolframscript, including two conventions that are not derivable: CosineDistance of a zero vector is 0 rather than Indeterminate, and HammingDistance on unequal lengths stays unevaluated.

Known limitations, stated rather than discovered

  • Only Automatic, "Agglomerate" and "SpanningTree" accept the new element kinds; the other seven methods read a sorted 1-D projection and decline.
  • Caps: 20,000 machine points, 2,000 exact points or strings. Both paths are quadratic. One dimension has no cap and does 10^6 in ~2.3 s.
  • Abs and Sign decline on rationals with bigint components (Abs[-1/10^20] does not evaluate). Pre-existing, not introduced here, but it blocked every exact high-precision distance; the distances route around it and the underlying gap deserves its own fix.
  • 289 pages still have no verified examples: their spec sections contain none to mine.
  • MeanShift costs 1.8 s at n=4,000, an order of magnitude above the rest of the density family — measured, not addressed.

FindClusters accepted only a flat list of real scalars; a list of vectors --
Wolfram's own second example -- returned unevaluated. It now clusters
equal-length numeric vectors in any dimension, colours (RGBColor, GrayLevel,
Hue, CMYKColor), and strings.

  FindClusters[{{1, 1}, {1, 2}, {9, 9}, {9, 8}}]
    -> {{{1, 1}, {1, 2}}, {{9, 9}, {9, 8}}}
  FindClusters[{"cat", "cot", "dog", "dig"}, 2]
    -> {{"cat", "cot"}, {"dog", "dig"}}

One dimension is unchanged by construction. The structure is now a spanning
tree with exact edge weights, and on a line the minimum spanning tree IS the
sorted adjacency chain -- so the general code reduces to the original algorithm
exactly there. The 132 existing acceptance rows were the gate and stayed green;
35 rows were added.

Exactness survives in n dimensions because ranking uses squared Euclidean
distance, which is rational for rational input and monotone in the true
distance, so no root is ever taken.

A colour needs no colour-specific code: RGBColor[r, g, b] is a compound
expression carrying numeric arguments, i.e. the same shape as a 3-vector. Point
heads are nonetheless an explicit list, because Rational and Complex are also
compound -- reading any compound head as a point would cluster 1/2 by its
numerator and denominator.

Scope limits, stated rather than discovered: only Automatic, "Agglomerate" and
"SpanningTree" accept the new element kinds, since the other seven methods read
a sorted one-dimensional projection; vectors and strings are capped at 2000
elements, both being quadratic without a sort to lean on.

Seven distance functions that did not exist -- Names["*Distance*"] returned only
GraphDistance, while FindClusters' DistanceFunction option accepted
EuclideanDistance and friends as inert symbols, which is why its documentation
could truthfully claim all the choices behaved alike:

  EuclideanDistance, SquaredEuclideanDistance, ManhattanDistance,
  CosineDistance, EditDistance, HammingDistance

Exact input gives exact output where the value is rational; complex components
contribute their modulus (Abs before squaring, as Mathematica defines it);
symbolic input survives as an expression. Every value was cross-checked against
wolframscript, including two conventions that are not derivable: CosineDistance
of a zero vector is 0 rather than Indeterminate, and HammingDistance on unequal
lengths is left unevaluated.

Two fixes:

  - FindClusters with an explicit count was quadratic. FindClusters[data, 10]
    over 100,000 points took 2.24 s against 0.087 s for Automatic on the same
    input, growing fourfold per doubling. The union-find introduced with the
    spanning-tree rewrite had no path compression, so when few edges are cut the
    surviving components form one long chain and every find walks it. Automatic
    hid the bug because many cuts leave many short components. With path halving
    and union by rank: 2.24 s -> 0.083 s, linear again.

  - Abs and Sign decline on rationals with bigint components: Abs[-1/10^20] does
    not evaluate while Abs[-1/7] does. Pre-existing, but it silently blocked
    every exact high-precision distance. The distances route around it by taking
    the sign through the exact comparator; the underlying Abs/Sign gap remains
    and is worth fixing separately.

Verified: full list_tests green (167 FindClusters and distance rows), make
check-c99, check-packed-aware, check-array-exactness and check-nd-surfaces all
pass.
…aved figures

The generated pages were thinner than their sources. Six defects, each of which
was discarding content the repository already had.

Two bugs in the example pipeline, both silent:

  - Graphics results were thrown away. A Plot result arrives over the pipe as
    {"type":"plot"}, not {"type":"expr"}, and the verifier collected only expr
    messages -- so every graphics example recorded nothing, was judged
    unevaluated, and was dropped. Plot's page was left with the single example
    that trivially reproduces itself, Plot[Sin[x], {x, a, b}], which is a
    signature rather than a worked example.

  - Multi-line examples were truncated at the line break, then fed to the binary
    as a syntax error and dropped. Continuation lines are now joined.

Together: 1792 -> 1897 verified examples.

The Features miner stopped at the first line that did not begin with "-". The
spec files are hard-wrapped near 80 columns, so a bullet routinely spills onto an
indented continuation: this truncated 168 of the 305 sections that have a
Features block -- 3351 bullet lines, often mid-sentence. TrigFactor kept 2 lines
of 42.

Four sections are new, each derived from something that already exists rather
than authored here:

  - Algorithm (197 pages) -- the implementation's own file header comment, used
    only where the file is effectively dedicated to that function. Its ALL-CAPS
    labels become subheadings; aligned tables and worked examples are preserved
    rather than reflowed, because their layout is load-bearing.

  - Performance (65 pages) -- measured timings from the new tools/docs_perf.py,
    plus Mathilda/Wolfram/Python rows joined from the existing benchmark suite
    for the 62 functions an experiment covers.

  - See also (465 pages) -- from the spec's own groupings: a heading naming
    several functions is a curated set, and a function named in this one's spec
    prose is one the author thought worth mentioning.

  - Options & behaviour (60 pages) -- spec prose after the Features block, which
    never reached a page at all. For FindClusters this is the entire Method
    matrix and every suboption.

Examples are grouped with counts -- Basic examples (464 pages), Applications
(399), Options (83), Scope (16). The groupings are derived, not invented: an
example passing an option is an options example, the first fenced block is the
basic set, and Applications is the overlay's own "Worked examples", previously
stranded below the references.

Graphics examples now save their figure (12 functions), so a page shows the plot
before anything is run. Raw payloads came to 18 MB, mostly StreamPlot and
Plot3D; coordinates are rounded and any single figure over 150 KB is skipped,
giving 1.6 MB. A skipped figure still runs. A hard cap rather than resampling,
because thinning a curve would misrepresent the figure being documented.

Docstrings render as a signature list rather than a fenced block, which never
wrapped -- Sum's one-sentence docstring ran off the right edge. Trailing prose
that is not a signature ("Sin is Listable...") goes into a collapsed Notes block
instead of being set in code style as if it were a call.

The duplicated "Implementation status" section is gone; it repeated the badge at
the top of the page verbatim.

docs_perf.py confirms a call produced a result before recording its time. Without
that check it reported 96 ms at n=1,000 and 968 us at n=10,000 for FindClusters
on 2-D points -- the larger inputs exceeded the 2,000-point cap and were refused,
and the refusal was being timed. A timing that falls as the input grows is the
signature of this mistake.
Documentation was only reachable by reading files. Three ways in now:

  - Cmd/Ctrl+click a symbol name anywhere -- code cell or rendered output.
  - Cmd+I, or F1 where the OS does not claim it.
  - ?Find* lists matching symbols as a grid of links.

Both gestures live on the document rather than in CodeMirror's keymap, and that
is the fix rather than a preference: an EditorView is built once in CodeCell's
onMount, so a keymap added later reaches only cells created after it. The first
two attempts appeared to do nothing for exactly that reason.

?pat* now returns the Names[...] list instead of printing, so it composes --
Length[?Find*] is 7 -- and the frontend lays it out as a grid of links.

A reference page opens as a real notebook, not a block of rendered text: prose
becomes Markdown cells, headings become section cells, and every In[n]:= /
Out[n]= example becomes a code cell pre-filled with the input and seeded with the
recorded output. So the page reads as complete on arrival and any example can be
edited and re-run in place. Graphics examples arrive drawn, from the saved
figures.

Headings are section cells specifically so the notebook's existing fold
machinery applies to them -- collapsing "Examples" folds its subsections,
collapsing "Options" folds just that group. No folding logic was written; the
headings were only ever inert because they lived inside a Markdown cell. A table
of contents at the top scrolls to them by id, since a page spread over many cells
has no document to anchor into.

Recorded output uses its own output kind rather than 'expr'. An expr with no
kernel LaTeX falls through to KaTeX, which typeset Derivative[1][g][x] as italic
mathematics -- it is Mathilda syntax, not maths.

Fixes found along the way:

  - Light mode was broken app-wide. App.svelte assumed :root carried the dark
    palette with a .light override; app.css did the opposite, defining dark only
    inside a prefers-color-scheme media query with nothing responding to .light.
    Choosing light while the OS was dark therefore left every var() holding a
    dark value on a light surface. Now three-state, and both classes are set so
    an explicit choice wins in either direction.

  - Everything on a reference page was centred, including bullets and code: #app
    sets text-align: center for the shell and it inherits all the way down.

  - Two-finger pan died over a card being edited. The handler deferred to the
    card whenever a cell inside held focus, so on a card with nothing to scroll
    the gesture did nothing at all. It now asks whether the card can scroll in
    the direction of the gesture, which also lets a card scrolled to its bottom
    chain the rest to the canvas.

  - Closing a full-screen notebook left focusedId pointing at a deleted card: an
    empty window with a toolbar acting on nothing.

  - A markdown HTML block runs until a blank line, so the status admonition was
    swallowing every page's "## Description".

The app bar replaces a floating theme toggle that overlapped a full-screen card's
own toolbar. In full-screen mode it carries that card's controls with the same
icons in the same order as on the canvas, so the row does not reshuffle; only the
full-screen icon flips, being the one action whose meaning reverses.

Pages are mirrored into public/ by npm run sync:refpages, wired into dev and
build so a stale copy cannot ship. Fetches are no-store: a cached page looked
exactly like the generator having failed to apply a change.
Clustering vectors was capped at 2000 points and spent nearly all its time
allocating expressions. Exact Prim computes O(n^2) distances and each one built
and evaluated a chain of Expr nodes to keep the comparison exact.

For input whose coordinates are ALREADY machine numbers that buys nothing --
there is no precision beyond a double to preserve. Such input now takes a
double-precision Prim; only the n-1 chosen edge weights become expressions, so
allocation drops from O(n^2) to O(n). Input with a Rational, bigint or MPFR
coordinate keeps the exact builder, which is the only one that can order it
correctly.

  2-D points     before     after
      500         95 ms    0.62 ms
     1000        398 ms     2.0 ms
     2000       1.49 s      6.8 ms

219x at n=2000, which lifts the machine ceiling from 2000 to 20000 points
(0.81 s there). The exact path keeps its 2000 cap, and each cap is enforced
against the builder that will actually run.

Nothing is given up. Distinctness was never decided from a distance on either
path -- it compares the elements with the exact comparator -- so
FindClusters[{{1/3, 1/7}, {1/3, 1/7 + 1/10^20}, {5, 5}}] still groups the two
near-identical exact vectors.

Two builders for one definition is a standing risk, so a test clusters the same
points both ways -- as integers, which are machine, and divided by a constant,
which makes them exact -- and requires the partitions to match, including over
400 random points. Integer coordinates must be within 2^53 to take the fast path,
since beyond that a double stops representing integers exactly and the builders
would quietly disagree.

Verified: list_tests green, check-c99, check-packed-aware and
check-array-exactness pass.
Main added NumberForm, Row, the Chop/Clip buffer paths and other builtins after
these pages were generated, so the checked-in set was missing them. 759 pages,
1900 verified examples, two new symbols documented.

No generator changes: this is the output of site/generate.py at this commit.

Also gitignores frontend/public/refpages, the mirror that npm run sync:refpages
builds from site/docs/documentation. Both dev and build regenerate it, so
tracking it would double every documentation change in the history.
…y sections

Five changes to what the generator surfaces, each removing something that was
being dropped or shown twice.

Worked examples written as prose become runnable. 108 pairs across 19 spec files
read `Expr[...]` -> `result`, where the result is prose -- √π/2, Γ[s], π a/2 --
and not Mathilda syntax. Only the input is taken; the binary supplies the output.
That turns a claim into an example the reader can execute, and it immediately
exposed one the prose was hiding:

  Integrate[Exp[-a x] Sin[b x]/x, {x,0,Infinity}, Assumptions->a>0]
    spec says  ArcTan[b/a]
    build says 1/2 (Pi b)/Sqrt[b^2] - ArcTan[a/b]

Same value for b > 0, but not simplified -- a real gap. 1900 -> 1976 verified
examples, 25 pages gaining a "Worked examples" group. The prose is stripped
afterwards so nothing is stated twice.

An example carrying a `(* ... *)` comment now renders that comment as a sentence
above its own cell, and the comment is dropped from the input the reader runs.
Examples without one are grouped as before: only 61 of 3814 examples have such a
note, and inventing the rest would put made-up prose on a documentation page.
Expand's overlay is rewritten as the exemplar -- its six worked examples were
four variations of the same binomial expansion, now six distinct cases (binomial,
distribution, multinomial, coefficient folding, scaling, denominators untouched)
each with its reason.

Headings left with nothing under them are dropped. The spec marks example blocks
with an "### Examples" heading, and those blocks are stripped here because the
examples are mined and re-verified into the Examples section -- which left the
heading behind. Integrate carried four empty "Examples" subsections interleaved
with its real topics.

Spec sections nest four deep; a page has two levels below its title, so H4 is
demoted to H3 rather than rendering as literal '#### text' in a paragraph.

"See also" is folded into References. Both answer "where do I go from here", and
one heading for that is enough.

Applications now render through the same path as every other example group, so
numbering and formatting are consistent across all four.
… off

Section folding was broken in two ways that only reference pages exposed, being
the first notebooks with several sibling subsections under one heading.

Walking back from a row to find its parent, the scan tested EVERY subsection it
passed rather than the nearest one, so collapsing "Basic examples" also emptied
"Options" and "Applications" below it. And walking back from a subsection
HEADING it stopped at the sibling above, never reaching the enclosing section --
so collapsing "Examples" left its subsection headings visible with all their
content hidden: a heading that could not be opened.

A subsection's parent is the section; anything between is a sibling.

Long blank gaps under a collapsed section had two causes. The insertion strip
after each row was rendered outside the visibility guard, so a collapsed section
still emitted one 3px strip per hidden row -- 93px behind "Examples (26)".
Second, stripping fenced examples and empty headings out of a section could
leave a segment holding only punctuation, which became a cell that rendered as
nothing but still occupied a row. A segment must now contain an alphanumeric
character to become a cell; verified 4592 prose segments, 0 invisible.

Scroll chaining is gesture-aware. A trackpad flick keeps firing wheel events
after the fingers lift, and the rule was positional: the instant a card could
scroll no further the remaining momentum panned the canvas and the view shot
away. Events within 160ms are now one gesture, and a gesture that has scrolled a
card cannot pan -- it stops at the boundary and the momentum is absorbed. A pause
ends the gesture, so scrolling again does pan, which is how you leave a card
deliberately.

Reference pages behave as documents rather than notebooks: headings are not
editable and clicking one folds the section, and the cell-type control is
hidden. That is derived from the content (the notebook contains 'ref' cells) as
well as the flag, so it holds for cards opened before the flag existed and for
notebooks restored from a saved library, which does not persist it.

Cmd+I works. It read window.getSelection().anchorNode and required a text node,
but in CodeMirror the caret's anchor is usually the line element with
anchorOffset a child index, so it silently bailed -- which is why the key looked
dead while Cmd+click worked. It now takes the caret's screen rect and runs the
same lookup the click uses.

A reference page opens beside the SYMBOL rather than beside the whole card (on a
wide notebook the far edge is most of a screen away from what was pointed at),
and on top: the active card moved into canvasState so openRefpage can raise the
card it creates, instead of opening behind the notebook it was asked from.

Also: a table of contents after the definition rather than above it, collapsible;
one button to collapse or expand every section, in both toolbars; closing a
full-screen notebook returns to the canvas instead of leaving focusedId pointing
at a deleted card.
@msollami

Copy link
Copy Markdown
Collaborator Author

Update: reference pages are now readable documents (0e324631, 351e19a0)

Two commits from a review-and-polish pass over the reference pages in the notebook.

Generator

  • Prose worked examples become runnable. 108 pairs across 19 spec files read `Expr[...]` → `result` where the result is prose (√π/2, Γ[s]), not Mathilda syntax. Only the input is taken; the binary supplies the output. 1900 → 1976 verified examples, 25 pages gaining a Worked examples group.

    This immediately exposed a discrepancy the prose was hiding:

    Integrate[Exp[-a x] Sin[b x]/x, {x,0,Infinity}, Assumptions->a>0]
      spec says   ArcTan[b/a]
      build says  1/2 (Pi b)/Sqrt[b^2] - ArcTan[a/b]
    

    Same value for b > 0, but not simplified — a real gap, visible only because the example became executable.

  • Per-example notes. An example carrying a (* ... *) comment renders it as a sentence above its own cell. Only 61 of 3814 examples have one; the rest are grouped as before, because inventing explanations would put made-up prose on a documentation page. Expand's overlay is the exemplar — its six "applications" were four variations of one binomial expansion, now six distinct cases each with its reason.

  • Empty headings dropped. The spec marks example blocks with ### Examples; those blocks are stripped here (the examples are mined and re-verified into the Examples section), which left the heading behind. Integrate carried four empty ones interleaved with its real topics.

  • H4 demoted to H3 (a page has two levels below its title); See also folded into References; Applications rendered through the same path as every other group.

Notebook

  • Section folding, broken in two ways that only reference pages exposed, being the first notebooks with sibling subsections: the parent scan tested every subsection it passed rather than the nearest, so collapsing one group emptied the ones below it; and a subsection heading stopped at its sibling and never learned its section was collapsed, leaving a heading that could not be opened.

  • Blank gaps: insertion strips were rendered outside the visibility guard (93px behind a collapsed Examples (26)), and punctuation-only segments became cells that rendered as nothing but occupied a row.

  • Scroll chaining is gesture-aware. A flick that reached a card's bottom handed its momentum to the canvas and the view shot away. Events within 160 ms are one gesture; a gesture that scrolled a card cannot pan. A pause ends it, so scrolling again does pan.

  • Cmd+I read getSelection().anchorNode and required a text node, but CodeMirror's caret anchor is usually the line element — it now uses the caret's screen rect and the same lookup Cmd+click uses.

  • Reference pages behave as documents: non-editable headings that fold on click, no cell-type control, derived from content so it survives reload. Pages open beside the symbol and in front. Collapsible TOC after the definition; one button to collapse/expand all sections.

Testing

list_tests green, make check-c99 passes, svelte-check at its 2 pre-existing errors, 759 pages regenerated against this commit.

@stblake
stblake merged commit d8f440b into stblake:main Aug 13, 2026
2 checks passed
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