Skip to content

feat(cli): add -st/--stratification manifest loading and validation (#234) - #244

Merged
TimD1 merged 3 commits into
devfrom
234_td_stratify-manifest
Aug 11, 2026
Merged

feat(cli): add -st/--stratification manifest loading and validation (#234)#244
TimD1 merged 3 commits into
devfrom
234_td_stratify-manifest

Conversation

@TimD1-bot

@TimD1-bot TimD1-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Note

Authorship: the content below was drafted by Claude Opus 5 (an AI coding agent) and
filed via gh under @TimD1-bot, a bot account operated by @TimD1. It reflects the
agent's analysis, not a statement authored by @TimD1.

Resolves #234. Part of #47.

Change

-st, --stratification <STRING> takes one hap.py-compatible manifest TSV:

ancestry_AFR	ancestry/GRCh38_ancestry_AFR.bed.gz
refseq_cds	FunctionalRegions/GRCh38_refseq_cds.bed.gz
  • Two tab-separated columns, name then BED path; further columns are ignored.
  • Relative BED paths resolve against the manifest's own directory, not the working
    directory, so the GIAB manifests work unmodified.
  • Blank lines and lines beginning with # are skipped.
  • Load order is manifest order, which is also the output row order the strata will take.

Region sets are parsed, merged, and resident, and nothing evaluates against them yet — the
only output they reach is the parameters.tsv provenance rows below. New Globals members
(src/globals.h:33):

std::string strat_tsv_fn;              ///< Stratification manifest TSV filename (empty if none)
std::vector<std::string> strat_names;  ///< Stratum names, in manifest order
std::vector<bedData> strata;           ///< Parsed stratum regions, parallel to strat_names
int nstrata = 0;                       ///< Number of strata (0 if --stratification unused)

Each region set is normalized at load (src/bed.cpp:407), so it is sorted and merged before being
validated. These are third-party files we neither author nor control, and contains() locates a
variant with two binary searches, so an unsorted or overlapping set would return wrong answers
rather than merely being untidy. Normalizing does not exempt a stratum BED from validation: a
malformation sorting and merging cannot repair — a flipped or zero-length interval — is as fatal
here as in an evaluation BED.

parameters.tsv

The manifest path and the number of strata loaded from it are recorded as stratification and
nstrata, so a run's provenance says whether the region sets actually arrived — a manifest that
parsed to nothing warns at load, but only nstrata 0 preserves that after the fact.

Auditing every CLI-settable parameter against the writer turned up two pre-existing omissions of
the same shape, now also recorded: -sc/--max-supercluster-size (added but never wired in) and
-v/--verbosity. All 22 keys that were already there still map to live Globals members.

write_params() now emits one row per fprintf instead of building all 26 from a single call
whose format string was split across seven fragments and whose arguments ran across five lines —
which is how a key could exist with no value passed for it, and how the two omissions above went
unnoticed. WriteParams.WritesEveryKeyOnceInOrder pins the full key list in order, since one call
per row makes a dropped or duplicated row a one-line edit away.

Validation

All ERROR, in load_strata() (src/bed.cpp:357):

Condition Message names
Manifest unreadable the manifest path
A line has fewer than 2 usable fields the manifest and the line number
Duplicate stratum name the name and both the colliding line and the manifest
Stratum named * the manifest, the line, and that the name is reserved
Stratum BED unreadable both the manifest-relative and the resolved absolute path

Line numbers count skipped comment and blank lines, so a reported line is the line in the file.
An empty second field (a trailing tab) counts as a missing BED path rather than parsing as one.

A manifest that parses to zero strata is a WARN, not an error; nstrata stays 0, which is the
state every future consumer gates on, so the run proceeds as though -st were absent.

The zero-overlap warning

The realistic failure mode is an assembly or contig-naming mismatch — chr20-prefixed region
sets against 20-named reference contigs, or GRCh37 sets against a GRCh38 run — producing rows of
zeroes that read as genuine results. check_strata_contigs() (src/bed.cpp:438) runs from
main() right after the reference FASTA is read, before any evaluation, and WARNs by name for
each region set whose contigs do not intersect the reference's at all.

If every set has zero overlap, that pattern is diagnostic rather than incidental, so it becomes
one message naming the likely cause instead of one message per set.

A region set that shares a contig but contains no evaluated variant is not warned about — that
is a legitimate result, and it will emit a full zero row rather than being omitted, so a consumer
joining on stratum name never sees a missing key.

Testing

Unit (tests/unit/src/test_bed.cpp, 19 new cases): relative-path resolution against the manifest
directory (the path is unresolvable from the working directory, so only manifest-relative
resolution can find it); absolute paths; comment and blank lines; extra columns ignored; manifest
order preserved; normalize-at-load, including the sort warning and the flipped interval that is
still rejected; each of the five ERRORs; the zero-stratum WARN; the zero-overlap WARN naming
the offending set; and the all-zero case naming the cause instead.

Unit (tests/unit/src/test_print.cpp, 4 new cases): the manifest path and stratum count are
recorded; both are empty/zero when -st is unused; -sc and -v are recorded; and the full key
list is pinned in order.

Unit (tests/unit/src/test_globals.cpp, 6 new cases): both flag forms populate strat_tsv_fn,
strat_names, strata and nstrata; the flag's absence leaves all four unset; a missing value
and an unreadable manifest each exit 1; an empty manifest warns and leaves nstrata 0.
PrintUsage.ListsDocumentedFlags now requires -st, --stratification to be advertised.

Integration: test_stratification_manifest-loads-without-bed runs the swallowed_snps fixtures
with -st and no -b, pinning counts identical to the default run and requiring no contig-mismatch
warning; since pytest-workflow runs each case in its own temporary directory and the new
strata_synthetic.tsv fixture names its BEDs relatively, this also exercises manifest-relative
resolution end to end. test_stratification_missing-manifest-exits-nonzero requires exit 1 with
Failed to open stratification manifest.

write_tmp_bed()'s uncompressed branch was extracted as write_tmp_text() so a manifest fixture
can be written without a BED-specific writer.

Rebased onto dev now that the rest of the chain (#241, #242, #243) has merged, which brought in
the bedData::merge()normalize() rename and the always-run check() from #241; the loader
and its tests were updated to that contract.

Three commits, intended to be squashed: the loader and flag, the parameters.tsv omissions, and
the per-row writer plus nstrata.

Verified locally on a clean rebuild of both src/ and tests/unit/build/: 810/810 unit tests pass
(781 on dev, plus 29 new), and 132/132 pytest cases pass (124 before, plus the eight assertion
nodes of the two new integration cases). A real run's parameters.tsv was inspected directly to
confirm both new rows are written with the values the flag was given. -Wall -Wextra produces no
warnings, and doxygen src/Doxyfile reports none.

Base automatically changed from 233_td_sc-cli to dev August 11, 2026 17:07
…234)

Part of #47. The flag and the loader only: region sets are parsed, merged, and
resident in Globals, and nothing reads them yet, so no output changes but --help.

-st takes one hap.py-compatible manifest TSV whose two tab-separated columns name
a stratum and its BED file, with further columns ignored. Relative BED paths
resolve against the manifest's own directory rather than the working directory, so
the GIAB manifests work unmodified. Blank and '#'-prefixed lines are skipped, and
load order is manifest order, which is also the output row order the strata will
take. Each region set is normalized at load, since a third-party set we neither
author nor control would otherwise have to be sorted and non-overlapping for
contains() to answer correctly; normalizing does not exempt it from validation, so a
malformation sorting and merging cannot repair is still fatal.

Five conditions are fatal: an unreadable manifest, a line naming fewer than two
fields, a repeated stratum name (which would make an output row ambiguous), a
stratum named '*' (reserved for the all-regions row), and an unreadable stratum
BED, which names both the manifest-relative and the resolved absolute path so that
a path resolved somewhere unintended is distinguishable from a missing file. A
manifest that parses to zero strata warns instead, and the run proceeds as though
-st were absent.

The realistic failure mode is an assembly or contig-naming mismatch, which would
otherwise emit rows of zeroes that read as genuine results, so each region set's
contigs are compared against the reference FASTA's once it is loaded. A set
sharing no contig is named; every set failing at once is diagnostic rather than
incidental, so that case names the likely cause instead.
@TimD1-bot
TimD1-bot force-pushed the 234_td_stratify-manifest branch from 77a65be to 11a5083 Compare August 11, 2026 17:41
Comment thread src/globals.cpp
/**************************************************************************************************/
} else if (std::string(argv[i]) == "-p" ||
} else if (std::string(argv[i]) == "-st" ||
std::string(argv[i]) == "--stratification") {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we added a new CLI parameter. Shouldn't this be added to parameters.tsv? Are there other parameters you forgot to add/delete?

@TimD1-bot TimD1-bot removed their assignment Aug 11, 2026
TimD1 added 2 commits August 11, 2026 14:01
…n parameters.tsv

The new -st/--stratification manifest path was not written to parameters.tsv.
Auditing every CLI-settable parameter against the writer surfaced two
pre-existing omissions with the same shape: -sc/--max-supercluster-size
(added in 41cc0a0, never wired in) and -v/--verbosity. No stale rows: all
22 existing keys still map to live Globals members.
… nstrata

The writer emitted all 26 rows from a single fprintf, whose format string was
split across seven fragments and whose arguments ran across five lines, so a key
and its value were never adjacent and a row could be added to one without the
other. Each row is now its own call, with its key, conversion, and value on one
line.

Two changes to the rows themselves. The stratification manifest key becomes
'stratification', matching the --stratification flag that sets it. And nstrata is
now recorded: the manifest path alone does not say how many region sets were
loaded from it, which is precisely what a reader needs to tell a fully-loaded run
from one whose manifest parsed to nothing and warned.

WriteParams.WritesEveryKeyOnceInOrder pins the whole key list in order, since one
call per row makes a dropped or duplicated row a one-line edit away.
@TimD1
TimD1 merged commit f3a43e4 into dev Aug 11, 2026
1 check passed
@TimD1
TimD1 deleted the 234_td_stratify-manifest branch August 11, 2026 18:31
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