be5cbe094825357e026912debc79944cc15bacde hiram Sun Sep 27 12:58:52 2026 -0700 claude review fixing bugs better safety on database assembly mash sketch archive refs #34360 diff --git src/hg/utils/automation/AssemblyDivergence.pm src/hg/utils/automation/AssemblyDivergence.pm index 07ca47bb6f1..0ec5164f10c 100644 --- src/hg/utils/automation/AssemblyDivergence.pm +++ src/hg/utils/automation/AssemblyDivergence.pm @@ -1,222 +1,239 @@ # AssemblyDivergence: measure genome-to-genome divergence with mash and # use it to help pick a pairwise alignment pipeline / minimap2 preset. # # DO NOT EDIT the /cluster/bin/scripts copy of this file -- # edit ~/kent/src/hg/utils/automation/AssemblyDivergence.pm instead. # This is a *measurement* helper, not a pipeline in itself: it sketches # two sequence files with mash, returns a mash distance, and offers a # threshold-based recommendation of which UCSC pairwise pipeline fits # that distance: # doMiniMap2.pl (minimap2/chain/net) for closely related pairs # doBlastzChainNet.pl (lastz/chain/net) for everything else # # The thresholds below are a starting point, not calibrated against a # survey of real pairs -- sanity-check against a few known cases (e.g. a # T2T trio-binned maternal/paternal pair, and a known cross-species pair # already run through doBlastzChainNet.pl) before trusting them blindly. package AssemblyDivergence; use warnings; use strict; use Carp; use FindBin qw($Bin); use lib "$Bin"; use HgAutomate; use AsmHub qw(accessionFromPath mashSketchDir); use File::Basename qw(basename); use File::Path qw(make_path); use vars qw(@ISA @EXPORT_OK); use Exporter; @ISA = qw(Exporter); @EXPORT_OK = qw( mashDistance choosePipeline $mashAsm5Max $mashAsm10Max $mashLastzMin $mashWarnMax ); # mash distance thresholds used by choosePipeline(): our $mashAsm5Max = 0.01; # distance < this -> minimap2 -x asm5 (~<1% div) our $mashAsm10Max = 0.05; # distance < this -> minimap2 -x asm10 (~1-5% div) our $mashWarnMax = 0.10; # distance above this (but still < mashLastzMin): # routed to asm20, but flagged as on the high # end -- worth a second look our $mashLastzMin = 0.15; # distance >= this -> doBlastzChainNet.pl (lastz); # minimap2's asm* presets aren't meant for this # much divergence, and mash distance itself # gets less reliable as a linear divergence # proxy out here anyway ######################################################################### # mashDistance($seqA, $seqB, $workDir, $regenerate) -> distance (float, ~0.0 .. 1.0) # $seqA, $seqB: paths to .2bit or fasta(.gz)/fastq(.gz) files -- anything # mash itself or 'twoBitToFa ... stdout | mash sketch -' # can consume. See sketch() below for what else these # can be (a GenArk accession, a UCSC db name). # $workDir: fallback scratch directory to sketch into, used only # when a sequence file can't be resolved to a persistent # cache location (see sketch() below). Fallback sketch # files are named mashSketch.a.msh / .b.msh and are left # behind (reused on a repeat call with the same # $workDir); it's the caller's job to clean them up. # $regenerate: optional; if true, re-sketch and overwrite even if a # .msh already exists (cached or fallback) -- e.g. after # changing sketch parameters, or to pick up a rebuilt # assembly, or to fix an ID stored under an older format. # Dies if mash/twoBitToFa can't be run or their output can't be parsed. sub mashDistance { my ($seqA, $seqB, $workDir, $regenerate) = @_; my $aMsh = &sketch($seqA, $workDir, 'a', $regenerate); my $bMsh = &sketch($seqB, $workDir, 'b', $regenerate); my $mashOut = `mash dist $aMsh $bMsh`; chomp $mashOut; my @fields = split(/\t/, $mashOut); my $dist = $fields[2]; if (! defined $dist || $dist !~ /^[0-9.eE+-]+$/) { croak "mashDistance: couldn't parse 'mash dist $aMsh $bMsh' output: '$mashOut'\n"; } return $dist; } # mashDistance # basename of $path with a trailing .2bit / .fa(.gz) / .fasta(.gz) # stripped off -- used only to get a candidate UCSC db name to test. sub seqBaseName { my ($path) = @_; my $base = basename($path); $base =~ s/\.(2bit|fa|fasta)(\.gz)?$//; return $base; } # sketch($seq, $workDir, $tag, $regenerate) -> path to a .msh file for $seq. # $seq can be an existing sequence file path, a bare GenArk accession # (e.g. GCA_939628115.1), or a bare UCSC database name (e.g. hg38). # # The persistent mashSketch/ cache -- and everything needed to find # it -- lives entirely under $HgAutomate::clusterData # (/hive/data/genomes/), which is reachable from every cluster node. # This deliberately never touches /gbdb/ or hgcentraltest: cluster # jobs can't reach hgwdev (where hgcentraltest lives) and shouldn't # try, and /gbdb isn't mounted on cluster nodes at all. So: +# - a GenArk accession only qualifies for the persistent cache when +# mashSketchDir() finds a real on-disk _* build directory; +# a UCSC db name only qualifies when $HgAutomate::clusterData/$db +# is a real directory. Both are pure filesystem checks (no ssh), +# and both exist so an arbitrary local file that merely happens to +# share a real assembly's basename (e.g. a stray /tmp/hg38.2bit) +# can't get cached into -- and silently corrupt -- that assembly's +# real, shared cache. Anything not backed by a real build/db +# directory falls through to the -workDir one-off sketch instead. # - a GenArk accession or UCSC db name with an already-cached .msh -# is a pure clusterData filesystem check -- always works, anywhere. +# is then just a pure clusterData filesystem check -- always +# works, anywhere. # - on a cache MISS, this only proceeds if $seq already IS a real, # existing sequence file (i.e. the caller resolved it themselves, # e.g. to a GenArk build-tree .2bit under clusterData, or handed a # literal /gbdb/... path from somewhere that does have it mounted) # -- a bare name with no cache and no real file in hand just fails # (see the final croak below), it never goes looking for one. # # Cached sketches accumulate once per assembly and are shared by # every future comparison involving it, cluster run or standalone # mashDistance.pl check alike. Anything that isn't a GenArk accession # or a recognized UCSC db falls back to a one-off sketch named # mashSketch.$tag.msh under $workDir, as before. sub sketch { my ($seq, $workDir, $tag, $regenerate) = @_; my $prefix; my $id; my $srcSeq = $seq; # actual file to sketch from, only needed on a cache miss my $accession = &accessionFromPath($seq); if ($accession) { my $cacheDir = &mashSketchDir($accession); # pure clusterData 'ls', no /gbdb if ($cacheDir) { if (! $regenerate && -e "$cacheDir/$accession.msh") { return "$cacheDir/$accession.msh"; # cache hit -- done } if (-e $srcSeq) { make_path($cacheDir) if (! -d $cacheDir); $prefix = "$cacheDir/$accession"; $id = $accession; } else { # Bare accession, no cache yet, no path in hand -- mashSketchDir() # only returns a cacheDir when it already found a real build # directory, so the real .2bit is right there next to # mashSketch/ in that same GenArk build tree (clusterData, not # /gbdb): ...//mashSketch -> ...//.2bit my $buildDir = $cacheDir; $buildDir =~ s#/mashSketch$##; my $builtSeq = "$buildDir/" . basename($buildDir) . ".2bit"; if (-e $builtSeq) { make_path($cacheDir) if (! -d $cacheDir); $prefix = "$cacheDir/$accession"; $id = $accession; $srcSeq = $builtSeq; } } } } if (! $prefix) { my $db = &seqBaseName($seq); - if ($db ne '') { + # Require a real /hive/data/genomes/$db directory before trusting + # $db as an actual UCSC assembly -- pure filesystem check, mirroring + # mashSketchDir()'s build-directory requirement for GenArk above. + # Without this, any existing sequence file whose basename happens to + # match a real db name (e.g. a stray local /tmp/hg38.2bit passed via + # -target2Bit) would get cached straight into -- and silently + # corrupt -- that db's real, shared production cache. + if ($db ne '' && -d "$HgAutomate::clusterData/$db") { my $cacheDir = "$HgAutomate::clusterData/$db/mashSketch"; if (! $regenerate && -e "$cacheDir/$db.msh") { return "$cacheDir/$db.msh"; # cache hit -- pure filesystem, done } # Not cached yet -- only proceed with a source we already have in # hand (see the sub's header comment above); never search for one. if (-e $srcSeq) { make_path($cacheDir) if (! -d $cacheDir); $prefix = "$cacheDir/$db"; $id = $db; } } } if (! $prefix) { $prefix = "$workDir/mashSketch.$tag"; $id = basename($seq); } # Label the sketch with something meaningful (the accession or db name # when we have one) instead of letting mash default to the first # sequence's own ID (e.g. an arbitrary scaffold accession like # NC_007416.3) -- 'mash info' and 'mash dist' output are both far more # legible this way. my $mshFile = "$prefix.msh"; if ($regenerate || ! -e $mshFile) { croak "mashDistance: no sequence file to sketch for '$seq' -- not an " . "existing path, a cached/buildable GenArk accession, or a " . "cached/known UCSC db\n" if (! -e $srcSeq); if ($srcSeq =~ /\.2bit$/) { (system("twoBitToFa $srcSeq stdout | mash sketch -k 21 -s 10000 -I $id -o $prefix - 2> /dev/null") == 0) || croak "mashDistance: twoBitToFa/mash sketch failed on $srcSeq\n"; } else { (system("mash sketch -k 21 -s 10000 -I $id -o $prefix $srcSeq 2> /dev/null") == 0) || croak "mashDistance: mash sketch failed on $srcSeq\n"; } } return $mshFile; } # sketch ######################################################################### # choosePipeline($distance) -> ($pipeline, $minimapPreset, $warning) # $pipeline: 'minimap2' or 'lastz' # $minimapPreset: 'asm5'/'asm10'/'asm20' when $pipeline eq 'minimap2', # else undef # $warning: a string worth relaying to the user, or '' if nothing # notable sub choosePipeline { my ($dist) = @_; if ($dist >= $mashLastzMin) { return ('lastz', undef, "mash distance $dist >= $mashLastzMin: likely too diverged for " . "minimap2's asm* presets -- use doBlastzChainNet.pl instead."); } return ('minimap2', 'asm5', '') if ($dist < $mashAsm5Max); return ('minimap2', 'asm10', '') if ($dist < $mashAsm10Max); my $warning = ($dist > $mashWarnMax) ? "mash distance $dist is on the high end of what minimap2 asm20 " . "expects for a same-species pair -- doBlastzChainNet.pl may fit better." : ''; return ('minimap2', 'asm20', $warning); } # choosePipeline 1;