Skip to content

Merge dev changes - #39

Merged
DonFreed merged 65 commits into
mainfrom
dev
Oct 2, 2026
Merged

DonFreed merged 65 commits into
mainfrom
dev

Conversation

@DonFreed

@DonFreed DonFreed commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

No description provided.

DonFreed and others added 30 commits July 24, 2026 09:30
Correctness fixes in the pipeline AST:
- Command.__eq__ no longer mixes in self.proc (runtime state), so a
  command's identity is stable once its job runs. exec_kwargs (env, cwd)
  now participates in __eq__/__hash__ (canonicalised for
  order-independence) and renders into str() as a conventional bash
  prefix, so two jobs differing only in env/cwd no longer collide.
- ProcSub.__eq__ requires the same subclass: <(x) != >(x).
- Pipeline file/stream conflicts raise ValueError instead of calling
  sys.exit(), and the guards test `is not None` so fd 0 is not skipped.

Fix a deadlock when a proc-sub FIFO is never opened: if the outer
command failed to spawn or exited early, the background task's blocking
open() never returned and Context.cleanup() hung the whole run,
including the Ctrl-C path. cleanup() now unblocks still-unopened FIFOs
(holding both ends non-blocking), lets the unblocked task return without
spawning, and re-raises task exceptions only after all resources are
released.

Replace the injected `perl -MFcntl` pipe-buffer command with a native
Pipeline(pipe_size=...) option. The injected command received the
inherited stdout rather than the internal pipe it was meant to grow, so
F_SETPIPE_SZ hit a non-pipe fd and the pipe kept its default 64 KiB
size. The pipes are created with os.pipe() here, so they are now sized
directly (best-effort; logged fallback on non-Linux or over
fs.pipe-max-size) before the children inherit them. skip_pipe existed
only for the injection and is removed; the bwa alignment pipelines in
command_strings.py request the intended 256 MiB via pipe_size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the undocumented send(None)/send(job) generator protocol between
the DAG, scheduler, and executors with an explicit, typed interface:
DAG.mark_finished(job) returns the jobs a completion unblocked, and
BaseScheduler (new ABC) exposes start() / job_finished(job) with the
contract documented and covered by a conformance-test harness.

Make interrupt handling asyncio-correct and opt-in. The old handler ran
time.sleep(10) and asyncio.run(context.cleanup()) inside a signal
handler while the executor's event loop was running -- asyncio.run
raises RuntimeError from within a running loop, and the blocking sleep
froze it. Handlers (SIGINT/SIGTERM) are now installed on the running
loop via loop.add_signal_handler only for the duration of a run (and
only on the main thread), the previous handlers are restored afterwards,
and shutdown is fully async: SIGTERM -> bounded grace period -> SIGKILL
-> awaited cleanup. BasePipeline opts in, preserving Ctrl-C behavior.

Executor correctness fixes:
- A job that fails to launch is recorded in jobs_with_errors and stops
  new launches, instead of being dropped silently; the catch is narrowed
  to OSError so genuine bugs still propagate.
- A later pipeline stage failing to spawn now terminates the stages that
  already started (e.g. `sleep 600 | no_such_cmd` no longer leaks sleep
  past the end of the run).
- A proc-sub inner command that fails to spawn fails its own job instead
  of aborting the whole run mid-flight; teardown paths log (rather than
  re-raise) launch errors surfaced from cleanup so sibling contexts are
  still released.
- A sub-command killed by SIGPIPE (downstream reader exited early) is no
  longer treated as a job failure, matching non-pipefail bash.

Scheduler/DAG validation:
- Jobs that can never be scheduled (threads over budget, or a managed
  resource request over capacity) raise DagExecutionError up front
  instead of stalling the run silently.
- Adding a second job with an identical pipeline raises instead of
  silently collapsing onto the first.
- DAG.add_job accepts any iterable of dependencies; a list or generator
  used to pass validation but was silently discarded, leaving the job
  immediately ready.

Jobs may now declare inputs/outputs metadata: declared inputs are
verified before a run and declared outputs after (StorageProvider seam,
local presence checks), and a failed or interrupted job's declared
outputs are deleted so a partial file cannot be mistaken for a good one.
DAG.skip_satisfied plus the has_all_outputs predicate can prune
already-satisfied jobs before a run, groundwork for a future resume
option. The long-dead Job.run() method is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- main() created the temp dir and only removed it after build_dag() and
  run() returned, so any raise in between (an infeasible DAG, a launch
  failure, a KeyboardInterrupt) leaked the directory. Wrap
  build/run/check in try/finally so the temp dir is always removed, and
  honor retain_tmpdir on failure too so a failed run can be inspected.

- check_execution raised a bare ValueError("Execution failed"); raise
  DagExecutionError naming the failed jobs instead, to aid post-mortems.
  The unexecuted-jobs check uses DagExecutionError as well.

- setup_logging set the level on the logger's parent, which resolved to
  the root logger -- so -v/--debug turned on DEBUG for every third-party
  library. Add logging.set_level(), which scopes the level to the
  sentieon_cli package logger; the module loggers are at NOTSET and
  inherit from it, and the root logger is left untouched.

- add_arguments mutated the shared class-level params specs (deleting
  "flags" and injecting inferred "type" entries), so registering a
  pipeline class on a second parser lost its short flags (e.g. -r, -t).
  Build a fresh kwargs dict per argument instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove engine capabilities that nothing in the CLI uses:

- DAG.skip_satisfied() and the has_all_outputs predicate; no pipeline
  offers a resume option, so the pre-run prune had no callers.
- The storage module (StorageProvider/LocalStorageProvider) and the
  executor's input/output staging built on it, along with
  LocalExecutor._remove_outputs(); pipelines do not declare per-job
  files, so the staging and failed-output removal were dead paths.
- Job.inputs/outputs/image; Job is back to (pipeline, name, threads,
  resources).

Drop the tests that covered the removed functionality and condense some
overly verbose code comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move logging handlers to the package logger so verbosity is enforced
per handler: the console follows -v/-d (default now INFO) while a new
run.log always records DEBUG, both with timestamps. A new --log_dir
argument (default: the output VCF with .vcf.gz replaced by _logs)
holds run.log, a command.txt invocation record, and a task_logs/
directory for upcoming per-process tool logs. Runs now finish with a
status/elapsed-time message, emitted from a finally so it also lands
on failure paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Job now requires a keyword-only task_name grouping jobs by pipeline
stage, and assigns each job a job_id ({name}-{n}) from per-name
counters that reset once per CLI invocation, so ids stay unique across
multi-DAG runs. Jobs report themselves by job_id in scheduler and
error messages. These fields feed the upcoming per-process task logs;
neither is part of job identity, which stays keyed on the shell
pipeline alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each job gets a JobLogSink on its execution Context, so every process
it spawns -- including process substitutions, which previously
bypassed stderr redirection entirely -- writes stderr to its own
task_logs/{task}/{job_id}.{stage}.log file, beginning with a header
recording the task, job, and command. Stdout is captured alongside
(headerless, sharing the process's stage index when it would
otherwise reach the terminal. Both streams of one process share a
stage index, allocated in spawn order.

On failure, the run log now reports the failing sub-command with its
exit code, pid, log path, and the last 20 lines of its log, plus a
pointer to the task-log directory; interrupted and launch-failed jobs
list their logs. Successful jobs prune logs that hold nothing but a
header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOF
)
Create the log directory only after validation: setup_logging is now
console-only and a new start_run_logs(), called after validate(),
derives and prepares the log directory. A typo'd output path again
fails fast instead of being silently created, a rerun rejected by
validation no longer destroys the previous run's logs, and a log path
colliding with a regular file exits cleanly instead of raising.

Key job-id counters on the sanitized name (sanitize moved to util.py)
so log file paths stay unique even for names that differ only in
unsafe characters.

Make -v/-q/-d independent flags with explicit precedence: -d -v no
longer downgrades DEBUG to INFO, and the new --quiet restores a
warnings-only console. Document the flags and the per-run log
directory in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spawn pipeline processes with RusagePopen, a subprocess.Popen subclass
whose _try_wait() reaps with os.wait4() and records the child's rusage,
replacing asyncio.create_subprocess_exec (which only provided spawn and
reap here: every stdio stream is already a real FD). Waits move to
async_wait() (Popen.wait in a worker thread), and the executor raises
the loop's default thread pool to 512 lazily-created workers so blocked
waits and proc-sub FIFO opens cannot starve each other. Context.cleanup()
gains a non-blocking poll() sweep so children nobody awaited are still
reaped, as asyncio's watcher used to do. Behavior parity otherwise:
negative returncodes on signal death, SIGPIPE tolerance, and the
signal/kill paths are unchanged. Metrics emission lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each reaped process now logs a DEBUG line to run.log with its kernel
resource usage (utime, stime, peak RSS, page faults, block I/O, context
switches), emitted whatever the job's outcome -- signal-killed children
included. Successful jobs extend the "Finished command" INFO line with
the job's summed user/sys CPU and the per-process peak RSS (a sum would
overstate concurrent stages that peak at different times). ru_maxrss is
normalized across platforms (bytes on macOS, KiB elsewhere), and the
DEBUG line renders it without an internal space (maxrss=33.6MiB) so the
key=value line stays space-tokenizable for grep/awk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_simple_command now asserts rusage is recorded at reap, and a new
signal-death test pins the RusagePopen._try_wait override: if a future
CPython changes that private method's shape, the override silently stops
being called and rusage stays None, so the test asserts a SIGTERM'd
child reports both its negative return code and its rusage. New
tests/unit/test_rusage_metrics.py drives a job through LocalExecutor
(the first unit-level file to do so) and checks the per-process "rusage
for" DEBUG record, the aggregated "Finished command" INFO line, and
_maxrss_bytes unit normalization on both platform branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes from a code review of the branch. First, an exception escaping
_drive now tears the children down (_shutdown) before propagating: it
previously unwound into asyncio.run's shutdown, which joins the pool
threads blocked in wait4 on children nobody signalled, hanging the CLI
until the children exited on their own. BaseException, so cancellation
and KeyboardInterrupt are covered; a teardown failure is logged rather
than allowed to mask the original error.

Second, the wait/FIFO-open thread pool size is a keyword parameter
instead of a hidden constant: None on AsyncExecutor (an executor that
spawns nothing leaves the loop's default pool alone) and 512 on
LocalExecutor, beside shutdown_grace_period. async_wait() returns
immediately for an already-reaped child instead of burning a pool
dispatch and a slot.

Also document a known limitation honestly: a reap via poll() -- or
send_signal/kill, which poll internally -- uses the base Popen machinery
and records no rusage. The executor waits every process it reports
metrics for, so those paths only collect processes whose rusage is never
read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combine the human pangenome with sample short-read fastq and aligned
long-read data for variant calling: k-mer counting across both read
sets, personalized diploid graph construction with vg, graph updates
with PGHapUpdateAlgo using the long-read alignments and LongReadSV
calls, re-alignment of extracted short reads to the personalized
pangenome with liftover, short-read dedup and metrics, and
PangenomeSV + DNAscope calling with LR readgroup attributes.

The dedup and metrics job builders move from SentieonPangenome to
BasePangenome so both pangenome pipelines share them; the emitted
dnascope-pangenome commands are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngenome

Aligned short-read BAM/CRAM input via `--sr_aln`, mutually exclusive
with fastq input. Aligned input is assumed deduplicated: no dedup or
metrics jobs run, read extraction and k-mer counting share a single
pass over the input (driver ReadWriter | pgutil extract | kmc, with the
long-read FASTA streams concatenated), and the lifted alignment is
written directly to the output. The calling stages rewrite the input
readgroups with `--replace_rg` to set LR:0.

Unaligned long-read (uBAM/uCRAM) input via `--lr_align_input` and
`--lr_input_ref`. Each input is realigned with minimap2 using the new
`minimap2_lr.model` bundle member; k-mer counting reads the original
input files in parallel with the realignment.

Also fix `cmd_samtools_fastq_minimap2` and `cmd_samtools_fastq_bwa` to
decode the input file with `input_ref` rather than the alignment target
reference, matching the flag's documented purpose. This changes
behavior for dnascope-longread and dnascope-hybrid runs where the two
references differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace `sort -k1,1 -k2,2n` with `bedtools sort -faidx` in the
LongReadSV BED generation so the regions sort correctly for references
with an unusual contig order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `--pangenome_contig_prefix` (default GRCh38#0#) and pass it to
PGHapUpdateAlgo, pgutil lift, and PangenomeSV so a non-default
pangenome reference name uses a consistent prefix throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reject BAM/CRAM inputs with no @rg header lines (previously the LR
attribute was silently never applied, or build_dag crashed), require
readgroup IDs to be unique across all inputs, and report malformed @rg
header lines with the offending file instead of a traceback.

check_kmc_patch now parses the k-mer count from the KMC output and
fails on a zero count, catching KMC builds that read nothing from
piped stdin while still exiting successfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In Sentieon 202503.04 the HybridStage1 `--hap_bam` output is unsorted
and `-` sends it to stdout. Send the hap BAM to stdout and pipe it to
`sentieon util sort`, and move the algo's fastq output to a named
fifo, `stage1_hap.fq`, read by bwa in the first-stage alignment. The
haplotype job runs with zero scheduler threads so it always executes
concurrently with the first-stage job reading the fifo.

Requires sentieon driver 202503.04 or later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two-DAG main() override performed its tmpdir cleanup inline after
the last check_execution, so a failing run skipped it and leaked the
directory. Wrap the body in try/finally, mirroring BasePipeline.main.

Found by qualification (DEFECT_sentieon_pangenome_tmpdir_leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the exit-status-only probe; the piped-KMC defect is being
addressed in the KMC build itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hardcoded sentieon-version matrix value with
${{ vars.SENTIEON_VERSION }} in all three smoke jobs, matching the
pattern already used by the Docker workflow. Each "Install sentieon"
step now fails with an explicit error when the variable is unset,
instead of a confusing tar failure on a 404 from S3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In addition to the rusage log lines, write two machine-readable TSV
files to the run's log directory so metrics can be loaded into
pandas/awk without parsing run.log:

- process_metrics.txt: one row per reaped process, straight from
  rusage, with a job_id column linking each process to its job and a
  stage column matching the task_logs file names. Rows are written for
  every reaped process whatever the job's outcome; the exit code is the
  raw wait status. A process reaped without rusage keeps its
  pid/exit_code and leaves the usage columns empty.

- job_metrics.txt: one row per job aggregated across its processes:
  status (ok/failed), wall clock, summed user/sys CPU, per-process peak
  RSS (matching the "Finished command" line's semantics), and process
  count. Jobs that never reach accounting (launch failure, interrupt)
  get no row.

Headers are (re)written in RunLogs.create_dirs(), so a rerun into the
same --log_dir replaces the previous run's rows; rows append as each
job finishes accounting, which also keeps partial data on failure and
lets the pangenome pipeline's two DAGs share one set of files.
Whitespace in the command field is flattened so rows stay rectangular,
and a metrics write failure degrades to a warning rather than failing
the job. JobLogSink gains a non-allocating stage_index_for() lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The metrics job received both the bwa and mm2 deduped alignments, but
the mm2 alignment is a re-alignment of a subset of the same reads, so
the extracted reads were double-counted in the collected metrics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add scripts/sad_lad_update.py to the dnascope-pangenome pipeline as a
final ad-update job. The joint pileup calls the bwa and mm2 alignments
together; the script sets each sample's FORMAT/AD to either FORMAT/SAD
or FORMAT/LAD -- whichever likelihood vector (SPL/LPL) better supports
the called genotype -- and derives FORMAT/DP and INFO/DP from that
choice. The previous values are kept as AD_ORIG/DP_ORIG and the choice
is recorded in FORMAT/ADSRC and FORMAT/ADRULE.

DNAModelApply (or the transfer concat under --skip_model_apply) now
writes an intermediate in tmp_dir and the ad-update job writes the
real output. With --gvcf the update runs on the gVCF ahead of
GVCFtyper, so the genotyped VCF at --output_vcf carries the updated
counts; gVCF hom-ref blocks pass through unchanged. No job is added
under --skip_small_variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DonFreed and others added 29 commits September 2, 2026 01:16
`DNAscopeHybridPipeline.call_variants` returned its 23 jobs as a
tuple for `build_dag` to wire by hand. `add_variant_calling` now
builds each job and inserts it with its dependencies as it goes,
using `DNAscopeStage`, `ModelApplyStage`, `TransferStage` and the
`driver_job` / `rm_job` helpers where they fit. Cleanup jobs are
simply not created under `--retain_tmpdir`.

The two DNAscope passes are now named `dnascope-1` and `dnascope-2`
(were `calling-1` and `call2`). Commands and dependency edges are
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`DNAscopeLRPipeline.lr_call_variants` returned a 27-field dataclass
of jobs that `build_dag` wired by hand. `add_small_variant_calling`
now composes the small-variant stages and inserts each job with its
dependencies as it goes: `DNAscopeStage` for the five calling passes,
`TransferApplyStage` for the diploid and unphased tails, and
`TransferStage` plus `ModelApplyStage` for the two haplotypes, whose
model-apply jobs wait on both haplotype transfers.

The passes are now named after the stage: `dnascope-diploid`,
`dnascope-hap1`/`hap2`, `dnascope-unphased`, `dnascope-haploid`, with
matching `model-apply-*` jobs, and the four annotation transfers carry
`diploid`/`hap1`/`hap2`/`unphased` tags in their job names. Commands
and dependency edges are unchanged; a new unit test pins the
non-obvious edges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`stages/alignment.py` holds the bwa and minimap2 alignment stages
(from BAM/CRAM input and from fastq), the pangenome bwa-plus-extract
stage, and a ReadWriter stage, together with the shared `aln_suffix`
and `find_unzip` helpers. Alignment jobs are always DAG roots, so
these stages expose `build()` for callers that insert the jobs
themselves as well as the usual `add_to()`.

The hybrid-pangenome pipeline's copy of the minimap2 realignment and
both pangenomes' bwa-extract builders are gone; the dnascope pipeline
builds its alignment cleanup jobs from the stage's `cleanup_paths`.
The long-read pipeline keeps its `lr_align_inputs`, `lr_align_fastq`
and `merge_input_files` methods as thin wrappers. Commands, job names
and dependency edges are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the repeated version-check loops with `require_versions` /
`versions_available`, and the repeated "argument is required" exits
with `BasePipeline.required` or the values already carried by the
stage context. Drop `BasePipeline.cnv_sex_args`, which lost its last
caller when CNV calling moved into a stage.

The long-read and hybrid tests no longer patch `check_version` in the
pipeline modules: they skip version checks, so the patches were
inert, and the modules no longer import the function. The pangenome
KMC test, which does exercise the check, patches it in `util` instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop `cmd_bedtools_merge`, `cmd_vg_giraffe`, `cmd_vg_pack`,
`cmd_sv_call`, `cmd_vg_surject` and `strip_ctg_prefix` from
`command_strings`, `Shard.bcftools_str` from `shard`, and
`get_read_length_aln` (with its regex and `shlex` import) from
`util`. None had a caller in the package, the tests, or the docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- `find_unzip` always warns; the per-caller log level is gone.
- The bwa alignment stages report `cleanup_paths` only when the
  alignment is an intermediate that duplicate marking consumes, as the
  field's documentation already said; final outputs get none, and the
  pipelines build the `rm-*-aln` job only when there is something to
  remove.
- `TransferApplyStage`'s DNAModelApply depends on the transfer's concat
  alone (the concat already depends on the raw-VCF producer), or on
  `upstream` when there is no transfer. The hybrid pipelines now use
  the composite too, and the long-read haplotype passes each run their
  own transfer-then-apply, so a haplotype's model-apply waits only on
  its own transfer.
- `build_transfer_jobs` moves into `stages/transfer.py`, its only
  caller, and the top-level `transfer.py` is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ShortReadPreprocessingStage` carries the duplicate-marking and
metrics policy that lived in `DNAscopePipeline.add_sr_preprocessing`;
`MosdepthStage` and `LongReadSVStage` replace the raw job construction
in the long-read pipeline's `mosdepth` and `call_svs`. The alignment,
mosdepth and LongReadSV version constants move next to the stages
they guard. The pipeline methods remain as thin wrappers that hold
the version-check policy, so the hybrid pipeline, which still
inherits them, is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`DNAscopeHybridPipeline` inherited from both `DNAscopePipeline` and
`DNAscopeLRPipeline` to reuse six methods. It now subclasses
`BasePipeline`, declares every attribute its arguments need, and
composes the short-read preprocessing, minimap2 realignment, mosdepth
and LongReadSV stages through its own small wrappers. The bwt_max_mem
computation becomes `util.set_bwt_max_mem`, shared with the dnascope
pipeline, and the long-read `call_svs` loses the `replace_rg`
parameter only the hybrid used.

Two latent defects go away with the inheritance: `--lr_fastq_taglist`
now reaches the minimap2 command (the inherited method read the
long-read pipeline's `fastq_taglist`), and a hybrid built without the
argument parser gets `minimap2_args="-Y"`, its documented default,
instead of the long-read pipeline's `-YL`. A new test asserts, for
every pipeline, that each argument is declared in `__init__` with its
argparse default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dnascope and long-read pipelines renamed their input attributes
during validation (`r1_fastq` to `sr_r1_fastq`, `sample_input` to
`lr_aln`, and so on) so that the hybrid pipeline could inherit from
both without the names colliding. The hybrid no longer inherits from
either, so each pipeline now reads its own argument names throughout.

Two validation tests that set the renamed attributes before
`validate()` ran, and so exercised the wrong branch, now assert the
messages they were written for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The version constants no longer all live in the pipeline modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hybrid pipeline's long-read input may be ONT, whose very long
reads can carry more than 65535 CIGAR operations, which the BAM format
cannot store; `-L` moves such CIGAR strings into the `CG` tag. The
hidden `--minimap2_args` default therefore becomes `-YL`, matching the
long-read pipeline and the minimap2 stages, instead of `-Y`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`SentieonPangenome` overrode `main()` to parse the reference index and
population VCF before validation, call `build_first_dag()` instead of
`build_dag()`, and gate the second, sex-aware DAG itself. It now uses
the base flow like every other pipeline: `validate()` parses the index,
shards and VCF contigs (as the hybrid pangenome already did), the main
DAG builder is `build_dag()`, and `build_second_dag()` returns `None`
when no sex-aware caller is enabled and calls `get_sex` itself, as the
hybrid DNAscope pipeline does. The reference index is validated once
rather than twice.

A new test asserts that no pipeline overrides `main()`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four pipelines carried an identical `transfer_config()` helper
building a `TransferConfig` from `pop_vcf`, `shards`,
`pop_vcf_contigs` and `fai_data`. A `TransferInputs` protocol names
those four attributes and `TransferConfig.from_pipeline` reads them
structurally, raising a clear error when there is no population VCF.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`LongReadSVStage` gains an explicit `output` (the derived
`.sv.vcf.gz` path stays the default) and the LongReadSV algo options
(`min_map_qual`, `min_sv_size`, `min_dp`, `min_af`), which is what the
hybrid pangenome's graph-update SV call needed. Its hand-built
`build_longreadsv_job` is gone; the job keeps its `longreadsv` name
under the `pangenome-update` task and its command is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The smoke reference is not a recognised genome build, so no PAR BED
resolves and CNV calling now fails validation up front. The smoke
runs exercise alignment, duplicate marking and variant calling, not
CNVscope, so they skip it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop DEFAULT_UTIL_SORT_ARGS and the matching "--cram_write_options
version=3.0,compressor=rans" defaults from the alignment stages, the
command builders, and the hidden --util_sort_args argument of the
dnascope, dnascope-longread and dnascope-hybrid pipelines, and stop
passing the same option to Dedup. The default pipeline now writes CRAM
with the tools' own defaults; the intermediate BAMs written before
duplicate marking keep --bam_compression 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CNVscope can write its per-probe debugging output with --dump_probes.
Add the argument to the CNVscope algo and CNVscopeStage, and have both
sex-aware callers supply it: <output>.cnv.probes in dnascope-hybrid and
<output>_cnv.probes in dnascope-pangenome, next to each CNV VCF.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`sentieon pgutil gfa2fa` in driver release 202503.04 handles only GRCh38
pangenomes; on a CHM13 graph it fails with "Failed to parse ref
CHM13#0#chr1 in S line". Have hybrid-pangenome choose the tool for the
gfa2fa job in validate(): when the reference is not GRCh38 (per
detect_reference_build) and the driver is 202503.04 or older, extract the
graph's reference paths with `vg paths -x <gfa> -Q <pangenome_ref_name>
-F`, which is slower but works with every release; otherwise keep pgutil.
An unknown driver version (--skip_version_check, or a failed probe) also
takes the vg path for a non-GRCh38 reference, with the reason logged at
INFO. The job keeps its name, threads, task and dependencies.

Add util.executable_version, which returns a tool's parsed version, and
build check_version on it; unparseable --version output now logs an
error instead of raising.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Extract the optional pangenome callers into reusable stages so both
pangenome pipelines can compose the same building blocks: `SegdupStage`
(`stages/segdup.py`), `ExpansionHunterStage` (`stages/expansion.py`),
`T1KStage` (`stages/t1k.py`, with the default HLA/KIR loci) and the
composite `PangenomeCNVStage` in `stages/cnv.py` (CNVscope and
CNVModelApply, indel2cnv.py on the PangenomeSV output, and combine_cnv.py
over the two).

`cmd_segdup_caller` gains optional `lr_alignments` / `lr_bundle` arguments
for long-read calling; `cmd_pyexec_indel2cnv` and `cmd_pyexec_combine_cnv`
move the two script invocations into `command_strings.py`. `util` gains
`caller_sex_arg`, the shared `--sex male|female` mapping for
ExpansionHunter and segdup-caller.

No pipeline uses the new stages yet -- `sentieon_pangenome.py` keeps its
inline builders, so the module-level version constants are duplicated for
now -- and the DAG snapshots of all 88 configurations are identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`SentieonPangenome` now builds its optional callers from the stages added
in the previous commit, and everything both pangenome pipelines will need
moved up to `BasePangenome`.

`BasePangenome` gains the shared caller arguments -- `par_bed`,
`sample_sex`, `segdup_caller`, `expansion_catalog` and the six `t1k_*`
(the two loci defaulting to the constants in `stages/t1k.py`) -- with the
matching `__init__` attributes, `has_cnv_model`, and the DAG-2 stashes
`ploidy_json` / `sr_alignments` / `sr_replace_rg` (`sr_replace_rg` is
unused until the hybrid pipeline needs it). `validate_t1k` moved up
unchanged, and four thin helpers build the stages: `output_path(suffix)`,
`add_t1k(dag, ctx, inputs, upstream)`, `add_expansion(dag, ctx,
alignment)`, `add_segdup(dag, ctx, sr_alignment, *, lr_alignment,
lr_bundle, overrides)` and `add_pangenome_cnv(dag, ctx, sv_vcf, inputs,
*, replace_rg, interval)`. `--call_svs` stays on `SentieonPangenome`.

`hybrid-pangenome` inherits the new flags, which are accepted but build
no jobs yet.

Deleted from `sentieon_pangenome.py`: the `SEGDUP_MIN_VERSION` /
`EXPANSION_MIN_VERSION` / `T1K_MIN_VERSION` constants (now
`*_MIN_VERSIONS` next to their stages) and the default T1K loci, the
moved params and `__init__` lines, `validate_t1k`, `build_segdup_job`,
`build_t1k_jobs`, `build_expansion_job`, `_add_cnv_jobs`,
`_build_indel2cnv_job`, `_build_combine_cnv_job`, and the dead
`self.rw_bam` that `build_dag` assigned but nothing read.

The DAG snapshot over all 88 configurations is identical, including
`compare --with-ids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Give `hybrid-pangenome` the argument surface, validation, and first-DAG
jobs of the optional callers that `dnascope-pangenome` already has.

The new `--call_cnvs` flag opts into CNVscope calling, and the shared
`--sample_sex`, `--par_bed`, `--segdup_caller`, `--expansion_catalog`
and `--t1k_*` arguments come from `BasePangenome`. `validate_bundle`
now records whether the bundle carries a 'cnv.model' file and requires
`diploid_model` when `--segdup_caller` is set, since segdup-caller
reads it from the bundle passed as its `--lr_model`.

`validate()` runs `validate_segdup`, `validate_expansion`,
`validate_t1k` and `validate_cnv` after the readgroup checks.
segdup-caller and ExpansionHunter accept a single `--sr_aln` file
(segdup-caller also a single `--lr_aln`), `--segdup_caller` cannot be
combined with `--skip_small_variants`, and `--call_cnvs` requires SV
calling and a bundle CNV model. CNV runs resolve the PAR BED file
during validation and exit when none is available; a run without
`--bed` warns that CNVscope will cover every contig. `--bed` now
restricts CNV calling as well as small-variant calling.

The first DAG always adds the `PloidyStage`, writing
`<output>_ploidy.json` whatever the enabled callers, and runs T1K
HLA/KIR on the short reads. It also stashes the short-read alignments,
their `--replace_rg` rows, and the single long-read alignment for the
second DAG.

`--call_cnvs`, `--segdup_caller` and `--expansion_catalog` are
validated but build no second-DAG jobs yet; `build_second_dag` follows
in the next commit. The DAG snapshots gain one `estimate-ploidy` row
per hybrid-pangenome config and are otherwise identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd DAG

`HybridPangenome.build_second_dag` builds the sex-aware callers once
ploidy estimation has run, using the short-read alignments, the sample
sex and the long-read alignment stashed by `build_dag`. With
`--call_cnvs` the DAG holds the `PangenomeCNVStage` jobs: CNVscope over
the short reads only -- restricted by `--bed` and carrying the
short-read `--replace_rg` rows for `--sr_aln` input -- CNVModelApply,
`indel2cnv.py` over the PangenomeSV VCF, and `combine_cnv.py` writing
`<output>_cnv.vcf.gz`. `--expansion_catalog` adds ExpansionHunter over
the short reads. `--segdup_caller` adds segdup-caller with both data
types (`--short` and `--long`) and this pipeline's model bundle as both
`--sr_model` and `--lr_model`, since it ships the `diploid_model`.

The DAG-snapshot harness gains seven hybrid-pangenome configs for the
new callers; every pre-existing config is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The `-Q <pangenome_ref_name>` (--paths-by) selector passed to `vg paths`
selected incorrectly on the updated graph during testing. Run
`vg paths -x <gfa> -F` without a selector, which writes every path in
the GFA file, and rename the builder to cmd_vg_gfa2fa, mirroring
cmd_pgutil_gfa2fa, now that it takes only the FASTA and GFA paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The image's segdup-caller version check imported `SEGDUP_MIN_VERSION` from
`sentieon_cli.sentieon_pangenome`, which moved to
`sentieon_cli.stages.segdup.SEGDUP_MIN_VERSIONS` when the caller stages were
extracted, so the build failed with an ImportError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A wrong `--pangenome_ref_name` was silent: `vg haplotypes --set-reference`
only warns, the sampled graph loses its reference paths, `vg convert -Q`
writes no rGFA tags and `pgutil lift` exits 0 with every read unmapped.

Read the reference name and contig prefix from the GBWT metadata in the
GBZ header instead (new `pangenome_meta` module, well under a second per
graph). The backbone reference is the unfragmented reference sample. Make
`--pangenome_ref_name` and `--pangenome_contig_prefix` hidden overrides
that must agree with the graph, check the backbone contigs and build
against the reference FASTA, replace the `grch38.gbz` filename-suffix
check, apply the GRCh38 contig-length check only to GRCh38 backbones, and
add `check-sample-gbz` / `check-sample-gfa` jobs to both DAGs so a
sampled graph or GFA without the reference fails the run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo4R3WSNwCYMcKzvGKCqSs
`validate_grch38_contigs` compared the reference index and the `--pop_vcf`
with a hard-coded table of the 25 GRCh38 chromosome lengths, so it only ran
for a GRCh38 backbone and never looked at the 170 unplaced contigs the HPRC
graph also carries. Drop the table and compare the two files instead: every
contig of the backbone reference detected in the GBZ must carry the same
length in the `--pop_vcf` header as in the reference FASTA index. A CHM13
graph is now checked the same way a GRCh38 one is, a contig absent from the
pop VCF or without a length is an error, and the message names the first ten
offenders with both lengths. Only the hidden `--skip_contig_checks` flag
bypasses the check; `--skip_pangenome_name_checks` does not.

The pop VCF header is now parsed in pure Python. `vcf_contigs` and `vcf_id`
shelled out to `bcftools view -h`, and `vcf_contigs` returned a fake
three-contig dictionary in dry runs, which left the dry run checking nothing
and the real run checking everything. `read_vcf_header` opens the file with
`gzip` when its first two bytes say so -- the extension is not trusted -- and
stops at the first line that is not a header line, so the body is never read:
the header of the 10 GB population VCF parses in about 1 ms. Dry runs now
parse it too, and fall back only when the file cannot be read at all, which
is what the unit tests and the placeholder files behind them need. The
`SentieonVcfID` comparison follows the same rule, so a dry run catches a pop
VCF that does not belong with the model bundle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWPD3suVQRpMZbJHijXhao
@DonFreed
DonFreed merged commit a001660 into main Oct 2, 2026
12 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