FindClusters in n dimensions, runnable reference pages, and the distance functions - #56
Conversation
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.
Update: reference pages are now readable documents (
|
Summary
Five commits, rebased onto current
main. Three separable pieces of work:FindClustersbeyond one dimension — vectors, colours and strings — plus seven distance functions, none of which existed.Changes
Kernel
FindClustersaccepts 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.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, becauseRationalandComplexare also compound — reading any compound head as a point would cluster1/2by its numerator and denominator.EuclideanDistance,SquaredEuclideanDistance,ManhattanDistance,CosineDistance,EditDistance,HammingDistance.Names["*Distance*"]previously returned onlyGraphDistance, whileDistanceFunctionaccepted 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 forAutomaticon identical data, growing 4× per doubling. The union-find had no path compression, so few cuts left one long chain and everyfindwalked it.Automatichid it because many cuts leave many short components. 2.24 s → 0.083 s, linear again.Exact Prim allocated an
Exprper 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.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:
Plotresult 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.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.
TrigFactorkept 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.pyplus 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
?pat*returns theNames[...]list instead of printing, soLength[?Find*]is 7.:rootheld the light palette whileApp.svelteassumed dark); reference pages were centre-aligned; two-finger pan died over a card being edited; closing a full-screen notebook left a danglingfocusedId.Testing
Full suite, both trees built fresh and run from
tests/build: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_testsgreen,make check-c99passes, exactness and the 219× both hold. Distance values cross-checked againstwolframscript, including two conventions that are not derivable:CosineDistanceof a zero vector is0rather thanIndeterminate, andHammingDistanceon unequal lengths stays unevaluated.Known limitations, stated rather than discovered
Automatic,"Agglomerate"and"SpanningTree"accept the new element kinds; the other seven methods read a sorted 1-D projection and decline.AbsandSigndecline 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.MeanShiftcosts 1.8 s at n=4,000, an order of magnitude above the rest of the density family — measured, not addressed.