Skip to content

1.0.1: parser fixes, correctness audit, 6x faster, NumPy output and a docs site - #1

Merged
Paururo merged 47 commits into
mainfrom
1.0.1
Jul 29, 2026
Merged

1.0.1: parser fixes, correctness audit, 6x faster, NumPy output and a docs site#1
Paururo merged 47 commits into
mainfrom
1.0.1

Conversation

@Paururo

@Paururo Paururo commented Jul 29, 2026

Copy link
Copy Markdown
Member

Everything for the 1.0.1 release. main is still at 1.0.0, so this is the whole
version: the parser rewrite and bug fixes from earlier work on the branch, plus
a correctness audit, the performance work, three new flags and a documentation
site.

Bugs fixed

The expensive class here is not a crash, it is a run that returns a matrix that
looks entirely reasonable and is wrong. Most of what follows is that.

Parsing

  • A leading [&R] / [&U] rooting marker, which IQ-TREE, MrBayes and BEAST all
    write, made the tree unparseable: comments were only skipped after a label,
    so [&R] (A,B); died with "No labeled leaves found in the tree". Per-branch
    metadata written before the label failed the same way.
  • Non-ASCII labels were mangled. Built with byte as char, Señor_Ñu came back
    out of the matrix as mojibake, so any label with an accent, a Greek letter or
    CJK text silently stopped matching the sample names used downstream.
  • A truncated tree was accepted. ((A:1,B:2),C:3 with the last ) missing had
    its open parentheses closed at end of input, and every internal node left
    dangling quietly lost its branch length.
  • A file holding several trees produced a matrix for the first one, without
    saying so. So did trailing junk after the tree, and an unclosed [ swallowed
    the rest of the file as a comment.
  • ( was not a label delimiter, so free-form prose parsed as a single leaf
    named after it.
  • An apostrophe anywhere in the file opened a quoted region, so O'Brien broke
    parsing.
  • A:1e910 parsed as infinity, because Rust returns infinity for an exponent
    past f64's range rather than an error. Every distance came out inf, the
    diagonal NaN, and the exit code said success.
  • Deeply nested trees still overflowed the stack. The parser and flattener were
    made iterative in earlier work on this branch, but RawNode's drop glue was
    not, so a 300,000-level tree aborted after parsing had succeeded.

Output

  • -o FILE was created and truncated before the tree was read, so a run that
    then failed on a malformed tree left an empty file where the previous matrix
    had been.
  • Nothing flushed the writer, so a full disk gave a silently truncated matrix
    and exit code 0. A closed pipe went the other way, printing an error for
    distree tree.nwk | head.
  • A newline or carriage return in a quoted label split a row, exactly as a tab
    does; only tabs were rejected.
  • A label with a space silently broke --lower: PHYLIP readers treat whitespace
    as the end of the name, so every row after it landed a column out.
  • --lower --lmm dropped the diagonal, which under --lmm is each leaf's
    root-to-tip length rather than zeros.
  • Unlabelled leaves were skipped without a word, giving a matrix smaller than
    the tree.

Arguments and modes

  • --midpoint --topology inflated every crossing distance by one hop: the node
    inserted at the midpoint splits an edge in two. Rooting cannot change an edge
    count, so it is skipped there now.
  • Negative patristic distances were rounded up to zero, claiming two distinct
    taxa were the same sample. Neighbour-joining trees carry negative branches
    routinely. The clamp could not have been doing what it was for: rounding
    cannot make that subtraction negative.
  • -p 50000000 panicked inside the formatter; -t 0 reached Rayon, which reads
    0 as "use the default" and started a thread per core.

Infrastructure

  • CI never ran on this branch: the filter was [main, "*.*.x", "*.x"], and
    1.0.1 matches neither glob.
  • The integration tests were hardcoded to target/debug/distree, so
    cargo test --release exercised a stale debug binary and reported a pass for
    code it never ran.
  • The README's three example matrices had lost their header row to the opening
    code fence, and the topological one was not a realisable tree: it put two
    leaves one edge apart.

New

  • --npy writes the matrix as a NumPy array of 64-bit floats: exact, half
    the size of equivalent text, and 2.6x faster. With --lower it writes the
    condensed vector SciPy reads directly. Labels go to <FILE>.labels.txt.
  • --taxa FILE restricts the matrix to a list of leaf labels. It filters
    the output rather than pruning the tree, so the distances are the ones the
    full tree gives, which is the opposite of what pruning first would produce.
  • --stats prints a summary to stderr: leaves, nodes, mode, cells, and the
    minimum, maximum and mean off the diagonal.
  • Gzipped input is read directly, detected by content rather than by file
    name, so it works from stdin too.

Performance

Two things kept the parallelism from paying. Each row was its own parallel job,
and a row of a small matrix is nowhere near enough work to pay for synchronising
a thread pool: an 8,000-leaf run took 2.52s on 14 cores against 1.52s on one.
The other two thirds of the time was float formatting, sitting in the serial
write loop where no number of cores could reach it.

Rows are now computed in batches, each worker formats its own, and fixed-
precision formatting is done by hand where the rounding is unambiguous and
handed to the standard formatter where it is not.

8,000 tips, -p 6 --lower Before After
1 core 1.52 s 0.56 s
14 cores 2.52 s 0.13 s

A 20,000-tip matrix went from 10.6s to 1.0s. The LCA table also stores plain
usize rather than Option<usize>, halving the largest allocation the tool
makes: about 350 MB on a million-leaf tree.

Output is byte-identical throughout. Verified across 612 matrices spanning 11
trees, 17 precisions and both float modes.

Correctness

The suite was self-consistent: it asserted that midpoint rooting preserves
distances, that the binary-lifting MRCA agrees with walking up from both nodes,
that a patristic distance is never negative. All necessary, all written against
the same understanding of the problem as the code, so none of it would catch a
distance being defined wrongly.

scripts/crossvalidate.R compares against ape over generated trees:
cophenetic.phylo for patristic, vcv.phylo for variance-covariance,
cophenetic.phylo over unit branch lengths for edge counts, and
phangorn::midpoint for the rooting. Over 250 trees the worst disagreement is
9.6e-10, which is the 12-decimal text round-trip, and exactly zero for edge
counts. That the midpoint agrees with phangorn is the reassuring part: it is the
only code in the tool that rewrites the tree.

Six of ape's matrices are committed under tests/fixtures/, so cargo test
checks the same thing without needing R. The full run is a weekly job.

The parser and the pipeline are also fuzzed, in the test suite on every push and
with cargo fuzz for longer runs. That is what found the 1e910 bug.

96 tests, up from 31.

Documentation

A MkDocs Material site under docs/, published to GitHub Pages: getting
started, a tutorial working through an outbreak investigation from tree to
transmission clusters, a user guide, how it works, recipes. Every matrix on the
site is real output and every error message is quoted from a run.

The README is now a landing page pointing at it. It had grown into a second copy
of the manual and had already drifted: its flag reference listed none of the new
flags.

Behaviour changes worth knowing about

The parser is stricter, and these were all previously accepted:

  • A file holding more than one tree is now an error. Split it first.
  • A truncated tree is an error rather than being closed at end of input.
  • Trailing content after the tree, and unclosed quotes or comments, are errors.
  • A label containing whitespace is rejected in --lower mode.
  • A branch length that is not finite is rejected.
  • --threads 0 and --precision above 30 are rejected.
  • Negative patristic distances are reported rather than rounded to zero.

On merge

.github/workflows/docs.yml deploys on a push to main, so merging publishes
the site at https://pathogenomics-lab.github.io/distree/ once Pages is enabled
(Settings → Pages → Deploy from a branch → gh-pages). Tagging v1.0.1 builds
the release binaries.

Paururo added 30 commits April 4, 2026 16:27
Split monolithic main.rs into parser.rs, tree.rs, lca.rs, midpoint.rs.

Critical fixes:
- Rewrite midpoint_root to fix infinite loop from cyclic graph
- Add NHX/bracket comment parsing to prevent crash on annotated trees
- Add single-quoted label support in parser
- Detect and reject duplicate leaf names

Additional fixes:
- Add --precision flag for floating point output formatting
- Add -t/--threads flag for rayon thread control
- Add stdin support (use '-' as phylogeny argument)
- Warn on missing branch lengths in patristic mode
- Warn on negative branch lengths
- Always print leading tab in header for R/Python compatibility
- Use env!("CARGO_PKG_VERSION") for version string
- Remove unused --format flag
- Fix clippy warnings (&Vec<Node> -> &[Node])
- Add 18 unit tests
- Rewrite parser to iterative (no recursion): handles 50k+ deep trees
- Rewrite flatten_raw to iterative: prevents stack overflow on flatten
- Strip whitespace/newlines outside quotes before parsing
- Support double-quoted labels (common in BEAST, FigTree output)
- Warn on --lmm + --topology conflict instead of silent precedence
- Topology mode outputs integers instead of float decimals
- Pre-process Newick string to remove whitespace outside quotes
- 24 tests (6 new: whitespace, newlines, deep tree, double quotes,
  nested brackets, negative length parsing)
- Replace process::exit with Result<(), Box<dyn Error>> for clean error propagation
- Add --lower flag for PHYLIP-compatible lower triangle output
- Add DistMode enum to centralize mode logic
- Add compute_distance() and format_distance() helpers
- Add CI workflow (clippy + test on push/PR)
- Add Build & Release workflow (4 binaries on tag push)
- BufWriter on stdout for better performance
- Better error messages (file not found, parse errors)
- 28 tests (4 new: single leaf, symmetric tree, self-distance, mode conflict)
- Update README with new flags and features
- 0 clippy warnings with -D warnings
Newick from IQ-TREE, MrBayes and BEAST often starts with a rooting marker
such as '[&R] (...)', and BEAST also puts per-branch metadata before the
label rather than after it. Comments were only skipped once a label had
been read, so '[&R] (A,B);' parsed as one unnamed leaf and died with "No
labeled leaves found in the tree", while '(A:1,[&x=1]B:2);' failed with a
confusing "Expected ',' or ')'".

Skip whitespace and bracketed comments at the start of every subtree.
Labels were assembled with 'byte as char', which turns every byte of a
multi-byte UTF-8 sequence into a code point of its own. A tree with
'Senor_Nu' spelled with its accents came back out of the matrix as
mojibake, so any label with an accent, a Greek letter or CJK text
silently stopped matching the sample names used downstream.

Collect the label bytes and decode them as UTF-8 in one step. Scanning
byte-wise stays valid because every Newick delimiter is ASCII.
Several kinds of broken input were accepted without a word and turned
into a plausible looking but wrong matrix:

  - A truncated tree ('((A:1,B:2),C:3' with the last ')' missing) had its
    open parentheses closed at end of input, so every internal node left
    dangling silently lost its branch length.
  - Text after the tree was dropped, so a file holding several trees
    quietly produced a matrix for the first one only.
  - An unclosed '[' swallowed the rest of the file as comment text.
  - '(' was not a label delimiter, so prose such as 'not a tree at all
    (((' parsed as a single leaf named after it.

Each of these now fails with a message naming the offending position.
Any apostrophe anywhere in the file opened a quoted region for the
whitespace-stripping pass, so "O'Brien:1.0 , B:2.0" left the rest of the
tree looking quoted and failed with "Expected ',' or ')', found ' '". An
apostrophe inside a comment did the same.

A quote now opens a label only at the start of the tree or right after
'(', ',' or ')'. The pass also consumes a doubled quote as the escaped
literal it is, rather than closing and immediately reopening the region,
which used to strip the whitespace in "'it''s a name'".
The parser and the flattener were rewritten iteratively in 1.0.1 so that
deep trees would work, but nothing was done about RawNode's drop glue,
which still recurses once per level. A 300,000-level ladder therefore
still aborted with "has overflowed its stack", after parsing had already
succeeded, because the parse tree was freed on the way out.

Dismantle the children iteratively in a hand-written Drop.
'-o results.tsv' opened and truncated the file before the tree was even
read, so a run that then failed on a malformed tree left the user with an
empty file where their previous matrix used to be.

Open the output once parsing, midpoint rooting and the leaf-label checks
have all succeeded.
Nothing ever flushed the writer explicitly. BufWriter does flush when it
is dropped, but it has nowhere to return an error and throws it away, so
a full disk left a silently truncated matrix behind and an exit code
telling the caller everything went fine. Flush explicitly and fail with
the output path in the message.

The opposite case is 'distree tree.nwk | head', which hit EPIPE partway
through and printed "Error: Broken pipe (os error 32)" for what is a
perfectly normal way to stop reading. That one now exits clean.
'-p 50000000' reached the formatting machinery unchecked and aborted with
"Formatting argument out of range", a panic and a backtrace note for what
is simply a bad argument. Reject anything above 30 decimals, which is
already well past what a 64-bit float can distinguish.

'-t 0' was passed straight to Rayon, which reads 0 as "choose the
default" and started a thread per core, the opposite of what the flag was
asked for, and no way to tell.
Tab characters in a label were already rejected because they would split
a row, but a quoted label may hold any whitespace, and a newline or a
carriage return breaks the output exactly the same way. A label with a
newline in it produced a matrix whose row count no longer matched its
header.

Unlabeled leaves were a quieter version of the same surprise. They cannot
be named in the matrix so they are skipped, and a tree written with empty
labels came back one row shorter with nothing said about it.
Midpoint rooting inserts a node halfway along the diameter edge, which
splits that edge in two and adds one hop to every pair whose path crosses
it. On '((A:1,B:1):1,(C:1,D:1):1);' the A-C distance went from 4 to 5 the
moment --midpoint was added.

Edge counts do not depend on where a tree is rooted, so there is nothing
for --midpoint to contribute in topology mode. Skip the rooting there and
say why, rather than reporting counts that are one too high.
Midpoint rooting rewires parent/child links and swaps branch lengths all
the way up to the old root, which the three hand-written trees exercised
only lightly. Cross-check it on 300 generated trees, with polytomies,
zero length branches and 2 to 25 leaves, against the three properties
that matter: every pairwise patristic distance survives the rewiring, the
result is still a tree with no cycle and nothing orphaned, and the
deepest tip sits exactly half a diameter from the new root.
The binary-lifting table is the largest allocation distree makes, n log n
entries, and each one was an Option<usize>. That type has no spare niche,
so every entry paid 8 bytes for the ancestor and 8 more for a
discriminant that only ever marks the root. Measured on a 4,000-leaf
tree, peak RSS drops from 7.2 MB to 6.4 MB, which is the 0.8 MB the table
itself was wasting. The same arithmetic on a million-leaf tree comes to
about 350 MB.

Store the ancestors as plain usize with usize::MAX standing in for the
root, in one flat allocation rather than a Vec per level.

Also cross-check mrca() against walking up from both nodes, over every
pair in 200 generated trees, since the query loop was rewritten with it.
The path was hardcoded to target/debug/distree, so 'cargo test --release'
ran whatever stale debug binary happened to be lying around and reported
a pass for code it never executed. On a clean checkout it could not spawn
anything at all. CARGO_BIN_EXE_distree resolves to the profile under
test.
The clamp to zero was there to absorb floating-point noise, but it cannot
have been doing that: depth_len accumulates lengths from the root, so d_i
and d_j are always >= d_m, 2*d_m is exact, and rounding a non-negative
exact result to nearest keeps it non-negative. Checked over every pair in
200 generated trees.

What the clamp did catch was the real thing, a negative branch length,
which neighbour-joining trees routinely carry. Those distances were
reported as 0.0, claiming two distinct taxa sit on top of each other, in
a matrix that then went on into clustering. Report the value as computed;
the tree already gets a warning, now saying what to expect.
The branch filter was [main, "*.*.x", "*.x"], and a release branch named
after its version matches neither glob, since '*.*.x' needs a literal
'.x' suffix. Work on 1.0.1 has therefore never been checked by CI.

Clippy also ran over the binary alone, leaving the test code unlinted,
and the last step was a bare release build rather than a release test
run, which only became worth doing once the integration tests stopped
looking for the binary in target/debug.
The three example matrices had their header row absorbed into the opening
code fence, so each block rendered with the label row missing and the
rest of the table a column short.

The numbers were invented too, and the topological one is not a tree: it
puts LeafA and LeafD one edge apart, which no pair of leaves can be.
Replace all three with the real output of one worked example, and add the
--lower layout, which had no example at all.
Paururo added 17 commits July 26, 2026 20:00
Records what changed for anyone reading the README or the changelog
rather than the diff: one tree per file, malformed input rejected instead
of guessed at, the --precision ceiling, --midpoint ignored in topology
mode, non-ASCII labels surviving, and negative branch lengths reaching
the output.
Two things kept the matrix loop from using the cores it asked for.

Each row was its own parallel job. A cell is one MRCA query and three
array reads, so a row of a small matrix is nowhere near enough work to
pay for synchronising a thread pool, and the fork/join per row dominated
everything: an 8,000-tip run took 2.52s on 14 cores against 1.52s on one,
with system time going from 0.10s to 14.32s. Rows are now computed in
batches sized to about a million cells, so the synchronisation happens
once per batch rather than once per row.

The other two thirds of the time was formatting. Turning a float into a
fixed number of decimals costs more than computing the distance it
prints, and it sat in the serial write loop where no number of cores
could reach it. Each worker now formats its own rows into a byte buffer
and the writer only hands finished bytes to the operating system.

  20,000 tips, -p 6 --lower:  10.61s -> 1.61s
   8,000 tips, -p 6 --lower:   2.52s -> 0.20s

and the run now scales with cores instead of against them (8,000 tips:
1.26s on one core, 0.21s on eight). Output is byte-identical.

The batch buffer costs a fixed ~15 MB, which does not grow with the tree,
and the output buffer goes from 8 KB to 1 MB so a multi-gigabyte matrix
is not hundreds of thousands of write syscalls.
flatten_raw copies what it needs into Vec<Node>, so from that point the
input text and the recursive parse tree are dead. They stayed in scope to
the end of run() regardless, which on a large tree means carrying a
second copy of it alongside the LCA table for the whole matrix loop, the
longest part of the run.

The measured effect is small (about 2 MB of resident memory on a
100,000-tip tree) because the allocator holds on to the freed pages
rather than returning them, but the pages are reclaimable once released
and were not before.
Fourteen pages of MkDocs Material, published to GitHub Pages from main.
The README had grown into the whole manual and was the only place any of
this was written down, which made it long enough that nobody reads to the
end and still left no room for the things that need a page of their own.

  Getting started  installation, and the commands most runs use
  User guide       input, the three distance modes, midpoint rooting,
                   output formats, and a full CLI reference
  How it works     the parser, the LCA structure, the streaming loop,
                   and what the run costs in time and memory
  Recipes          PCoA, transmission clusters, PGLS, neighbour-joining
  About            changelog, citation, contributing

Every matrix on the site is real output rather than a plausible-looking
table, every error message is quoted from a run, and the benchmark
figures are measured on a stated machine with the estimates marked as
estimates.

The site builds with --strict, so a dead link or a missing nav entry
fails CI rather than shipping. Pull requests build without deploying.
A table of the nine pages people actually go looking for, right under the
tagline. The rest of the README stays as it is, so anyone reading it on
GitHub without following a link still has everything.
A leaf label with a space passed every check and then broke --lower. A
PHYLIP row is a name followed by whitespace and then the values, so a
reader splitting on whitespace reads 'Taxon A<TAB>4.5' as three fields
and lands every row after it one column out. Tabs and newlines were
already refused for the same reason; whitespace is refused now too, but
only in --lower mode, since the square TSV is tab-delimited and reads it
back correctly.

--lower with --lmm silently dropped data. Omitting the diagonal costs
nothing on a distance matrix, where it is all zeros, but an LMM diagonal
holds each leaf's root-to-tip length, which is what the off-diagonal
covariances have to be read against. It now says so.

A gzipped tree failed with 'stream did not contain valid UTF-8', which
says nothing about what to do. Large trees usually arrive compressed, so
check for the magic bytes and give the gunzip line instead.
The guide pages explain what each flag does, and the recipes give snippets
to lift, but neither walks anyone through a whole question from tree to
answer. Seven M. tuberculosis isolates, ten steps, and the question the
tool exists for: which of these are transmission links.

It goes from the Newick through the matrix, the substitutions-to-SNPs
conversion, single-linkage clustering at the two standard thresholds,
nearest neighbours, the topological and variance-covariance matrices,
and what changes when the dataset gets big.

The two thresholds are the point of it. At 5 SNPs TB_003 is outside the
cluster and at 12 it is inside, which is a decision to report rather than
an output to read off, and no amount of precision settles it.

Every matrix on the page is real output from the tree the page ships, and
each command was run as written.
Every check in the suite so far was written against the same understanding
of the problem as the code: midpoint rooting preserves distances, the
binary-lifting MRCA agrees with walking up from both nodes, a patristic
distance is never negative. All true, all necessary, and none of it would
catch the definitions themselves being wrong.

scripts/crossvalidate.R compares distree against ape over generated trees
of mixed shape (random, ultrametric, with polytomies), in all four modes:
cophenetic.phylo for patristic, vcv.phylo for the variance-covariance
matrix, cophenetic over unit branch lengths for edge counts, and
phangorn::midpoint for the rooting. Over 250 trees the worst disagreement
is 9.6e-10 for the float modes, which is the 12-decimal text round-trip,
and exactly zero for edge counts.

That the midpoint agrees with phangorn is the reassuring part. It is the
only mode where rooting changes the answer and the only code in the tool
that rewrites the tree.

R is a heavy thing to need for
running 59 tests
test lca::tests::test_depth_top ... ok
test lca::tests::test_mrca_deeper ... ok
test midpoint::tests::test_midpoint_asymmetric ... ok
test lca::tests::test_mrca_self ... ok
test midpoint::tests::test_midpoint_simple ... ok
test parser::tests::test_apostrophe_in_comment ... ok
test midpoint::tests::test_midpoint_preserves_distances ... ok
test lca::tests::test_mrca_siblings ... ok
test parser::tests::test_apostrophe_in_unquoted_label ... ok
test parser::tests::test_bracket_in_label_position ... ok
test parser::tests::test_comment_before_label ... ok
test parser::tests::test_comment_before_subtree ... ok
test parser::tests::test_empty_branch_length_error ... ok
test parser::tests::test_empty_input_error ... ok
test parser::tests::test_escaped_double_quotes_in_labels ... ok
test parser::tests::test_escaped_quote_keeps_inner_whitespace ... ok
test parser::tests::test_escaped_single_quotes_in_labels ... ok
test parser::tests::test_free_text_is_not_a_leaf ... ok
test parser::tests::test_flatten ... ok
test parser::tests::test_multiple_trees_error ... ok
test parser::tests::test_leading_rooting_comment ... ok
test parser::tests::test_negative_branch_length_parsed ... ok
test parser::tests::test_nested_brackets ... ok
test parser::tests::test_newlines_in_newick ... ok
test parser::tests::test_no_trailing_semicolon ... ok
test parser::tests::test_non_ascii_labels_preserved ... ok
test parser::tests::test_parse_internal_labels ... ok
test parser::tests::test_parse_nhx_comments ... ok
test parser::tests::test_parse_no_branch_lengths ... ok
test parser::tests::test_parse_quoted_labels_double ... ok
test parser::tests::test_parse_quoted_labels_single ... ok
test parser::tests::test_parse_scientific_notation ... ok
test parser::tests::test_parse_simple_tree ... ok
test parser::tests::test_quoted_internal_node_label ... ok
test parser::tests::test_trailing_comment_allowed ... ok
test parser::tests::test_trailing_content_error ... ok
test parser::tests::test_unclosed_comment_error ... ok
test parser::tests::test_unclosed_quote_error ... ok
test parser::tests::test_unmatched_paren_error ... ok
test parser::tests::test_whitespace_in_newick ... ok
test tests::test_duplicate_leaves_detected ... ok
test tests::test_large_symmetric_tree ... ok
test tests::test_lmm_matrix ... ok
test tests::test_mode_conflict ... ok
test tests::test_negative_branch_length_detected ... ok
test tests::test_negative_branch_length_gives_negative_distance ... ok
test tests::test_lower_triangle_row_counts ... ok
test tests::test_patristic_clamped_nonnegative ... ok
test tests::test_no_branch_lengths_all_zero ... ok
test tests::test_single_leaf ... ok
test tests::test_topology_distances ... ok
test tests::test_trifurcation ... ok
test tests::test_simple_patristic_distances ... ok
test tests::test_zero_distance_same_leaf ... ok
test parser::tests::test_deep_tree_no_stack_overflow ... ok
test tests::test_patristic_never_negative_on_random_trees ... ok
test midpoint::tests::test_midpoint_random_trees ... ok
test parser::tests::test_deeply_nested_tree_drops_without_overflow ... ok
test lca::tests::test_mrca_matches_walking_up_on_random_trees ... ok

test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s

running 4 tests
test test_topology_matches_ape_unit_branch_cophenetic ... ok
test test_patristic_matches_ape_cophenetic ... ok
test test_lmm_matches_ape_vcv ... ok
test test_midpoint_leaves_patristic_alone ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s

running 17 tests
test test_binary_stdin ... ok
test test_binary_empty_input_error ... ok
test test_binary_rejects_out_of_range_precision ... ok
test test_binary_duplicate_leaf_error ... ok
test test_binary_rejects_zero_threads ... ok
test test_binary_rejects_newline_in_label ... ok
test test_binary_names_gzip_input ... ok
test test_binary_keeps_output_file_on_parse_error ... ok
test test_binary_basic_patristic ... ok
test test_binary_lower_triangle ... ok
test test_binary_precision_flag ... ok
test test_binary_topology_flag ... ok
test test_binary_midpoint_does_not_inflate_topology ... ok
test test_binary_midpoint_preserves_patristic ... ok
test test_binary_rejects_whitespace_label_in_lower_mode ... ok
test test_binary_warns_about_unlabeled_leaves ... ok
test test_binary_warns_lower_drops_lmm_diagonal ... ok

test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s, so six of ape's matrices are
committed under tests/fixtures/ and tests/crossvalidation.rs checks those
everywhere. The full run is a weekly job and a button.
src/main.rs held the whole tool: argument handling, the Newick parser,
the LCA structure, the distance definitions and the output loop. Nothing
outside the binary could reach any of it, which rules out fuzzing the
parser, generating API documentation, and using distree from another Rust
program.

src/lib.rs is now the tree side (parser, tree, lca, midpoint, DistMode
and compute_distance) and src/main.rs is the command line over the top:
flags, input, warnings, formatting, output. The unit tests for the
distance definitions moved with the code they test.

No behaviour changes; the output is byte-identical.
The torture test found this on its first run. 'A:1e910' parses: Rust
returns infinity for an exponent past f64's range rather than an error.
An infinite depth then makes every distance infinite and the diagonal
NaN, since inf - inf is NaN, and the whole thing came out with exit code
0 as a matrix of inf and NaN.

Two guards, because there are two ways in. The parser now refuses a
branch length that is not finite, which is the case anyone will actually
hit. And since a distance is d_i + d_j - 2*d_m, a depth past half of
f64's range overflows that sum even when every individual length is
finite, so the run also stops if the deepest leaf is far enough out for
that: '((A:1e308,B:1e-308):1e300,C:0.0);' used to produce a NaN diagonal
and now says to rescale the tree.

Trees with large but usable lengths are untouched: 1e100 still works.
Two ways in, because they catch different things.

tests/torture.rs runs on every push and needs no extra toolchain. It
takes valid trees and truncates, flips, duplicates and swaps bytes in
them, the way a real Newick file gets damaged, then asserts only that
nothing panics and nothing hangs. It found the infinite-branch-length bug
on its first run.

fuzz/ is the coverage-guided version, for when there is time to leave it
going. Two targets: the parser alone, and everything that parses pushed
through flattening, rooting and all three distance modes. 12.4M runs on
the first and 5.1M on the second, no crashes, starting from the seeds in
fuzz/seeds/.
Three things people kept having to work around.

Large trees arrive compressed, and distree made you pipe them through
gunzip. It now decompresses them itself, detected by magic bytes rather
than by extension so it works from stdin too.

--taxa FILE restricts the matrix to the labels listed in a file, one per
line, blanks and # comments skipped. It filters the output rather than
pruning the tree, which is the point: the path between two leaves does
not depend on which other leaves are in the matrix, so a subset carries
the same distances as the full run. A label that is not in the tree is an
error naming it, since a quietly smaller matrix is the kind of thing
nobody notices until much later.

--stats prints a summary to stderr: leaves, nodes, mode, cells, and the
minimum, maximum and mean off the diagonal. The workers accumulate it as
they go, so it costs one pass and nothing measurable.
Text is an expensive way to move a large matrix. At twelve decimals a
cell is fourteen bytes and most of the run is spent producing them;
as a 64-bit float it is eight bytes, exact, and costs nothing to write.
On an 8,000-leaf tree, to a real file:

  --lower -p 12   0.26s   458 MB
  --lower --npy   0.10s   244 MB
  -p 12           0.56s   916 MB
  --npy           0.19s   488 MB

so 2.6x faster and 47% smaller, at full precision rather than twelve
decimals. numpy.load reads it directly.

--lower switches it to the condensed vector rather than PHYLIP's lower
triangle, because those are not the same triangle: SciPy reads the upper
one row by row, and emitting PHYLIP's order would hand squareform the
right values in the wrong places. Checked against squareform in the
tests.

.npy has nowhere to put labels, so they go to <FILE>.labels.txt in row
order, which is why --npy needs -o.
write!("{:.p$}") expands the float to its exact decimal form and goes
through core::fmt to print it. That is correct and it was about half the
cost of a text run. Scaling by a power of ten and emitting the digits
directly is roughly seven times faster at the formatting itself:

  --lower -p 6    0.22s -> 0.13s
  --lower -p 10   0.24s -> 0.15s
  -p 10 (square)  0.47s -> 0.29s

The reason a shortcut like this is normally a bad idea is that
multiplying by 10^p rounds once, and where that carries the product
across the nearest .5 boundary there is no way to tell from here which
way the exact value rounds. So it does not guess: it measures its
distance from the boundary against the error bound, and anything inside
that goes to the standard formatter. Roughly one value in a thousand of
real distance data takes that path, and every exact tie does.

Verified rather than assumed. The unit tests check the fast path against
the standard formatter over 200,000 random values across 24 orders of
magnitude, straight down the middle of the rounding boundaries at every
precision, over distance-shaped values, and on subnormals, 2^53, signed
zero and the rest. Separately, 612 matrices across 11 trees, 17
precisions and both float modes are byte-identical to the old output.

The first version of this got signed zero wrong: a small negative value
that rounds to zero prints as -0.00, and taking the sign from the rounded
integer loses it. The boundary test caught it.
Covers --npy, --taxa, --stats and gzipped input across the CLI reference,
input, output and recipes pages, and refreshes the benchmark tables now
that the formatter is faster. Also records what the fuzzing and the ape
cross-validation are for, since they are the part of the suite that would
otherwise look like more of the same.
It was 329 lines and had become a second copy of the documentation site:
the full flag reference, the output formats, five worked use cases, the
troubleshooting list. Two copies of the same thing only stay in step
while someone is watching, and this one had already drifted. Its Usage
block listed neither --npy nor --taxa nor --stats, and said nothing about
reading gzip, so anyone reading the README on GitHub got a flag reference
that the site contradicted.

What is left is what a landing page is for: what the tool does, where the
documentation is, how to install it, eight commands worth copying, a
performance table and how to cite it. The detail lives on the site, which
is the only copy now.

Every command in the quick start was run as written.
@Paururo
Paururo merged commit 2dea282 into main Jul 29, 2026
4 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.

1 participant