Skip to content

feat(group): accept --index-threshold always|never, reject unsatisfiable requests - #636

Merged
nh13 merged 1 commit into
nh/fix-index-threshold-docfrom
nh/feat-index-threshold-never
Jul 24, 2026
Merged

nh13 merged 1 commit into
nh/fix-index-threshold-docfrom
nh/feat-index-threshold-never

Conversation

@nh13

@nh13 nh13 commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

Stacked on #632 — review that first; this PR's diff is against it.

Why

#632 corrected the --index-threshold help, which had claimed "Set to 0 to always use linear scan" when 0 in fact indexes every group. But correcting the docs removed a documented capability rather than delivering it: the gate is num_umis >= index_threshold, so there is no value that disables the index short of one larger than any position group — literally --index-threshold 18446744073709551615.

What

Make the option a keyword-or-number, matching --parallel-group-min-templates one screen away in the same file (Auto | Fixed(usize), with a hand-written FromStr):

--index-threshold always     index every position group
--index-threshold never      always scan all UMI pairs
--index-threshold <N>        index groups of N or more   (default 100)
  • never is the new capability.
  • always is sugar for 0, which already meant this but read as its opposite — precisely the trap that produced the wrong docs. Once always exists, nobody needs to write or interpret a bare 0 again.

Backward compatible: every existing integer invocation keeps working unchanged, 0 included. No one's command line silently flips meaning — which is the decisive argument against the alternative of redefining 0 to mean "off".

Why not --no-index? Two knobs controlling one behaviour needs a precedence rule for --no-index --index-threshold 50. Keeping it in one option makes the contradictory state unrepresentable. (--index-threshold -1 was also considered: it needs an isize widening plus allow_negative_numbers, and puts a third magic value into an integer whose existing magic value already caused this bug.)

Unsatisfiable requests are now errors

always asserts that indexing will happen, so a configuration that can never index is rejected rather than accepted and ignored:

$ fgumi group --strategy identity --index-threshold always
Error: --index-threshold always cannot be honoured with --strategy identity: the identity
strategy never uses the UMI index. Drop --index-threshold to leave indexing to the default
threshold, or pass --index-threshold never to state that a linear scan is intended.

$ fgumi group --strategy adjacency --edits 2 --index-threshold always
Error: --index-threshold always cannot be honoured with --strategy adjacency --edits 2: the
adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit and #632 could only describe: adjacency silently ignores the index at any --edits other than 1, while paired indexes at all of them. Strategy::can_use_index(edits) now states it in code.

The check runs against the effective strategy and edits, so --no-umi (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to end up inert — otherwise the default 100 could not coexist with --strategy identity. That includes 0, even though 0 admits every group exactly as always does. This asymmetry is the one judgement call in the PR; the alternative (erroring on 0 too) is defensible and I'll change it if you prefer.

Structure

  • IndexThreshold lives in fgumi-umi next to the assigners that consult it, with FromStr, Display (round-trips), Default, From<usize>, admits() and demands_indexing().
  • dedup carries its own copy of the option, so validate_index_threshold lives in commands::common and both commands call it.
  • Assigner constructors take T: Into<IndexThreshold>, so all 41 existing integer call sites are untouched.

Tests

  • 33 cases on the type: parsing (both keywords, mixed case, integers), rejection messages naming the accepted forms, admits() across the boundary, demands_indexing(), and Display round-trip.
  • The feat(group): accept --index-threshold always|never, reject unsatisfiable requests #632 assigner tables extend into it — #[case::usize_max_never_indexes(usize::MAX, …)] becomes #[case::never_skips_huge_group(IndexThreshold::Never, …)], which is what that case was always trying to say — plus a new case proving never wins at every edit distance for both strategies.
  • CLI: group rejects always under identity/edit/adjacency-with-edits≠1 and accepts every satisfiable combination; dedup gets the same two tests through try_parse_from, since it has its own copy of the flag.
  • All three error paths verified against the built binary, including the clap parse error for a bad value.

Full suite green: 5746 passed, plus ci-fmt, ci-lint, ci-doc (RUSTDOCFLAGS=-D warnings) and ci-doctest.

Summary by CodeRabbit

  • New Features

    • Added flexible --index-threshold settings: minimum UMI count, always, or never.
    • Applied threshold controls to dedup and group commands.
    • Added clear command-line validation for incompatible strategy and threshold combinations.
    • Exposed the threshold configuration for broader application use.
  • Bug Fixes

    • Prevented unsupported indexing requests from proceeding and provided actionable error messages.
    • Improved handling of indexing decisions for different UMI assignment strategies.
  • Tests

    • Added coverage for valid, invalid, and incompatible threshold configurations.

@nh13
nh13 temporarily deployed to github-actions July 22, 2026 07:44 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f58f7dab-ff6c-4cce-b04f-fd7ccce5db19

📥 Commits

Reviewing files that changed from the base of the PR and between dc5e8ba and b916451.

📒 Files selected for processing (7)
  • crates/fgumi-umi/src/assigner.rs
  • crates/fgumi-umi/src/index_threshold.rs
  • crates/fgumi-umi/src/lib.rs
  • src/lib/commands/common.rs
  • src/lib/commands/dedup.rs
  • src/lib/commands/group.rs
  • tests/integration/test_dedup_command.rs

Walkthrough

Adds IndexThreshold with always, never, and minimum-UMI modes; applies it to adjacency and paired assigners; exposes typed CLI options; and rejects impossible indexing requests in dedup and group.

Changes

UMI index-threshold control

Layer / File(s) Summary
Threshold contract and parsing
crates/fgumi-umi/src/index_threshold.rs, crates/fgumi-umi/src/lib.rs
Defines IndexThreshold, default behavior, CLI parsing/formatting, gating methods, public exports, and unit tests.
Assigner integration
crates/fgumi-umi/src/assigner.rs
Uses typed thresholds in strategy construction and adjacency/paired index eligibility checks, with updated semantics tests.
CLI validation and wiring
src/lib/commands/common.rs, src/lib/commands/dedup.rs, src/lib/commands/group.rs, tests/integration/test_dedup_command.rs
Propagates IndexThreshold through command execution, validates incompatible strategy/edit combinations, and tests always rejection and never success.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant DedupOrGroupCommand
  participant validate_index_threshold
  participant Strategy
  User->>DedupOrGroupCommand: provide --index-threshold
  DedupOrGroupCommand->>validate_index_threshold: validate threshold, strategy, edits
  validate_index_threshold->>Strategy: can_use_index(edits)
  Strategy-->>validate_index_threshold: index eligibility
  validate_index_threshold-->>DedupOrGroupCommand: success or configuration error
Loading

Suggested labels: fgumi runall

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main behavioral change: typed --index-threshold values with unsatisfiable-request rejection.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/feat-index-threshold-never

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.98990% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.55%. Comparing base (dc5e8ba) to head (b916451).

Files with missing lines Patch % Lines
crates/fgumi-umi/src/assigner.rs 96.66% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           nh/fix-index-threshold-doc     #636      +/-   ##
==============================================================
- Coverage                       93.57%   93.55%   -0.03%     
==============================================================
  Files                             175      176       +1     
  Lines                          106770   106851      +81     
==============================================================
+ Hits                            99912    99963      +51     
- Misses                           6858     6888      +30     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nh13
nh13 force-pushed the nh/fix-index-threshold-doc branch from 4d70bdd to b27997d Compare July 23, 2026 23:05
@nh13
nh13 force-pushed the nh/feat-index-threshold-never branch from f40466c to c69569b Compare July 23, 2026 23:05
@nh13
nh13 temporarily deployed to github-actions July 23, 2026 23:06 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/fix-index-threshold-doc branch from b27997d to dc5e8ba Compare July 24, 2026 00:39
…ble requests

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
corrected the help text, which had claimed `0` disabled the index when it
in fact enables it for every group — but correcting the docs removed a
documented capability rather than delivering it.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs. Every existing integer invocation keeps working unchanged, `0`
included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the paired strategy indexes at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving all 41 existing integer call sites
untouched.
@nh13
nh13 force-pushed the nh/feat-index-threshold-never branch from c69569b to b916451 Compare July 24, 2026 00:40
@nh13
nh13 temporarily deployed to github-actions July 24, 2026 00:40 — with GitHub Actions Inactive
@nh13

nh13 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13
nh13 merged commit 1bbb9fa into nh/fix-index-threshold-doc Jul 24, 2026
14 checks passed
@nh13
nh13 deleted the nh/feat-index-threshold-never branch July 24, 2026 16:30
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 25, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the
adjacency strategy silently ignores the index at any edit distance other
than 1, while the edit and paired strategies index at all of them. `Strategy::can_use_index`
now states it, and the check runs against the EFFECTIVE strategy and edits
so `--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

That also settles what `Strategy::can_use_index` reports for `Edit`: it
indexes, and at every edit distance. `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so unlike adjacency
there is no one-mismatch gate. Three places said otherwise — the module doc,
the `Index threshold:` startup line, and the index/scan parity test's stated
reason for sweeping only one mismatch. All three are corrected, and that
parity test now sweeps one through three mismatches, where it previously
would have compared the scan against itself had the claim been true.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 26, 2026
…ble requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the edit
and adjacency strategies silently ignore the index at any edit distance other
than 1, while paired indexes at all of them. `Strategy::can_use_index` now
states it, and the check runs against the EFFECTIVE strategy and edits so
`--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

What `Strategy::can_use_index` reports for `Edit` is `edits == 1`, the same as
adjacency. It is tempting to say otherwise: `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so the index is
*capable* at any distance — a new test pins that, brute-force, across five UMI
lengths and four distances. But `assign` never *reaches* it elsewhere: #645
gated the defer/index branch on one mismatch deliberately, because
`EDIT_INDEX_THRESHOLD`'s crossover was measured there and nowhere else. Past
it the index loses — the partitions get too short to be selective (4 bases at
k=1, 2 at k=2 for an 8-base UMI) while single linkage collapses the group into
one component, making the set-merge's early-exiting scan cheaper rather than
dearer. Widening the gate is a benchmarking question;
`benches/umi_assigner_threshold.rs` sweeps one mismatch only.

So both `edits == 1` gates now read from the assigner that enforces them —
`SimpleErrorUmiAssigner::indexes_at_edit_distance` and its adjacency twin —
rather than being restated in `can_use_index`, in the `Index threshold:`
startup line, and in the parity test's rationale. Restating them is how they
came to disagree in the first place, and the parity test is back to sweeping
one mismatch: at any other distance both sides run the identical set-merge, so
sweeping wider compared the scan against itself.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.
nh13 added a commit that referenced this pull request Jul 26, 2026
…ble requests (#632)

* docs(group): pin --index-threshold's semantics behind a named gate

The `--index-threshold` help was silent about a second gate.
`build_adjacency_graph_bitenc` requires `self.max_mismatches == 1`, so the
Adjacency strategy ignores `--index-threshold` entirely at any other `--edits`
and falls back to the O(u^2) scan. The Paired strategy has no such restriction:
its generic `build_adjacency_graph` indexes at every edit distance, N-gram for
k=1 and BK-tree for k>1.

Rather than only rewording the help, extract the gate into
`AdjacencyUmiAssigner::uses_index` and `PairedUmiAssigner::uses_index` and call
the former from `build_adjacency_graph_bitenc`. The condition now has a name, a
doc comment, and three rstest tables covering the threshold boundary, `--edits`
sensitivity, and the paired strategy's wider coverage -- so the help is checked
against behaviour instead of restating it.

The help also still read as though the threshold were a switch. It is a
minimum: the gate is `distinct >= threshold`, so `0` indexes every position
group rather than disabling the index, and no small value turns the index off --
that takes a threshold larger than any group. Anyone reaching for `0` to force a
linear scan, to isolate the index in a profile or to sidestep a suspected index
bug, gets maximum indexing instead. The help now says so outright.

Behaviour is unchanged; this is documentation plus the extraction needed to test
it.

* feat(group): accept --index-threshold always|never, reject unsatisfiable requests (#636)

`--index-threshold` was a bare `usize` meaning "index once a position
group holds at least this many distinct UMIs". That left no way to turn
the index off: suppressing it needed a value larger than any group, i.e.
literally `--index-threshold 18446744073709551615`. The previous commit
spelled out that `0` enables the index for every group rather than
disabling it — but saying so plainly only made the missing capability
more obvious.

Make the option a keyword-or-number, matching `--parallel-group-min-templates`
one screen away in the same file:

    --index-threshold always     index every position group
    --index-threshold never      always scan all UMI pairs
    --index-threshold <N>        index groups of N or more (default 100)

`never` is the new capability. `always` is sugar for `0`, which already
meant this but read as its opposite — the trap that produced the wrong
docs in the first place. Every existing integer invocation keeps working
unchanged, `0` included, so no command line silently changes meaning.

Keeping this in one option rather than adding a `--no-index` flag makes
the contradictory state unrepresentable: two knobs controlling one
behaviour would need a precedence rule for `--no-index --index-threshold 50`.

`always` asserts that indexing will happen, so a configuration that can
never index is now a command-line error rather than a flag that is quietly
ignored:

    $ fgumi group --strategy identity --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    identity: the identity strategy never uses the UMI index. ...

    $ fgumi group --strategy adjacency --edits 2 --index-threshold always
    Error: --index-threshold always cannot be honoured with --strategy
    adjacency --edits 2: the adjacency strategy only indexes at --edits 1. ...

That second case surfaces an asymmetry the help had left implicit: the edit
and adjacency strategies silently ignore the index at any edit distance other
than 1, while paired indexes at all of them. `Strategy::can_use_index` now
states it, and the check runs against the EFFECTIVE strategy and edits so
`--no-umi` (which forces identity) is caught too.

A bare integer is deliberately not checked. It is a tuning knob allowed to
end up inert — otherwise the default `100` could not coexist with
`--strategy identity` — and that includes `0`, even though `0` admits every
group exactly as `always` does.

`Edit` grew an index of its own in #645, gated on its own measured crossover
(`EDIT_INDEX_THRESHOLD`, 200) rather than the shared default.
`IndexThreshold::floored_at` expresses that floor: a numeric threshold is
raised to the crossover, while the keywords pass through untouched, so
`always` still means every group — the escape hatch that isolates the index
in a profile — and `never` still means none. Every existing numeric
invocation keeps the `max(flag, 200)` behaviour it had.

What `Strategy::can_use_index` reports for `Edit` is `edits == 1`, the same as
adjacency. It is tempting to say otherwise: `components_via_index` hands
`max_mismatches` straight to `NgramIndex::new`, which partitions each UMI into
`max_mismatches + 1` pieces and pigeonholes over them, so the index is
*capable* at any distance — a new test pins that, brute-force, across five UMI
lengths and four distances. But `assign` never *reaches* it elsewhere: #645
gated the defer/index branch on one mismatch deliberately, because
`EDIT_INDEX_THRESHOLD`'s crossover was measured there and nowhere else. Past
it the index loses — the partitions get too short to be selective (4 bases at
k=1, 2 at k=2 for an 8-base UMI) while single linkage collapses the group into
one component, making the set-merge's early-exiting scan cheaper rather than
dearer. Widening the gate is a benchmarking question;
`benches/umi_assigner_threshold.rs` sweeps one mismatch only.

So both `edits == 1` gates now read from the assigner that enforces them —
`SimpleErrorUmiAssigner::indexes_at_edit_distance` and its adjacency twin —
rather than being restated in `can_use_index`, in the `Index threshold:`
startup line, and in the parity test's rationale. Restating them is how they
came to disagree in the first place, and the parity test is back to sweeping
one mismatch: at any other distance both sides run the identical set-merge, so
sweeping wider compared the scan against itself.

`dedup` carries its own copy of the option, so the validation lives in
`commands::common` and both call it. Assigner constructors take
`T: Into<IndexThreshold>`, leaving every existing integer call site
untouched.

This branch was previously deployed

1 inactive deployment
github-actions — b9164513 Deployed Jul 24, 2026 by nh13 via coverage #3006
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