orchestrata_ultimate.harness.reporting

Reference › orchestrata_ultimate › harness › reporting

Reports and trend queries for harness results, used by the report and trends subcommands of the cli. generate_report(history_dir, output_path) loads every JSON file written by the JSON history tracker, computes overall statistics (total runs, pass rate, average score), per-volk run and pass counts, per-scenario score trends and the flattened list of snapshot regressions, and renders HTML with Jinja2 when it is available, or a built-in rendering otherwise. generate_junit_xml(results, output_path) writes a JUnit XML file with one test case per trial (_trial_to_junit_case) for CI consumers. TrendQueries is a set of static methods that return Cypher query strings for the graph history: the pass rate of a volk over the last 30 days, the regression count of a scenario and its average score trend. The class builds strings and does not execute them. Result types are in models.

At a glance

Source src/orchestrata_ultimate/harness/reporting.py
Lines of code 419
Classes 1
Functions 9
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.harness.models
Imported by orchestrata_ultimate.harness.cli
Tests that import it tests/harness/test_reporting.py

Class diagram

Class diagram orchestrata_ultimate.harness.reporting

Dependency diagram

Dependency diagram orchestrata_ultimate.harness.reporting

API

Classes

TrendQueries

Decorators: traces(spec='102', story='US-002', reqs=['REQ-102-028']) · Line 385

Static Cypher queries for historical trend analysis in the graph.

Method Signature Summary
pass_rate_last_30_days staticmethod pass_rate_last_30_days(volk_name: str) -> str Return Cypher query string for pass rate of volk_name over last 30 days.
regression_count staticmethod regression_count(scenario_id: str) -> str Return Cypher query string for number of regressions for scenario_id.
avg_score_trend staticmethod avg_score_trend(scenario_id: str) -> str Return Cypher query string for average score trend of scenario_id.

Functions

Function Signature Summary
_load_history_entries traces _load_history_entries(history_dir: Path) -> list[dict[str, Any]] Load all JSON history files and return a flat list of entries.
_compute_overall_stats traces _compute_overall_stats(entries: list[dict[str, Any]]) -> tuple[int, float, float] Return (total_runs, pass_rate, avg_score) across all history entries.
_compute_volk_stats traces _compute_volk_stats(entries: list[dict[str, Any]]) -> list[dict[str, Any]] Per-volk run/pass counts and pass rate.
_compute_scenario_stats traces _compute_scenario_stats(entries: list[dict[str, Any]]) -> list[dict[str, Any]] Per-(volk, scenario) score trend: latest score, average score, run count.
_compute_regressions traces _compute_regressions(entries: list[dict[str, Any]]) -> list[dict[str, Any]] Flatten per-trial snapshot-diff regressions across all history entries.
_render_report_html traces _render_report_html(context: dict[str, Any]) -> str Render the report context into HTML — Jinja2 if available, else a partially-rendered fallback using the stdlib string.Template.
generate_report traces generate_report(history_dir: Path, output_path: Path) -> Path Read all JSON history files and generate an HTML report.
_trial_to_junit_case _trial_to_junit_case(trial: TrialResult, scenario_id: str, volk_name: str) -> ET.Element Build a <testcase> element for a single trial.
generate_junit_xml generate_junit_xml(results: list[ScenarioResult], output_path: Path) -> Path Generate a JUnit XML file from scenario results.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/harness/reporting.py

"""HTML report generation and JUnit XML export for Volk Test Harness."""

from __future__ import annotations

import json
import logging
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.harness.models import ScenarioResult, TrialResult

_logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# HTML Report
# ---------------------------------------------------------------------------

_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Volk Harness Report</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; background: #f8f9fa; color: #212529; }
h1, h2 { color: #1a1a2e; border-bottom: 2px solid #dee2e6; padding-bottom: .3rem; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; background: #fff; }
th, td { border: 1px solid #dee2e6; padding: .5rem .75rem; text-align: left; }
th { background: #e9ecef; font-weight: 600; }
tr:nth-child(even) { background: #f8f9fa; }
.bar-container { background: #e9ecef; height: 1.2rem; border-radius: .25rem; overflow: hidden; width: 100%; }
.bar { height: 100%; background: #198754; border-radius: .25rem; }
.bar.warn { background: #ffc107; }
.bar.danger { background: #dc3545; }
.pass { color: #198754; font-weight: 600; }
.fail { color: #dc3545; font-weight: 600; }
.regression { background: #fff3cd; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
.card { background: #fff; padding: 1rem; border-radius: .5rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.card h3 { margin: 0 0 .5rem; font-size: 1rem; color: #495057; }
.card .value { font-size: 1.5rem; font-weight: 700; }
</style>
</head>
<body>
<h1>Volk Harness Report</h1>
<p>Generated: {{ generated_at }}</p>

<div class="summary">
  <div class="card">
    <h3>Total Runs</h3>
    <div class="value">{{ total_runs }}</div>
  </div>
  <div class="card">
    <h3>Pass Rate</h3>
    <div class="value">{{ "%.1f"|format(pass_rate) }}%</div>
  </div>
  <div class="card">
    <h3>Avg Score</h3>
    <div class="value">{{ "%.3f"|format(avg_score) }}</div>
  </div>
  <div class="card">
    <h3>Regressions</h3>
    <div class="value">{{ regression_count }}</div>
  </div>
</div>

<h2>Per-Volk Pass Rates</h2>
<table>
  <thead>
    <tr><th>Volk</th><th>Runs</th><th>Passed</th><th>Pass Rate</th><th>Bar</th></tr>
  </thead>
  <tbody>
    {% for v in volk_stats|sort(attribute='pass_rate', reverse=True) %}
    <tr>
      <td>{{ v.name }}</td>
      <td>{{ v.runs }}</td>
      <td>{{ v.passes }}</td>
      <td class="{{ 'pass' if v.pass_rate >= 90 else 'fail' }}">{{ "%.1f"|format(v.pass_rate) }}%</td>
      <td style="width: 30%;">
        <div class="bar-container">
          <div class="bar {{ 'danger' if v.pass_rate < 70 else 'warn' if v.pass_rate < 90 else '' }}"
               style="width: {{ v.pass_rate }}%;"></div>
        </div>
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>

<h2>Per-Scenario Score Trends</h2>
<table>
  <thead>
    <tr><th>Scenario</th><th>Volk</th><th>Latest Score</th><th>Avg Score</th><th>Runs</th></tr>
  </thead>
  <tbody>
    {% for s in scenario_stats|sort(attribute='scenario_id') %}
    <tr>
      <td>{{ s.scenario_id }}</td>
      <td>{{ s.volk }}</td>
      <td>{{ "%.3f"|format(s.latest_score) }}</td>
      <td>{{ "%.3f"|format(s.avg_score) }}</td>
      <td>{{ s.runs }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>

<h2>Regressions</h2>
{% if regressions %}
<table>
  <thead>
    <tr><th>Timestamp</th><th>Scenario</th><th>Volk</th><th>Trial</th><th>Diff Preview</th></tr>
  </thead>
  <tbody>
    {% for r in regressions %}
    <tr class="regression">
      <td>{{ r.timestamp }}</td>
      <td>{{ r.scenario_id }}</td>
      <td>{{ r.volk }}</td>
      <td>{{ r.trial_number }}</td>
      <td><pre style="max-width: 400px; overflow: auto; font-size: .8rem;">{{ r.diff_preview }}</pre></td>
    </tr>
    {% endfor %}
  </tbody>
</table>
{% else %}
<p>No regressions detected. 🎉</p>
{% endif %}
</body>
</html>
"""


@traces(spec="102", story="US-002", reqs=["REQ-102-022"])
def _load_history_entries(history_dir: Path) -> list[dict[str, Any]]:
    """Load all JSON history files and return a flat list of entries."""
    entries: list[dict[str, Any]] = []
    if not history_dir.exists():
        return entries
    for path in sorted(history_dir.glob("*.json")):
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            if isinstance(data, list):
                entries.extend(data)
        except (json.JSONDecodeError, OSError) as exc:
            _logger.warning("Skipping corrupt history file %s: %s", path, exc)
    return entries


@traces(spec="102", story="US-002", reqs=["REQ-102-030"])
def _compute_overall_stats(entries: list[dict[str, Any]]) -> tuple[int, float, float]:
    """Return (total_runs, pass_rate, avg_score) across all history entries."""
    total_runs = len(entries)
    passes = sum(1 for e in entries if e.get("passed"))
    pass_rate = (passes / total_runs * 100) if total_runs else 0.0
    scores = [e.get("aggregate_score", 0.0) for e in entries]
    avg_score = sum(scores) / len(scores) if scores else 0.0
    return total_runs, pass_rate, avg_score


@traces(spec="102", story="US-002", reqs=["REQ-102-031"])
def _compute_volk_stats(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Per-volk run/pass counts and pass rate."""
    volk_data: dict[str, dict[str, Any]] = {}
    for e in entries:
        name = e.get("volk", "unknown")
        volk_data.setdefault(name, {"runs": 0, "passes": 0})
        volk_data[name]["runs"] += 1
        if e.get("passed"):
            volk_data[name]["passes"] += 1

    return [
        {
            "name": name,
            "runs": d["runs"],
            "passes": d["passes"],
            "pass_rate": (d["passes"] / d["runs"] * 100) if d["runs"] else 0.0,
        }
        for name, d in volk_data.items()
    ]


@traces(spec="102", story="US-002", reqs=["REQ-102-032"])
def _compute_scenario_stats(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Per-(volk, scenario) score trend: latest score, average score, run count."""
    scenario_data: dict[str, dict[str, Any]] = {}
    for e in entries:
        sid = e.get("scenario_id", "unknown")
        volk = e.get("volk", "unknown")
        key = f"{volk}/{sid}"
        if key not in scenario_data:
            scenario_data[key] = {
                "scenario_id": sid,
                "volk": volk,
                "scores": [],
                "timestamps": [],
            }
        scenario_data[key]["scores"].append(e.get("aggregate_score", 0.0))
        scenario_data[key]["timestamps"].append(e.get("timestamp", ""))

    return [
        {
            "scenario_id": d["scenario_id"],
            "volk": d["volk"],
            "latest_score": d["scores"][-1] if d["scores"] else 0.0,
            "avg_score": sum(d["scores"]) / len(d["scores"]) if d["scores"] else 0.0,
            "runs": len(d["scores"]),
        }
        for d in scenario_data.values()
    ]


@traces(spec="102", story="US-002", reqs=["REQ-102-033"])
def _compute_regressions(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Flatten per-trial snapshot-diff regressions across all history entries."""
    regressions: list[dict[str, Any]] = []
    for e in entries:
        for trial in e.get("trials", []):
            if trial.get("snapshot_diff"):
                diff = trial["snapshot_diff"]
                regressions.append(
                    {
                        "timestamp": e.get("timestamp", "unknown"),
                        "scenario_id": e.get("scenario_id", "unknown"),
                        "volk": e.get("volk", "unknown"),
                        "trial_number": trial.get("trial_number", 0),
                        "diff_preview": diff[:300] + ("..." if len(diff) > 300 else ""),
                    }
                )
    return regressions


@traces(spec="102", story="US-002", reqs=["REQ-102-029"])
def _render_report_html(context: dict[str, Any]) -> str:
    """Render the report context into HTML — Jinja2 if available, else a
    partially-rendered fallback using the stdlib ``string.Template``.
    """
    try:
        from jinja2 import Template

        template = Template(_HTML_TEMPLATE)
        return template.render(**context)
    except ImportError:
        from string import Template as StringTemplate

        st = StringTemplate(_HTML_TEMPLATE)
        html = st.safe_substitute(
            {
                k: str(v)
                for k, v in context.items()
                if k not in ("volk_stats", "scenario_stats", "regressions")
            }
        )
        return html + "\n<!-- Jinja2 not available; report partially rendered -->\n"


@traces(
    spec="102",
    story="US-002",
    reqs=[
        "REQ-102-029",
        "REQ-102-030",
        "REQ-102-031",
        "REQ-102-032",
        "REQ-102-033",
    ],
)
def generate_report(history_dir: Path, output_path: Path) -> Path:
    """Read all JSON history files and generate an HTML report.

    Returns:
        Path to the written HTML file.
    """
    entries = _load_history_entries(history_dir)

    total_runs, pass_rate, avg_score = _compute_overall_stats(entries)
    volk_stats = _compute_volk_stats(entries)
    scenario_stats = _compute_scenario_stats(entries)
    regressions = _compute_regressions(entries)

    context = {
        "generated_at": datetime.now(UTC).isoformat(),
        "total_runs": total_runs,
        "pass_rate": pass_rate,
        "avg_score": avg_score,
        "regression_count": len(regressions),
        "volk_stats": volk_stats,
        "scenario_stats": scenario_stats,
        "regressions": regressions,
    }

    html = _render_report_html(context)

    output_path.write_text(html, encoding="utf-8")
    _logger.info("Report written to %s", output_path)
    return output_path


# ---------------------------------------------------------------------------
# JUnit XML
# ---------------------------------------------------------------------------


def _trial_to_junit_case(
    trial: TrialResult,
    scenario_id: str,
    volk_name: str,
) -> ET.Element:
    """Build a <testcase> element for a single trial."""
    case = ET.Element("testcase")
    case.set("name", f"{scenario_id} trial {trial.trial_number}")
    case.set("classname", volk_name)
    case.set("time", str(trial.execution_time_ms / 1000.0))

    if not trial.passed:
        failure = ET.SubElement(case, "failure")
        failure.set("message", f"Trial {trial.trial_number} failed")
        details: list[str] = []
        for er in trial.evaluator_results:
            status = "PASS" if er.passed else "FAIL"
            details.append(f"  {er.name}: {status} (score={er.score}) — {er.details}")
        if trial.error:
            details.append(f"Error: {trial.error}")
        if trial.snapshot_diff:
            details.append(f"Snapshot diff:\n{trial.snapshot_diff[:500]}")
        failure.text = "\n".join(details)

    if trial.error and trial.passed:
        # Error that didn't cause failure (unexpected)
        err = ET.SubElement(case, "error")
        err.set("message", str(trial.error))
        err.text = str(trial.error)

    return case


def generate_junit_xml(results: list[ScenarioResult], output_path: Path) -> Path:
    """Generate a JUnit XML file from scenario results.

    Returns:
        Path to the written XML file.
    """
    testsuites = ET.Element("testsuites")

    # Group by volk
    by_volk: dict[str, list[ScenarioResult]] = {}
    for result in results:
        by_volk.setdefault(result.volk_name, []).append(result)

    for volk_name, volk_results in by_volk.items():
        suite = ET.SubElement(testsuites, "testsuite")
        suite.set("name", f"volk_harness.{volk_name}")
        total_tests = sum(len(r.trials) for r in volk_results)
        total_failures = sum(
            sum(1 for t in r.trials if not t.passed) for r in volk_results
        )
        total_time = sum(r.execution_time_ms for r in volk_results) / 1000.0
        suite.set("tests", str(total_tests))
        suite.set("failures", str(total_failures))
        suite.set("errors", "0")
        suite.set("time", str(total_time))
        suite.set("timestamp", datetime.now(UTC).isoformat())

        for result in volk_results:
            for trial in result.trials:
                case = _trial_to_junit_case(trial, result.scenario_id, volk_name)
                suite.append(case)

    tree = ET.ElementTree(testsuites)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    tree.write(output_path, encoding="utf-8", xml_declaration=True)
    _logger.info("JUnit XML written to %s", output_path)
    return output_path


# ---------------------------------------------------------------------------
# Neo4j Trend Queries
# ---------------------------------------------------------------------------


@traces(spec="102", story="US-002", reqs=["REQ-102-028"])
class TrendQueries:
    """Static Cypher queries for historical trend analysis in the graph."""

    @staticmethod
    @traces(spec="102", story="US-002", reqs=["REQ-102-028"])
    def pass_rate_last_30_days(volk_name: str) -> str:
        """Return Cypher query string for pass rate of *volk_name* over last 30 days."""
        return (
            "MATCH (r:Run)-[:RESULT_OF]->(s:Scenario)-[:EVALUATED_BY]->(v:Volk {alias: $volk_name}) "
            "WHERE datetime(r.timestamp) > datetime() - duration('P30D') "
            "RETURN count(r) AS total_runs, "
            "       sum(CASE WHEN r.passed THEN 1 ELSE 0 END) AS passes, "
            "       (sum(CASE WHEN r.passed THEN 1 ELSE 0 END) * 100.0 / count(r)) AS pass_rate"
        )

    @staticmethod
    @traces(spec="102", story="US-002", reqs=["REQ-102-028"])
    def regression_count(scenario_id: str) -> str:
        """Return Cypher query string for number of regressions for *scenario_id*."""
        return (
            "MATCH (reg:Regression)-[:DETECTED_IN]->(s:Scenario {id: $scenario_id}) "
            "RETURN count(reg) AS regression_count"
        )

    @staticmethod
    @traces(spec="102", story="US-002", reqs=["REQ-102-028"])
    def avg_score_trend(scenario_id: str) -> str:
        """Return Cypher query string for average score trend of *scenario_id*."""
        return (
            "MATCH (r:Run)-[:RESULT_OF]->(s:Scenario {id: $scenario_id}) "
            "RETURN date(datetime(r.timestamp)) AS run_date, "
            "       avg(r.aggregate_score) AS avg_score "
            "ORDER BY run_date DESC "
            "LIMIT 30"
        )