Skip to content

Reports

A gate run produces three renderings of the same findings, for three different readers.

Reader Call
Summary whoever is watching the pipeline report.summary()
JSON the system that files it report.to_json(path)
HTML the person who has to sign it report.to_html(path)

BLOCKED and PASS need no page — the pipeline acts on the exit code. NEEDS_REVIEW is the verdict that delegates to a human, and that human should not be handed a JSON blob.

One file, nothing fetched

from bdp_model_gate import ModelGate

report = ModelGate().run(context)
report.to_html("gate-report.html")

That is the whole API. ModelGate.run attaches the checks and the context to the report, so the charts are drawn without you re-supplying anything.

The page has no <script>, no stylesheet, no font and no image fetched from anywhere. A governance record gets emailed, filed, and reopened years later, and every external reference is a way for it to stop rendering. It opens from a file:// URL on a laptop with no network.

Charts are inlined as SVG, not <img src="data:...">. Inline SVG participates in the page's CSS, which is what lets one render read correctly in light and dark, and it stays sharp when printed.

Options

report.to_html(
    path="gate-report.html",
    title="Retail credit scorecard v4",  # shown in the tab and the header
    include_plots=True,
)

Pass checks= and context= explicitly when rendering a report you rebuilt from somewhere else:

render_html(report, checks=gate.checks, context=context)

It degrades; it does not fail

Three ways the page can lose its charts, and none of them loses a finding:

  • No [plots] extra installed — text-only.
  • No checks or context (a report reconstructed from JSON) — text-only.
  • A plot() raised — that one chart is replaced in place by a note naming the exception. The findings around it are untouched.

That last one is deliberate. A chart is an aid; a renderer that throws must never cost a reviewer the results it was illustrating.

What is in the page

  • The verdict, in plain words: "A blocking check failed. This model must not be promoted as it stands."
  • The headline metric — whichever metric was configured, named, never assumed to be AUC.
  • Categories in the order that matters: performance and compliance stop a deploy outright, security next, then fairness, which asks for a judgement.
  • Every result, including NOT_APPLICABLE ones. What was skipped and why is part of the record — a report that silently omits them lets a reader assume coverage that never happened.
  • Each result's metadata behind an evidence toggle, so the numbers behind a sentence are one click away and not in the way.
  • The plot for each check that has one, beneath that check's findings.

What is deliberately not in the page

The check objects and the validation set are attached to the GateReport for rendering, and excluded from the constructor, the repr, equality and to_dict(). A report is an archival record of findings. Neither your data nor your model belongs in one.

report.to_dict()  # findings only — safe to file, safe to ship
report.to_json("run.json")

Printing and archiving

The page carries a print stylesheet: cards avoid breaking across pages and the background drops out. Ctrl/Cmd-P → Save as PDF gives you the artefact to attach to a change request.

API

bdp_model_gate.reporting

A gate report as a page a reviewer can read and sign.

NEEDS_REVIEW is a verdict that delegates to a human. Until now that human received a JSON blob: correct, archival, and close to unreadable at the moment a decision has to be made. This renders the same report as one self-contained HTML file — no network, no JavaScript, nothing to install to open it — with each check's plot inlined beside the number it explains.

Three properties are deliberate:

  • Self-contained. No <script>, no external stylesheet, no remote font. A governance record is emailed, filed and reopened years later, and every external reference is a way for it to stop rendering.
  • Plots inlined as SVG, not <img src="data:...">. Inline SVG inherits the page's CSS, which is what makes one render read correctly in light and dark. It also stays sharp when printed.
  • Degrades rather than fails. Without the [plots] extra the page renders text-only. A plot that raises is reported in place as a note, because a broken chart must never cost a reviewer the findings around it.

CATEGORY_ORDER module-attribute

CATEGORY_ORDER = (
    "performance",
    "compliance",
    "security",
    "fairness",
)

render_html

render_html(
    report,
    checks=None,
    context=None,
    title="Model gate report",
    include_plots=True,
    generated_at=None,
)

Renders a GateReport as one self-contained HTML document.

checks and context are what plotting needs — a plot recomputes from the data rather than reading presentation arrays out of the archived JSON. ModelGate.run attaches both to the report it returns, so report.to_html() normally supplies them for you; pass them explicitly when rendering a report reconstructed from elsewhere.

Without them, or without the [plots] extra, the page renders text-only.

Source code in bdp_model_gate/reporting.py
def render_html(
    report: Any,
    checks: Any = None,
    context: Any = None,
    title: str = "Model gate report",
    include_plots: bool = True,
    generated_at: str | None = None,
) -> str:
    """Renders a `GateReport` as one self-contained HTML document.

    `checks` and `context` are what plotting needs — a plot recomputes from
    the data rather than reading presentation arrays out of the archived
    JSON. `ModelGate.run` attaches both to the report it returns, so
    `report.to_html()` normally supplies them for you; pass them explicitly
    when rendering a report reconstructed from elsewhere.

    Without them, or without the `[plots]` extra, the page renders text-only.
    """
    from .plots import plotting_available

    stamp = generated_at or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    verdict = report.gate_status

    parts: list[str] = [
        "<main>",
        f'<header class="card verdict {verdict}">',
        f"<div><h1>{_esc(title)}</h1>"
        f'<p class="sub">Generated {_esc(stamp)} · bdp-model-gate</p></div>',
        f'<p class="verdict-name">{_esc(verdict)}</p>',
        f"<p>{_esc(_VERDICT_BLURB.get(verdict, ''))}</p>",
        '<dl class="facts">',
    ]

    facts: list[tuple[str, str]] = [("Findings", str(len(report.flags)))]
    if report.task:
        facts.append(("Task", report.task))
    if report.model_metric is not None and report.model_score is not None:
        facts.append((report.model_metric, f"{report.model_score:.4f}"))
    facts.append(("Checks run", str(len({r.check_name for r in report.results}))))
    facts.append(("Duration", f"{report.total_duration_ms:.0f} ms"))
    for name, value in facts:
        parts.append(f'<div class="fact"><dt>{_esc(name)}</dt><dd>{_esc(value)}</dd></div>')
    parts.append("</dl></header>")

    by_name = {getattr(c, "name", type(c).__name__): c for c in (checks or [])}
    draw = include_plots and bool(by_name) and context is not None and plotting_available()
    if include_plots and not draw:
        logger.debug(
            "rendering text-only: checks=%s context=%s plots_installed=%s",
            bool(by_name),
            context is not None,
            plotting_available(),
        )

    categories = list(CATEGORY_ORDER) + sorted(
        {r.category for r in report.results} - set(CATEGORY_ORDER)
    )
    for category in categories:
        rows = report.by_category(category)
        if not rows:
            continue
        flagged = sum(1 for r in rows if not r.is_ok)
        parts.append(
            f'<section class="card"><div><h2>{_esc(category.title())}</h2>'
            f'<p class="sub">{flagged} finding(s) across {len(rows)} result(s)</p></div>'
        )

        # Grouped by check so a plot sits with the numbers it illustrates,
        # preserving the order the checks ran in.
        seen: list[str] = []
        for r in rows:
            if r.check_name not in seen:
                seen.append(r.check_name)

        for check_name in seen:
            own = [r for r in rows if r.check_name == check_name]
            parts.append(f'<div class="check"><h3>{_esc(check_name)}</h3>')
            for r in own:
                pill = _pill_class(r.flag, r.blocking)
                blocking_note = (
                    "" if r.is_ok else ("blocks promotion" if r.blocking else "needs review")
                )
                parts.append(
                    f'<div class="finding">'
                    f'<span class="pill {pill}">{_esc(r.flag)}</span>'
                    f'<div><p class="detail">{_esc(r.detail)}</p>'
                    + (f'<p class="blocking-note">{blocking_note}</p>' if blocking_note else "")
                    + _render_metadata(r.metadata)
                    + "</div></div>"
                )
            check = by_name.get(check_name)
            if draw and check is not None and _draws(check):
                try:
                    svg = _figure_svg(check, context, own)
                except Exception as exc:
                    # A chart is an aid. Losing the findings around it because
                    # a renderer raised would be a worse outcome than no chart.
                    logger.warning("plot failed for check=%s: %r", check_name, exc)
                    parts.append(
                        f'<p class="note">chart unavailable — {_esc(type(exc).__name__)}: '
                        f"{_esc(exc)}</p>"
                    )
                else:
                    if svg:
                        parts.append(f"<figure>{svg}</figure>")
            parts.append("</div>")
        parts.append("</section>")

    parts.append(
        "<footer>Produced by bdp-model-gate. Findings are evidence for a human decision, "
        "not the decision itself.</footer></main>"
    )

    body = "\n".join(parts)
    return (
        "<!doctype html>\n"
        f'<html lang="en"><head><meta charset="utf-8">'
        f'<meta name="viewport" content="width=device-width, initial-scale=1">'
        f"<title>{_esc(title)}{_esc(verdict)}</title>"
        f"<style>{_STYLE}</style></head><body>\n{body}\n</body></html>\n"
    )