"""Algorithm benchmark report generator — JSON results → markdown tables."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any


def _fmt_ms(val: float) -> str:
    """Format milliseconds for display."""
    if val == 0.0:
        return "—"
    if val < 1.0:
        return f"{val:.2f}ms"
    if val < 1000.0:
        return f"{val:.1f}ms"
    return f"{val / 1000:.2f}s"


def load_algo_results(results_dir: str | Path) -> list[dict[str, Any]]:
    """Load all algo_*.json result files from a directory."""
    results_dir = Path(results_dir)
    runs: list[dict[str, Any]] = []
    for path in sorted(results_dir.glob("algo_*.json")):
        data = json.loads(path.read_text())
        runs.append(data)
    return runs


# ---------------------------------------------------------------------------
# Projected scaling estimates based on algorithmic complexity
# ---------------------------------------------------------------------------

# Complexity class for each algorithm, used to project from measured 10K data.
# Format: (V_exponent, E_exponent) where cost ~ V^a * E^b
# "linear" in edges = (0, 1), "V*E" = (1, 1), etc.
# For pair-based algorithms, cost is per-pair so scaling is weaker.
_COMPLEXITY: dict[str, tuple[str, str]] = {
    "C01_degree":                ("O(V+E)",     "linear"),
    "C02_betweenness":           ("O(V*E)",     "quadratic"),
    "C03_closeness":             ("O(V*E)",     "quadratic"),
    "C04_eigenvector":           ("O(V^2)",     "quadratic"),
    "C05_harmonic":              ("O(V*(V+E))", "quadratic"),
    "M01_louvain":               ("O(V*log(V))", "near-linear"),
    "M02_label_propagation":     ("O(V+E)",     "linear"),
    "M03_greedy_modularity":     ("O(V*E*log(V))", "super-quadratic"),
    "M04_leiden":                ("O(V*log(V))", "near-linear"),
    "P01_shortest_path":         ("O(V+E)",     "constant-pairs"),
    "P02_dijkstra":              ("O(E+V*log(V))", "constant-pairs"),
    "P03_all_shortest_paths":    ("O(V+E)",     "constant-pairs"),
    "R01_pagerank":              ("O(k*(V+E))", "linear"),
    "S01_jaccard":               ("O(pairs*deg)", "constant-pairs"),
    "S02_adamic_adar":           ("O(pairs*deg)", "constant-pairs"),
    "S03_common_neighbors":      ("O(pairs*deg)", "constant-pairs"),
    "L01_preferential_attachment": ("O(pairs)", "constant-pairs"),
    "L02_resource_allocation":   ("O(pairs*deg)", "constant-pairs"),
    "E01_node2vec":              ("O(walks*V*len)", "linear"),
}

# Scale multipliers for graph sizes relative to 10K baseline (10K nodes, 50K edges)
_SCALE_MULTIPLIERS = {
    "100k": {"V": 10, "E": 10},
    "1m":   {"V": 100, "E": 100},
}


def _project_time(p50_10k: float, scale_class: str, target: str) -> float | None:
    """Project 10K timing to a larger scale based on complexity class."""
    if target not in _SCALE_MULTIPLIERS:
        return None
    m = _SCALE_MULTIPLIERS[target]
    v, e = m["V"], m["E"]

    if scale_class == "linear":
        return p50_10k * v  # O(V+E) or O(V*log(V))
    elif scale_class == "near-linear":
        import math
        return p50_10k * v * (math.log2(v * 10_000) / math.log2(10_000))
    elif scale_class == "quadratic":
        return p50_10k * v * e  # O(V*E)
    elif scale_class == "super-quadratic":
        import math
        return p50_10k * v * e * (math.log2(v * 10_000) / math.log2(10_000))
    elif scale_class == "constant-pairs":
        # Fixed number of pairs; cost scales with avg degree (roughly constant)
        return p50_10k * 1.5  # Slight increase from larger neighborhoods
    return None


def _generate_projections(
    scale_p50s: dict[str, dict[str, float]],
) -> list[str]:
    """Generate projected scaling estimates from 10K data."""
    lines: list[str] = []
    base_data = scale_p50s.get("10k", {})
    if not base_data:
        return lines

    lines.append("## Projected Scaling Estimates\n")
    lines.append(
        "Projections from 10K measurements based on algorithmic complexity. "
        "These are theoretical estimates — actual performance depends on "
        "cache effects, memory pressure, and graph structure.\n"
    )

    for target in ("100k", "1m"):
        lines.append(f"### Projected at {target.upper()}\n")
        lines.append("| Algorithm | Complexity | 10K (actual) | "
                     f"{target.upper()} (projected) | Feasible? |")
        lines.append("|---|---|---|---|---|")

        for aid in sorted(base_data.keys()):
            p50 = base_data[aid]
            complexity_info = _COMPLEXITY.get(aid)
            if not complexity_info:
                continue
            big_o, scale_class = complexity_info
            projected = _project_time(p50, scale_class, target)
            if projected is None:
                continue

            # Feasibility assessment
            if projected < 1_000:
                feasible = "Fast"
            elif projected < 30_000:
                feasible = "OK"
            elif projected < 300_000:
                feasible = "Slow"
            else:
                feasible = "Prohibitive"

            lines.append(
                f"| {aid} | {big_o} | {_fmt_ms(p50)} | "
                f"{_fmt_ms(projected)} | {feasible} |"
            )

        lines.append("")

    return lines


# ---------------------------------------------------------------------------
# Improvement analysis
# ---------------------------------------------------------------------------

def _generate_analysis(
    scale_p50s: dict[str, dict[str, float]],
    graph_load_ms: float | None,
) -> list[str]:
    """Generate improvement opportunity analysis."""
    lines: list[str] = []
    lines.append("## Improvement Opportunities\n")

    # Graph loading
    if graph_load_ms and graph_load_ms > 1000:
        lines.append("### 1. Graph Loading (highest impact)\n")
        lines.append(f"Current `to_networkx()` load time: **{_fmt_ms(graph_load_ms)}**\n")
        lines.append(
            "The current implementation makes N+1 Cypher queries (one per label/type), "
            "each going through AGE's Cypher planner and ANTLR4 agtype deserialization. "
            "Replacing with direct SQL reads from AGE label tables "
            "(`SELECT id::text::bigint, properties FROM graph.\"Node\"`) "
            "would eliminate Cypher planning overhead and reduce to 2 bulk queries.\n"
        )
        lines.append("| Approach | Expected Load Time | Effort |")
        lines.append("|---|---|---|")
        lines.append(
            f"| Current (Cypher per-label) | {_fmt_ms(graph_load_ms)} | — |"
        )
        lines.append("| Direct SQL (2 bulk queries) | ~1-3s | Low |")
        lines.append("| SQL COPY protocol | ~0.5-1s | Medium |")
        lines.append("| Arrow export (pg → pyarrow) | ~0.2-0.5s | Medium |")
        lines.append("")

    # Algorithm backends
    lines.append("### 2. Algorithm Backends\n")
    lines.append(
        "NetworkX is pure Python. Alternative backends offer 5-2000x speedups "
        "with no algorithm changes:\n"
    )
    lines.append("| Backend | Speedup | Install | Notes |")
    lines.append("|---|---|---|---|")
    lines.append(
        "| NetworkX (current) | 1x | `pip install networkx` | Pure Python, reference impl |"
    )
    lines.append(
        "| igraph | 5-30x | `pip install igraph` | "
        "C core, all centrality/community/paths |"
    )
    lines.append(
        "| NetworKit | 10-2000x | `pip install networkit` | "
        "Parallel C++, approximate algorithms |"
    )
    lines.append(
        "| nx-cugraph | 100-1000x | `pip install nx-cugraph-cu12` | "
        "GPU, NetworkX 3.x dispatch backend |"
    )
    lines.append("")
    lines.append(
        "NetworkX 3.x supports backend dispatch — `nx.betweenness_centrality(G, backend=\"cugraph\")` "
        "routes to a faster implementation with zero code changes to the algorithm call.\n"
    )

    # Approximate algorithms
    base_data = scale_p50s.get("10k", {})
    slow_algos = {
        aid: p50 for aid, p50 in base_data.items() if p50 > 3000
    }
    if slow_algos:
        lines.append("### 3. Approximate Algorithms\n")
        lines.append(
            "For algorithms exceeding 3s at 10K scale, "
            "approximate variants trade accuracy for speed:\n"
        )
        lines.append("| Algorithm | 10K Time | Approximate Option | Expected Speedup |")
        lines.append("|---|---|---|---|")

        approx_options: dict[str, tuple[str, str]] = {
            "C02_betweenness": (
                "`nx.betweenness_centrality(G, k=100)` — O(k*E) sampling",
                "~100x (k=100 vs V=10K)",
            ),
            "C03_closeness": (
                "NetworKit `ApproxCloseness` — hybrid sampled estimator",
                "~10-50x",
            ),
            "C05_harmonic": (
                "NetworKit `TopHarmonicCloseness` — top-k approximation",
                "~10-50x",
            ),
            "M03_greedy_modularity": (
                "Louvain (already fast) or Leiden as alternative",
                "~25x (use M01 instead)",
            ),
        }

        for aid in sorted(slow_algos.keys()):
            p50 = slow_algos[aid]
            if aid in approx_options:
                option, speedup = approx_options[aid]
                lines.append(f"| {aid} | {_fmt_ms(p50)} | {option} | {speedup} |")

        lines.append("")
        lines.append(
            "For betweenness centrality specifically, no known exact algorithm "
            "improves below O(V*E) on general graphs. The KADABRA algorithm "
            "(Borassi-Natale 2019) provides adaptive sampling with confidence "
            "bounds and is available in NetworKit as `KadabraBetweenness`.\n"
        )

    # Recommended SDK roadmap
    lines.append("### 4. Recommended SDK Improvements\n")
    lines.append("| Priority | Improvement | Impact | Effort |")
    lines.append("|---|---|---|---|")
    lines.append(
        "| P0 | Direct SQL graph loading | "
        "10-50x faster `to_networkx()` | 1-2 days |"
    )
    lines.append(
        "| P1 | `approximate=True` param for centrality | "
        "100x for betweenness, 10x closeness/harmonic | 1 day |"
    )
    lines.append(
        "| P1 | Graph projection cache | "
        "Amortize load cost across multiple algo calls | 1 day |"
    )
    lines.append(
        "| P2 | igraph/networkit backend option | "
        "5-2000x algorithm speedup | 2-3 days |"
    )
    lines.append(
        "| P3 | In-database algorithms (SQL PageRank, etc.) | "
        "Zero data export for simple algorithms | 3-5 days |"
    )
    lines.append("")

    return lines


def generate_algo_markdown(runs: list[dict[str, Any]]) -> str:
    """Generate a markdown report from algorithm benchmark results."""
    lines: list[str] = []
    lines.append("# Algorithm Performance Report\n")

    # Group by scale
    by_scale: dict[str, list[dict[str, Any]]] = {}
    for run in runs:
        scale = run.get("scale", "unknown")
        by_scale.setdefault(scale, []).append(run)

    # Collect per-scale p50 values for scaling factor comparison
    scale_p50s: dict[str, dict[str, float]] = {}
    graph_load_ms: float | None = None

    for scale, scale_runs in sorted(by_scale.items()):
        lines.append(f"## Scale: {scale.upper()}\n")

        # System info from first run
        sys_info = scale_runs[0].get("system_info", {})
        if sys_info:
            lines.append("**System:**")
            lines.append(f"- Platform: {sys_info.get('platform', 'N/A')}")
            lines.append(f"- CPUs: {sys_info.get('cpu_count', 'N/A')}")
            lines.append(f"- RAM: {sys_info.get('memory_gb', 'N/A')} GB")
            lines.append(f"- Python: {sys_info.get('python_version', 'N/A')}")
            graph_nodes = sys_info.get("graph_nodes")
            graph_edges = sys_info.get("graph_edges")
            if graph_nodes and graph_edges:
                lines.append(f"- Graph: {graph_nodes:,} nodes, {graph_edges:,} edges")
            lines.append("")

        # Graph load time
        load_p50 = sys_info.get("graph_load_p50_ms")
        if load_p50 is not None:
            lines.append(f"**Graph load (to_networkx):** {_fmt_ms(load_p50)}\n")
            if graph_load_ms is None:
                graph_load_ms = load_p50

        # Collect all results, grouped by family
        by_family: dict[str, list[dict[str, Any]]] = {}
        for run in scale_runs:
            for r in run.get("results", []):
                aid = r["workload_id"]
                family = _family_from_id(aid)
                by_family.setdefault(family, []).append(r)

        scale_p50s[scale] = {}

        for family in ("centrality", "community", "paths", "ranking",
                       "similarity", "link_prediction", "embeddings"):
            results = by_family.get(family, [])
            if not results:
                continue

            lines.append(f"### {family.replace('_', ' ').title()}\n")
            lines.append("| Algorithm | p50 | p95 | Mean | Result |")
            lines.append("|---|---|---|---|---|")

            for r in results:
                aid = r["workload_id"]
                if r.get("skipped"):
                    lines.append(
                        f"| {aid} | SKIP | SKIP | SKIP "
                        f"| {r.get('skip_reason', 'skipped')} |"
                    )
                    continue
                if r.get("error"):
                    lines.append(
                        f"| {aid} | ERR | ERR | ERR "
                        f"| {r['error'][:60]} |"
                    )
                    continue

                p50 = r.get("p50", 0.0)
                p95 = r.get("p95", 0.0)
                mean = r.get("mean", 0.0)

                rv = r.get("result_value", "")
                lines.append(
                    f"| {aid} "
                    f"| {_fmt_ms(p50)} "
                    f"| {_fmt_ms(p95)} "
                    f"| {_fmt_ms(mean)} "
                    f"| {rv} |"
                )
                scale_p50s[scale][aid] = p50

            lines.append("")

    # Multi-scale comparison (actual data)
    scale_keys = sorted(scale_p50s.keys())
    if len(scale_keys) >= 2:
        lines.append("## Scaling Analysis (Measured)\n")
        base = scale_keys[0]
        for compare in scale_keys[1:]:
            lines.append(f"### {base.upper()} → {compare.upper()} Scaling Factor\n")
            lines.append(
                f"| Algorithm | {base.upper()} p50 | {compare.upper()} p50 | Factor |"
            )
            lines.append("|---|---|---|---|")

            all_ids = sorted(
                set(scale_p50s[base].keys()) & set(scale_p50s[compare].keys())
            )
            for aid in all_ids:
                base_val = scale_p50s[base][aid]
                compare_val = scale_p50s[compare][aid]
                factor = compare_val / base_val if base_val > 0 else 0.0
                lines.append(
                    f"| {aid} "
                    f"| {_fmt_ms(base_val)} "
                    f"| {_fmt_ms(compare_val)} "
                    f"| {factor:.1f}x |"
                )
            lines.append("")

    # Projected scaling estimates
    lines.extend(_generate_projections(scale_p50s))

    # Improvement analysis
    lines.extend(_generate_analysis(scale_p50s, graph_load_ms))

    lines.append("---\n")
    lines.append("*Generated by `python -m benchmarks algo-report`*\n")

    return "\n".join(lines)


_FAMILY_MAP = {
    "C": "centrality",
    "M": "community",
    "P": "paths",
    "R": "ranking",
    "S": "similarity",
    "L": "link_prediction",
    "E": "embeddings",
}


def _family_from_id(algo_id: str) -> str:
    """Derive family name from algorithm ID prefix."""
    prefix = algo_id[0] if algo_id else ""
    return _FAMILY_MAP.get(prefix, "unknown")


def write_algo_report(
    results_dir: str | Path,
    reports_dir: str | Path | None = None,
) -> Path:
    """Load algorithm results and write a markdown report."""
    results_dir = Path(results_dir)
    if reports_dir is None:
        reports_dir = results_dir.parent / "reports"
    reports_dir = Path(reports_dir)
    reports_dir.mkdir(parents=True, exist_ok=True)

    runs = load_algo_results(results_dir)
    if not runs:
        raise FileNotFoundError(
            f"No algorithm result files (algo_*.json) found in {results_dir}"
        )

    md = generate_algo_markdown(runs)
    out_path = reports_dir / "algorithm_report.md"
    out_path.write_text(md)
    return out_path


# ---------------------------------------------------------------------------
# Algorithm Scorecard — Piggie vs Neo4j GDS
# ---------------------------------------------------------------------------

# Canonical algorithm display names
_ALGO_NAMES: dict[str, str] = {
    "C01_degree": "Degree Centrality",
    "C02_betweenness": "Betweenness Centrality",
    "C02a_betweenness_approx": "Betweenness (approx)",
    "C03_closeness": "Closeness Centrality",
    "C04_eigenvector": "Eigenvector Centrality",
    "C05_harmonic": "Harmonic Centrality",
    "M01_louvain": "Louvain",
    "M02_label_propagation": "Label Propagation",
    "M03_greedy_modularity": "Greedy Modularity",
    "M04_leiden": "Leiden",
    "P01_shortest_path": "Shortest Path",
    "P02_dijkstra": "Dijkstra",
    "P03_all_shortest_paths": "All Shortest Paths",
    "R01_pagerank": "PageRank",
    "S01_jaccard": "Jaccard Similarity",
    "S02_adamic_adar": "Adamic-Adar",
    "S03_common_neighbors": "Common Neighbors",
    "L01_preferential_attachment": "Preferential Attachment",
    "L02_resource_allocation": "Resource Allocation",
    "E01_node2vec": "Node2Vec",
}


def _status_icon(ratio: float) -> str:
    """Return status icon based on ratio (piggie/neo4j). < 1 means we're faster."""
    if ratio <= 1.0:
        return "WIN"
    elif ratio <= 2.0:
        return "CLOSE"
    else:
        return "LOSE"


def _extract_p50s(
    runs: list[dict[str, Any]], db_filter: str, scale_filter: str
) -> tuple[dict[str, float], float | None, int | None]:
    """Extract p50 values from the latest run matching db+scale.

    Returns (algo_p50s, graph_load_p50, round_number).
    """
    # Filter matching runs, take the latest by timestamp
    matching = [
        r for r in runs
        if r.get("scale") == scale_filter
        and r.get("db", "age") == db_filter
    ]
    if not matching:
        return {}, None, None

    # Sort by timestamp descending, take latest
    matching.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
    latest = matching[0]

    p50s: dict[str, float] = {}
    for result in latest.get("results", []):
        aid = result["workload_id"]
        if result.get("skipped") or result.get("error"):
            continue
        p50s[aid] = result.get("p50", 0.0)

    graph_load = latest.get("system_info", {}).get("graph_load_p50_ms")
    round_num = latest.get("round")

    return p50s, graph_load, round_num


def _extract_round_p50s(
    runs: list[dict[str, Any]], db_filter: str, scale_filter: str, round_num: int
) -> tuple[dict[str, float], float | None]:
    """Extract p50 values from a specific round."""
    matching = [
        r for r in runs
        if r.get("scale") == scale_filter
        and r.get("db", "age") == db_filter
        and r.get("round") == round_num
    ]
    if not matching:
        return {}, None

    matching.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
    latest = matching[0]

    p50s: dict[str, float] = {}
    for result in latest.get("results", []):
        aid = result["workload_id"]
        if result.get("skipped") or result.get("error"):
            continue
        p50s[aid] = result.get("p50", 0.0)

    graph_load = latest.get("system_info", {}).get("graph_load_p50_ms")
    return p50s, graph_load


def generate_scorecard(runs: list[dict[str, Any]]) -> str:
    """Generate a competitor scorecard comparing Piggie vs Neo4j GDS."""
    lines: list[str] = []
    lines.append("# Algorithm Scorecard — Piggie vs Neo4j GDS\n")

    # Group available scales
    scales_with_data: set[str] = set()
    for run in runs:
        scales_with_data.add(run.get("scale", "unknown"))

    for scale in sorted(scales_with_data):
        age_p50s, age_load, age_round = _extract_p50s(runs, "age", scale)
        neo_p50s, neo_load, neo_round = _extract_p50s(runs, "neo4j_gds", scale)

        if not age_p50s and not neo_p50s:
            continue

        round_label = f" (R{age_round})" if age_round is not None else ""
        lines.append(f"## {scale.upper()}{round_label}\n")

        # Check if there's a previous round for delta tracking
        prev_age_p50s: dict[str, float] = {}
        prev_age_load: float | None = None
        if age_round is not None and age_round > 0:
            prev_age_p50s, prev_age_load = _extract_round_p50s(
                runs, "age", scale, age_round - 1
            )

        # Build header
        has_prev = bool(prev_age_p50s)
        if has_prev:
            lines.append(
                "| # | Algorithm | Piggie | Neo4j GDS | Ratio | Delta | Status |"
            )
            lines.append("|---|-----------|--------|-----------|-------|-------|--------|")
        else:
            lines.append(
                "| # | Algorithm | Piggie | Neo4j GDS | Ratio | Status |"
            )
            lines.append("|---|-----------|--------|-----------|-------|--------|")

        # Graph load row
        if age_load is not None or neo_load is not None:
            age_str = _fmt_ms(age_load) if age_load else "—"
            neo_str = _fmt_ms(neo_load) if neo_load else "—"
            if age_load and neo_load and neo_load > 0:
                ratio = age_load / neo_load
                ratio_str = f"{ratio:.1f}x"
                status = _status_icon(ratio)
            else:
                ratio_str = "—"
                status = "—"

            if has_prev:
                if prev_age_load and age_load:
                    delta = prev_age_load / age_load
                    delta_str = f"{delta:.1f}x faster" if delta > 1 else f"{1/delta:.1f}x slower"
                else:
                    delta_str = "—"
                lines.append(
                    f"|   | **Graph Load** | {age_str} | {neo_str} | {ratio_str} | {delta_str} | {status} |"
                )
            else:
                lines.append(
                    f"|   | **Graph Load** | {age_str} | {neo_str} | {ratio_str} | {status} |"
                )

        # Algorithm rows — iterate in canonical order
        all_ids = sorted(set(age_p50s.keys()) | set(neo_p50s.keys()))
        wins = 0
        close = 0
        losses = 0

        for aid in all_ids:
            name = _ALGO_NAMES.get(aid, aid)
            age_val = age_p50s.get(aid)
            neo_val = neo_p50s.get(aid)

            age_str = _fmt_ms(age_val) if age_val is not None else "—"
            neo_str = _fmt_ms(neo_val) if neo_val is not None else "—"

            if age_val is not None and neo_val is not None and neo_val > 0:
                ratio = age_val / neo_val
                ratio_str = f"{ratio:.1f}x"
                status = _status_icon(ratio)
            elif (age_val is not None and neo_val is None) or (
                neo_val is not None and age_val is None
            ):
                ratio_str = "—"
                status = "—"
            else:
                ratio_str = "—"
                status = "—"

            if status == "WIN":
                wins += 1
            elif status == "CLOSE":
                close += 1
            elif status == "LOSE":
                losses += 1

            if has_prev:
                prev_val = prev_age_p50s.get(aid)
                if prev_val and age_val and age_val > 0:
                    delta = prev_val / age_val
                    if delta > 1:
                        delta_str = f"{delta:.1f}x faster"
                    elif delta < 1:
                        delta_str = f"{1/delta:.1f}x slower"
                    else:
                        delta_str = "—"
                else:
                    delta_str = "—"
                lines.append(
                    f"| {aid.split('_')[0]} | {name} | {age_str} | {neo_str} | {ratio_str} | {delta_str} | {status} |"
                )
            else:
                lines.append(
                    f"| {aid.split('_')[0]} | {name} | {age_str} | {neo_str} | {ratio_str} | {status} |"
                )

        lines.append("")

        # Summary
        total = wins + close + losses
        if total > 0:
            lines.append(f"**Summary:** {wins} WIN / {close} CLOSE / {losses} LOSE "
                         f"out of {total} algorithms\n")
            lines.append(
                "- **WIN** = Piggie faster (ratio <= 1.0x)\n"
                "- **CLOSE** = within 2x\n"
                "- **LOSE** = more than 2x slower\n"
            )

    lines.append("---\n")
    lines.append("*Generated by `python -m benchmarks scorecard`*\n")

    return "\n".join(lines)


def write_scorecard(
    results_dir: str | Path,
    reports_dir: str | Path | None = None,
) -> Path:
    """Load algorithm results and write a competitor scorecard."""
    results_dir = Path(results_dir)
    if reports_dir is None:
        reports_dir = results_dir.parent / "reports"
    reports_dir = Path(reports_dir)
    reports_dir.mkdir(parents=True, exist_ok=True)

    runs = load_algo_results(results_dir)
    if not runs:
        raise FileNotFoundError(
            f"No algorithm result files (algo_*.json) found in {results_dir}"
        )

    md = generate_scorecard(runs)
    out_path = reports_dir / "algorithm_scorecard.md"
    out_path.write_text(md)
    return out_path
