From c4bfc9231cb3a6faf53f9139a587d776ae1700ed Mon Sep 17 00:00:00 2001 From: Vin Vomero <36705133+vinvomero@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:55:40 -0400 Subject: [PATCH 1/3] fix: rebuild consensus predictions when weights change on re-run (#100) On a re-run, predict reused misc/consensus.predictions.gff3 whenever it existed, so new -w/--weights were calculated and logged but never used (#100). The UTR-refined consensus was reused the same way. The weights used to build the consensus are now recorded in consensus.predictions.weights.json. On re-run the consensus is reused only if the current weights match the record; otherwise (or when there is no record, e.g. a run folder from before this change) the stale consensus, UTR-refined consensus, pre-refinement backup and record are removed and consensus is regenerated, with the reason logged. The decision and file removal live in small helpers in utilities.py, covered by tests/unit/test_utilities_consensus.py. Co-Authored-By: Claude Opus 5.5 --- funannotate2/predict.py | 20 +++- funannotate2/utilities.py | 76 ++++++++++++++ tests/unit/test_utilities_consensus.py | 134 +++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_utilities_consensus.py diff --git a/funannotate2/predict.py b/funannotate2/predict.py index 5ce7d17..9ad8fcb 100755 --- a/funannotate2/predict.py +++ b/funannotate2/predict.py @@ -36,14 +36,17 @@ from .utilities import ( busco_lineage_from_taxonomy, checkfile, + consensus_needs_rebuild, create_directories, create_tmpdir, ensure_busco_lineage, find_files, lookup_taxonomy, naming_slug, + remove_files, runProcessJob, which_path, + write_consensus_weights, ) @@ -974,8 +977,16 @@ def sort_gff_line(line): consensus_tmp = os.path.join(tmp_dir, "evm") if not os.path.isdir(consensus_tmp): os.makedirs(consensus_tmp) - # now run consensus - if not checkfile(Consensus): + # now run consensus, re-using existing predictions only if built with the same weights + consensus_weights = os.path.join(misc_dir, "consensus.predictions.weights.json") + refined_consensus = os.path.join(misc_dir, "consensus.predictions.utr_refined.gff3") + consensus_backup = os.path.join(misc_dir, "consensus.predictions.pre_utr_refinement.gff3") + rebuild_consensus, reason = consensus_needs_rebuild(Consensus, consensus_weights, weightings) + if rebuild_consensus: + if checkfile(Consensus): + logger.info(f"Re-generating consensus predictions: {reason}") + # remove stale consensus and anything derived from it + remove_files([Consensus, refined_consensus, consensus_backup, consensus_weights]) p_aligns = [] if checkfile(ProtAlign): p_aligns.append(ProtAlign) @@ -998,8 +1009,11 @@ def sort_gff_line(line): evidence_derived_models=["miniprot-gene", "gapmm2-gene"], num_processes=args.cpus, ) + write_consensus_weights(consensus_weights, weightings) else: - logger.info("Existing consensus predictions found, will re-use and continue") + logger.info( + "Existing consensus predictions found with the same weights, will re-use and continue" + ) # Refine UTRs using RNA-seq data if BAM file is provided if ( diff --git a/funannotate2/utilities.py b/funannotate2/utilities.py index deb6593..07f5fa3 100755 --- a/funannotate2/utilities.py +++ b/funannotate2/utilities.py @@ -920,6 +920,82 @@ def checkfile(infile): return False +def _weightings_to_dict(weightings): + """ + Convert a list of "tool:weight" strings into a {tool: int(weight)} dictionary. + """ + weights = {} + for x in weightings: + tool, weight = x.rsplit(":", 1) + weights[tool] = int(weight) + return weights + + +def consensus_needs_rebuild(consensus, record, weightings): + """ + Decide whether existing consensus predictions can be re-used for these weights. + + The weights used to build the consensus are stored in a JSON record next to it + (see write_consensus_weights). A consensus without a readable record is treated + as stale, since the weights it was built with are unknown. + + Parameters: + - consensus (str): Path to the consensus predictions GFF3 file. + - record (str): Path to the JSON record of the weights used to build it. + - weightings (list): Current weights as "tool:weight" strings. + + Returns: + - tuple: (bool, str) whether to rebuild, and the reason for logging. + """ + if not checkfile(consensus): + return True, "no existing consensus predictions found" + try: + with open(record, "r") as infile: + previous = {k: int(v) for k, v in json.load(infile).items()} + except (OSError, ValueError, TypeError, AttributeError): + return True, "no record of the weights used for existing consensus predictions" + current = _weightings_to_dict(weightings) + if previous == current: + return False, "weights unchanged" + changes = [] + for tool in sorted(set(previous) | set(current)): + old = previous.get(tool, "none") + new = current.get(tool, "none") + if old != new: + changes.append(f"{tool}: {old} -> {new}") + return True, f"weights changed ({', '.join(changes)})" + + +def write_consensus_weights(record, weightings): + """ + Write the weights used to build consensus predictions to a JSON record. + + Parameters: + - record (str): Path to the JSON record to write. + - weightings (list): Weights as "tool:weight" strings. + """ + with open(record, "w") as outfile: + json.dump(_weightings_to_dict(weightings), outfile, indent=2, sort_keys=True) + + +def remove_files(paths): + """ + Remove the files that exist from a list of paths. + + Parameters: + - paths (list): File paths to remove; missing files are skipped. + + Returns: + - list: The paths that were removed. + """ + removed = [] + for f in paths: + if os.path.isfile(f) or os.path.islink(f): + os.remove(f) + removed.append(f) + return removed + + @contextmanager def process_handle(handle, mode="w"): """ diff --git a/tests/unit/test_utilities_consensus.py b/tests/unit/test_utilities_consensus.py new file mode 100644 index 0000000..134038d --- /dev/null +++ b/tests/unit/test_utilities_consensus.py @@ -0,0 +1,134 @@ +""" +Unit tests for the consensus re-use helpers in the utilities module. +""" + +import json + +from funannotate2.utilities import ( + consensus_needs_rebuild, + remove_files, + write_consensus_weights, +) + +WEIGHTS = ["augustus-hiq:4", "augustus:2", "helixer:2", "glimmerhmm:1", "snap:1"] + + +def make_consensus(tmp_path, content="##gff-version 3\n"): + consensus = tmp_path / "consensus.predictions.gff3" + consensus.write_text(content) + return str(consensus) + + +class TestConsensusNeedsRebuild: + """Tests for consensus_needs_rebuild.""" + + def test_no_consensus(self, tmp_path): + """A missing consensus file needs to be built.""" + rebuild, reason = consensus_needs_rebuild( + str(tmp_path / "consensus.predictions.gff3"), + str(tmp_path / "consensus.predictions.weights.json"), + WEIGHTS, + ) + assert rebuild + assert "no existing consensus" in reason + + def test_no_record(self, tmp_path): + """A consensus without a weights record is rebuilt.""" + consensus = make_consensus(tmp_path) + rebuild, reason = consensus_needs_rebuild( + consensus, str(tmp_path / "consensus.predictions.weights.json"), WEIGHTS + ) + assert rebuild + assert "no record" in reason + + def test_same_weights(self, tmp_path): + """A consensus built with the same weights is re-used.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS) + rebuild, _ = consensus_needs_rebuild(consensus, record, WEIGHTS) + assert not rebuild + + def test_same_weights_different_order(self, tmp_path): + """Weight order does not matter.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS) + rebuild, _ = consensus_needs_rebuild(consensus, record, list(reversed(WEIGHTS))) + assert not rebuild + + def test_changed_weight(self, tmp_path): + """A changed weight triggers a rebuild and is named in the reason.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS) + new_weights = [w if w != "helixer:2" else "helixer:4" for w in WEIGHTS] + rebuild, reason = consensus_needs_rebuild(consensus, record, new_weights) + assert rebuild + assert "helixer: 2 -> 4" in reason + + def test_added_and_removed_tools(self, tmp_path): + """Adding or removing a tool triggers a rebuild.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS) + new_weights = [w for w in WEIGHTS if not w.startswith("snap")] + ["miniprot-gene:1"] + rebuild, reason = consensus_needs_rebuild(consensus, record, new_weights) + assert rebuild + assert "snap: 1 -> none" in reason + assert "miniprot-gene: none -> 1" in reason + + def test_malformed_record(self, tmp_path): + """An unreadable record is treated as missing rather than raising.""" + consensus = make_consensus(tmp_path) + record = tmp_path / "consensus.predictions.weights.json" + for content in ["{not json", "[1, 2]", '{"augustus": "high"}']: + record.write_text(content) + rebuild, reason = consensus_needs_rebuild(consensus, str(record), WEIGHTS) + assert rebuild + assert "no record" in reason + + def test_empty_consensus(self, tmp_path): + """An empty consensus file is rebuilt even with a matching record.""" + consensus = make_consensus(tmp_path, content="") + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS) + rebuild, _ = consensus_needs_rebuild(consensus, record, WEIGHTS) + assert rebuild + + +class TestWriteConsensusWeights: + """Tests for write_consensus_weights.""" + + def test_record_contents(self, tmp_path): + """The record stores weights as a tool to integer mapping.""" + record = tmp_path / "consensus.predictions.weights.json" + write_consensus_weights(str(record), ["augustus:2", "snap:1"]) + assert json.loads(record.read_text()) == {"augustus": 2, "snap": 1} + + +class TestRemoveFiles: + """Tests for remove_files.""" + + def test_removes_existing_files(self, tmp_path): + """All existing files are removed and returned.""" + paths = [] + for name in ["a.gff3", "b.gff3", "c.json"]: + f = tmp_path / name + f.write_text("x") + paths.append(str(f)) + assert remove_files(paths) == paths + assert not any((tmp_path / name).exists() for name in ["a.gff3", "b.gff3", "c.json"]) + + def test_consensus_missing_refined_present(self, tmp_path): + """A stale refined file is removed even when the consensus was deleted by hand.""" + consensus = tmp_path / "consensus.predictions.gff3" + refined = tmp_path / "consensus.predictions.utr_refined.gff3" + refined.write_text("##gff-version 3\n") + removed = remove_files([str(consensus), str(refined)]) + assert removed == [str(refined)] + assert not refined.exists() + + def test_nothing_to_remove(self, tmp_path): + """Missing files are skipped without error.""" + assert remove_files([str(tmp_path / "missing.gff3")]) == [] From 6c474d70ef4b7e407e3a664388585da5426604b5 Mon Sep 17 00:00:00 2001 From: Vin Vomero <36705133+vinvomero@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:09:28 -0400 Subject: [PATCH 2/3] refactor: reuse consensus paths and load_json in consensus re-use check Define the UTR-refined and pre-refinement backup paths once, before the consensus rebuild check, instead of again inside the UTR-refinement block, and read the weights record with the existing load_json helper. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- funannotate2/predict.py | 6 ------ funannotate2/utilities.py | 3 +-- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/funannotate2/predict.py b/funannotate2/predict.py index 9ad8fcb..739302b 100755 --- a/funannotate2/predict.py +++ b/funannotate2/predict.py @@ -1022,9 +1022,6 @@ def sort_gff_line(line): and hasattr(args, "bam_library") and args.bam_library ): - refined_consensus = os.path.join( - misc_dir, "consensus.predictions.utr_refined.gff3" - ) if not checkfile(refined_consensus): logger.info( f"Refining UTRs using RNA-seq data from BAM file (library type: {args.bam_library})" @@ -1056,9 +1053,6 @@ def sort_gff_line(line): # Replace consensus with refined version logger.info("UTR refinement completed successfully") # Keep backup of original consensus - consensus_backup = os.path.join( - misc_dir, "consensus.predictions.pre_utr_refinement.gff3" - ) if not os.path.isfile(consensus_backup): shutil.copy(Consensus, consensus_backup) # Use refined version diff --git a/funannotate2/utilities.py b/funannotate2/utilities.py index 07f5fa3..eb36f2b 100755 --- a/funannotate2/utilities.py +++ b/funannotate2/utilities.py @@ -950,8 +950,7 @@ def consensus_needs_rebuild(consensus, record, weightings): if not checkfile(consensus): return True, "no existing consensus predictions found" try: - with open(record, "r") as infile: - previous = {k: int(v) for k, v in json.load(infile).items()} + previous = {k: int(v) for k, v in load_json(record).items()} except (OSError, ValueError, TypeError, AttributeError): return True, "no record of the weights used for existing consensus predictions" current = _weightings_to_dict(weightings) From b1e57c9d70d633c59b3f58c428afccda306497d0 Mon Sep 17 00:00:00 2001 From: Jon Palmer Date: Sun, 25 Oct 2026 08:41:22 -0700 Subject: [PATCH 3/3] fix: rebuild consensus and UTR refinement when any prediction or evidence inputs change --- funannotate2/predict.py | 197 ++++++++++++++++------- funannotate2/utilities.py | 213 ++++++++++++++++++++++--- tests/unit/test_utilities_consensus.py | 143 +++++++++++++++++ 3 files changed, 475 insertions(+), 78 deletions(-) diff --git a/funannotate2/predict.py b/funannotate2/predict.py index 739302b..fda293c 100755 --- a/funannotate2/predict.py +++ b/funannotate2/predict.py @@ -40,13 +40,16 @@ create_directories, create_tmpdir, ensure_busco_lineage, + file_sha256, find_files, lookup_taxonomy, naming_slug, remove_files, runProcessJob, + utr_refinement_needs_rebuild, which_path, write_consensus_weights, + write_utr_refinement_record, ) @@ -270,11 +273,17 @@ def predict(args): TranAlign = os.path.join(misc_dir, "transcript-alignments.gff3") TranGenes = os.path.join(misc_dir, "predictions.gapmm2-gene.gff3") Transcripts = os.path.join(misc_dir, "transcripts.to.align.fasta") - if args.transcripts and not checkfile(Transcripts): + if args.transcripts: + prev_tran_sha = file_sha256(Transcripts) if checkfile(Transcripts) else None n_transcripts, tot_transcripts = mergefasta(args.transcripts, Transcripts) logger.info( f"Parsed {n_transcripts} [out of {tot_transcripts}] transcripts to align for evidence, derived from:\n{'/n'.join(args.transcripts)}" ) + if prev_tran_sha is not None and prev_tran_sha != file_sha256(Transcripts): + logger.info( + "Transcript evidence changed, removing stale transcript alignments" + ) + remove_files([TranAlign, TranGenes]) if checkfile(Transcripts) and not checkfile(TranAlign): align_transcripts( GenomeFasta, @@ -305,10 +314,14 @@ def predict(args): if checkfile(uniprot_db) and uniprot_db not in args.proteins: args.proteins.append(uniprot_db) # merge and dereplicate + prev_prot_sha = file_sha256(Proteins) if checkfile(Proteins) else None n_prots, tot_prots = mergefasta(args.proteins, Proteins) logger.info( f"Parsed {n_prots} [out of {tot_prots}] proteins to align for evidence, derived from:\n{'/n'.join(args.proteins)}" ) + if prev_prot_sha is not None and prev_prot_sha != file_sha256(Proteins): + logger.info("Protein evidence changed, removing stale protein alignments") + remove_files([ProtAlign, ProtGenes]) if checkfile(Proteins) and not checkfile(ProtAlign): align_proteins( GenomeFasta, @@ -886,10 +899,21 @@ def sort_gff_line(line): logger.info(f"Memory monitoring data written to: {memory_log_file}") else: - abinitio_preds = [] - for f in os.listdir(misc_dir): - if f.startswith("predictions") and f.endswith(".gff3"): - abinitio_preds.append(os.path.join(misc_dir, f)) + abinitio_preds = list(external_abinitio) + if checkfile(ProtGenes): + abinitio_preds.append(ProtGenes) + if checkfile(TranGenes): + abinitio_preds.append(TranGenes) + for ab in list(params["abinitio"].keys()): + if ab == "augustus": + for suffix in ("", "-hiq"): + cand = os.path.join(misc_dir, f"predictions.{ab}{suffix}.gff3") + if checkfile(cand): + abinitio_preds.append(cand) + else: + cand = os.path.join(misc_dir, f"predictions.{ab}.gff3") + if checkfile(cand): + abinitio_preds.append(cand) logger.info( "Existing ab initio predictions found, re-using these files\n - {}".format( "\n - ".join(abinitio_preds) @@ -917,51 +941,87 @@ def sort_gff_line(line): ) busco_model_path = ensure_busco_lineage(busco_tax, logger) - # now we can loop through the abinitio predictions and run busco for completion - # write this to file for re-use if consensus file already present? + # Group active prediction files by tool name (augustus and augustus-hiq share the 'augustus' score) + tool_to_files = {} + for ap in abinitio_preds: + ab_initio_tool = os.path.basename(ap).split(".")[1] + if ab_initio_tool == "augustus-hiq": + ab_initio_tool = "augustus" + tool_to_files.setdefault(ab_initio_tool, []).append(ap) + abinitio_scores_file = os.path.join(misc_dir, "abinitio_algorithm_scoring.json") + cached_scores = {} if checkfile(abinitio_scores_file): + try: + with open(abinitio_scores_file, "r") as infile: + loaded = json.load(infile) + if isinstance(loaded, dict): + cached_scores = loaded + except (OSError, ValueError, TypeError): + cached_scores = {} + + tools_to_score = [] + for tool_name, tool_files in tool_to_files.items(): + current_fp = {os.path.basename(f): file_sha256(f) for f in tool_files} + entry = cached_scores.get(tool_name) + if not isinstance(entry, dict) or "busco" not in entry: + tools_to_score.append(tool_name) + elif "files" in entry and entry["files"] != current_fp: + tools_to_score.append(tool_name) + elif "files" not in entry: + # Legacy scoring file without file fingerprints: verify .prots.fa is not older than the GFF3 + for f in tool_files: + prots_fa = os.path.join(misc_dir, os.path.basename(f) + ".prots.fa") + if not checkfile(prots_fa) or os.path.getmtime(prots_fa) < os.path.getmtime(f): + tools_to_score.append(tool_name) + break + + if not tools_to_score and cached_scores: logger.info("Existing abinitio scoring file present, will re-use and continue") - with open(abinitio_scores_file, "r") as infile: - abinitio_scores = json.load(infile) else: - abinitio_scores = {} logger.info( - f"Measuring assembly completeness with buscolite [lineage={os.path.basename(busco_model_path)}] for all ab initio predictions" + f"Measuring assembly completeness with buscolite [lineage={os.path.basename(busco_model_path)}] for ab initio predictions" ) - for ap in abinitio_preds: - ProtPreds = os.path.join(misc_dir, os.path.basename(ap) + ".prots.fa") - gene_models = gff2dict(ap, GenomeFasta) - _dict2proteins(gene_models, output=ProtPreds) - if checkfile(ProtPreds): - d, m, stats, cfg = runbusco( - ProtPreds, - busco_model_path, - mode="proteins", - cpus=args.cpus, - logger=logger, - verbosity=0, - ) - cov = (stats["single-copy"] + stats["duplicated"]) / float( - stats["total"] - ) - else: - cov = 0.00 - # measure completeness for each tool - ab_initio_tool = os.path.basename(ap).split(".")[1] - if ab_initio_tool == "augustus-hiq": # add these just augustus - ab_initio_tool = "augustus" - if ab_initio_tool not in abinitio_scores: - abinitio_scores[ab_initio_tool] = {"busco": cov} - else: - abinitio_scores[ab_initio_tool]["busco"] += cov - # add the params based scoring to the dictionary to calculate weights - for k, v in params["abinitio"].items(): - if k in abinitio_scores: - abinitio_scores[k]["train"] = v["train_results"] - # write it to file + for tool_name in tools_to_score: + tool_cov = 0.0 + tool_files = tool_to_files[tool_name] + for ap in tool_files: + ProtPreds = os.path.join(misc_dir, os.path.basename(ap) + ".prots.fa") + gene_models = gff2dict(ap, GenomeFasta) + _dict2proteins(gene_models, output=ProtPreds) + if checkfile(ProtPreds): + d, m, stats, cfg = runbusco( + ProtPreds, + busco_model_path, + mode="proteins", + cpus=args.cpus, + logger=logger, + verbosity=0, + ) + cov = (stats["single-copy"] + stats["duplicated"]) / float( + stats["total"] + ) + else: + cov = 0.00 + tool_cov += cov + cached_scores[tool_name] = { + "busco": tool_cov, + "files": {os.path.basename(f): file_sha256(f) for f in tool_files}, + } + + abinitio_scores = {} + for tool_name, tool_files in tool_to_files.items(): + entry = dict(cached_scores[tool_name]) + if tool_name in params["abinitio"]: + entry["train"] = params["abinitio"][tool_name]["train_results"] + elif "train" in entry: + del entry["train"] + cached_scores[tool_name] = entry + abinitio_scores[tool_name] = {k: v for k, v in entry.items() if k != "files"} + + if tools_to_score or not checkfile(abinitio_scores_file): with open(abinitio_scores_file, "w") as outfile: - json.dump(abinitio_scores, outfile, indent=2) + json.dump(cached_scores, outfile, indent=2) logger.info( "ab initio models scoring by algorithm:\n{}".format( @@ -977,22 +1037,31 @@ def sort_gff_line(line): consensus_tmp = os.path.join(tmp_dir, "evm") if not os.path.isdir(consensus_tmp): os.makedirs(consensus_tmp) - # now run consensus, re-using existing predictions only if built with the same weights + # now run consensus, re-using existing predictions only if built with the same inputs + p_aligns = [] + if checkfile(ProtAlign): + p_aligns.append(ProtAlign) + t_aligns = [] + if checkfile(TranAlign): + t_aligns.append(TranAlign) consensus_weights = os.path.join(misc_dir, "consensus.predictions.weights.json") refined_consensus = os.path.join(misc_dir, "consensus.predictions.utr_refined.gff3") + utr_record = os.path.join(misc_dir, "consensus.predictions.utr_refined.json") consensus_backup = os.path.join(misc_dir, "consensus.predictions.pre_utr_refinement.gff3") - rebuild_consensus, reason = consensus_needs_rebuild(Consensus, consensus_weights, weightings) + rebuild_consensus, reason = consensus_needs_rebuild( + Consensus, + consensus_weights, + weightings, + max_intron=args.max_intron, + abinitio_preds=abinitio_preds, + protein_alignments=p_aligns, + transcript_alignments=t_aligns, + ) if rebuild_consensus: if checkfile(Consensus): logger.info(f"Re-generating consensus predictions: {reason}") # remove stale consensus and anything derived from it - remove_files([Consensus, refined_consensus, consensus_backup, consensus_weights]) - p_aligns = [] - if checkfile(ProtAlign): - p_aligns.append(ProtAlign) - t_aligns = [] - if checkfile(TranAlign): - t_aligns.append(TranAlign) + remove_files([Consensus, refined_consensus, utr_record, consensus_backup, consensus_weights]) # run gfftk consensus _ = generate_consensus( GenomeFasta, @@ -1009,10 +1078,17 @@ def sort_gff_line(line): evidence_derived_models=["miniprot-gene", "gapmm2-gene"], num_processes=args.cpus, ) - write_consensus_weights(consensus_weights, weightings) + write_consensus_weights( + consensus_weights, + weightings, + max_intron=args.max_intron, + abinitio_preds=abinitio_preds, + protein_alignments=p_aligns, + transcript_alignments=t_aligns, + ) else: logger.info( - "Existing consensus predictions found with the same weights, will re-use and continue" + "Existing consensus predictions found with matching inputs, will re-use and continue" ) # Refine UTRs using RNA-seq data if BAM file is provided @@ -1022,7 +1098,13 @@ def sort_gff_line(line): and hasattr(args, "bam_library") and args.bam_library ): - if not checkfile(refined_consensus): + rebuild_utr, utr_reason = utr_refinement_needs_rebuild( + refined_consensus, utr_record, Consensus, args.bam, args.bam_library + ) + if rebuild_utr: + if checkfile(refined_consensus): + logger.info(f"Re-running UTR refinement: {utr_reason}") + remove_files([refined_consensus, utr_record]) logger.info( f"Refining UTRs using RNA-seq data from BAM file (library type: {args.bam_library})" ) @@ -1055,6 +1137,9 @@ def sort_gff_line(line): # Keep backup of original consensus if not os.path.isfile(consensus_backup): shutil.copy(Consensus, consensus_backup) + write_utr_refinement_record( + utr_record, Consensus, args.bam, args.bam_library + ) # Use refined version Consensus = refined_consensus else: @@ -1069,7 +1154,7 @@ def sort_gff_line(line): logger.warning("Continuing with original consensus predictions") else: logger.info( - "Existing UTR-refined consensus found, will re-use and continue" + "Existing UTR-refined consensus found with matching BAM inputs, will re-use and continue" ) Consensus = refined_consensus diff --git a/funannotate2/utilities.py b/funannotate2/utilities.py index eb36f2b..544529b 100755 --- a/funannotate2/utilities.py +++ b/funannotate2/utilities.py @@ -1,3 +1,4 @@ +import hashlib import json import multiprocessing import os @@ -920,6 +921,45 @@ def checkfile(infile): return False +def file_sha256(path): + """ + Compute the SHA-256 hex digest of a file, or return None if the file does not exist. + """ + if not path or not os.path.isfile(path): + return None + h = hashlib.sha256() + with open(path, "rb") as infile: + for chunk in iter(lambda: infile.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _files_to_fingerprints(paths): + """ + Convert a list of file paths into a {basename: sha256} dictionary for existing files. + """ + fingerprints = {} + for p in paths or []: + if p and os.path.isfile(p): + fingerprints[os.path.basename(p)] = file_sha256(p) + return fingerprints + + +def _diff_fingerprints(previous, current): + """ + Describe added, removed, or modified files between two {basename: sha256} dictionaries. + """ + changes = [] + for name in sorted(set(previous) | set(current)): + if name not in previous: + changes.append(f"added {name}") + elif name not in current: + changes.append(f"removed {name}") + elif previous[name] != current[name]: + changes.append(f"modified {name}") + return changes + + def _weightings_to_dict(weightings): """ Convert a list of "tool:weight" strings into a {tool: int(weight)} dictionary. @@ -931,18 +971,31 @@ def _weightings_to_dict(weightings): return weights -def consensus_needs_rebuild(consensus, record, weightings): +def consensus_needs_rebuild( + consensus, + record, + weightings, + max_intron=None, + abinitio_preds=None, + protein_alignments=None, + transcript_alignments=None, +): """ - Decide whether existing consensus predictions can be re-used for these weights. + Decide whether existing consensus predictions can be re-used for the given inputs. - The weights used to build the consensus are stored in a JSON record next to it + The inputs used to build the consensus (weights, max_intron, prediction GFF3 files, + and evidence alignment GFF3 files) are stored in a JSON record next to it (see write_consensus_weights). A consensus without a readable record is treated - as stale, since the weights it was built with are unknown. + as stale, since the parameters it was built with are unknown. Parameters: - consensus (str): Path to the consensus predictions GFF3 file. - - record (str): Path to the JSON record of the weights used to build it. + - record (str): Path to the JSON record of the inputs used to build it. - weightings (list): Current weights as "tool:weight" strings. + - max_intron (int, optional): Current maximum intron length. + - abinitio_preds (list, optional): Current list of prediction GFF3 file paths. + - protein_alignments (list, optional): Current list of protein alignment GFF3 file paths. + - transcript_alignments (list, optional): Current list of transcript alignment GFF3 file paths. Returns: - tuple: (bool, str) whether to rebuild, and the reason for logging. @@ -950,31 +1003,147 @@ def consensus_needs_rebuild(consensus, record, weightings): if not checkfile(consensus): return True, "no existing consensus predictions found" try: - previous = {k: int(v) for k, v in load_json(record).items()} + raw = load_json(record) + if not isinstance(raw, dict): + raise TypeError("record must be a JSON object") + raw_weights = raw["weights"] if isinstance(raw.get("weights"), dict) else raw + previous_weights = {k: int(v) for k, v in raw_weights.items()} except (OSError, ValueError, TypeError, AttributeError): return True, "no record of the weights used for existing consensus predictions" - current = _weightings_to_dict(weightings) - if previous == current: - return False, "weights unchanged" - changes = [] - for tool in sorted(set(previous) | set(current)): - old = previous.get(tool, "none") - new = current.get(tool, "none") - if old != new: - changes.append(f"{tool}: {old} -> {new}") - return True, f"weights changed ({', '.join(changes)})" + + reasons = [] + current_weights = _weightings_to_dict(weightings) + if previous_weights != current_weights: + changes = [] + for tool in sorted(set(previous_weights) | set(current_weights)): + old = previous_weights.get(tool, "none") + new = current_weights.get(tool, "none") + if old != new: + changes.append(f"{tool}: {old} -> {new}") + reasons.append(f"weights changed ({', '.join(changes)})") + + if max_intron is not None: + prev_intron = raw.get("max_intron") if isinstance(raw.get("weights"), dict) else None + try: + prev_intron_val = int(prev_intron) if prev_intron is not None else None + except (ValueError, TypeError): + prev_intron_val = None + if prev_intron_val != int(max_intron): + old_label = prev_intron_val if prev_intron_val is not None else "unknown" + reasons.append(f"max_intron changed ({old_label} -> {int(max_intron)})") + + if abinitio_preds is not None: + prev_preds = raw.get("abinitio_preds") if isinstance(raw.get("weights"), dict) else None + curr_preds = _files_to_fingerprints(abinitio_preds) + if not isinstance(prev_preds, dict): + reasons.append("prediction inputs not recorded") + elif prev_preds != curr_preds: + diffs = _diff_fingerprints(prev_preds, curr_preds) + reasons.append(f"prediction inputs changed ({', '.join(diffs)})") + + if protein_alignments is not None: + prev_prots = raw.get("protein_alignments") if isinstance(raw.get("weights"), dict) else None + curr_prots = _files_to_fingerprints(protein_alignments) + if not isinstance(prev_prots, dict): + reasons.append("protein alignment inputs not recorded") + elif prev_prots != curr_prots: + diffs = _diff_fingerprints(prev_prots, curr_prots) + reasons.append(f"protein alignments changed ({', '.join(diffs)})") + + if transcript_alignments is not None: + prev_trans = raw.get("transcript_alignments") if isinstance(raw.get("weights"), dict) else None + curr_trans = _files_to_fingerprints(transcript_alignments) + if not isinstance(prev_trans, dict): + reasons.append("transcript alignment inputs not recorded") + elif prev_trans != curr_trans: + diffs = _diff_fingerprints(prev_trans, curr_trans) + reasons.append(f"transcript alignments changed ({', '.join(diffs)})") + + if reasons: + return True, "; ".join(reasons) + return False, "weights unchanged" + + +def write_consensus_weights( + record, + weightings, + max_intron=None, + abinitio_preds=None, + protein_alignments=None, + transcript_alignments=None, +): + """ + Write the weights and consensus inputs used to build consensus predictions to a JSON record. + + When called with only (record, weightings), writes a flat {tool: weight} mapping + for backward compatibility. When additional consensus inputs are supplied, writes + a structured manifest containing weights, max_intron, and input file fingerprints. + """ + weights_dict = _weightings_to_dict(weightings) + if ( + max_intron is None + and abinitio_preds is None + and protein_alignments is None + and transcript_alignments is None + ): + payload = weights_dict + else: + payload = {"weights": weights_dict} + if max_intron is not None: + payload["max_intron"] = int(max_intron) + if abinitio_preds is not None: + payload["abinitio_preds"] = _files_to_fingerprints(abinitio_preds) + if protein_alignments is not None: + payload["protein_alignments"] = _files_to_fingerprints(protein_alignments) + if transcript_alignments is not None: + payload["transcript_alignments"] = _files_to_fingerprints(transcript_alignments) + with open(record, "w") as outfile: + json.dump(payload, outfile, indent=2, sort_keys=True) -def write_consensus_weights(record, weightings): +def utr_refinement_needs_rebuild(refined_consensus, record, consensus, bam_file, bam_library): """ - Write the weights used to build consensus predictions to a JSON record. + Decide whether an existing UTR-refined consensus GFF3 can be re-used for the given BAM inputs. + """ + if not checkfile(refined_consensus): + return True, "no existing UTR-refined consensus found" + try: + raw = load_json(record) + if not isinstance(raw, dict): + raise TypeError("record must be a JSON object") + except (OSError, ValueError, TypeError, AttributeError): + return True, "no record of the inputs used for existing UTR refinement" - Parameters: - - record (str): Path to the JSON record to write. - - weightings (list): Weights as "tool:weight" strings. + reasons = [] + curr_consensus_sha = file_sha256(consensus) + if raw.get("consensus_sha256") != curr_consensus_sha: + reasons.append("base consensus predictions changed") + + abs_bam = os.path.abspath(bam_file) if bam_file else None + bam_size = os.stat(bam_file).st_size if bam_file and os.path.isfile(bam_file) else None + if raw.get("bam_file") != abs_bam or raw.get("bam_size") != bam_size: + reasons.append(f"bam file changed ({raw.get('bam_file', 'unknown')} -> {abs_bam})") + + if raw.get("bam_library") != bam_library: + reasons.append(f"bam_library changed ({raw.get('bam_library', 'unknown')} -> {bam_library})") + + if reasons: + return True, "; ".join(reasons) + return False, "UTR refinement inputs unchanged" + + +def write_utr_refinement_record(record, consensus, bam_file, bam_library): + """ + Write the inputs used for UTR refinement to a JSON record. """ + payload = { + "consensus_sha256": file_sha256(consensus), + "bam_file": os.path.abspath(bam_file) if bam_file else None, + "bam_size": os.stat(bam_file).st_size if bam_file and os.path.isfile(bam_file) else None, + "bam_library": bam_library, + } with open(record, "w") as outfile: - json.dump(_weightings_to_dict(weightings), outfile, indent=2, sort_keys=True) + json.dump(payload, outfile, indent=2, sort_keys=True) def remove_files(paths): diff --git a/tests/unit/test_utilities_consensus.py b/tests/unit/test_utilities_consensus.py index 134038d..edd8b45 100644 --- a/tests/unit/test_utilities_consensus.py +++ b/tests/unit/test_utilities_consensus.py @@ -7,7 +7,9 @@ from funannotate2.utilities import ( consensus_needs_rebuild, remove_files, + utr_refinement_needs_rebuild, write_consensus_weights, + write_utr_refinement_record, ) WEIGHTS = ["augustus-hiq:4", "augustus:2", "helixer:2", "glimmerhmm:1", "snap:1"] @@ -96,6 +98,85 @@ def test_empty_consensus(self, tmp_path): rebuild, _ = consensus_needs_rebuild(consensus, record, WEIGHTS) assert rebuild + def test_changed_max_intron(self, tmp_path): + """A change in max_intron triggers a rebuild.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + write_consensus_weights(record, WEIGHTS, max_intron=3000) + rebuild, _ = consensus_needs_rebuild(consensus, record, WEIGHTS, max_intron=3000) + assert not rebuild + rebuild, reason = consensus_needs_rebuild(consensus, record, WEIGHTS, max_intron=5000) + assert rebuild + assert "max_intron changed (3000 -> 5000)" in reason + + def test_abinitio_preds_added_removed_modified(self, tmp_path): + """Adding, removing, or modifying a prediction GFF3 triggers a rebuild.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + pred_a = tmp_path / "predictions.augustus.gff3" + pred_b = tmp_path / "predictions.snap.gff3" + pred_a.write_text("##gff-version 3\nchr1\taugustus\tgene\t1\t100\t.\t+\t.\tID=g1\n") + pred_b.write_text("##gff-version 3\nchr1\tsnap\tgene\t1\t100\t.\t+\t.\tID=g2\n") + + write_consensus_weights(record, WEIGHTS, abinitio_preds=[str(pred_a), str(pred_b)]) + rebuild, _ = consensus_needs_rebuild( + consensus, record, WEIGHTS, abinitio_preds=[str(pred_a), str(pred_b)] + ) + assert not rebuild + + # Modify pred_a content (e.g. external prediction re-run with updated GFF3) + pred_a.write_text("##gff-version 3\nchr1\taugustus\tgene\t1\t200\t.\t+\t.\tID=g1\n") + rebuild, reason = consensus_needs_rebuild( + consensus, record, WEIGHTS, abinitio_preds=[str(pred_a), str(pred_b)] + ) + assert rebuild + assert "modified predictions.augustus.gff3" in reason + + # Remove pred_b and add pred_c + pred_c = tmp_path / "predictions.helixer.gff3" + pred_c.write_text("##gff-version 3\n") + rebuild, reason = consensus_needs_rebuild( + consensus, record, WEIGHTS, abinitio_preds=[str(pred_a), str(pred_c)] + ) + assert rebuild + assert "removed predictions.snap.gff3" in reason + assert "added predictions.helixer.gff3" in reason + + def test_protein_and_transcript_alignments_changed(self, tmp_path): + """Changes to protein or transcript alignment files trigger a rebuild.""" + consensus = make_consensus(tmp_path) + record = str(tmp_path / "consensus.predictions.weights.json") + prot = tmp_path / "protein-alignments.gff3" + tran = tmp_path / "transcript-alignments.gff3" + prot.write_text("##gff-version 3\nchr1\tminiprot\tmRNA\t1\t100\t.\t+\t.\tID=mp1\n") + tran.write_text("##gff-version 3\nchr1\tgapmm2\tcDNA_match\t1\t100\t.\t+\t.\tID=tr1\n") + + write_consensus_weights( + record, + WEIGHTS, + protein_alignments=[str(prot)], + transcript_alignments=[str(tran)], + ) + rebuild, _ = consensus_needs_rebuild( + consensus, + record, + WEIGHTS, + protein_alignments=[str(prot)], + transcript_alignments=[str(tran)], + ) + assert not rebuild + + tran.write_text("##gff-version 3\nchr1\tgapmm2\tcDNA_match\t1\t250\t.\t+\t.\tID=tr1\n") + rebuild, reason = consensus_needs_rebuild( + consensus, + record, + WEIGHTS, + protein_alignments=[str(prot)], + transcript_alignments=[str(tran)], + ) + assert rebuild + assert "transcript alignments changed (modified transcript-alignments.gff3)" in reason + class TestWriteConsensusWeights: """Tests for write_consensus_weights.""" @@ -106,6 +187,68 @@ def test_record_contents(self, tmp_path): write_consensus_weights(str(record), ["augustus:2", "snap:1"]) assert json.loads(record.read_text()) == {"augustus": 2, "snap": 1} + def test_record_contents_with_extended_inputs(self, tmp_path): + """When additional consensus inputs are provided, a structured manifest is written.""" + record = tmp_path / "consensus.predictions.weights.json" + pred = tmp_path / "predictions.snap.gff3" + pred.write_text("##gff-version 3\n") + write_consensus_weights( + str(record), + ["augustus:2", "snap:1"], + max_intron=3000, + abinitio_preds=[str(pred)], + protein_alignments=[], + transcript_alignments=[], + ) + data = json.loads(record.read_text()) + assert data["weights"] == {"augustus": 2, "snap": 1} + assert data["max_intron"] == 3000 + assert "predictions.snap.gff3" in data["abinitio_preds"] + assert data["protein_alignments"] == {} + assert data["transcript_alignments"] == {} + + +class TestUtrRefinementNeedsRebuild: + """Tests for utr_refinement_needs_rebuild and write_utr_refinement_record.""" + + def test_utr_refinement_lifecycle(self, tmp_path): + """UTR refinement is re-used when consensus, BAM file, and library match, and rebuilt otherwise.""" + consensus = make_consensus(tmp_path) + refined = tmp_path / "consensus.predictions.utr_refined.gff3" + record = str(tmp_path / "consensus.predictions.utr_refined.json") + bam = tmp_path / "rnaseq.bam" + bam.write_bytes(b"BAM\x01mockcontent") + + # Missing refined file -> needs rebuild + rebuild, reason = utr_refinement_needs_rebuild( + str(refined), record, consensus, str(bam), "RF" + ) + assert rebuild + assert "no existing UTR-refined consensus" in reason + + # Write refined file and record -> re-used + refined.write_text("##gff-version 3\n") + write_utr_refinement_record(record, consensus, str(bam), "RF") + rebuild, _ = utr_refinement_needs_rebuild( + str(refined), record, consensus, str(bam), "RF" + ) + assert not rebuild + + # Changing bam_library triggers rebuild + rebuild, reason = utr_refinement_needs_rebuild( + str(refined), record, consensus, str(bam), "FR" + ) + assert rebuild + assert "bam_library changed (RF -> FR)" in reason + + # Changing base consensus triggers rebuild + consensus = make_consensus(tmp_path, content="##gff-version 3\n# updated\n") + rebuild, reason = utr_refinement_needs_rebuild( + str(refined), record, consensus, str(bam), "RF" + ) + assert rebuild + assert "base consensus predictions changed" in reason + class TestRemoveFiles: """Tests for remove_files."""