From 7a316f1562462d7142a81f14fd697775df62c662 Mon Sep 17 00:00:00 2001 From: Paula Ruiz-Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:21:46 +0200 Subject: [PATCH] chore: one licence, a citation file, synthetic-data notes and a portable benchmark LICENSE holds the AGPL-3.0, so Cargo.toml, the README and the website now say so, a CITATION.cff names both authors, and check_site.py fails if any of them drifts. The example report says it is made from synthetic data, next to its title and next to every link to it. performance_benchmark.py times Recore on any pangenome given Recore's own inputs and replaces a benchmark tied to one local dataset. --- .github/CONTRIBUTING.md | 16 +- .gitignore | 6 +- CHANGELOG.md | 19 + CITATION.cff | 23 + Cargo.toml | 2 +- README.md | 8 +- benchmarks/README.md | 49 +- benchmarks/amap_performance_benchmark.py | 505 --------------------- benchmarks/performance_benchmark.py | 432 ++++++++++++++++++ benchmarks/self_benchmark.py | 3 +- docs/about/citation.html | 4 +- docs/about/citation.md | 2 +- docs/about/contributing.html | 7 +- docs/about/contributing.md | 8 +- docs/assets/example-report.html | 4 +- docs/assets/search-index.js | 4 +- docs/assets/site.css | 23 + docs/documentation.html | 2 +- docs/getting-started/inputs.html | 2 +- docs/getting-started/tutorial.html | 2 +- docs/guide/matrix-policies.html | 2 +- docs/guide/outputs.html | 2 +- docs/guide/parameters.html | 2 +- docs/guide/report.html | 6 +- docs/guide/report.md | 4 +- docs/how-it-works.html | 2 +- docs/how-it-works/algorithm.html | 2 +- docs/how-it-works/evidence-states.html | 2 +- docs/how-it-works/evolutionary-events.html | 2 +- docs/how-it-works/limits.html | 2 +- docs/how-it-works/rescue-types.html | 2 +- docs/index.html | 10 +- docs/reference.html | 2 +- tools/build_docs.py | 2 +- tools/check_site.py | 74 ++- tools/label_example_report.py | 73 +++ 36 files changed, 717 insertions(+), 593 deletions(-) create mode 100644 CITATION.cff delete mode 100644 benchmarks/amap_performance_benchmark.py create mode 100644 benchmarks/performance_benchmark.py create mode 100644 tools/label_example_report.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1ac7992..d2b1f93 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -224,8 +224,9 @@ The Python is tooling, not a shipped package, and it is not covered by CI: what a change did to real output. - `benchmarks/make_tutorial_dataset.py` generates the dataset used by the tutorial and by the example report. -- `benchmarks/amap_performance_benchmark.py` profiles a run on a full local - dataset; `benchmarks/README.md` documents its invocation and its output. +- `benchmarks/performance_benchmark.py` times a run on a pangenome you have + (wall time, peak memory, disk); `benchmarks/README.md` documents its + invocation and its output. - `benchmarks/recore_report.py` and `tools/make_logo_variants.py` generate assets. @@ -282,14 +283,9 @@ By contributing you agree that your contribution is licensed under the same terms as the project, which are the ones in [LICENSE](../LICENSE) at the repository root. -> [!WARNING] -> The repository currently disagrees with itself about which licence that is. -> `Cargo.toml`, the README and the documentation all say GNU General Public -> License v3.0 (`GPL-3.0-only`), while the committed `LICENSE` file contains the -> text of the GNU **Affero** General Public License v3. Those are different -> licences with different obligations for anyone running a modified copy over a -> network. This needs resolving by the maintainer, one way or the other, before -> the first outside contribution lands. +That licence is the GNU Affero General Public License v3.0 (`AGPL-3.0-only`). +`Cargo.toml`, `CITATION.cff`, the README and the website name it too, and +`tools/check_site.py` fails if one of them drifts from `LICENSE`. Participation is covered by the [Code of Conduct](CODE_OF_CONDUCT.md). Security issues do not go in the issue tracker: see [SECURITY.md](SECURITY.md). diff --git a/.gitignore b/.gitignore index ba8528e..e3130f6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,6 @@ tutorial_out/ out_strict/ out_balanced/ out_sensitive/ -benchmarks/amap_performance_metrics.tsv +benchmarks/performance_metrics.tsv -# Added by cargo - -/target -.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9131d8e..c0f4d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,25 @@ and Recore uses semantic versioning. ### Changed +- Recore is licensed under the GNU Affero General Public License v3.0 + (`AGPL-3.0-only`), the licence its `LICENSE` file holds; `Cargo.toml`, the + README and the website said GPL-3.0. A `CITATION.cff` names the authors for + GitHub's citation button. `tools/check_site.py` fails if `Cargo.toml`, + `CITATION.cff`, the README, the contributing guide or the website name a + licence other than the one in `LICENSE`. +- The example report says it is made from synthetic data: next to its own + title, where it shows in the website's frame, full screen and on a phone, + and next to every link to it on the website, in the README and in the + documentation. `tools/label_example_report.py` adds the note after the report + is regenerated, and `tools/check_site.py` fails on a report or a link without + it. +- `benchmarks/performance_benchmark.py` times Recore on any pangenome, given + the same inputs as Recore, with an optional tree to keep a subset of genomes + and a cluster limit for a quick run. It replaces a benchmark that only ran on + one local dataset. `benchmarks/self_benchmark.py` works in the system's + temporary directory instead of a macOS path. +- The Run report page no longer prints a leftover MkDocs button attribute as + text, and `tools/check_site.py` fails on MkDocs syntax left in the markdown. - The documentation site is now a short website instead of fourteen MkDocs pages: an install line, a command builder that writes the run for your pangenome tool and the files you have, a one-minute example with the counts it diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..8dd4c38 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,23 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +title: "Recore" +abstract: "Post-hoc rescue and quality control of pangenome presence/absence matrices." +type: software +authors: + - family-names: Ruiz-Rodriguez + given-names: Paula + orcid: "https://orcid.org/0000-0003-0727-5974" + affiliation: "I2SysBio, University of Valencia-CSIC, FISABIO Joint Research Unit Infection and Public Health, Valencia, Spain" + email: paula.ruiz.rodriguez@csic.es + - family-names: Coscolla + given-names: Mireia + orcid: "https://orcid.org/0000-0003-0752-0538" + affiliation: "I2SysBio, University of Valencia-CSIC, FISABIO Joint Research Unit Infection and Public Health, Valencia, Spain" +repository-code: "https://github.com/PathoGenOmics-Lab/recore" +url: "https://pathogenomics-lab.github.io/recore/" +license: AGPL-3.0-only +keywords: + - pangenome + - presence/absence matrix + - Panaroo + - bioinformatics diff --git a/Cargo.toml b/Cargo.toml index 5f18f59..77e660b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" rust-version = "1.85" authors = ["Paula Ruiz-Rodriguez ", "Mireia Coscolla"] description = "Post-hoc rescue of falsely absent genes in pangenome matrices" -license = "GPL-3.0-only" +license = "AGPL-3.0-only" repository = "https://github.com/PathoGenOmics-Lab/recore" keywords = ["pangenome", "panaroo", "core-genome", "rescue", "bioinformatics"] categories = ["science", "command-line-utilities"] diff --git a/README.md b/README.md index 5f116e8..620e4ea 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ puts back the genes that are really there, and keeps the evidence for each one. For Panaroo, Roary, PPanGGOLiN, PanTA and PIRATE · pure Rust · Linux and macOS CI -License: GPLv3 +License: AGPLv3 Rust 1.85+ @@ -105,8 +105,8 @@ Four files answer most questions. [All of them](docs/guide/outputs.md). | `orthology_evidence.tsv` | One row per change: the evidence, the decision, and wording you can use in a paper. | | `recore_report.html` | The whole run in one page that works offline. Open it in any browser. | -[Explore a real report](https://pathogenomics-lab.github.io/recore/#report), -the one the example run writes. +[Explore an example report](https://pathogenomics-lab.github.io/recore/#report), +the one the example run writes from synthetic data. ## 5 · One setting that matters @@ -146,7 +146,7 @@ here as well: > Ruiz-Rodriguez P, Coscolla M. (2026). Recore: post-hoc rescue and quality > control of pangenome presence/absence matrices. *In preparation.* -GPL-3.0, see [LICENSE](LICENSE) · [PathoGenOmics Lab](https://github.com/PathoGenOmics-Lab) · Paula Ruiz-Rodriguez, paula.ruiz.rodriguez@csic.es +AGPL-3.0, see [LICENSE](LICENSE) · [PathoGenOmics Lab](https://github.com/PathoGenOmics-Lab) · Paula Ruiz-Rodriguez, paula.ruiz.rodriguez@csic.es --- diff --git a/benchmarks/README.md b/benchmarks/README.md index 4169dbe..ff2ea9a 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -155,43 +155,38 @@ cross-references, a product and a long note), six names per CDS, which is what an annotation index has to hold for most real assemblies; that run goes to `benchmarks/search_scaling_rich_metrics.tsv`. -## AMAP performance benchmark +## Performance on your own pangenome -`amap_performance_benchmark.py` profiles Recore on the full local AMAP -pangenome. It is a resource benchmark, not a precision/recall truth set. +`performance_benchmark.py` times Recore on a pangenome you have: wall time, +peak memory of the whole process tree, temporary disk, output size, and the +rescue counts, appended to `benchmarks/performance_metrics.tsv`. It is a +resource benchmark, not a truth set. -The script: - -- filters `AMAP-gene_presence_absence.csv` with the canonical 459-tip tree -- links FASTA and GFF inputs instead of copying them -- runs the release binary with the publication settings -- records wall time, peak RSS, temporary disk use, output size, rescues, and - corrected matrix entries - -Run from the repository root: +Give it the same inputs as Recore. A Newick tree keeps only the genomes that are +its tips, and `--max-clusters` keeps the first clusters, to time a subset; both +need a Panaroo or Roary `gene_presence_absence.csv`. FASTA and GFF files are +linked, not copied. The `publication` profile adds `--synteny`, +`--multi-allele` and `--nscluster`; `rescue_only` searches without them. ```bash -python3 benchmarks/amap_performance_benchmark.py \ - --threads 8 \ - --profile publication \ - --minimap2 /path/to/minimap2 +python3 benchmarks/performance_benchmark.py \ + -p gene_presence_absence.csv -r pan_genome_reference.fa \ + -g genomes/ -a annotations/ --tree species.nwk --threads 8 ``` -For a quick smoke test: +For a quick smoke test on the tutorial dataset (below): ```bash -python3 benchmarks/amap_performance_benchmark.py \ - --max-clusters 50 \ - --threads 4 \ - --profile rescue_only \ - --minimap2 /path/to/minimap2 \ - --metrics /tmp/recore_amap_smoke_metrics.tsv +python3 benchmarks/performance_benchmark.py \ + -p tutorial_data/gene_presence_absence.csv \ + -r tutorial_data/pan_genome_reference.fa \ + -g tutorial_data/genomes --max-clusters 20 --profile rescue_only \ + --threads 4 --metrics recore_smoke_metrics.tsv ``` -The default AMAP output metrics file is -`benchmarks/amap_performance_metrics.tsv`. Because it records local hardware -and temporary paths, treat it as a local run log rather than a portable -benchmark table. +It builds `target/release/recore` first unless you pass `--recore` with a +binary or `--skip-build`. The metrics file records the machine and temporary +paths, so treat it as a local run log rather than a portable benchmark table. ## Tutorial dataset diff --git a/benchmarks/amap_performance_benchmark.py b/benchmarks/amap_performance_benchmark.py deleted file mode 100644 index 5840946..0000000 --- a/benchmarks/amap_performance_benchmark.py +++ /dev/null @@ -1,505 +0,0 @@ -#!/usr/bin/env python3 -"""Run a reproducible Recore performance benchmark on the local AMAP dataset. - -The benchmark uses the PANTERA 459-genome working set by filtering the AMAP -presence/absence matrix with `pantera/data/phylogeny/pantera_459.treefile`. -It creates only symlinks to FASTA/GFF inputs, runs the release Recore binary, -and samples the process tree to estimate wall time, peak RSS, and temporary -disk use. -""" - -from __future__ import annotations - -import argparse -import csv -import os -import re -import shutil -import subprocess -import sys -import time -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -PROJECT_ROOT = REPO_ROOT.parent -DEFAULT_WORKDIR = Path("/private/tmp/recore_amap_performance") -DEFAULT_METRICS = REPO_ROOT / "benchmarks" / "amap_performance_metrics.tsv" -AS_RE = re.compile(r"AS\d{5}") - -METADATA_COLS = { - "Gene", - "Non-unique Gene name", - "Annotation", - "No. isolates", - "No. sequences", - "Avg sequences per isolate", - "Genome Fragment", - "Order within Fragment", - "Accessory Fragment", - "Accessory Order with Fragment", - "Accessory Order", - "QC", - "Min group size nuc", - "Max group size nuc", - "Avg group size nuc", -} - - -def as_id(value: str) -> str | None: - match = AS_RE.search(value) - return match.group(0) if match else None - - -def read_tree_ids(tree_path: Path) -> tuple[set[str], set[str]]: - text = tree_path.read_text() - tips = set(re.findall(r"([A-Za-z0-9]+_AS\d{5}|AS\d{5})", text)) - asids = {m.group(0) for m in AS_RE.finditer(text)} - if not tips or not asids: - raise SystemExit(f"ERROR: no AS IDs found in {tree_path}") - return tips, asids - - -def ensure_recore_binary(skip_build: bool) -> Path: - binary = REPO_ROOT / "target" / "release" / "recore" - if not skip_build: - subprocess.run(["cargo", "build", "--release"], cwd=REPO_ROOT, check=True) - if not binary.exists(): - raise SystemExit(f"ERROR: release binary not found: {binary}") - return binary - - -def find_minimap2(explicit: str | None) -> Path: - candidates = [] - if explicit: - candidates.append(Path(explicit)) - found = shutil.which("minimap2") - if found: - candidates.append(Path(found)) - candidates.extend( - [ - Path.home() / "miniconda3" / "envs" / "bioconda" / "bin" / "minimap2", - Path.home() / "miniconda3" / "bin" / "minimap2", - ] - ) - for candidate in candidates: - if candidate.exists(): - return candidate - raise SystemExit("ERROR: minimap2 not found. Pass --minimap2 /path/to/minimap2.") - - -def find_existing(paths: list[Path], label: str) -> Path: - for path in paths: - if path.exists(): - return path - raise SystemExit(f"ERROR: cannot find {label}: " + ", ".join(map(str, paths))) - - -def source_paths(project_root: Path) -> dict[str, Path]: - return { - "pa": project_root / "03pangenome-cds" / "AMAP-gene_presence_absence.csv", - "pan_ref": project_root / "03pangenome-cds" / "AMAP-pan_genome_reference.fa", - "tree": project_root / "pantera" / "data" / "phylogeny" / "pantera_459.treefile", - "genomes": project_root / "01data" / "genomes" / "all_genomes", - "annotations": project_root / "02annot" / "AMAP", - } - - -def filter_pa_matrix( - pa_path: Path, - tree_path: Path, - output_path: Path, - max_clusters: int | None, -) -> tuple[list[str], int]: - tips, asids = read_tree_ids(tree_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - with pa_path.open(newline="") as handle: - reader = csv.reader(handle) - header = next(reader) - keep_indices = [] - kept_genomes = [] - for i, col in enumerate(header): - if col in METADATA_COLS: - keep_indices.append(i) - continue - col_as = as_id(col) - if col in tips or (col_as and col_as in asids): - keep_indices.append(i) - kept_genomes.append(col) - - if len(kept_genomes) != len(asids): - raise SystemExit( - f"ERROR: kept {len(kept_genomes)} genomes but tree has {len(asids)} AS IDs" - ) - - isolates_col = header.index("No. isolates") if "No. isolates" in header else None - sequences_col = header.index("No. sequences") if "No. sequences" in header else None - avg_col = ( - header.index("Avg sequences per isolate") - if "Avg sequences per isolate" in header - else None - ) - kept_genome_indices = [i for i in keep_indices if header[i] not in METADATA_COLS] - - n_rows = 0 - with output_path.open("w", newline="") as out_handle: - writer = csv.writer(out_handle, lineterminator="\n") - writer.writerow([header[i] for i in keep_indices]) - for row in reader: - if max_clusters is not None and n_rows >= max_clusters: - break - kept_row = [row[i] if i < len(row) else "" for i in keep_indices] - present = sum( - 1 - for i in kept_genome_indices - if i < len(row) and row[i].strip() - ) - for source_col in (isolates_col, sequences_col): - if source_col is not None and source_col in keep_indices: - kept_row[keep_indices.index(source_col)] = str(present) - if avg_col is not None and avg_col in keep_indices: - kept_row[keep_indices.index(avg_col)] = "1.00" if present else "0.00" - writer.writerow(kept_row) - n_rows += 1 - - return kept_genomes, n_rows - - -def symlink_inputs( - kept_genomes: list[str], - source_genomes: Path, - source_annotations: Path, - input_root: Path, -) -> tuple[int, int]: - genome_dir = input_root / "genomes" - ann_dir = input_root / "annotations" - genome_dir.mkdir(parents=True, exist_ok=True) - ann_dir.mkdir(parents=True, exist_ok=True) - linked_genomes = 0 - linked_annotations = 0 - - for genome in kept_genomes: - genome_as = as_id(genome) - if not genome_as: - continue - genome_src = find_existing( - [ - source_genomes / f"{genome_as}.fna", - source_genomes / f"{genome_as}.fa", - source_genomes / f"{genome_as}.fasta", - source_genomes / f"{genome_as}.fna.gz", - ], - f"FASTA for {genome}", - ) - genome_dst = genome_dir / genome_src.name - if not genome_dst.exists(): - genome_dst.symlink_to(genome_src) - linked_genomes += 1 - - ann_src = find_existing( - [ - source_annotations / f"{genome}.gff", - source_annotations / f"{genome}.gff3", - source_annotations / f"{genome_as}.gff", - source_annotations / f"{genome_as}.gff3", - source_annotations / f"{genome}.gff.gz", - source_annotations / f"{genome}.gff3.gz", - ], - f"GFF for {genome}", - ) - ann_dst = ann_dir / ann_src.name - if not ann_dst.exists(): - ann_dst.symlink_to(ann_src) - linked_annotations += 1 - - return linked_genomes, linked_annotations - - -def dir_size(path: Path) -> int: - if not path.exists(): - return 0 - total = 0 - for root, _dirs, files in os.walk(path): - for file_name in files: - file_path = Path(root) / file_name - try: - if not file_path.is_symlink(): - total += file_path.stat().st_size - except FileNotFoundError: - pass - return total - - -def process_tree_rss_kb(root_pid: int) -> int: - try: - output = subprocess.run( - ["ps", "-axo", "pid=,ppid=,rss="], - check=True, - text=True, - capture_output=True, - ).stdout - except (OSError, subprocess.SubprocessError): - return 0 - children: dict[int, list[int]] = {} - rss: dict[int, int] = {} - for line in output.splitlines(): - parts = line.split() - if len(parts) != 3: - continue - pid, ppid, rss_kb = map(int, parts) - children.setdefault(ppid, []).append(pid) - rss[pid] = rss_kb - - stack = [root_pid] - total = 0 - seen = set() - while stack: - pid = stack.pop() - if pid in seen: - continue - seen.add(pid) - total += rss.get(pid, 0) - stack.extend(children.get(pid, [])) - return total - - -def parse_summary(path: Path) -> dict[str, str]: - summary = {} - if not path.exists(): - return summary - for line in path.read_text(errors="replace").splitlines(): - if ":" not in line: - continue - key, value = line.split(":", 1) - key = key.strip().lower().replace(" ", "_") - value = value.strip() - if key: - summary[key] = value - return summary - - -def run_monitored( - cmd: list[str], - env: dict[str, str], - cwd: Path, - log_dir: Path, - output_dir: Path, -) -> dict[str, float | int]: - log_dir.mkdir(parents=True, exist_ok=True) - stdout_path = log_dir / "stdout.log" - stderr_path = log_dir / "stderr.log" - start = time.monotonic() - peak_rss_kb = 0 - peak_tmp_bytes = 0 - peak_output_bytes = 0 - tmpdir = Path(env["TMPDIR"]) - - with stdout_path.open("w") as stdout, stderr_path.open("w") as stderr: - proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=stdout, stderr=stderr) - last_disk_sample = 0.0 - while proc.poll() is None: - try: - peak_rss_kb = max(peak_rss_kb, process_tree_rss_kb(proc.pid)) - except subprocess.SubprocessError: - pass - now = time.monotonic() - if now - last_disk_sample >= 2.0: - peak_tmp_bytes = max(peak_tmp_bytes, dir_size(tmpdir)) - peak_output_bytes = max(peak_output_bytes, dir_size(output_dir)) - last_disk_sample = now - time.sleep(0.5) - return_code = proc.wait() - - peak_tmp_bytes = max(peak_tmp_bytes, dir_size(tmpdir)) - peak_output_bytes = max(peak_output_bytes, dir_size(output_dir)) - runtime = time.monotonic() - start - if return_code != 0: - raise SystemExit( - f"ERROR: Recore failed with exit code {return_code}. See {stderr_path}" - ) - return { - "runtime_seconds": runtime, - "peak_rss_mb": peak_rss_kb / 1024.0, - "peak_tmp_mb": peak_tmp_bytes / (1024.0 * 1024.0), - "peak_output_mb": peak_output_bytes / (1024.0 * 1024.0), - } - - -def write_metrics(path: Path, row: dict[str, object]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fields = [ - "timestamp", - "profile", - "threads", - "clusters", - "genomes", - "runtime_seconds", - "peak_rss_mb", - "peak_tmp_mb", - "peak_output_mb", - "rescued", - "matrix_corrected", - "core_genome", - "output_dir", - ] - exists = path.exists() - with path.open("a", newline="") as handle: - writer = csv.DictWriter(handle, fieldnames=fields, delimiter="\t", lineterminator="\n") - if not exists: - writer.writeheader() - writer.writerow({field: row.get(field, "") for field in fields}) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--project-root", default=str(PROJECT_ROOT)) - parser.add_argument("--workdir", default=str(DEFAULT_WORKDIR)) - parser.add_argument("--metrics", default=str(DEFAULT_METRICS)) - parser.add_argument("--threads", type=int, default=8) - parser.add_argument("--minimap2") - parser.add_argument("--skip-build", action="store_true") - parser.add_argument("--max-clusters", type=int) - parser.add_argument( - "--profile", - choices=("publication", "rescue_only"), - default="publication", - ) - parser.add_argument("--keep-workdir", action="store_true") - return parser.parse_args() - - - -WORKDIR_MARKER = ".recore-benchmark-workdir" - - -def clean_workdir(workdir: Path) -> None: - """Delete a previous benchmark workdir, and only that. - - The path comes from --workdir, and an unguarded rmtree would take the - repository, a home directory or any folder passed by mistake with it. Only a - directory this script created (it carries a marker file), an empty one, or - an older run's (it holds an input/ folder) is removed.""" - workdir = workdir.resolve() - if not workdir.exists(): - return - root = Path(__file__).resolve().parents[1] - protected = {root, root.parent, Path("/").resolve(), Path.home().resolve()} - ours = (workdir / WORKDIR_MARKER).exists() or (workdir / "input").is_dir() - empty = not any(workdir.iterdir()) - if workdir in protected or not (ours or empty): - raise SystemExit( - f"ERROR: refusing to delete {workdir}: it is not a workdir this benchmark " - f"created (no {WORKDIR_MARKER}). Remove it yourself or pick another --workdir." - ) - shutil.rmtree(workdir) - - -def mark_workdir(workdir: Path) -> None: - workdir.mkdir(parents=True, exist_ok=True) - (workdir / WORKDIR_MARKER).touch() - -def main() -> int: - args = parse_args() - project_root = Path(args.project_root).resolve() - paths = source_paths(project_root) - for label, path in paths.items(): - if not path.exists(): - raise SystemExit(f"ERROR: missing {label}: {path}") - - recore = ensure_recore_binary(args.skip_build) - minimap2 = find_minimap2(args.minimap2) - workdir = Path(args.workdir).resolve() - if not args.keep_workdir: - clean_workdir(workdir) - mark_workdir(workdir) - input_dir = workdir / "input" - output_dir = workdir / f"output_{args.profile}" - tmpdir = workdir / "tmp" - log_dir = workdir / "logs" - tmpdir.mkdir(parents=True, exist_ok=True) - - filtered_pa = input_dir / "AMAP-gene_presence_absence_459.csv" - kept_genomes, n_clusters = filter_pa_matrix( - paths["pa"], - paths["tree"], - filtered_pa, - args.max_clusters, - ) - linked_genomes, linked_annotations = symlink_inputs( - kept_genomes, - paths["genomes"], - paths["annotations"], - input_dir, - ) - - cmd = [ - str(recore), - "-p", - str(filtered_pa), - "-r", - str(paths["pan_ref"]), - "-g", - str(input_dir / "genomes"), - "--preset", - "asm5", - "--min-prevalence", - "0.05", - "--matrix-policy", - "balanced", - "--reference-genome", - "AS00146", - "-t", - str(args.threads), - "-o", - str(output_dir), - ] - if args.profile == "publication": - cmd.extend( - [ - "-a", - str(input_dir / "annotations"), - "--synteny", - "--multi-allele", - "--nscluster", - "--nsc-engine", - "minhash", - ] - ) - - env = os.environ.copy() - env["PATH"] = f"{minimap2.parent}{os.pathsep}{env.get('PATH', '')}" - env["TMPDIR"] = str(tmpdir) - - print(f"Running AMAP benchmark: profile={args.profile}, clusters={n_clusters}, genomes={len(kept_genomes)}") - print(f"Linked inputs: genomes={linked_genomes}, annotations={linked_annotations}") - print("Command:", " ".join(cmd)) - - monitored = run_monitored(cmd, env, REPO_ROOT, log_dir, output_dir) - summary = parse_summary(output_dir / "summary.txt") - row = { - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - "profile": args.profile, - "threads": args.threads, - "clusters": n_clusters, - "genomes": len(kept_genomes), - "runtime_seconds": f"{monitored['runtime_seconds']:.2f}", - "peak_rss_mb": f"{monitored['peak_rss_mb']:.1f}", - "peak_tmp_mb": f"{monitored['peak_tmp_mb']:.1f}", - "peak_output_mb": f"{monitored['peak_output_mb']:.1f}", - "rescued": summary.get("rescued", ""), - "matrix_corrected": summary.get("matrix-corrected", ""), - "core_genome": summary.get("core_genome_(strict)", ""), - "output_dir": str(output_dir), - } - write_metrics(Path(args.metrics), row) - - print("Metrics:") - for key, value in row.items(): - print(f" {key}: {value}") - print(f"Wrote metrics: {args.metrics}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/benchmarks/performance_benchmark.py b/benchmarks/performance_benchmark.py new file mode 100644 index 0000000..fd01250 --- /dev/null +++ b/benchmarks/performance_benchmark.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +"""Measure how long Recore takes, and how much memory and disk it uses, on a +pangenome you have. + +Give it what your pangenome tool wrote and the assemblies, the way you would +give them to Recore. A Newick tree keeps only the genomes that are its tips, +and --max-clusters keeps the first N clusters, to time a subset; both need a +Panaroo or Roary gene_presence_absence.csv. The inputs are linked, not copied, +and the run's wall time, peak memory of the whole process tree, temporary disk +and output size are appended to a TSV. + +It is a resource benchmark, not a truth set: synthetic_benchmark.py and +self_benchmark.py measure whether the answers are right. +""" + +from __future__ import annotations + +import argparse +import csv +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "recore_performance_benchmark" +DEFAULT_METRICS = REPO_ROOT / "benchmarks" / "performance_metrics.tsv" +FASTA_EXTENSIONS = (".fasta", ".fa", ".fna", ".fasta.gz", ".fa.gz", ".fna.gz") +GFF_EXTENSIONS = (".gff", ".gff3", ".gff.gz", ".gff3.gz") + +# The columns of a Panaroo or Roary gene_presence_absence.csv that are not +# genomes. +METADATA_COLS = { + "Gene", + "Non-unique Gene name", + "Annotation", + "No. isolates", + "No. sequences", + "Avg sequences per isolate", + "Genome Fragment", + "Order within Fragment", + "Accessory Fragment", + "Accessory Order with Fragment", + "Accessory Order", + "QC", + "Min group size nuc", + "Max group size nuc", + "Avg group size nuc", +} + + +def read_tips(tree_path: Path) -> set[str]: + """The tip labels of a Newick tree: the names right after '(' or ','.""" + text = tree_path.read_text() + tips = {label.strip("'\"") for label in re.findall(r"[(,]\s*([^():,;\s]+)", text)} + if not tips: + raise SystemExit(f"ERROR: no tip labels found in {tree_path}") + return tips + + +def matrix_genomes(matrix: Path) -> tuple[list[str], list[str]]: + with matrix.open(newline="") as handle: + header = next(csv.reader(handle)) + return header, [col for col in header if col not in METADATA_COLS] + + +def filter_matrix( + matrix: Path, + keep: set[str] | None, + output: Path, + max_clusters: int | None, +) -> tuple[list[str], int]: + """Write the matrix with only the genomes in `keep` (all of them when None) + and at most `max_clusters` rows, recounting the isolate columns.""" + header, genomes = matrix_genomes(matrix) + if header[:1] != ["Gene"]: + raise SystemExit( + f"ERROR: {matrix} is not a Panaroo or Roary gene_presence_absence.csv; " + "--tree and --max-clusters only work on that format" + ) + kept = [g for g in genomes if keep is None or g in keep] + if not kept: + raise SystemExit("ERROR: no matrix column is a tip of the tree") + if keep is not None and len(kept) < len(keep): + missing = sorted(keep - set(kept)) + print(f"Note: {len(missing)} tree tips are not matrix columns, e.g. {', '.join(missing[:3])}") + keep_indices = [i for i, col in enumerate(header) if col in METADATA_COLS or col in kept] + genome_indices = [i for i in keep_indices if header[i] not in METADATA_COLS] + recount = [header.index(c) for c in ("No. isolates", "No. sequences") if c in header] + average = header.index("Avg sequences per isolate") if "Avg sequences per isolate" in header else None + + output.parent.mkdir(parents=True, exist_ok=True) + n_rows = 0 + with matrix.open(newline="") as handle, output.open("w", newline="") as out: + reader = csv.reader(handle) + next(reader) + writer = csv.writer(out, lineterminator="\n") + writer.writerow([header[i] for i in keep_indices]) + for row in reader: + if max_clusters is not None and n_rows >= max_clusters: + break + kept_row = [row[i] if i < len(row) else "" for i in keep_indices] + present = sum(1 for i in genome_indices if i < len(row) and row[i].strip()) + for column in recount: + kept_row[keep_indices.index(column)] = str(present) + if average is not None: + kept_row[keep_indices.index(average)] = "1.00" if present else "0.00" + writer.writerow(kept_row) + n_rows += 1 + return kept, n_rows + + +def find_input(directory: Path, genome: str, extensions: tuple[str, ...], label: str) -> Path: + for extension in extensions: + path = directory / f"{genome}{extension}" + if path.exists(): + return path + raise SystemExit(f"ERROR: no {label} for {genome} in {directory} (tried {', '.join(extensions)})") + + +def link_inputs(genomes: list[str], fasta_dir: Path, gff_dir: Path | None, input_root: Path) -> None: + """Link each genome's FASTA, and its GFF when there is one, named after its + matrix column the way Recore expects.""" + for name, source, extensions in (("genomes", fasta_dir, FASTA_EXTENSIONS), ("annotations", gff_dir, GFF_EXTENSIONS)): + if source is None: + continue + target = input_root / name + target.mkdir(parents=True, exist_ok=True) + for genome in genomes: + found = find_input(source, genome, extensions, "FASTA" if name == "genomes" else "GFF") + link = target / found.name + if not link.exists(): + link.symlink_to(found.resolve()) + + +def recore_binary(explicit: str | None, skip_build: bool) -> Path: + if explicit: + binary = Path(explicit).resolve() + else: + binary = REPO_ROOT / "target" / "release" / "recore" + if not skip_build: + subprocess.run(["cargo", "build", "--release"], cwd=REPO_ROOT, check=True) + if not binary.exists(): + raise SystemExit(f"ERROR: Recore binary not found: {binary}") + return binary + + +def find_minimap2(explicit: str | None) -> Path: + found = explicit or shutil.which("minimap2") + if not found or not Path(found).exists(): + raise SystemExit("ERROR: minimap2 not found on PATH. Pass --minimap2 /path/to/minimap2.") + return Path(found).resolve() + + +def dir_size(path: Path) -> int: + if not path.exists(): + return 0 + total = 0 + for root, _dirs, files in os.walk(path): + for file_name in files: + file_path = Path(root) / file_name + try: + if not file_path.is_symlink(): + total += file_path.stat().st_size + except FileNotFoundError: + pass + return total + + +def process_tree_rss_kb(root_pid: int) -> int: + try: + output = subprocess.run( + ["ps", "-axo", "pid=,ppid=,rss="], + check=True, + text=True, + capture_output=True, + ).stdout + except (OSError, subprocess.SubprocessError): + return 0 + children: dict[int, list[int]] = {} + rss: dict[int, int] = {} + for line in output.splitlines(): + parts = line.split() + if len(parts) != 3: + continue + pid, ppid, rss_kb = map(int, parts) + children.setdefault(ppid, []).append(pid) + rss[pid] = rss_kb + + stack = [root_pid] + total = 0 + seen = set() + while stack: + pid = stack.pop() + if pid in seen: + continue + seen.add(pid) + total += rss.get(pid, 0) + stack.extend(children.get(pid, [])) + return total + + +def parse_summary(path: Path) -> dict[str, str]: + summary = {} + if not path.exists(): + return summary + for line in path.read_text(errors="replace").splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip().lower().replace(" ", "_") + value = value.strip() + if key: + summary[key] = value + return summary + + +def run_monitored( + cmd: list[str], + env: dict[str, str], + cwd: Path, + log_dir: Path, + output_dir: Path, +) -> dict[str, float | int]: + log_dir.mkdir(parents=True, exist_ok=True) + stdout_path = log_dir / "stdout.log" + stderr_path = log_dir / "stderr.log" + start = time.monotonic() + peak_rss_kb = 0 + peak_tmp_bytes = 0 + peak_output_bytes = 0 + tmpdir = Path(env["TMPDIR"]) + + with stdout_path.open("w") as stdout, stderr_path.open("w") as stderr: + proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=stdout, stderr=stderr) + last_disk_sample = 0.0 + while proc.poll() is None: + try: + peak_rss_kb = max(peak_rss_kb, process_tree_rss_kb(proc.pid)) + except subprocess.SubprocessError: + pass + now = time.monotonic() + if now - last_disk_sample >= 2.0: + peak_tmp_bytes = max(peak_tmp_bytes, dir_size(tmpdir)) + peak_output_bytes = max(peak_output_bytes, dir_size(output_dir)) + last_disk_sample = now + time.sleep(0.5) + return_code = proc.wait() + + peak_tmp_bytes = max(peak_tmp_bytes, dir_size(tmpdir)) + peak_output_bytes = max(peak_output_bytes, dir_size(output_dir)) + runtime = time.monotonic() - start + if return_code != 0: + raise SystemExit( + f"ERROR: Recore failed with exit code {return_code}. See {stderr_path}" + ) + return { + "runtime_seconds": runtime, + "peak_rss_mb": peak_rss_kb / 1024.0, + "peak_tmp_mb": peak_tmp_bytes / (1024.0 * 1024.0), + "peak_output_mb": peak_output_bytes / (1024.0 * 1024.0), + } + + +def write_metrics(path: Path, row: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "timestamp", + "profile", + "threads", + "clusters", + "genomes", + "runtime_seconds", + "peak_rss_mb", + "peak_tmp_mb", + "peak_output_mb", + "rescued", + "matrix_corrected", + "core_genome", + "output_dir", + ] + exists = path.exists() + with path.open("a", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, delimiter="\t", lineterminator="\n") + if not exists: + writer.writeheader() + writer.writerow({field: row.get(field, "") for field in fields}) + + +WORKDIR_MARKER = ".recore-benchmark-workdir" + + +def clean_workdir(workdir: Path) -> None: + """Delete a previous benchmark workdir, and only that. + + The path comes from --workdir, and an unguarded rmtree would take the + repository, a home directory or any folder passed by mistake with it. Only a + directory this script created (it carries a marker file), an empty one, or + an older run's (it holds an input/ folder) is removed.""" + workdir = workdir.resolve() + if not workdir.exists(): + return + root = Path(__file__).resolve().parents[1] + protected = {root, root.parent, Path("/").resolve(), Path.home().resolve()} + ours = (workdir / WORKDIR_MARKER).exists() or (workdir / "input").is_dir() + empty = not any(workdir.iterdir()) + if workdir in protected or not (ours or empty): + raise SystemExit( + f"ERROR: refusing to delete {workdir}: it is not a workdir this benchmark " + f"created (no {WORKDIR_MARKER}). Remove it yourself or pick another --workdir." + ) + shutil.rmtree(workdir) + + +def mark_workdir(workdir: Path) -> None: + workdir.mkdir(parents=True, exist_ok=True) + (workdir / WORKDIR_MARKER).touch() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("-p", "--matrix", required=True, help="the presence/absence matrix your pangenome tool wrote") + parser.add_argument("-r", "--reference", required=True, help="its representative sequences, pan_genome_reference.fa") + parser.add_argument("-g", "--genomes", required=True, help="one assembly per genome, named like the matrix columns") + parser.add_argument("-a", "--annotations", help="one GFF per genome; the publication profile needs it") + parser.add_argument("--tree", help="a Newick tree: keep only the genomes that are its tips") + parser.add_argument("--max-clusters", type=int, help="keep only the first N clusters of the matrix") + parser.add_argument("--profile", choices=("publication", "rescue_only"), default="publication", + help="publication adds --synteny, --multi-allele and --nscluster (default)") + parser.add_argument("--preset", help="passed to Recore; its default when omitted") + parser.add_argument("--min-prevalence", help="passed to Recore; its default when omitted") + parser.add_argument("--reference-genome", help="passed to Recore, to audit a reference column") + parser.add_argument("--threads", type=int, default=8) + parser.add_argument("--recore", help="the Recore binary to time (default: build target/release/recore)") + parser.add_argument("--skip-build", action="store_true", help="use target/release/recore as it is") + parser.add_argument("--minimap2", help="the minimap2 to put first on PATH (default: the one on PATH)") + parser.add_argument("--workdir", default=str(DEFAULT_WORKDIR)) + parser.add_argument("--metrics", default=str(DEFAULT_METRICS)) + parser.add_argument("--keep-workdir", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + matrix = Path(args.matrix).resolve() + reference = Path(args.reference).resolve() + fasta_dir = Path(args.genomes).resolve() + gff_dir = Path(args.annotations).resolve() if args.annotations else None + for label, path in (("matrix", matrix), ("reference", reference), ("genomes", fasta_dir), ("annotations", gff_dir)): + if path is not None and not path.exists(): + raise SystemExit(f"ERROR: missing {label}: {path}") + if args.profile == "publication" and gff_dir is None: + raise SystemExit("ERROR: the publication profile checks neighbours and needs -a/--annotations") + + recore = recore_binary(args.recore, args.skip_build) + minimap2 = find_minimap2(args.minimap2) + workdir = Path(args.workdir).resolve() + if not args.keep_workdir: + clean_workdir(workdir) + mark_workdir(workdir) + input_dir = workdir / "input" + output_dir = workdir / f"output_{args.profile}" + tmpdir = workdir / "tmp" + log_dir = workdir / "logs" + tmpdir.mkdir(parents=True, exist_ok=True) + + if args.tree or args.max_clusters is not None: + keep = read_tips(Path(args.tree)) if args.tree else None + run_matrix = input_dir / matrix.name + genomes, n_clusters = filter_matrix(matrix, keep, run_matrix, args.max_clusters) + else: + run_matrix = matrix + _header, genomes = matrix_genomes(matrix) if matrix.suffix == ".csv" else ([], []) + with matrix.open() as handle: + n_clusters = max(0, sum(1 for _ in handle) - 1) + if genomes: + link_inputs(genomes, fasta_dir, gff_dir, input_dir) + genomes_arg, annotations_arg = input_dir / "genomes", input_dir / "annotations" + else: + # A format whose columns this script does not parse: hand Recore the + # folders as they are. + genomes_arg, annotations_arg = fasta_dir, gff_dir + + cmd = [str(recore), "-p", str(run_matrix), "-r", str(reference), "-g", str(genomes_arg)] + for flag, value in (("--preset", args.preset), ("--min-prevalence", args.min_prevalence), + ("--reference-genome", args.reference_genome)): + if value: + cmd += [flag, value] + if args.profile == "publication": + cmd += ["-a", str(annotations_arg), "--synteny", "--multi-allele", "--nscluster", "--nsc-engine", "minhash"] + cmd += ["--matrix-policy", "balanced", "-t", str(args.threads), "-o", str(output_dir)] + + env = os.environ.copy() + env["PATH"] = f"{minimap2.parent}{os.pathsep}{env.get('PATH', '')}" + env["TMPDIR"] = str(tmpdir) + + print(f"Timing Recore: profile={args.profile}, clusters={n_clusters}, genomes={len(genomes) or 'as given'}") + print("Command:", " ".join(cmd)) + + monitored = run_monitored(cmd, env, workdir, log_dir, output_dir) + summary = parse_summary(output_dir / "summary.txt") + row = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "profile": args.profile, + "threads": args.threads, + "clusters": n_clusters, + "genomes": len(genomes) or "", + "runtime_seconds": f"{monitored['runtime_seconds']:.2f}", + "peak_rss_mb": f"{monitored['peak_rss_mb']:.1f}", + "peak_tmp_mb": f"{monitored['peak_tmp_mb']:.1f}", + "peak_output_mb": f"{monitored['peak_output_mb']:.1f}", + "rescued": summary.get("rescued", ""), + "matrix_corrected": summary.get("matrix-corrected", ""), + "core_genome": summary.get("core_genome_(strict)", ""), + "output_dir": str(output_dir), + } + write_metrics(Path(args.metrics), row) + + print("Metrics:") + for key, value in row.items(): + print(f" {key}: {value}") + print(f"Wrote metrics: {args.metrics}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/self_benchmark.py b/benchmarks/self_benchmark.py index 9f79106..a393c60 100644 --- a/benchmarks/self_benchmark.py +++ b/benchmarks/self_benchmark.py @@ -17,13 +17,14 @@ import shutil import subprocess import sys +import tempfile import time from collections import Counter, defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -DEFAULT_WORKDIR = Path("/private/tmp/recore_self_benchmark") +DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "recore_self_benchmark" DEFAULT_METRICS = ROOT / "benchmarks" / "self_benchmark_metrics.tsv" METADATA_COLS = { diff --git a/docs/about/citation.html b/docs/about/citation.html index b4f2a7d..31accaa 100644 --- a/docs/about/citation.html +++ b/docs/about/citation.html @@ -87,7 +87,7 @@

Contactgithub.com/PathoGenOmics-Lab

License#

-

GNU General Public License v3.0. See LICENSE.

+

GNU Affero General Public License v3.0 (AGPL-3.0-only). See LICENSE.

Edit this page on GitHub

@@ -114,7 +114,7 @@

Project

  • GitHub
  • Questions
  • Report a bug
  • -
  • GPL-3.0 · PathoGenOmics Lab
  • +
  • AGPL-3.0 · PathoGenOmics Lab
  • diff --git a/docs/about/citation.md b/docs/about/citation.md index 75482ed..ab0cc29 100644 --- a/docs/about/citation.md +++ b/docs/about/citation.md @@ -37,5 +37,5 @@ PathoGenOmics Lab, [github.com/PathoGenOmics-Lab](https://github.com/PathoGenOmi ## License -GNU General Public License v3.0. See +GNU Affero General Public License v3.0 (`AGPL-3.0-only`). See [LICENSE](https://github.com/PathoGenOmics-Lab/recore/blob/main/LICENSE). diff --git a/docs/about/contributing.html b/docs/about/contributing.html index 85555c8..0f523eb 100644 --- a/docs/about/contributing.html +++ b/docs/about/contributing.html @@ -100,7 +100,7 @@

    Documentation
    cargo build
     python3 tools/check_site.py --recore target/debug/recore --run-tutorial

    That fails on a documentation page that no longer matches its markdown, a dead link or anchor, an option the site mentions that recore --help does not have, an option missing from the reference, a default that differs from the binary's, an em-dash, and an example run whose counts differ from the ones the page shows.

    -

    docs/assets/example-report.html is the report the website and the Run report page link to. It is the tutorial run's own output, so regenerate it whenever the viewer changes:

    +

    docs/assets/example-report.html is the report the website and the Run report page link to. It is the tutorial run's own output, so regenerate it whenever the viewer changes. Its genomes are made up, so the last step marks it as synthetic example data next to its title, and tools/check_site.py fails on a report without that note, or on a link to it that does not say so:

    python3 benchmarks/make_tutorial_dataset.py --out tutorial_data
     recore -p tutorial_data/gene_presence_absence.csv \
            -r tutorial_data/pan_genome_reference.fa \
    @@ -108,7 +108,8 @@ 

    Documentation

    +cp tutorial_out/recore_report.html docs/assets/example-report.html +python3 tools/label_example_report.py docs/assets/example-report.html

    The two logo variants and the report's dark-mode colour overrides all come from one source file, so regenerate those together rather than editing either by hand:

    python3 tools/make_logo_variants.py

    Reporting a problem#

    @@ -139,7 +140,7 @@

    Project

  • GitHub
  • Questions
  • Report a bug
  • -
  • GPL-3.0 · PathoGenOmics Lab
  • +
  • AGPL-3.0 · PathoGenOmics Lab
  • diff --git a/docs/about/contributing.md b/docs/about/contributing.md index db76eae..4b5f561 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -104,8 +104,11 @@ that differs from the binary's, an em-dash, and an example run whose counts differ from the ones the page shows. `docs/assets/example-report.html` is the report the website and the -[Run report](../guide/report.md) page link to. It is the tutorial run's own output, so regenerate it whenever the -viewer changes: +[Run report](../guide/report.md) page link to. It is the tutorial run's own +output, so regenerate it whenever the viewer changes. Its genomes are made up, +so the last step marks it as synthetic example data next to its title, and +`tools/check_site.py` fails on a report without that note, or on a link to it +that does not say so: ```bash python3 benchmarks/make_tutorial_dataset.py --out tutorial_data @@ -116,6 +119,7 @@ recore -p tutorial_data/gene_presence_absence.csv \ --reference-genome L4_AS00401 --tree tutorial_data/species.tree \ --write-html-report -o tutorial_out/ cp tutorial_out/recore_report.html docs/assets/example-report.html +python3 tools/label_example_report.py docs/assets/example-report.html ``` The two logo variants and the report's dark-mode colour overrides all come from diff --git a/docs/assets/example-report.html b/docs/assets/example-report.html index c9622b5..30aeb44 100644 --- a/docs/assets/example-report.html +++ b/docs/assets/example-report.html @@ -3848,7 +3848,7 @@ display: table-header-group; } } -

    Recore run report