Skip to content

Release 3.1.0 - #75

Merged
rob-p merged 6 commits into
mainfrom
release/3.1.0
Oct 1, 2026
Merged

rob-p merged 6 commits into
mainfrom
release/3.1.0

Conversation

@rob-p

@rob-p rob-p commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Merges develop into main for the 3.1.0 release: #71, #72 and #73, plus the release preparation in #74 (CHANGELOG, docs and the release-script fixes).

The branch is develop with main merged in on develop's side, so it merges without the Cargo.toml conflict that v3.0.3's squashed sync left behind. Its tree is identical to develop.

After merge, main is bumped to 3.1.0 with scripts/bump_and_publish.sh 3.1.0 --publish, which verifies packaging, tags, and publishes scratch-probe 0.1.0, cuttlefish-rs and the CLI. main is then synced back to develop with a merge commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

rob-p and others added 6 commits September 25, 2026 06:49
* perf(partition): pack each fragment once and keep error drops out of emission

Weak super-k-mers overlap by k - 1 bases, so packing every label from ASCII
packed each input base about four times (pack_valid_label was 21% of the
colored partition profile at 150k). The emitter now packs a whole fragment
into 2-bit words once and slices each label out with two shifts per word,
producing exactly the words pack_valid_label did; a unit test checks the two
agree across offsets, lengths and label widths. Records are appended with a
fixed-size copy into reserved capacity instead of a variable-length memcpy
call per record.

The per-record BucketError sites now use ok_or_else, for the same reason as
the unitig walk: an eagerly built error the optimizer fails to prove trivially
droppable leaves drop_glue calls in the hot loop (1.8% of partition).

10k colored, t16: worker scan+pack 201 -> 183 s, partition 22.4 -> 21.0 s,
output identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(buckets): per-bucket label cache for weak super-k-mer records

Weak super-k-mers repeat heavily across related genomes: 150k Salmonella
assemblies emit 51.9 G records carrying 1.28 G distinct labels (40.6x).
Each bucket now keeps a 4,096-slot direct-mapped cache of recent labels at
flush time, in the order blocks are written:

- Colored: a record whose label is cached is written as its 2-byte slot, with
  bit 31 of the attribute set (source ids use bits 10-30). The reader replays
  the same cache in the same order and expands references back to full
  labels at block decode, so every record accessor is unchanged. The manifest
  records the cache size in its previously reserved flag byte.
- Uncolored at edge-frequency cutoff 1: a record identical (label and
  attribute) to its cached entry adds nothing to the graph and is dropped.
  BucketEmitStats reports the drop count, and the compat tests' record
  invariant now reads written + dropped == emitted.

CF3_RS_LABEL_CACHE_BITS sets the cache size (0 disables it).

10k Salmonella, t16, output identical:
- uncolored: 87% of records dropped; buckets 17.3 -> 6.5 GB, local
  contraction 29.6 -> 13.0 s, wall 1:13 -> 0:56.
- colored: 87% of records referenced; buckets 23.6 -> 19.7 GB, time
  unchanged (the reader expands references, so local work is the same).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(local): apply label-cache references without re-rolling their k-mers

Colored buckets now mark records whose label repeats one the bucket's label
cache held (87-91% of records). The reader passes each record's cache slot
to local contraction, which uses it in both passes over a bucket:

- Build pass: a slot remembers the vertex-table indices and orientations of
  its label's k-mers. A reference applies its source and discontinuity
  flags straight to those vertices -- no k-mer rolling, hashing or probing.
  Records are only replayed while the table has not moved entries since
  (a generation counter on the flat maps) and at cutoff 1, where the skipped
  edge updates are idempotent; anything else takes the full path and
  records the slot afresh.
- Color pass: a slot remembers which wanted color classes its label's
  k-mers reach, in order. A reference replays that list with its own source,
  giving exactly the source sets re-rolling would.

Both caches are per thread and reused across buckets with an epoch stamp,
so no bucket allocates them. The color pass is also skipped outright for a
bucket with no wanted classes.

10k Salmonella colored, t16, interleaved: build pass 405-412 -> 279-286
worker-s, local contraction 53.0-54.6 -> 45.3-47.6 s; the color-pass change
alone took walk time from 404 to 313-320 worker-s. The order-independent
color digest matches 3.0.2 exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(collation): store intermediate unitig labels 2 bits per base

Local-unitig bucket labels and materialized coordinate labels (.mlabel,
and the retained in-memory tails) were ASCII, 1 byte per base, written in
local contraction, read in the path-info map, rewritten into .mlabel and
read again in the reduce. Both are now packed 2 bits per base (MSB-first,
byte-aligned per label), and unpacked to ASCII only where a label is
transformed: direct unitigs in the map and each path member in the reduce,
so reverse-complement, K-overlap trimming, canonicalization and cycle
normalization all still run on the same ASCII.

Offsets stay in bytes, so every concatenation and rebase is unchanged and
the 46-bit offset field now covers four times as many bases. Lengths stay
in bases; the two bounds checks use the packed length. The flush predicates
count label bases so batches flush at the same record cadence. The .mcoord
magic advances to CF3MCB4. The single-worker global .lmtig-labels path
stays ASCII and packs at the materialized sink.

10k Salmonella, t16, output and color digest identical: colored writes
94 -> 85 GB and reads 131 -> 122 GB; uncolored writes 72.6 -> 63.9 GB and
reads 91.9 -> 82.6 GB; time neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(buckets): compare cached labels word by word

The label-cache lookup compared a cached label with slice equality, which
compiles to a memcmp call per record at flush time (8.4% of the colored
partition profile). An inline word loop removes the call.

10k Salmonella colored, t16, interleaved: bucket flush 89.0-89.2 -> 78.9-79.6
worker-s, partition 22.8 -> 22.2 s. Interleaving each slot's tag and words
into one entry was also tried and was slower (117-119 worker-s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(local): run the color pass from a build-pass log instead of rereading

The color pass read and decoded every colored bucket a second time to
collect exact source sets for new color classes. With label-cache slots on
every record, the build pass can keep what that pass needs: source runs,
each record's slot, and the label words of literals only (about 7% of
records at 150k). The color pass replays the log, using the per-slot wanted
classes it already records, and no longer opens the bucket.

The log is per worker thread and reused. A record without a slot, or a log
past OCCURRENCE_LOG_BYTES (64 MiB), turns it off for that subgraph and the
pass reads the bucket as before. The local contraction summary reports how
many subgraphs replayed and how many reread.

10k Salmonella colored, t16: all 16,384 subgraphs replayed, reads
112-114 -> 93-94 GB, time within noise, color digest identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(buckets): compact colored block encoding against the label cache

Colored blocks with a label cache now hold two streams instead of a 4-byte
attribute per record plus inline labels or 2-byte slots:

- codes: the block's (source, records) runs -- records arrive grouped by
  source, so the source id is stored once per run -- then one u16 per
  record: a reference is its 12-bit slot and 2 flag bits (the cached entry
  already fixes the length), a literal its length and flags;
- labels: literal label words only.

A reference is 2 bytes before compression, and consecutive genomes, whose
records follow nearly the same sequence of cached labels, still give LZ4
long matches. The reader rebuilds the usual per-record attributes and
labels at block decode, so nothing downstream changes. The cache is capped
at 12 bits to fit the slot field; the manifest magic advances to CF3WSKC2.

10k Salmonella colored, t16, interleaved: bucket bytes 19.74 -> 11.39 GB,
bucket flush 88-89 -> 66-67 worker-s, partition 24.0 -> 21.9-22.4 s,
writes 76 -> 68 GB, reads 93-94 -> 85 GB; color digest identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(collation): narrow edge path-info records from 24 to 16 bytes

P_e records stored the path rank as a u64 and three padding bytes. The rank
is C++'s weight_t and the materialized record already narrows it to 16 bits
(panicking past that), so the extra width only carried zeros. Records are
now path id (u64), local unitig index (u32), rank (u16), flags (u8) and one
zero byte; a rank past 16 bits is an error at write time instead of a panic
later. The .scb magic advances to CF3SCB3.

10k Salmonella uncolored, t16: P_e directory 7.04 -> 4.69 GB, writes
55.2 -> 53.1 GB, reads 74.2 -> 72.5 GB; output identical, colored color
digest identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(collation): narrow vertex path-info records from 24 to 20 bytes

At k <= 31 a `.pv` record stored the rank and its two flag bits in a u64.
Ranks count unitigs along a path, so 32 bits hold them with room to spare;
the record is now packed to 20 bytes. At 10k Salmonella uncolored the `.pv`
stream drops from 9.87 to 8.25 GB and total writes from 53.1 to 51.1 GB,
with identical output and colour digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(collation): write uncolored coordinate shards without colour fields

Uncolored runs never set a `.mcoord` record's colour index or count, yet
each record carried them in its 24-byte native layout. Uncolored shards now
carry a separate magic and store the other 18 bytes; the loader widens them
back into the native in-memory record. Colored shards are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(collation): pack k <= 31 discontinuity edges into 24 bytes

Each endpoint word now carries its vertex side in bit 62, which a 62-bit
vertex never uses, and phi stays all ones, which no vertex-and-side word can
equal. The exit-side, swapped and phantom flags move into the top three bits
of the local-unitig bucket field (buckets are capped at 1024). The separate
flag byte is gone, taking the record from 25 to 24 bytes. At 10k Salmonella
uncolored the edge matrix drops from 12.75 to 12.24 GB with identical output
and colour digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(collation): emit colored local unitigs when there are no discontinuity edges

When every super-k-mer falls in one subgraph there are no discontinuity
edges, so the external expansion writes no path-info files. Collation mapped
range buckets only from those files, fell through to a branch for in-memory
records that the external expansion never produces, and reported the direct
local unitigs complete without emitting any: colored runs wrote an empty
FASTA. Uncolored runs escaped because their edge-free unitigs go out through
the trivial FASTA during local contraction.

Every range bucket is now mapped from the (possibly empty) manifest. A new
compat test builds the k = 33 colored fixture into a single subgraph and
checks every vertex's colours against its sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(partition): vectorize the minimizer scan after simd-minimizers

For minimizer lengths up to 16 (the default is 12), the minimum l-mer hash
of every (k-1)-mer window now comes from `window_min`, which follows
simd-minimizers (Groot Koerkamp and Martayan, SEA 2025,
doi:10.4230/LIPIcs.SEA.2025.20):
- eight contiguous chunks scanned in lock step in AVX2 lanes, with gathers
- the two-stacks sliding minimum
- a cheap vectorizable hash instead of two 64-bit wyhash calls per base

Unlike simd-minimizers, whole 32-bit hashes are compared. It keeps only 16
bits and breaks ties by position, which differs between strands and would
send a k-mer and its reverse complement to different subgraphs. The hash (a
seeded murmur3 finalizer of the canonical l-mer) is a bijection, so the
minimum value, and the subgraph taken from it, is strand-invariant by
construction. The AVX2 scan is picked at run time and matches the scalar
scan exactly, so output never depends on the CPU; longer minimizers keep the
wyhash scan.

This changes which subgraph a super-k-mer goes to, but not the unitigs. At
10k Salmonella, t16:
- uncolored partition: 19.7 s -> 14.55 s; wall 56.3 s -> 51.4 s
- colored partition: 20.4 s -> 15.2 s; wall 1:18 -> 1:13
- `cuttlefish compare` matches all 51,644,203 unitigs, and the colour
  digest is unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: record the intermediate-I/O and minimizer-scan work

Adds a performance-record section: each representation change with its
stream sizes and 150k results, the simd-minimizers benchmark and why its tie
rule could not be used, the vectorized scan's measurements at t16 and t64,
the colored single-subgraph bug it exposed, the two rejected partition
experiments, and the NUMA node-0 memory pressure behind this session's host
drift. The CHANGELOG gains an Unreleased entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: explain the larger largest subgraph under the new minimizer hash

A replay of both partition schemes over all 149,998 genomes reproduces the
logged colored maxima exactly. The subgraph-size distributions agree except
for their single maximum, which swings with the seed in both schemes: the
old hash with seed 1 is worse than the new one, and the new hash with seed 1
matches the old. Each largest subgraph is dominated by one repeated or
low-complexity l-mer. The uncolored 3x figure reflects the duplicate filter,
not the contents. Records the fixes worth trying if it ever matters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(partition): build the minimizer scan cleanly on non-x86 targets

On aarch64 the AVX2 branch of `fill_window_mins` is compiled out. That left
`done` needlessly mutable and `MIN_WINDOWS_PER_LANE` unused, which the
cross-check's `-D warnings` rejects. The vectorized prefix now lives in its
own function with a scalar-only twin for other architectures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(input): skip the fragment scan for all-ACGT FASTA records

About a quarter of partition time is each worker inflating and parsing its
own inputs, outside the per-fragment timer, and half of that was FASTA
parsing. Every record was scanned byte by byte for non-ACGT symbols after
its lines were assembled. Each sequence line is now tested for upper-case
ACGT 16 bytes at a time (SSE2, part of the x86-64 baseline). A record made
only of such lines is emitted as one fragment, which is exactly what the
scan would have found. Other lines take the unchanged path.

At 10k Salmonella, t16:
- parse-only partition: 3.74 -> 2.89 s
- full partition: ~14.5 -> ~13.9 s

`cuttlefish compare` matches every unitig. A randomized test checks the
parser against the previous algorithm on messy FASTA: lowercase, N,
interior whitespace, CRLF and blank lines.

Whole-file inflation with zune-inflate was tried as well. It is 1.3x
zlib-rs in isolation, but slower inside the pipeline (parse-only 3.7 s
against 2.9 s), and was dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(local): keep cutoff-1 edges as presence bits in the vertex flags

Local contraction is bound by memory traffic over the vertex table. At
cutoff 1, the reference default, an edge only needs to be present. Its
eight presence bits fit in `VertexState`'s unused flag bits 3-10, which
frees the 4-byte counts. The state is packed to 4-byte alignment, and a
flat-map slot stores its key as two `u32`s so it carries no padding.

| slot | before | after |
| --- | ---: | ---: |
| colored | 24 B | 20 B |
| uncolored | 16 B | 12 B |

The colour slot parameter now also picks the edge storage:
- `()` and `u64` keep presence bits;
- the new `Counted` and `ColoredCounted` keep 4-bit counts, for any cutoff.

The dispatch picks presence slots only at cutoff 1, so read mode (cutoff 2)
keeps today's layout. Defaults are the counting slots.

At 10k Salmonella, t16, order-alternated:
- colored local: 34.8 / 35.2 s -> 33.3 / 33.0 s
- uncolored local: 14.2 / 14.3 s -> 12.9 / 12.9 s
- colored wall: 1:12.6 -> 1:10.3
- uncolored wall: 50.9 -> 49.4 s
- read mode (SRR105788, t4): 99.0 / 99.4 s -> 97.5 / 97.8 s

Peak RSS is unchanged. `cuttlefish compare` matches every uncolored and
read-mode unitig, and the colour digest is unchanged. A randomized test
checks that the presence slots answer every edge, flag and colour question
as the counting slots do at cutoff 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: record partition's hidden parse cost and the cutoff-1 vertex state

Covers the parser fast path and the two rejected whole-file decoders, the
colored local-contraction profile with its two dropped experiments, and the
presence-bit vertex state's results at 10k and 150k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(simd): add NEON forms of the minimizer scan and the ACGT line check

Both new fast paths were x86-only, so aarch64 ran the scalar code:
- the eight-lane window-minimum scan (AVX2);
- the FASTA parser's all-ACGT line test (SSE2).

The NEON scan keeps the same eight lanes as two four-lane registers, so
its chunking and bounds are the AVX2 scan's. NEON has no gather, so each
lane's four bases are plain, bounds-checked loads. The line test compares
16 bytes against A, C, G and T and checks the lane minimum. NEON is part
of the aarch64 baseline, so neither needs run-time detection.

The existing equivalence tests exercise both on aarch64: the scan against
a naive reference, and the parser against the previous algorithm. CI's
macOS job runs on Apple Silicon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(buckets): add a NEON form of the 32-base label packer

pack_valid_word_32 used BMI2 PEXT on x86-64 and the scalar loop
everywhere else. On aarch64 it now uses NEON, which is part of the
baseline, so there is no run-time detection:
- each of 32 bytes becomes its 2-bit code;
- each code is shifted into its place in a group of four (6, 4, 2, 0
  bits, first base highest);
- two pairwise adds collapse 32 bytes to the 8 packed bytes in base
  order, which read big-endian are the scalar word.

The fields are disjoint, so the adds cannot carry.

A test checks the dispatched packer and a scalar model of the NEON lane
steps against the scalar packer on 5,003 words, including all-A, all-T
and ACGT repeats, and pack_valid_label on every length from 1 to 96.
The model runs on every architecture, so the lane arithmetic is checked
on x86 as well. On aarch64 the scalar packer now remains only as the
tests' reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(input): emit nothing for an empty FASTA record at min_len 0

The all-ACGT fast path handed an empty record straight to
`emit_fragment`, which at `min_len == 0` emitted an empty fragment where
the reference parser emits none. Empty records now take the general
path. The randomized parser test adds min_len 0 and U bases, and notes
that header-only records already occur in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* refactor(state): name the presence slots and guard their cutoff

Review follow-ups for the cutoff-1 presence-bit vertex state:

- The presence slots are now named types, `Presence` and
  `ColoredPresence`, not `()` and `u64`.
- `LocalSubgraph` rejects a presence slot at a cutoff above 1 with
  `LocalSubgraphError::PresenceSlotCutoff`, rather than relying on each
  dispatch site to choose the right slot.
- `update_edges` debug-asserts ACGT-or-E on the presence path; an N
  would have set a bit in the last-source field.
- The default `edge_frequency` debug-asserts that the slot counts edges.
- There is a size assert for `FlatSlot<ColoredCounted>` (24 B), and the
  stale size comment is corrected.
- A comment explains why the in-memory `contract_local_subgraphs`
  deliberately stays on counting states.

Tests:

- `presence_and_counting_states_contract_alike_at_cutoff_one` runs
  `contract_local_subgraph` over real buckets, with vertex-map reuse,
  once per slot, colored and uncolored. It checks that local unitigs,
  exits, colour runs and trivial FASTA all match.
- The randomized state test now covers the visited bit and
  discontinuity marks on both sides.

Cutoff ≥ 2 end to end was already covered by the read-fixture compat
tests. They go through the external dispatch, which picks `Counted`
whenever cutoff != 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(subgraph): stop padding K > 31 vertex-map slots to 32 bytes

The wide flat map stored `(u128, VertexState)`. The key's 16-byte
alignment padded every slot to 32 bytes. The key is now four `u32`s, as
the narrow map stores its key as two, which gives 20 B uncolored at
cutoff 1 and 24 B with counts.

The colored presence slot would be 28 B, but that measured 6% slower in
colored local contraction at k = 55. About 44% of 28-byte slots
straddle cache lines. `ColorSlot::WidePad` keeps it at 32 B, which
measured neutral.

10k, k = 55, t16, order-alternated:
- Uncolored local contraction: 23.9 / 23.8 / 23.8 → 20.6 / 20.4 / 20.3 s;
  wall 57 → 54 s.
- Colored (padded): 38.2 / 38.4 → 38.2 / 38.3 s.
- Read mode, SRR105788: time flat; peak RSS 7.07 / 7.14 → 5.98 / 6.13 GB.

Outputs compare equal and the colour digest is unchanged. The
slot-equivalence test now also runs at K = 35, which covers the wide map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: correct the cutoff-1 record and add the wide-slot results

- Read mode's 99 → 97.5 s was noise. Neither change reaches it: its
  input is FASTQ, and cutoff 2 keeps counting states.
- Explain the 0.2 GB uncolored RSS rise at 150k. The peak falls in
  discontinuity contraction, which is unchanged. With immediate page
  return, local contraction's peak fell, and the rest of the gap is
  allocator state, not live data.
- Mention NEON alongside SSE2, the named slot types, the cutoff guard,
  and the new equivalence test.
- Add the K > 31 slot measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* refactor(discontinuity): drop the unreachable external-to-memory path

`emit_uncolored_discontinuity_inputs_with_threads_impl` could contract
into the external streams and read them back into memory, but its only
caller passed no label path, so that branch never ran. It could not
have worked anyway:
- The external unitig table was compacted to 8-byte records (label
  length and flags), but `external_inputs_to_memory_inputs` still read
  whole `DiscontinuityUnitig` structs, and so failed at the first
  record.
- It also dropped the trivial unitigs, which go to their own FASTA.

This removes the branch, that helper and its record reader, and folds
the in-memory body into `emit_uncolored_discontinuity_inputs_with_threads`.
The slot-equivalence test covers the external path at the level each
worker uses, `contract_local_subgraph`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: second-review follow-ups for the vertex-state changes

- The presence-path comment now says why N cannot arrive (bucket
  labels are ACGT by construction), not that callers skip it.
- `WidePad`'s doc no longer blames "odd sizes". It explains that the
  32-byte colored slot relies on the table starting on a cache line,
  which jemalloc's large blocks do. A `repr(align(32))` pad that would
  guarantee this under any allocator measured 1.5% slower and was not
  kept; the record now has the numbers.
- Correct the straddle figure: a 28-byte stride splits 37.5% of slots,
  not 44%.
- The prefetch doc no longer says it serves the narrow map only.
- CHANGELOG entries for the FASTA fast path, the cutoff-1 vertex state,
  the K > 31 slots and the NEON forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…it (#73)

* perf(io): lz4-block the local-unitig record stream

Local-unitig buckets write a `.unitigs` stream: fixed 8-byte records (a
u32 label length, a flag byte and padding), plus colour runs when colored.
It is written once and read back sequentially, and it compresses about
3x with lz4. A small `block_io` module frames it as 256 KiB lz4 blocks,
each with an 8-byte header giving the raw and stored lengths; a block that
does not shrink is stored raw.

At 10k Salmonella, t16, order-alternated:
- uncolored: 1.6 GB less written, 1.7 GB less read, same wall time
  (48.4 / 48.5 s against 48.1 / 48.5 s)
- colored: 3.4 GB less written, 3.0 GB less read (one pair)

The colour digest is unchanged and `cuttlefish compare` matches every
unitig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(io): lz4-block the materialized coordinate and colour streams

`.mcoord` records (after their raw 32-byte header, whose counts `finish`
patches in place) and `.mcolor` runs are now lz4 block streams.
- Block writers take a block size, so the shard writers keep their 128 KiB
  budget.
- The compression scratch is per thread.
- Coordinate records, already batched by the shard writer, go straight out
  as blocks without a second buffer.
- The loader streams both through the block reader and replaces the raw
  file-size checks with an end-of-stream check.
- Tests that poked raw offsets now decode, edit and re-encode.

At 10k Salmonella, t16, order-alternated (with the `.unitigs` blocking):

| | written | read | wall |
| --- | ---: | ---: | ---: |
| uncolored | -2.6 GB | -2.7 GB | unchanged (48.9 / 48.9 s against 48.9 / 48.2 s) |
| colored | -6.5 GB (63.4 -> 56.9) | -6.5 GB | +1.7 s (1:11.5 against 1:09.8, three pairs) |

On this host, which keeps intermediates in page cache over fast NVMe,
every blocked stream costs about 0.26 s of wall per GB of writes saved,
`.mcoord` included. The blocking pays wherever writing and reading those
bytes costs more than that. The colour digest is unchanged and
`cuttlefish compare` matches every unitig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* feat(scratch-probe): a small crate that sizes up a work directory's storage

`classify` reads metadata only:
- the filesystem, by statfs magic, and whether it is local, networked
  (NFS, SMB, Lustre, GPFS, CephFS, BeeGFS, ...) or memory-backed;
- whether the block device under it rotates, following device-mapper and
  md stacks down to their disks.

`probe` also times a sequential write (with fdatasync) and read-back of
random bytes, capped at 256 MiB and about half a second. It uses O_DIRECT
where the filesystem allows, and falls back to buffered I/O plus a page-cache
drop elsewhere. `Probe::sustains(rate, margin)` answers the usual question.
Policy stays with the caller.

On this host (XFS over two striped NVMe drives) a probe takes 0.07 s and
measures about 5.5 GB/s writing and 13 GB/s reading. The crate is
versioned on its own (0.1.0) so it can be published and reused
independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* feat(build): choose intermediate compression from the work directory's storage

`build --compress-intermediates auto|on|off` (default `auto`) controls the
lz4 blocking of the local-unitig, coordinate and colour streams. With it
off, blocks are stored raw, so the on-disk format and the reader stay the
same.

`auto` decides at startup with scratch-probe:
- a network filesystem or a rotational disk compresses, with no timing;
- otherwise a probe of at most 256 MiB and about half a second compares
  the storage with the rate the build produces intermediates, anchored at
  0.96 GB/s for 16 threads and growing as threads^0.65 (measured at 10k
  and 150k);
- storage that sustains twice that rate is left uncompressed; slower
  storage, or storage the probe cannot measure, compresses.

The decision and its reason are logged. `cuttlefish probe [-w DIR] [-t N]`
prints the measurement and what `auto` would choose, and `cleanup`
recognises a probe file left by an interrupted build.

On this host `auto` picks "off" in 0.07 s, and 10k builds match the
uncompressed baseline:
- colored: 1:10.33 / 1:09.56 against 1:09.64 / 1:09.62
- uncolored: 48.26 / 48.43 s against 48.00 / 48.73 s

The colour digest is unchanged and `cuttlefish compare` matches every
unitig. The model keeps compression off here up to 64 threads and turns it
on by 256.

The release script now publishes scratch-probe ahead of cuttlefish-rs
whenever its version is not yet on crates.io.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(buckets): pin the weak-super-k-mer append path's inlining

`add_packed` and `add_impl` run once per weak super-k-mer. They carried no
inline hints, so whether they inlined into the partition's scan loop
depended on how the crate fell into codegen units. Adding the unrelated
intermediate-compression code outlined `add_impl`, taking partition from
13.8 to 16.4 s at 10k, t16 (scan+pack 172 -> 213 worker-s). With
`#[inline]` on `add_packed` and `#[inline(always)]` on `add_impl`,
partition is back to 13.5 / 13.6 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* feat(scratch-probe): bypass the cache and classify storage on macOS

macOS has no O_DIRECT. The probe therefore set no cache flag and measured
through the unified buffer cache, so its read-back described memory
rather than the disk.

- The probe now sets `F_NOCACHE` on its descriptors and reports
  `direct_io`. If the fcntl is refused, it falls back to buffered I/O as
  before.
- `classify` names the filesystem from `statfs::f_fstypename` (apfs, hfs,
  nfs, smbfs, ...). For filesystems it does not know, it uses `MNT_LOCAL`,
  except for FUSE mounts, which stay Unknown.
- Rotation stays `None`: the answer lives in IOKit, and an SSD is the
  norm on Macs.

The macOS CI job runs a new test that checks a temp dir classifies as
Local and that the probe bypasses the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(scratch-probe): cap the whole probe in time, not just the writes

`max_time` stopped the write loop, but the final `fdatasync` and the
read-back were not timed. Where O_DIRECT is refused, 256 MiB goes into
the page cache in about 0.1 s, then syncs and reads back at device speed.
On a FUSE mount that `auto` probes (s3fs, sshfs), that could take tens
of seconds.

Buffered writes now sync every chunk, so the clock sees the device. The
read-back stops at the same time cap, after at least one chunk. A probe
now takes about half a second each way.

A new test probes tmpfs, which refuses O_DIRECT, to cover the buffered
path and its cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(io): reject impossible block headers and check stream ends

Three integrity gaps in the lz4 block streams:

- The reader accepted a zero-length block and a block "compressed" to
  more than its raw size. The writer emits neither, and accepting them
  let zero-filled or torn tails pass the end-of-stream checks. Both are
  now `InvalidData`, with a test.
- The local-unitig map reader consumed its records and labels but never
  checked that both files were exhausted. It now does, like the other
  loaders.
- `CF3_RS_MCOORD_STREAM_BUFFER` above 1 MiB became a block size over the
  format's cap and panicked in a map worker. It is reproduced at 10k with
  2 MiB. Block sizes are now capped at `MAX_BLOCK_BYTES`; the plain
  buffers still take the full value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(build): apply each build's own intermediate-compression setting

Only the CLI read `BuildParams::compress_intermediates`. The library
never did, and its process-wide switch starts on. So a library caller
that asked for `Off` still got compressed streams, and `Auto` never
probed.

The library's build entry points (colored, uncolored, and the debug
contractor) now apply the build's setting before opening any stream,
probing for `Auto`. The CLI still decides before partitioning, so the
probe sees the work directory before it fills, and passes its decision
down as `On` or `Off` so the library does not probe again.

The switch stays process-wide. Every block records how it is stored,
so concurrent builds with different settings in one process can only
affect each other's speed, never their output. This is documented.

Tests:
- A unit test checks that a build's `On`/`Off` reaches the writers it
  opens.
- A compat test builds colored and uncolored with compression off and on
  through the library entry points, and requires the same unitigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(release): never publish scratch-probe on a failed crates.io lookup

`crate_published` treated any `cargo info` failure as "not on crates.io".
A network or index error for an already-published version would then
run `cargo publish`, which fails after the tag has been pushed.

Only cargo's "could not find `crate@version`" now means no. Other
failures are retried twice, and then the release stops with the lookup's
output rather than guessing. Dry runs report it as a validation failure.

Checked against crates.io (published, unpublished) and with a failing
`cargo`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* test(scratch-probe): drive the buffered path directly

The tmpfs probe test assumed tmpfs refuses O_DIRECT. Linux 6.6 added
O_DIRECT support to tmpfs, so on the Ubuntu CI kernel the probe took the
direct path and the assertion failed. The buffered write and read are
now tested by calling them directly, which does not depend on what the
kernel accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* feat(build): compress on FUSE work directories, with a warning

A FUSE mount may front a fast local disk or a remote object store
(s3fs, gcsfuse), and a short probe can time a cache rather than the
store behind it. `auto` now compresses on FUSE without timing and warns,
pointing at `--compress-intermediates off` for fast local storage.

- scratch-probe gets a `StorageKind::Fuse`. On Linux it comes from the
  fuse magic; on macOS, from a `*fuse*` `f_fstypename`, which used to
  map to `Unknown`.
- `CompressionChoice` carries an optional warning, which the build log
  and `cuttlefish probe` print.
- The untimed cases (network, FUSE, rotational) move into
  `choose_without_timing`, which is tested for each storage kind.
- As the review suggested, the timed decision's log line now states the
  rate the storage must sustain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(build): stop extrapolating the intermediate rate past 64 threads

`auto` estimated the build's intermediate rate as
0.96 GB/s × (threads/16)^0.65, fitted at 16 and 64 threads. Extrapolated,
that is 3.7 GB/s at 128 threads and 5.8 at 256, so on this host's NVMe
(about 6 GB/s) `auto` compressed from 128 threads up.

Measured with compression off, the rate plateaus near 2 GB/s:
- 10k (colored / uncolored): 1.96 / 1.89 GB/s at 128 threads, 2.00 / 1.78
  at 256.
- 150k: 1.60 / 1.53 and 2.03 / 1.75.

The estimate now holds its 64-thread value (2.36 GB/s) beyond 64 threads,
which is still above every measurement.

On 150k, forcing compression on or off at 128 and 256 threads moved wall
time by about 1% or less, apart from one noisy colored pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: record compressed intermediates and the auto policy

- A performance-record section for this PR:
  - what compression costs at 10k and at 150k, t128 and t256;
  - the measured intermediate rate by thread count, and why the estimate
    now stops growing at 64 threads;
  - the choices not taken (free memory; timing rotational disks);
  - the inlining regression found on the way.
- A CHANGELOG entry for `--compress-intermediates`, `cuttlefish probe`,
  the `scratch-probe` crate and the changed private formats.
- Fix a line-wrap slip in the previous section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* test(compat): state each build's compression and serialize the builds

The compat builds that left `compress_intermediates` at its default
`Auto` probed `$TMPDIR` (up to 256 MiB each, sometimes concurrently).
Because the compat tests run in parallel, they also flipped the
process-wide switch during the new on/off equivalence test. That test
could not fail from this, but it could not guarantee that both modes
ran either.

Every build in the file now states its setting and holds a shared lock
while it runs, so no build probes and none runs under another's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(scratch-probe): exclusive probe files, bounded chunks, build-like sync

Review follow-ups for the probe and for `cleanup`:

- The probe file is created with `create_new`, under a fresh name if one
  is taken. A pid+nanos collision can no longer share a file, or remove
  one the probe did not create.
- `chunk_bytes` is capped at 64 MiB, so a huge value cannot overflow the
  4 KiB rounding.
- On macOS the probe syncs with `fsync`, not `sync_data`. On macOS
  `sync_data` is `F_FULLFSYNC`, which also flushes the drive cache; a
  build never does that, so it understated the storage.
- `direct_io`'s doc notes that ZFS before 2.3 can serve reads from its
  ARC despite O_DIRECT.
- `cuttlefish cleanup` leaves alone probe files modified in the last
  minute, which may belong to a build starting in the same directory.
  Deleting one fails that probe and makes the build compress. The check
  uses age rather than a pid because work directories are often shared
  across hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* perf(io): write each block in one call, and bound the colour-run reserve

- `write_block` wrote the header and the payload as two write calls on
  an unbuffered file. A compressed block is now compressed behind room
  for its header and goes out as one write. A raw block uses one
  vectored write, so the payload is not copied. A test checks one call
  per block and correct output under a writer that takes only 3 bytes
  per call.
- `write_blocks` still emits a short last block per batch. Holding it
  back would give each of the up to 1,024 live coordinate writers a
  pending block (up to 128 MiB more at the colored map peak). The doc
  says why.
- `read_unitig_color_runs` reserved whatever count the file claimed.
  The reserve is now capped at 65,536 runs; larger counts grow as they
  are read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(scratch-probe): sync buffered probes every 32 MiB, not every chunk

Syncing every 4 MiB chunk timed a journal commit at queue depth 1 each
time. On a work directory that refuses O_DIRECT (overlayfs on some
kernels, as in containers) that could read below a fast device's real
rate and make `auto` compress without need; a build never syncs at all.

Buffered probes now sync every 32 MiB. The time cap still holds, and can
overrun by at most one such sync on slow storage (about 0.3 s at
100 MB/s). Each sync still waits for the device, so the measurement
stays honest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(release): bump cuttlefish workspace to v3.0.3

* docs(website): document intermediate compression and `cuttlefish probe`

- Command line:
  - the `--compress-intermediates` flag in the build options;
  - a section on what `auto` decides, and when to force `on` or `off`;
  - a `cuttlefish probe` section.
- Resource control: an intermediate-compression section beside bucket
  compression.
- Cleaning up: cleanup also removes interrupted probes' files, but skips
  any from the last minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(release): tolerate a dependency missing from crates.io in dry runs

A dry run validates each crate with `cargo publish --dry-run`, which
resolves workspace dependencies against the index. It already tolerated
"failed to select a version" (the index holds only other versions).
It did not tolerate "no matching package named ... found", which is
what cargo says for scratch-probe before its first release. So the
3.1.0 dry run failed although the real publish, which publishes
scratch-probe first, would succeed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: prepare 3.1.0

- The CHANGELOG's Unreleased section becomes 3.1.0, restructured, with
  headline numbers from one set of back-to-back runs against 3.0.3.
- The performance record gains the spinning-disk measurement (newton HDD,
  16 GiB cgroup) and the Apple Silicon on/off pair.
- The scratch-probe README notes that on macOS a short probe of an SSD can
  time the drive's cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* style: spell color the American way throughout

The project's CLI and API already say `--color`, `colors dump` and
`ColoredCounted`, but comments, docs and one panic message used "colour" and
"coloured". Code, comments, the engineering record, the website sources and the
logo script now use "color" consistently. The only remaining "colour" is the
third-party npm package `@img/colour` in `website/package-lock.json`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs: address the 3.1.0 release-prep review

- CHANGELOG 3.1.0: corrections checked against the raw runs.
  - colored t16 17:46 to 11:56, and 347 GB written (the redo's 362 GB is a
    writeback artifact);
  - C++ uncolored 644 GB; the memory gap as measured;
  - k = 55 14%, read mode 6.1 GB, partitioning 23-28%;
  - cyclic unitigs may start elsewhere.
  - Also: the NEON label packer, the library compatibility notes for
    `BuildParams` and the entry points' probe, and `auto` leaving compression
    off only at moderate thread counts.
- Performance record: the slow-disk written GB are pair means, and the
  cgroup-limit claim covers only the runs where the peak was recorded.
- Website:
  - the `auto` list now renders as one list, and gains the Linux-only
    rotational detection and the failed-probe case;
  - the probe output is marked as an excerpt, and it times FUSE too;
  - cleanup's "only" covers probe files, and `--prefix` does not narrow
    them;
  - the resources page softens its NVMe claim.
- `cuttlefish cleanup --help` mentions probe files.
- The READMEs and docs/index.md list `probe` and show version 3.1.0. The
  website's version strings and its exact-version pin move to 3.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* fix(release): verify every crate's packaging before anything irreversible

The dry run checked each crate on its own, and had to wave through
cuttlefish-rs and the CLI while their dependencies were not yet on
crates.io. The real release therefore committed, tagged, pushed and
published scratch-probe before either crate had ever been packaged, and a
failure after that point could not be retried: the version and the tag
already existed.

Both paths now run a single `cargo publish --workspace --dry-run` instead.
Cargo resolves the workspace's own unpublished versions through a temporary
local registry, so all three crates are packaged and verified against each
other.
- The dry run does this at the current version.
- The real release does it right after the version bump, before the commit.
- A dependency crate already on crates.io at its version is excluded, as the
  real publish skips it.

The usage text and the crate summary now include scratch-probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

* docs(changelog): compare 64-thread uncolored like for like

The 3.1.0 run behind the 64-thread uncolored figure ran compressed:
`auto` measured 4.44 GB/s against the 4.73 GB/s it needs at 64 threads.
Two reruns with compression off took 2:38 and 2:52, a mean of 2:45, so the
row reads 4:45 to 2:45 (-42%). The compressed run, at 2:42, was no
different. The table now says the figures are uncompressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
main's v3.0.3 version bump reached develop only as a squashed copy, so a plain
merge of develop into main conflicts in Cargo.toml's [workspace.dependencies].
develop has since added scratch-probe there. This merge keeps develop's side,
leaving the tree identical to develop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUjJrrX3K6C7yC2fZ6J6xR
@rob-p
rob-p merged commit d4ffe66 into main Oct 1, 2026
11 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