A test-driven translation of Charles Kemp's formdiscovery1.0 MATLAB code, which implements
the structural form discovery model from:
Kemp, C., & Tenenbaum, J. B. (2008). The discovery of structural form. PNAS, 105(31), 10687–10692.
The model fits graph structures (partitions, chains, orders, rings, hierarchies, trees, grids, cylinders) to feature, similarity, or relational data and returns the best structure of each form together with its posterior probability.
The model is fully translated and verified against the original code running in GNU Octave.
The Python port runs the original masterrun demo end to end:
- With Octave's random permutations replayed, all nine demo runs (chain, ring, tree × three feature data sets) reproduce the Octave baseline: final scores to ten significant digits, and identical final graphs and growth histories.
- Without replay (numpy permutations, scipy optimiser, three seeds), every run finds the same clustering as Octave (adjusted Rand index 1, same cluster count) with scores within about 1e-5 relative.
- Deterministic code (utilities, priors, graph construction, likelihoods, relational model)
matches Octave to near machine precision. The one irreducible gap is the branch-length
optimiser: Octave's
fminuncstops early, so log-evidence values agree to about 2e-4 relative, and Python's optimum is never worse than Octave's.
Anomalies found along the way (for example, on the animals data the code prefers a hierarchy
where the paper reports a tree) are logged in ANOMALIES.md.
-
Paper-level checks: every synthetic data set recovers its true form and
colorsgives a ring, in Octave and in Python. -
The display code (
draw_dot,graph_to_dot,dot_to_graph,graph_draw) is ported with pygraphviz and matplotlib, and gives the same DOT text and node positions as the original. networkx, plotly and pyvis backends and GraphML/DOT exports are also available. -
Speed: per call, fast-mode scoring takes about half of Octave's time and relational scoring a fifth. Slow mode, with scipy's trust-exact optimiser, takes 1.4-3.6 times Octave's
fminunc. Whole runs take 0.8-1.4 times Octave's, 0.25 on relational data. The model keeps BLAS to one thread, because OpenBLAS threads made it up to 20 times slower on these small matrices; the results do not change. Runpython tools/bench_perf.pyto measure it on your machine.
Progress is tracked in RalphLoops/loop0001/PROGRESS.md and iterations.md.
conda env create -f environment.yml # env "fd": Python, numpy/scipy, pygraphviz, PySide6 (no Octave)
conda activate fd
pip install -e .
formdiscovery run # masterrun demo: chain,ring,tree × datasets 1-3
formdiscovery run --structures dirringnoself --datasets 4 --seed 3 --out results/
formdiscovery run --helpFrom Python:
from formdiscovery.run import masterrun
res = masterrun() # MasterResults: names, ll, cluster counts, z, graphs, ps per runenvironment.yml is Python-only. The Octave oracle that produced the test fixtures needs
conda env update -f legacy/environment-octave.yml as well (see "Legacy: the Octave oracle").
Data sets and structure names follow the original setps.m (20 data sets, 24 forms); both
can be given by name or 1-based index.
The notebook examples/formdiscovery_demo.ipynb runs
the whole masterrun demo, compares it with Octave and draws the results with every
backend. python tools/gen_demo_notebook.py rebuilds and runs it.
from formdiscovery.run import masterrun, masterrun_ps, runmodel, save_results
ps = masterrun_ps() # defaultps(setps()) with reloutsideinit = 'overd'
res = masterrun(ps, thisstruct=[1, 3, 5], thisdata=[0, 1, 2], seed=1, log=print)
res.modellike[:, :, 0] # log posterior per (structure, data set)
graph = res.structure[5, 2, 0] # the tree fitted to demo_tree_feat
names = res.names[0, 2]
save_results(res, "results/resultsdemo", ps) # .npz + .json, read back with load_results
ll, graph, names, bestglls, bestgraph = runmodel(ps, 5, 2, 1, rng=1) # a single runIn Python, the structure and data set indices are 0-based (ps.structures[5] == 'tree',
ps.data[2] == 'demo_tree_feat'). The CLI takes names or MATLAB's 1-based indices.
rng is a seed or a permutation provider (formdiscovery.rng).
runmodel and structurefit limit BLAS to one thread while they run
(formdiscovery.threads, needs threadpoolctl). Wrap your own loops over graph_like in
with formdiscovery.threads.blas_threads(): for the same speed-up. On the command line
the option is formdiscovery run --blas-threads N (0 keeps the library's setting).
draw_dot(adj, labels, backend=...) is draw_dot.m. It returns MATLAB's
(xret, yret, labels), and return_figure=True also returns the figure.
| Backend | Needs | Layout | Output |
|---|---|---|---|
pygraphviz (default) |
pygraphviz or the neato executable |
neato with draw_dot's flags (positions equal the original's) | matplotlib, the graph_draw.m ellipses and arrows |
networkx |
nothing extra | neato via pygraphviz, else Kamada-Kawai (layout=) |
matplotlib, nx.draw_networkx |
plotly |
pip install -e .[interactive] |
as networkx |
interactive plotly figure with hover text |
pyvis |
pip install -e .[interactive] |
as networkx |
vis-network HTML page |
from formdiscovery.viz.draw import draw_dot, draw_graph, draw_results, ProgressFigures
from formdiscovery.viz.networkx_backend import to_networkx, to_graphml, to_dot
draw_results(res, path="results.png", backend="networkx", layout="kamada_kawai")
draw_results(res, path="results.html", backend="plotly") # all runs, interactive
fig = draw_graph(graph, names, backend="plotly") # hover: object -> cluster,
fig.show() # cluster -> members
draw_graph(graph, names, backend="pyvis").write_html("tree.html")
G = to_networkx(graph, names) # DiGraph; node kind/label/cluster_id, edge W and 1/W
to_graphml(graph, "tree.graphml", names) # for Gephi / Cytoscape
# MATLAB's progress figures 1-3 (pre-clean, post-clean, best split / result)
show = ProgressFigures(outdir="figures", backend="networkx")
runmodel(ProgressFigures.enable(ps), 5, 2, 1, show=show)From the shell:
formdiscovery run --structures chain,ring,tree --datasets 1,2,3 --out results/ --figures figs/
formdiscovery draw results/resultsdemo.npz --out results.png # neato + matplotlib
formdiscovery draw results/resultsdemo.npz --out results.png --backend networkx --layout kamada_kawai
formdiscovery draw results/resultsdemo.npz --out results.html --backend plotly
formdiscovery draw results/resultsdemo.npz --out tree.html --backend pyvis --runs 8
formdiscovery draw results/resultsdemo.npz --out tree.svg --graphviz --runs 8 # Graphviz renders (pygraphviz)Display quirks of the original are kept by default. Each undirected edge is drawn as two
arrows (KI-37); use undirected='lines' for plain lines. draw_dot's neato flags are
malformed (KI-32/35); use flags='intended' for the documented ones. See
KNOWN_ISSUES.md.
A PySide6 desktop app (pip install -e .[gui]; in the fd env it is already installed):
formdiscovery gui # pick a file with "Open data file…"
formdiscovery gui data/demo_chain_feat.mat # start with a data set loaded
formdiscovery gui --demo # open demo_chain_feat and run chainOpen any .mat data file (the shipped ones or your own: data as features, a similarity
matrix or a relational struct, optional names). The left column describes the data set
and has the forms (chain, ring, tree preselected), seed, speed, drawing backend and "Draw
best splits". Run fits every selected form, each in its own worker thread ("Parallel runs"
at a time; one by default), so the window stays responsive and Stop works at any time. The middle canvas redraws the graph each time the
model transforms it (pre-clean / post-clean per depth, best splits if chosen, then the
inferred graph), with node positions kept between frames. When the run ends, the
Statistics column shows the final score and its parts (log prior from graph_prior, log
likelihood from graph_like), the clusters and their members, the score per depth
(bestglls) as a chart, wall time and frame count. "Export results…" writes the .npz +
.json pair that formdiscovery run writes (read back with load_results), and "Save
figure…" saves the graph as PNG or SVG.
The "Results" table under the graph ranks the forms by ll (the winner in bold, as
masterrun's modellike would pick it); click a row to see that form's final graph and
statistics. The slider under the graph scrubs back through the frames the shown form sent
(the last 500 per form are kept); at its right end the canvas is live again. Parallel runs
share one Python process, so they overlap little (the search holds the GIL; ANOMALIES A27).
Keyboard shortcuts (also in the File and Run menus): Ctrl+O open, Ctrl+R or F5 run,
Esc or Ctrl+. stop, Ctrl+E export results, Ctrl+Shift+S save figure, Ctrl+Q quit. A file
that cannot be read, a failed run or a failed export opens a warning box (a failed run's
traceback is under "Show Details…"). The file dialog starts in the last directory a file
was loaded from (~/.config/formdiscovery/gui.ini). The window title names the file. The
GUI needs no Octave (only the tests compare against it).
The GUI is a thin shell over the port (src/formdiscovery/gui/); its only hook into the
model is search.run_hooks (per-thread cancel and per-depth callbacks, off by default, so
results are unchanged). Screenshots, with a description of each: examples/gui/
(QT_QPA_PLATFORM=offscreen python tools/gui_screenshots.py).
The Octave oracle has been frozen and removed from
main. Everything below that mentionslegacy/(the MATLAB sources, the fixture scripts, the Octave tools) now lives only at the git tagoctave-oracle-final;legacy/FREEZE.mdthere records the environment and the final verification. The committed fixtures remain the reference.
- Every MATLAB function has an Octave fixture script in
legacy/tests_octave/that runs the original code on real inputs and saves inputs and outputs totests/fixtures/*.mat. The pytest suite compares the Python translation against those fixtures, so the tests run without Octave. Tests markedoctaveadditionally drive Octave live through oct2py. They are skipped when it is not installed, except in strict mode (thefdenv's Python, orRALPH_REQUIRE_OCTAVE=1), where a skipped Octave test fails the run;tests/test_gate_env.pychecks the toolchain itself. Octave 10.3.0 and oct2py 6.1.1 come fromlegacy/environment-octave.yml, added to the Python-onlyenvironment.ymlenv; every committed fixture was produced with those versions. - Structural outputs are compared exactly; deterministic floats at
rtol=1e-10; optimiser-dependent values by optimality (objective and gradient norm no worse than Octave's) plus a documented tolerance. - Randomness enters only through
randperm. An Octave shim (legacy/matlab/octave_shims/) records or replays permutations, andformdiscovery.rngprovides matching Python providers, so the search heuristics are compared with identical random choices. - The Octave baseline runs (
legacy/matlab/run_baseline.m) for the feature and relational demo grids are committed undertests/fixtures/baseline/. - Every process runs one BLAS/OpenMP thread (
OPENBLAS_NUM_THREADS,OMP_NUM_THREADS,MKL_NUM_THREADS= 1,formdiscovery.threads.PIN_ENV). The test session, the Oct2Py Octave and everyoctave-clithe tools start are pinned; speed comes from running processes side by side. Pinned and unpinned runs give identical.matcontents (legacy/tools/mat_compare.py DIR_A DIR_Bcompares two output directories), except forgibbs.mat. Itsgrid x synthgridrun depends on the BLAS thread count, so it is regenerated unpinned (ANOMALIES A19,BLAS_DEFAULT_FIXTURESintests/conftest.py). - Fixtures regenerate in parallel:
legacy/tools/gen_fixtures.py --jobs N(default cores/2) runs oneoctave-cliper fixture script (paperlevel split into its 45 runs), so a script that reads another fixture waits for it. It keeps a log per task and prints a timing table. All 31 fixtures take 7 min 56 s with--jobs 16against 68 min 49 s with--jobs 1. The two runs and the committed fixtures have the same content (--compare DIR: loaded arrays to all digits, timing fields skipped, temp-directory names masked). - The baselines regenerate in parallel too:
legacy/tools/gen_baselines.py --kind feat|rel --jobs Nruns oneoctave-cliper (structure, dataset) pair, each into its own directory. A merge (legacy/matlab/baseline_merge.m, plus a copy of theresults/trees) then writes what the serialrun_baselinewrites. The merged output has the same file set and content as the committedtests/fixtures/baseline/{feat,rel}(--compare DIR; onlytimings.secondsis skipped). With--jobs 16feat takes 5.8 s and rel 14.6 s, against 33–46 s and 140–176 s for serialrun_baseline. - The gate runs in parallel with pytest-xdist (
-n 16). Each worker has its own Octave process (its own session-scopedoctavefixture), and tests write only totmp_path. A run that leaves a new file undertests/fails. Under xdist the tests that take 30 s or more (LONG_TESTSintests/conftest.py) start first, one per worker (--maxschedchunk 1). The gate then takes about 6 min (the longest test, gibbstest_live_fresh_seeds, alone takes about 6 min) against about 40 min serial. Three runs at-n 16and one at-n 8gave the same pass set. legacy/tools/compare_live.py --pairs S:D[:SEED] ... --jobs Nruns Octave and Python side by side. For each (structure, dataset, seed) a worker runsrun_baselinein its ownoctave-cliwith therandpermshim logging every draw. It then runs Python'srunmodelon the same pair, replaying those draws without oracles: Python uses its own optimizer and tie breaking, and once a draw no longer fits it goes on with numpy. The output is one table: Octave and Python ll, relative difference, ARI, cluster counts, how far the replay went, and each side's wall time. The default set (the 9 feature and 54 relational baseline pairs, seed 1) passes PLAN §7.1 on all 63 rows. Octave reproduces the committed baseline exactly. The relational scores agree to 1e-15, and the feature scores to 7.4e-6 relative, with ARI 1 everywhere. This takes 26 s with--jobs 16against 3 min 35 s with--jobs 1, and the rows are identical apart from the times.
Before and after the parallel harness (RalphLoops/loop0002, 32-core machine; every
parallel run gives the same contents as the serial one):
| task | before | after | CPU (user+sys) before → after | workers after |
|---|---|---|---|---|
all 31 fixtures (gen_fixtures.py) |
68 min 49 s (--jobs 1; paperlevel alone ≈ 81 min in one process before) |
7 min 56 s (--jobs 16) |
4530 s → 5310 s | 16 octave-cli |
both baseline grids (gen_baselines.py) |
2 min 45 s (serial run_baseline feat + rel) |
20 s (feat 5.8 s + rel 14.6 s, --jobs 16) |
176 s → 193 s (--jobs 1 → 16) |
9 / 16 octave-cli |
| gate, Octave live (strict) | 38 min 18 s (serial, unpinned BLAS) | ≈ 6 min (-n 16, 6:01–6:12 over three runs) |
4482 s → ≈ 3570 s | 16 pytest workers + 16 Octave |
gate, fixture-only (make test, loop0004; no Octave) |
— | 52 s (-n 16, 3792 tests) |
— | 16 pytest workers |
Octave vs Python, 63 pairs (compare_live.py) |
3 min 35 s (--jobs 1) |
26 s (--jobs 16) |
217 s → 257 s | 16 workers + their octave-cli |
slow suite (-m slow -n 16, 80 tests) |
37 min 41 s, 1 timing failure (each worker reran the 45 paperlevel Python runs on all cores) | 9 min 02 s (run once, shared through a file: tests/helpers.xdist_shared) |
62 794 s → 6 100 s | 16 pytest workers + 16 Octave |
Before this work the gate ran under a Python without oct2py, so the live Octave tests were skipped; it now runs them all and fails if one is skipped.
make test # default gate: fixture-only, no Octave (~1 min, 16 workers)
python -m pytest -q -m "not slow and not octave" -n 16 # the same without make
make fixture-sums # tests/fixtures against tests/fixtures/SHA256SUMS (also checked by the gate)
make slow # long runs (Python only)
git checkout octave-oracle-final # the frozen oracle: `make legacy-check`, legacy/tools/gen_fixtures.py --compare, ...The original MATLAB code, run in GNU Octave 10.3.0, is the reference the port was checked
against. On 2026-10-01 it was frozen: the strict live gate passed (3866 tests), all 31
fixtures and both baseline grids regenerated identical to the committed files, and the
tree was tagged octave-oracle-final. The next commit removed legacy/ from main.
To reconstruct it:
git checkout octave-oracle-final
conda env create -f environment.yml && conda env update -f legacy/environment-octave.yml
make legacy-check # strict live-Octave gatePolicy since the freeze: a change that alters any fixture value must first be checked
against the oracle at the tag, and the reason recorded in ANOMALIES.md (see CLAUDE.md).
| Path | Contents |
|---|---|
src/formdiscovery/ |
The port. graph.py (graph structure and grammars), likelihood_feat.py / likelihood_rel.py (scores), search.py (split, swap, prune-and-regraft, gibbs_clean, structurefit), run.py (runmodel, masterrun), cli.py, rng.py, params.py, preprocess.py, weights.py, matlab_compat.py, io.py |
src/formdiscovery/viz/ |
Display: dot.py (DOT text), pygraphviz_backend.py + graph_draw.py (draw_dot's layout and drawing), draw.py (the draw_dot facade, progress figures, results), networkx_backend.py (conversion, exports), plotly_backend.py, pyvis_backend.py, interactive.py |
examples/ |
formdiscovery_demo.ipynb: the masterrun demo end to end, with figures |
src/formdiscovery/CONVENTIONS.md |
Index, ordering and dtype rules used throughout the port |
data/ |
The 20 data sets of the original (*.mat, README.txt); io.DATA_DIR, overridden by $FORMDISCOVERY_DATA |
ANOMALIES.md |
Curated log of anomalies found: paper vs code, Octave vs MATLAB, surprising results, original bugs, each with a status |
KNOWN_ISSUES.md |
Bugs and quirks of the original and how the port treats each one (replicate, fix, or not ported) |
tests/ |
Fixture-based parity tests; tests/fixtures/ holds the Octave-produced reference answers (hashes in tests/fixtures/SHA256SUMS) and the Octave baselines |
Makefile |
make test (default fixture-only gate), make slow, make fixture-sums; make legacy-check points at the tag |
environment.yml, pyproject.toml |
Python-only conda env fd; package metadata and extras (test, gui, graphviz, interactive, notebook) |
tools/ |
Python-side tools: true-graph score comparison (compare_runs.py), benchmark (bench_perf.py; --live needs the tag), notebook, GUI screenshots |
PLAN.md, PLAN_LEGACY.md, PLAN_ARC_AGI_JS.md |
The translation plan: test architecture, dependency-ordered steps, hazards, milestones |
RalphLoops/ |
The fresh-context iteration loops that carried out the plan (loop_template/loop.py for new loops, fixture-only gate) |
Python function names match the MATLAB file names (graph_like_conn, gibbs_clean, ...),
and each docstring cites the source lines it translates. Indices are 0-based in Python and
converted only at the I/O boundary. Quirks of the original are replicated by default; the
only deliberate deviations are that keyboard debug stops become exceptions and runmodel
writes to an explicit output directory instead of changing the working directory.
Original MATLAB code: Charles Kemp (2008), with contributions from Kevin Murphy (BNT),
Thomas Minka (lightspeed), Leon Peshkin (Graphviz interface), Carl Rasmussen, John Burkardt,
and Michael Kay, as listed in the original README.txt.
Translation by Alex Linhares using Claude Code.
