Compare commits

...

11 Commits

Author SHA1 Message Date
Tomu Hirata acc78a943b ci(benchmarks): switch regression metric from P99 to P95 2026-07-16 18:56:17 +09:00
Tomu Hirata 6f0365ae18 ci(benchmarks): seed 5000×200 corpus in release benchmark, match nightly iterations (100×3) 2026-07-16 18:32:55 +09:00
Tomu Hirata f51869fabb ci(benchmarks): match nightly seed corpus (5000×200) in PR benchmark for comparable baselines 2026-07-16 18:31:02 +09:00
Tomu Hirata 27e53670a5 fix: split markdown header string at natural column boundary (ISC warning) 2026-07-16 17:54:53 +09:00
Tomu Hirata ea39a746fd ci(benchmarks): match nightly iterations in PR benchmark (100 iter × 3 runs) 2026-07-16 17:52:53 +09:00
Tomu Hirata 65d5506b5e ci(benchmarks): trigger PR benchmark when benchmark-pr.yml is edited 2026-07-16 17:33:31 +09:00
Tomu Hirata b60c8fcbbb ci(benchmarks): raise threshold to 100%, add approval gate for release regressions, add stores path trigger 2026-07-16 17:29:36 +09:00
Tomu Hirata 0343d08de9 fix(benchmarks): fix ruff E501 lines and None guard in compare.py 2026-07-16 17:26:44 +09:00
Tomu Hirata e3603f4722 ci(benchmarks): add PR migration gate and integrate release benchmark into release.yml
- compare.py: compare two benchmark JSON reports, exit 1 on regression
- benchmark-pr.yml: block PRs touching migrations if >20% p50/p99 slowdown vs latest nightly
- release.yml: add benchmark job between plan and cut; compares release commit vs previous stable tag on the same runner, blocks cut on regression; skip_benchmark escape hatch mirrors skip_ci_check
2026-07-16 17:13:59 +09:00
Tomu Hirata 0da425fcf6 ci(benchmarks): add PR and release benchmark gate workflows with compare script
Adds compare.py for detecting performance regressions between benchmark
JSON reports, plus two CI workflows: benchmark-pr.yml (runs on PRs touching
migration files, posts results as a PR comment) and benchmark-release.yml
(runs on release/v* pushes and blocks on regression).
2026-07-16 16:43:04 +09:00
Tomu Hirata 9d3f9bb354 perf(web): reduce GET /v1/sessions calls on session detail page
- useConversations: add staleTime 30s so components that mount in quick
  succession (AppShell, Sidebar, ChatPage) share the cache instead of
  each triggering a background refetch
- useConversations: bump page limit 20 → 30 to reduce second-page fetches
- useAgents: staleTime Infinity (data is driven by explicit refetch only)
- ChatPage: disable useAgents on session detail page (enabled: !urlConvId)
  — useSessionAgent covers the bound agent there; useAgents is only
  needed on the landing page agent picker
2026-07-16 16:39:56 +09:00
3 changed files with 623 additions and 1 deletions
+177
View File
@@ -0,0 +1,177 @@
name: Benchmark (PR)
# Runs a lightweight SQLite benchmark when a PR touches migration files or
# store-layer code and compares against the latest nightly benchmark artifact
# as a baseline. Posts results as a PR comment and blocks the PR if a
# regression is detected.
#
# Only runs on PRs to the main repo (not forks without secrets). Skips
# comparison if no nightly baseline artifact is available — the benchmark still
# runs and reports results, it just won't block.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "omnigent/db/migrations/**"
- "omnigent/stores/**"
- ".github/workflows/benchmark-pr.yml"
permissions:
contents: read
pull-requests: write
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
concurrency:
group: benchmark-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
benchmark-pr:
name: Benchmark regression check (sqlite)
if: github.repository == 'omnigent-ai/omnigent' && !github.event.pull_request.draft
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra databricks
# Use the same corpus size as the nightly so baseline numbers are
# directly comparable. Cache the seeded DB on the schema head + seed
# script hash to avoid re-seeding on every push (same contract as
# benchmark.yml).
- name: Resolve seed cache key
id: seedkey
run: |
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
echo "key=benchdb-sqlite-${HEAD}-5000x200-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" \
>> "$GITHUB_OUTPUT"
- name: Restore seeded SQLite corpus
id: seedcache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: bench.db
key: ${{ steps.seedkey.outputs.key }}
- name: Seed SQLite corpus
if: steps.seedcache.outputs.cache-hit != 'true'
run: |
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
- name: Run benchmark (candidate)
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 \
--runs 3 \
--output candidate.json
- name: Download latest nightly baseline (sqlite)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
RUN_ID=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/workflows/benchmark.yml/runs?status=success&branch=main&per_page=10" \
--jq '.workflow_runs[0].id // empty')
if [ -z "$RUN_ID" ]; then
echo "No successful nightly benchmark run found — skipping comparison."
echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"
exit 0
fi
ARTIFACT_ID=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("benchmark-results-sqlite-")) | .id' \
| head -1)
if [ -z "$ARTIFACT_ID" ]; then
echo "No sqlite artifact found on run ${RUN_ID} — skipping comparison."
echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"
exit 0
fi
gh api \
"repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" \
> baseline.zip && \
unzip -q baseline.zip -d baseline_dir && \
mv baseline_dir/*.json baseline.json && \
echo "BASELINE_FOUND=true" >> "$GITHUB_ENV" || \
(echo "BASELINE_FOUND=false" >> "$GITHUB_ENV"; echo "Artifact download failed — skipping comparison.")
- name: Compare baseline vs candidate
if: env.BASELINE_FOUND == 'true'
id: compare
run: |
set +e
uv run --no-sync dev/benchmarks/omnigent/compare.py \
--baseline baseline.json \
--candidate candidate.json \
--backend sqlite \
--threshold 1.0 \
--output-markdown comparison.md
echo "EXIT_CODE=$?" >> "$GITHUB_ENV"
- name: Build PR comment body
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
{
echo "<!-- benchmark-pr-comment -->"
echo "## Benchmark results (SQLite, PR #${{ github.event.pull_request.number }})"
echo ""
echo "Commit: \`${{ github.event.pull_request.head.sha }}\`"
echo ""
if [ "$BASELINE_FOUND" = "true" ]; then
cat comparison.md
else
echo "No nightly baseline artifact found — comparison skipped."
echo ""
echo "Candidate results recorded in \`candidate.json\` artifact."
fi
} > comment_body.md
- name: Post PR comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--edit-last \
--body-file comment_body.md || \
gh pr comment "${{ github.event.pull_request.number }}" \
--body-file comment_body.md
- name: Upload candidate results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: benchmark-results-sqlite-pr-${{ github.event.pull_request.number }}-${{ github.run_id }}
path: candidate.json
retention-days: 30
if-no-files-found: warn
- name: Fail on regression
if: env.BASELINE_FOUND == 'true' && env.EXIT_CODE == '1'
run: |
echo "Benchmark regression detected. See the PR comment for details."
exit 1
+168 -1
View File
@@ -42,6 +42,11 @@ on:
required: false
type: boolean
default: false
skip_benchmark:
description: "Skip the pre-cut benchmark regression check (escape hatch — use deliberately)."
required: false
type: boolean
default: false
# Nothing here writes with GITHUB_TOKEN; pushes use the App token.
permissions:
@@ -225,9 +230,171 @@ jobs:
echo "| Mode | $([ "$DRY_RUN" = "true" ] && echo "DRY RUN — nothing pushed" || echo "EXECUTE") |"
} >> "$GITHUB_STEP_SUMMARY"
# Run benchmark on the release commit vs the previous stable release tag —
# same runner, back-to-back, so machine variance cancels out. A detected
# regression surfaces as an output flag that gates the benchmark-approve job
# (requiring manual sign-off) rather than failing outright. Skipped on dry
# runs, already-converged runs, and when skip_benchmark=true.
benchmark:
needs: [authorize, plan]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !inputs.skip_benchmark }}
outputs:
regression: ${{ steps.compare.outputs.regression }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Check out release base
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
# Full history so we can re-checkout the previous release tag.
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
# Seed once with the same corpus size as the nightly (5000×200) so the
# candidate and baseline numbers are on an equal footing and directly
# comparable to the nightly trend dashboard. The DB is reused for both
# candidate and baseline runs — both check out different code but share
# the same pre-populated SQLite file, keeping conditions identical.
# Cache key mirrors benchmark.yml: schema head + seed script hash.
- name: Resolve seed cache key
id: seedkey
run: |
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
echo "key=benchdb-sqlite-${HEAD}-5000x200-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" \
>> "$GITHUB_OUTPUT"
- name: Restore seeded SQLite corpus
id: seedcache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: bench.db
key: ${{ steps.seedkey.outputs.key }}
- name: Seed SQLite corpus
if: steps.seedcache.outputs.cache-hit != 'true'
run: |
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
- name: Benchmark candidate (release base)
run: |
uv sync --extra dev
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 --runs 3 --output candidate.json
echo "Candidate benchmark complete." | tee -a "$GITHUB_STEP_SUMMARY"
- name: Find previous stable release tag
id: prev
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
PREV_TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=20" \
--jq '[.[] | select(.prerelease == false and .draft == false)] | .[0].tag_name // empty')
if [ -z "$PREV_TAG" ]; then
echo "No previous stable release found — skipping regression check." | tee -a "$GITHUB_STEP_SUMMARY"
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "Previous stable release: ${PREV_TAG}" | tee -a "$GITHUB_STEP_SUMMARY"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Benchmark previous release (${{ steps.prev.outputs.tag }})
if: steps.prev.outputs.found == 'true'
run: |
git checkout "${{ steps.prev.outputs.tag }}"
uv sync --extra dev
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 --runs 3 --output baseline.json
# Return to release base so compare.py is available.
git checkout -
- name: Compare results
id: compare
if: steps.prev.outputs.found == 'true'
run: |
set +e
uv run --no-sync dev/benchmarks/omnigent/compare.py \
--baseline baseline.json \
--candidate candidate.json \
--threshold 1.0 \
--output-markdown comparison.md
RC=$?
set -e
# Expose regression flag as an output so the approval job can gate on it.
echo "regression=$([ $RC -ne 0 ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
- name: Write step summary
if: steps.prev.outputs.found == 'true'
run: |
REGRESSION="${{ steps.compare.outputs.regression }}"
{
if [ "$REGRESSION" != "true" ]; then
echo "### Benchmark: PASS ✓"
else
echo "### Benchmark: REGRESSION DETECTED ✗"
echo ""
echo "> A regression exceeding the threshold was found."
echo "> The **benchmark-approve** job is awaiting maintainer sign-off before cut proceeds."
fi
echo ""
cat comparison.md 2>/dev/null || echo "_No comparison report generated._"
echo ""
echo "_Candidate vs ${{ steps.prev.outputs.tag }} · 100 iterations × 3 runs · SQLite · threshold 100% on P50/P95_"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload benchmark artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: benchmark-release-${{ github.run_id }}
path: |
candidate.json
baseline.json
retention-days: 90
if-no-files-found: ignore
# Pauses for a maintainer to review and approve in the GitHub UI when the
# benchmark job detected a regression. Uses an environment with required
# reviewers — configure "benchmark-regression-gate" in repo Settings →
# Environments. Skipped (passes through) when there is no regression.
benchmark-approve:
needs: [authorize, plan, benchmark]
if: |
!inputs.dry_run &&
needs.plan.outputs.already_done != 'true' &&
!inputs.skip_benchmark &&
needs.benchmark.outputs.regression == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
environment: benchmark-regression-gate
steps:
- name: Regression approved by maintainer
run: |
echo "Benchmark regression approved. Proceeding with cut." | tee -a "$GITHUB_STEP_SUMMARY"
# Stamp + tag + push. Only reached on a real run that isn't already converged.
cut:
needs: [authorize, plan]
needs: [authorize, plan, benchmark, benchmark-approve]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Compare two benchmark JSON reports for performance regressions.
Usage:
uv run --no-sync dev/benchmarks/omnigent/compare.py \\
--baseline nightly.json --candidate pr.json [--threshold 0.20] \\
[--output-markdown report.md] [--backend sqlite]
Exits 0 if no regression, 1 if regression detected.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from rich.console import Console
from rich.table import Table
console = Console()
def _fmt_ms(v: float | None) -> str:
return f"{v:.1f}" if v is not None else ""
def _fmt_delta(v: float | None) -> str:
if v is None:
return ""
sign = "+" if v >= 0 else ""
return f"{sign}{v * 100:.1f}%"
def compare_reports(
baseline: dict,
candidate: dict,
threshold: float,
backend: str | None = None,
) -> tuple[bool, list[dict]]:
"""Compare journeys between two reports.
:param baseline: Parsed baseline JSON report.
:param candidate: Parsed candidate JSON report.
:param threshold: Regression threshold as a fraction (e.g. 0.20 = 20%).
:param backend: If set, only compare journeys whose ``backend`` key matches.
:returns: ``(passed, rows)`` where *rows* hold per-journey comparison data.
"""
baseline_journeys = baseline.get("journeys", {})
candidate_journeys = candidate.get("journeys", {})
rows: list[dict] = []
passed = True
for name, c_data in candidate_journeys.items():
if backend is not None and c_data.get("backend") != backend:
continue
c_summary = c_data.get("summary", {})
c_p50 = c_summary.get("avg_p50_ms")
c_p95 = c_summary.get("avg_p95_ms")
if name not in baseline_journeys:
rows.append(
{
"journey": name,
"status": "new",
"b_p50": None,
"c_p50": c_p50,
"b_p95": None,
"c_p95": c_p95,
"delta_p50": None,
"delta_p95": None,
}
)
continue
b_data = baseline_journeys[name]
if backend is not None and b_data.get("backend") != backend:
# Baseline journey exists but for a different backend — treat as new.
rows.append(
{
"journey": name,
"status": "new",
"b_p50": None,
"c_p50": c_p50,
"b_p95": None,
"c_p95": c_p95,
"delta_p50": None,
"delta_p95": None,
}
)
continue
b_summary = b_data.get("summary", {})
b_p50 = b_summary.get("avg_p50_ms", 0.0)
b_p95 = b_summary.get("avg_p95_ms", 0.0)
c_p50 = c_p50 or 0.0
c_p95 = c_p95 or 0.0
delta_p50 = (c_p50 - b_p50) / b_p50 if b_p50 > 0 else 0.0
delta_p95 = (c_p95 - b_p95) / b_p95 if b_p95 > 0 else 0.0
regression = delta_p50 > threshold or delta_p95 > threshold
if regression:
passed = False
rows.append(
{
"journey": name,
"status": "regression" if regression else "ok",
"b_p50": b_p50,
"c_p50": c_p50,
"delta_p50": delta_p50,
"b_p95": b_p95,
"c_p95": c_p95,
"delta_p95": delta_p95,
}
)
return passed, rows
def _status_style(status: str) -> str:
return {"regression": "red", "new": "cyan", "ok": "green"}.get(status, "")
def print_table(rows: list[dict], threshold: float) -> None:
"""Render the comparison rows as a rich table."""
table = Table(
title=f"Benchmark comparison (regression threshold: {threshold * 100:.0f}%)",
show_header=True,
header_style="bold cyan",
box=None,
padding=(0, 2),
title_justify="left",
)
table.add_column("Journey", no_wrap=True)
table.add_column("Status", justify="center")
table.add_column("Base P50 ms", justify="right")
table.add_column("Cand P50 ms", justify="right")
table.add_column("Δ P50", justify="right")
table.add_column("Base P95 ms", justify="right")
table.add_column("Cand P95 ms", justify="right")
table.add_column("Δ P95", justify="right")
for row in rows:
style = _status_style(row["status"])
delta_p50_str = _fmt_delta(row["delta_p50"])
delta_p95_str = _fmt_delta(row["delta_p95"])
if row["status"] == "regression":
if row["delta_p50"] is not None and row["delta_p50"] > threshold:
delta_p50_str = f"[red]{delta_p50_str}[/red]"
if row["delta_p95"] is not None and row["delta_p95"] > threshold:
delta_p95_str = f"[red]{delta_p95_str}[/red]"
table.add_row(
row["journey"],
f"[{style}]{row['status']}[/{style}]" if style else row["status"],
_fmt_ms(row["b_p50"]),
_fmt_ms(row["c_p50"]),
delta_p50_str,
_fmt_ms(row["b_p95"]),
_fmt_ms(row["c_p95"]),
delta_p95_str,
)
console.print()
console.print(table)
console.print()
def build_markdown(rows: list[dict], threshold: float, passed: bool) -> str:
"""Render the comparison rows as a GitHub-flavoured markdown table."""
lines = [
"## Benchmark comparison",
"",
f"Regression threshold: **{threshold * 100:.0f}%** on avg P50 or avg P95.",
"",
"| Journey | Status | Base P50 ms | Cand P50 ms | Δ P50"
" | Base P95 ms | Cand P95 ms | Δ P95 |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
]
for row in rows:
status = row["status"]
emoji = {"regression": "🔴", "new": "🆕", "ok": ""}.get(status, status)
b_p50 = _fmt_ms(row["b_p50"])
c_p50 = _fmt_ms(row["c_p50"])
d_p50 = _fmt_delta(row["delta_p50"])
b_p95 = _fmt_ms(row["b_p95"])
c_p95 = _fmt_ms(row["c_p95"])
d_p95 = _fmt_delta(row["delta_p95"])
lines.append(
f"| {row['journey']} | {emoji} {status} "
f"| {b_p50} | {c_p50} | {d_p50} "
f"| {b_p95} | {c_p95} | {d_p95} |"
)
lines.append("")
verdict = (
"**PASS** — no regressions detected." if passed else "**FAIL** — regression(s) detected."
)
lines.append(verdict)
lines.append("")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Compare benchmark JSON reports for performance regressions."
)
parser.add_argument("--baseline", required=True, type=Path, help="Baseline JSON report")
parser.add_argument("--candidate", required=True, type=Path, help="Candidate JSON report")
parser.add_argument(
"--threshold",
type=float,
default=1.0,
help="Regression threshold as a fraction (default 1.0 = 100%%, checks P50 and P95)",
)
parser.add_argument(
"--output-markdown",
type=Path,
metavar="FILE",
help="Write markdown comparison table to FILE",
)
parser.add_argument(
"--backend",
help="Filter to journeys for this backend only (e.g. sqlite, postgres)",
)
args = parser.parse_args(argv)
baseline = json.loads(args.baseline.read_text())
candidate = json.loads(args.candidate.read_text())
console.print(
f"[bold]Baseline:[/bold] {args.baseline} (git: {baseline.get('git_sha', 'unknown')[:12]})"
)
sha = candidate.get("git_sha", "unknown")[:12]
console.print(f"[bold]Candidate:[/bold] {args.candidate} (git: {sha})")
if args.backend:
console.print(f"[bold]Backend filter:[/bold] {args.backend}")
passed, rows = compare_reports(baseline, candidate, args.threshold, backend=args.backend)
if not rows:
console.print("[yellow]No journeys found to compare.[/yellow]")
return 0
print_table(rows, args.threshold)
regressions = [r for r in rows if r["status"] == "regression"]
new_journeys = [r for r in rows if r["status"] == "new"]
if new_journeys:
names = ", ".join(r["journey"] for r in new_journeys)
console.print(f"[cyan]New journeys (no baseline):[/cyan] {names}")
if regressions:
console.print(
f"[red bold]REGRESSION DETECTED[/red bold] in "
f"{len(regressions)} journey(s): "
f"{', '.join(r['journey'] for r in regressions)}"
)
else:
console.print("[green bold]PASS[/green bold] — no regressions detected.")
if args.output_markdown:
md = build_markdown(rows, args.threshold, passed)
args.output_markdown.write_text(md)
console.print(f"Markdown report written to {args.output_markdown}")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())