Skip to content

Seeding by tree - #626

Open
jeromekelleher wants to merge 10 commits into
mainfrom
seeding-by-tree
Open

Seeding by tree#626
jeromekelleher wants to merge 10 commits into
mainfrom
seeding-by-tree

Conversation

@jeromekelleher

Copy link
Copy Markdown
Member

Alternative to #625 - stacked on for simplicity

Each include_samples entry may now be a (strain, match_date) tuple in
addition to a bare strain ID. A non-None match_date matches the seed in
on that date instead of its actual date; it may be before the actual
date, in which case the seed node retains its actual date via a negative
("in the future") node time relative to the match-day time-zero. This
lets widely-diverged seeds (e.g. Omicron BA.1/BA.2/BA.3) be matched in on
the same early day so a rudimentary ancestry tree forms before other
tree building, instead of matching to derived samples with many
reversions.

Also detach future (negative-time) nodes when setting up match_tsinfer,
so that other samples cannot copy from a node that lies in the future
relative to the current time-zero.
A seed strain in include_samples that is not present in the dataset now
raises a ValueError up-front in extend() instead of being silently
warned-and-skipped, so typos and bad configs fail fast. The no-alignment
corner case is handled the same as for any other sample (no seed-specific
path).

detach_future_nodes now fully detaches negative-time nodes: in addition
to removing incident edges, it drops mutations over those nodes and
clears their NODE_IS_SAMPLE flag so the matcher cannot treat them as
ancestors, recomputing mutation parents afterwards. Node IDs and times
are preserved.

Rewrite the detach_future_nodes tests with hand-drawn topologies
(including recombination) following the conventions in test_tree_ops.py.
include_samples changes from a list of strains (optionally with a
match_date) to a list of groups, each a list of strain IDs inserted into
the ARG together as a single local tree.

Rather than matching each seed individually, infer a tree over the
group's haplotypes, match the haplotype of the group's inferred ancestor
against the ARG with recombination disallowed, and attach the whole group
at that single placement. Individual seeds from a major saltation are so
far diverged from the contemporaneous ARG that matching them one at a
time gives either spurious recombination or a match to some derived
sample with a pile of reversions; the inferred ancestor is much closer.

A group is inserted on the minimum date over its members; later-dated
members keep their real dates and so get negative ("future") node times,
using the same mechanism the old match_date override relied on.

Seeds no longer go through the MatchDb. Their matches are constructed
directly by forcing them down the ancestor's path, which removes the
hmm_match.cost = 0.5 hack and the risk of a group being split apart by
add_matching_results' (path, immediate_reversions) grouping key.

Main changes:

- tree_ops.infer_binary_topology(ts) now returns the (pi, tau) oriented
  forest instead of mutating a table collection, and returns None for
  fewer than two samples. tree_ops.infer_binary(ts, topology=None)
  accepts a pre-inferred topology, so a group's topology can be inferred
  once against the reference and reused when its mutations are re-mapped
  against the matched path.

- New inference helpers: node_haplotypes, reference_haplotype,
  path_haplotype, flat_group_ts and force_match.

- normalise_include_samples is replaced by check_include_samples and
  seed_group_dates.

- add_matching_results is split into the MatchDb query/grouping and
  add_sample_groups, which add_seed_groups also uses.

- create_mask_table is now called unconditionally in _extend, since a day
  can consist solely of seeds and the retrospective query must not run
  against a stale (or absent) used_samples table.

Backwards compatibility with the old include_samples format is
deliberately not preserved.
create_mask_table records the samples already in the ARG so that the
retrospective query skips them, but it was only called when there were
samples to match. On a day with no new samples the query therefore ran
against the previous day's table, and could add a second copy of a
sample that had just been inserted. The mask table is now rebuilt
unconditionally in _extend.

Update the retro group tests, which were pinning the old behaviour: the
2020-02-15 run has no samples of its own, so ERR4204459 and ERR4206593
were being re-added despite already being samples in the 2020-02-13 base
ARG. Assert that samples_strain has no duplicates so this can't regress
silently.
@codecov-commenter

codecov-commenter commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.58%. Comparing base (3c342d8) to head (951c46f).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #626      +/-   ##
==========================================
+ Coverage   88.13%   88.58%   +0.45%     
==========================================
  Files          12       12              
  Lines        4163     4293     +130     
  Branches      596      615      +19     
==========================================
+ Hits         3669     3803     +134     
+ Misses        363      361       -2     
+ Partials      131      129       -2     
Flag Coverage Δ
python-tests 88.58% <100.00%> (+0.45%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

The option is a list of seed groups, not a list of samples, so name it for
what it is. Pure rename: check_include_samples becomes check_seed_groups and
the error messages follow, but the behaviour is unchanged.

Since the CLI splats [extend_parameters] straight into extend(), this renames
the TOML config key too. An old config now fails with a TypeError from
extend(), which is the same way any other unknown parameter is reported.
A seed group was represented as a bare tuple of strain IDs, with its date and
per-day working state carried alongside in separate maps and lists. Give it a
class instead, analogous to Sample: strains and date are its specification,
and samples, topology and root are the state filled in on the day it is
inserted.

check_seed_groups now returns SeedGroups, absorbing seed_group_dates, which
is removed. The group hash moves to a sample_group_id function shared with
SampleGroup, so the two provably agree on the group_id written to node
metadata.

SeedGroup.sample_group() builds the SampleGroup used to insert the group,
which will let add_seed_groups construct it with all fields known rather than
patching path and immediate_reversions in afterwards.
Seeds were preprocessed in the same loop as ordinary samples and then
diverted into a dict, with a seed_date map deciding which strains the day's
metadata query should suppress and which to inject. Every strain in a seed
group is now simply excluded from the standard pipeline on every date, and
add_seed_groups preprocesses its own samples. This is the same set of
samples by construction: a seed never reached the samples list before
either.

_extend now mentions seeds twice: one set-difference and one call. The
per-day state that was carried in parallel lists lives on the SeedGroup, so
the pending list of (group, root_sample) tuples goes away.

New helpers, both reusing what was there:

- prepare_samples wraps preprocess with the metadata decoration and the
  no-alignment drop, and is used by both pipelines.
- infer_seed_group_root is the group ancestor inference lifted out of
  add_seed_groups, which makes it testable on its own.

Seed samples are no longer merged back into the day's samples, so they no
longer appear in the daily samples_processed statistics. A forced match is
not an HMM match, and since force_match replaced the old cost = 0.5 sentinel
a seed matching its parent exactly was being counted as an exact match.
Construct SampleGroup by keyword in add_matching_results, so that topology no
longer has to be the trailing field.

Let tmp_dataset take a date per sample, so that behaviour depending on
samples having different dates can be tested on the 30bp genome rather than
through the 21-day fixture.

Add unit tests for infer_seed_group_root, which is now separable enough to
test directly.
reference.fasta.fai is generated on demand when the bundled reference is
read, and vim swap files turn up next to whatever is being edited. Both were
showing up as untracked and are easy to commit by accident.
The group's local tree was built by a round trip: infer a topology against
the reference, carry it on the group, synthesise a per-sample hmm_match
against the matched path so that match_path_ts could rebuild the same tree
from HMM mutations, and reuse the stored topology to keep the two inferences
consistent. None of that is needed.

The stored hmm_match is informational, so every member of a seed group now
reports the match that placed the group. Once the tree is no longer derived
from hmm_match it can be built straight from the samples' haplotypes, and
each inference is a single infer_binary call:

- infer_binary_topology and infer_binary go back to their previous form; the
  only remaining tree_ops change on this branch is detach_future_nodes.
- flat_group_ts takes samples rather than bare haplotypes, and optionally a
  group_id, in which case the sample nodes carry the metadata attach_tree
  needs. It now writes each site's ancestral state from the supplied
  ancestral haplotype rather than from the ARG reference, which is a no-op
  for the reference-rooted call and is what makes it usable for the other.
- SampleGroup.topology becomes SampleGroup.flat_ts, the star tree to infer
  from. MatchDb groups leave it None and build it from their HMM matches.
- infer_seed_group_root returns just the ancestor's haplotype.
- force_match and path_haplotype are gone. The parent haplotype comes from
  node_haplotypes, since a seed group attaches to a single node.

The group's tree is still inferred twice, once against the reference to find
the ancestor to match and once against the matched node's haplotype to build
the tree that is attached. That second rooting is not optional: attach_tree
hangs the group's root children off the matched node, so a tree built
against the reference silently gives the seeds the parent's allele wherever
the parent differs from the reference and the group does not.
TestSeedGroupAttachment pins this down; it fails if the tree is built
against the reference.

Also drop the unreachable "haplotype is None" handling: preprocess always
assigns a haplotype, and Dataset.alignment and Dataset.metadata share one
sample_id_map, so a strain that passes check_seed_groups has an alignment.
@jeromekelleher

Copy link
Copy Markdown
Member Author

This seems to be working, but hasn't been tested over the long term, so holding off on merging.

One minor TODO is to fix the debug code to be able to handle nodes with negative times. Currently the ArgInfo class crashes on inputs with future nodes.

@jeromekelleher
jeromekelleher marked this pull request as ready for review July 30, 2026 13:03
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.

2 participants