Skip to content

k4bench.analysis.trend

k4bench.analysis.trend

Pure, Streamlit-free trend aggregation over benchmark run directories.

Each build_*_trend function takes the already date-windowed set of cached run directories (see :func:k4bench.remote.fetch_runs_windowed) and returns a long-form DataFrame keyed by run_id / x_date (nightly tag), or None when no data could be loaded. The dashboard wraps these in @st.cache_data (see dashboard/data.py); the nightly regression report calls them directly from CI, which is why they must not import Streamlit.

upper_trimmed_mean

upper_trimmed_mean(times: ndarray, fraction: float = TRIM_FRACTION) -> float | None

Mean of times with the slowest fraction of events dropped.

A run total is a sum of per-event costs, and where that distribution is heavy-tailed a handful of events carry a disproportionate share of it. That makes the plain mean a statistic about the tail as much as about the software. Dropping the slow tail leaves a statistic about the typical event, which moves when the typical event's cost moves and not otherwise.

Deliberately a complement to the untrimmed mean, never a replacement: the same tail that destabilises the mean is where a tail-confined regression would show up first, so a detector that only watched this would be blind to exactly the most interesting kind of change.

Returns None for fewer than :data:MIN_TRIM_EVENTS events, where the trim would drop nothing and the value would merely duplicate the mean.

Source code in k4bench/analysis/trend.py
def upper_trimmed_mean(times: np.ndarray, fraction: float = TRIM_FRACTION) -> float | None:
    """Mean of *times* with the slowest *fraction* of events dropped.

    A run total is a sum of per-event costs, and where that distribution is
    heavy-tailed a handful of events carry a disproportionate share of it. That
    makes the plain mean a statistic about the tail as much as about the
    software. Dropping the slow tail leaves a statistic about the *typical*
    event, which moves when the typical event's cost moves and not otherwise.

    Deliberately a complement to the untrimmed mean, never a replacement: the
    same tail that destabilises the mean is where a tail-confined regression
    would show up first, so a detector that only watched this would be blind to
    exactly the most interesting kind of change.

    Returns ``None`` for fewer than :data:`MIN_TRIM_EVENTS` events, where the
    trim would drop nothing and the value would merely duplicate the mean.
    """
    n = len(times)
    if n < MIN_TRIM_EVENTS:
        return None
    n_drop = int(n * fraction)
    if n_drop <= 0:
        return None
    kept = np.sort(times)[: n - n_drop]
    return float(kept.mean())

parse_run_dir

parse_run_dir(run_dir: Path) -> dict

Extract run metadata from a date-level run directory.

Expected path structure::

{detector}/{platform}/{stack}/{sample}/{YYYY-MM-DD}/

Prefers run_info.json when present; falls back to inferring fields from the directory path.

Source code in k4bench/analysis/trend.py
def parse_run_dir(run_dir: Path) -> dict:
    """Extract run metadata from a date-level run directory.

    Expected path structure::

        {detector}/{platform}/{stack}/{sample}/{YYYY-MM-DD}/

    Prefers ``run_info.json`` when present; falls back to inferring fields
    from the directory path.
    """
    def _unknown_meta(run_dir: Path) -> dict:
        """Return a minimal metadata dict when no structured info is available."""
        return {
            "run_dir":          str(run_dir),
            "run_date":         pd.to_datetime(run_dir.name[:10], errors="coerce"),
            "platform":         "unknown",
            "k4h_release":      "unknown",
            "k4h_release_date": pd.NaT,
            "k4h_packages":     {},
            "sample":           "unknown",
            "xml_path":         None,
            "github_run_url":   None,
            "commit_sha":       None,
            "n_events":         None,
            "random_seed":      None,
            "status":           None,
            "failed_configs":   [],
            "configured_labels": None,
            "machine_consistent": None,
        }

    info_path = run_dir / RUN_INFO
    if info_path.exists():
        try:
            with open(info_path) as fh:
                info = json.load(fh)
            k4h_release = info.get("k4h_release") or ""
            k4h_release_date_raw = info.get("k4h_release_date")
            if not k4h_release_date_raw and k4h_release:
                m = re.search(r"(\d{4}-\d{2}-\d{2})", k4h_release)
                k4h_release_date_raw = m.group(1) if m else None
            return {
                "run_dir":          str(run_dir),
                "run_date":         pd.to_datetime(info.get("date"), errors="coerce"),
                "platform":         info.get("platform", "unknown") or "unknown",
                "k4h_release":      k4h_release,
                "k4h_release_date": pd.to_datetime(k4h_release_date_raw, errors="coerce"),
                # Per-package upstream commits (see k4bench.provenance). Absent for
                # runs predating provenance capture, and for any night whose stack
                # could not be read — an empty map means "unknown", never "unchanged".
                "k4h_packages":     info.get("k4h_packages") or {},
                "sample":           info.get("sample", "unknown") or "unknown",
                # The compact file this run loaded, relative to $K4GEO. Absent
                # for every run benchmarked before it was captured, which reads
                # as "unknown" — never as "this run loads nothing".
                "xml_path":         info.get("xml_path"),
                "github_run_url":   info.get("github_run_url"),
                "commit_sha":       info.get("commit_sha"),
                "n_events":         info.get("n_events"),
                # The ddsim seed this run simulated with, or ``None``. Two
                # different facts share that ``None``: a run that drew a fresh
                # seed, and a run written before the seed was recorded at all.
                # Neither can be shown to have simulated the same events as any
                # other night, which is the only question downstream asks of
                # this field, so they are deliberately not distinguished.
                "random_seed":      info.get("random_seed"),
                "status":           info.get("status"),
                "failed_configs":   info.get("failed_configs") or [],
                # Exact result labels planned by the expanded benchmark config.
                # Absent on legacy runs; ``None`` tells the regression report to
                # retain its historical-inference fallback.
                "configured_labels": _parse_configured_labels(info),
                "machine_consistent": info.get("machine_consistent"),
            }
        except (OSError, json.JSONDecodeError, TypeError, ValueError):
            _log.warning("parse_run_dir: could not read run_info.json in '%s'", run_dir)
            # Do NOT fall through to path-based parsing: in the temp download
            # directories created by remote.py the parent path components are
            # meaningless tmp names, not the EOS hierarchy.
            return _unknown_meta(run_dir)

    # run_info.json absent — infer only the run date from the directory name.
    # Path-component parsing is intentionally omitted: run dirs live inside
    # temp directories whose parent paths carry no semantic meaning.
    _log.warning("parse_run_dir: run_info.json missing in '%s'", run_dir)
    return _unknown_meta(run_dir)

load_machine_info

load_machine_info(run_dir: str) -> dict | None

Load machine_info.json from a run directory, or return None if absent.

Source code in k4bench/analysis/trend.py
def load_machine_info(run_dir: str) -> dict | None:
    """Load ``machine_info.json`` from a run directory, or return ``None`` if absent."""
    path = Path(run_dir) / MACHINE_INFO
    if not path.exists():
        return None
    try:
        with open(path) as fh:
            return json.load(fh)
    except (OSError, json.JSONDecodeError):
        _log.warning("load_machine_info: could not read '%s'", path)
        return None

build_results_trend

build_results_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load per-config results across run_dirs into one combined DataFrame.

Each row gets run_id, run_date, platform, k4h_release, k4h_release_date, github_run_url, random_seed and x_date columns.

Source code in k4bench/analysis/trend.py
def build_results_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load per-config results across *run_dirs* into one combined DataFrame.

    Each row gets ``run_id``, ``run_date``, ``platform``, ``k4h_release``,
    ``k4h_release_date``, ``github_run_url``, ``random_seed`` and ``x_date``
    columns.
    """
    if not run_dirs:
        return None
    frames = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        try:
            df = load_results(run_dir)
        except EXPECTED_LOAD_ERRORS as exc:
            _log.debug("build_results_trend: skipping '%s': %s", run_dir, exc)
            continue
        except Exception:
            _log.exception("build_results_trend: error loading '%s'", run_dir)
            continue
        df["run_id"]           = run_dir.name
        df["run_date"]         = meta["run_date"]
        df["platform"]         = meta["platform"]
        df["k4h_release"]      = meta["k4h_release"]
        df["k4h_release_date"] = meta["k4h_release_date"]
        df["github_run_url"]   = meta["github_run_url"]
        df["xml_path"]         = meta["xml_path"]
        df["random_seed"]      = meta["random_seed"]
        frames.append(df)
    if not frames:
        return None
    return _finalize_dates(pd.concat(frames, ignore_index=True))

build_region_timing_trend

build_region_timing_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load per-region timing summary across run_dirs.

For each run directory, region timing is loaded for all available configs. Per detector per config per run, the median and mean event-level time are computed (event 0 excluded as warmup).

Returns a long-form DataFrame with columns: run_id, run_date, k4h_release_date, label, attribution, detector, median_time_s, mean_time_s or None if no data could be loaded. run_id lets callers join each row with its run's reliability verdict (see k4bench.results.reliability_evidence).

Source code in k4bench/analysis/trend.py
def build_region_timing_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load per-region timing summary across *run_dirs*.

    For each run directory, region timing is loaded for all available configs.
    Per detector per config per run, the median and mean event-level time are
    computed (event 0 excluded as warmup).

    Returns a long-form DataFrame with columns:
        run_id, run_date, k4h_release_date, label, attribution, detector,
        median_time_s, mean_time_s
    or ``None`` if no data could be loaded. ``run_id`` lets callers join each row
    with its run's reliability verdict (see ``k4bench.results.reliability_evidence``).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        try:
            region_data = load_region_timing(run_dir)
        except EXPECTED_LOAD_ERRORS as exc:
            _log.debug("build_region_timing_trend: skipping '%s': %s", run_dir, exc)
            continue
        except Exception:
            _log.exception("build_region_timing_trend: error loading '%s'", run_dir)
            continue

        for label, rdata in region_data.items():
            for attribution in ("at_location", "by_birth"):
                df_attr: pd.DataFrame = rdata.get(attribution)
                if df_attr is None or df_attr.empty:
                    continue
                # Exclude event 0 (warmup) from the index
                df_attr = df_attr[df_attr.index != 0]
                if df_attr.empty:
                    continue
                stats = zip(
                    df_attr.columns, df_attr.count(), df_attr.median(),
                    df_attr.mean(), df_attr.std(),
                )
                for detector, n, median, mean, std in stats:
                    if not n:
                        continue
                    rows.append({
                        "run_id":           run_dir.name,
                        "run_date":         meta["run_date"],
                        "k4h_release_date": meta["k4h_release_date"],
                        "k4h_release":      meta["k4h_release"],
                        "label":            label,
                        "attribution":      attribution,
                        "detector":         detector,
                        "n_events":         int(n),
                        "median_time_s":    float(median),
                        "mean_time_s":      float(mean),
                        "std_time_s":       float(std) if n > 1 else 0.0,
                    })

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows))

build_phase_trend

build_phase_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load how long each k4run job's phases took across run_dirs.

One row per run, config and phase (the :meth:~k4bench.analysis.components.ComponentTiming.phase_seconds phases, configure included) with component NaN, and one per run, config, :data:COMPONENT_PHASES phase and component that spent time in it, with the component's self seconds there (:meth:~k4bench.analysis.components.ComponentTiming.phase_component_seconds). Columns run_id, run_date, k4h_release_date, k4h_release, label, phase, component, seconds and x_date. Each component file is read on its own, so one that is malformed drops no other config of its run. None when no run has component files (ddsim runs have none).

Source code in k4bench/analysis/trend.py
def build_phase_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load how long each k4run job's phases took across *run_dirs*.

    One row per run, config and phase (the
    :meth:`~k4bench.analysis.components.ComponentTiming.phase_seconds`
    phases, ``configure`` included) with ``component`` ``NaN``, and one per
    run, config, :data:`COMPONENT_PHASES` phase and component that spent time
    in it, with the component's self ``seconds`` there
    (:meth:`~k4bench.analysis.components.ComponentTiming.phase_component_seconds`). Columns ``run_id``,
    ``run_date``, ``k4h_release_date``, ``k4h_release``, ``label``, ``phase``,
    ``component``, ``seconds`` and ``x_date``. Each component file is read
    on its own, so one that is malformed drops no other config of its run.
    ``None`` when no run has component files (ddsim runs have none).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        timings, unreadable = load_each_component_timing(run_dir)
        for label, reason in unreadable.items():
            _log.debug("build_phase_trend: skipping '%s' of '%s': %s", label, run_dir, reason)

        run = {
            "run_id":           run_dir.name,
            "run_date":         meta["run_date"],
            "k4h_release_date": meta["k4h_release_date"],
            "k4h_release":      meta["k4h_release"],
        }
        for label, timing in timings.items():
            for phase, seconds in timing.phase_seconds().items():
                rows.append({**run, "label": label, "phase": phase,
                             "component": None, "seconds": seconds})
            for phase in COMPONENT_PHASES:
                per_component = timing.phase_component_seconds(phase)
                spent = per_component[per_component > 0]
                rows.extend(
                    {**run, "label": label, "phase": phase,
                     "component": component, "seconds": float(seconds)}
                    for component, seconds in spent.items()
                )

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows))

build_component_summary_trend

build_component_summary_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load every k4run job's components across run_dirs, each with its :func:~k4bench.analysis.component_detail.component_summary (the warm-up event, event 0, left out).

One row per run, config and component its component file lists, whether or not it ran, and one per run and config with component NaN, saying the config's file was read: a component missing from a run that has that row was not in the job. Columns run_id, run_date, k4h_release_date, k4h_release, label, component, category, the summary's columns and x_date. Each component file is read on its own, so one that is malformed drops no other config of its run. None when no run has component files (ddsim runs have none).

Source code in k4bench/analysis/trend.py
def build_component_summary_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load every k4run job's components across *run_dirs*, each with its
    :func:`~k4bench.analysis.component_detail.component_summary` (the
    warm-up event, event 0, left out).

    One row per run, config and component its component file lists, whether
    or not it ran, and one per run and config with ``component`` ``NaN``,
    saying the config's file was read: a component missing from a run that
    has that row was not in the job. Columns ``run_id``, ``run_date``,
    ``k4h_release_date``, ``k4h_release``, ``label``, ``component``,
    ``category``, the summary's columns and ``x_date``. Each component file
    is read on its own, so one that is malformed drops no other config of its
    run. ``None`` when no run has component files (ddsim runs have none).
    """
    if not run_dirs:
        return None

    frames: list[pd.DataFrame] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        timings, unreadable = load_each_component_timing(run_dir)
        for label, reason in unreadable.items():
            _log.debug("build_component_summary_trend: skipping '%s' of '%s': %s", label, run_dir, reason)

        run = {
            "run_id":           run_dir.name,
            "run_date":         meta["run_date"],
            "k4h_release_date": meta["k4h_release_date"],
            "k4h_release":      meta["k4h_release"],
        }
        for label, timing in timings.items():
            summary = component_summary(timing).assign(category=timing.components["category"])
            frames.append(pd.DataFrame([{**run, "label": label, "component": None}]))
            frames.append(
                summary.rename_axis("component").reset_index().assign(**run, label=label)
            )

    if not frames:
        return None
    return _finalize_dates(pd.concat(frames, ignore_index=True))

build_heap_trend

build_heap_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load the heap each k4run job's components held across run_dirs, from its memory pass.

One row per run, config and component that held heap at some moment (:meth:~k4bench.analysis.heap.ComponentHeap.holders, what no component allocated among them), with its :meth:~k4bench.analysis.heap.ComponentHeap.summary, and one per run and config with component NaN for the whole job, with the :data:HEAP_TOTAL_COLUMNS of its :meth:~k4bench.analysis.heap.ComponentHeap.totals; the warm-up event, event 0, is left out of both. Columns run_id, run_date, k4h_release_date, k4h_release, label, component, the :data:~k4bench.analysis.heap.SUMMARY_COLUMNS (in MB) and x_date. Each heap file is read on its own, so one that is malformed drops no other config of its run. None when no run has heap files (runs from before the memory pass have none).

Source code in k4bench/analysis/trend.py
def build_heap_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load the heap each k4run job's components held across *run_dirs*,
    from its memory pass.

    One row per run, config and component that held heap at some moment
    (:meth:`~k4bench.analysis.heap.ComponentHeap.holders`, what no component
    allocated among them), with its
    :meth:`~k4bench.analysis.heap.ComponentHeap.summary`, and one per run and
    config with ``component`` ``NaN`` for the whole job, with the
    :data:`HEAP_TOTAL_COLUMNS` of its
    :meth:`~k4bench.analysis.heap.ComponentHeap.totals`; the warm-up event,
    event 0, is left out of both. Columns ``run_id``, ``run_date``,
    ``k4h_release_date``, ``k4h_release``, ``label``, ``component``, the
    :data:`~k4bench.analysis.heap.SUMMARY_COLUMNS` (in MB) and ``x_date``.
    Each heap file is read on its own, so one that is malformed drops no
    other config of its run. ``None`` when no run has heap files (runs from
    before the memory pass have none).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir() or not labelled_files(run_dir, HEAP_SUFFIX):
            continue
        meta = parse_run_dir(run_dir)
        run = {
            "run_id":           run_dir.name,
            "run_date":         meta["run_date"],
            "k4h_release_date": meta["k4h_release_date"],
            "k4h_release":      meta["k4h_release"],
        }
        heaps, unreadable = load_each_component_heap(run_dir)
        for label, reason in unreadable.items():
            _log.debug("build_heap_trend: skipping '%s' of '%s': %s", label, run_dir, reason)
        for label, heap in heaps.items():
            summary = heap.summary(warm_up=0).loc[heap.holders()]
            rows.extend(
                {**run, "label": label, "component": component, **values}
                for component, values in summary.to_dict("index").items()
            )
            totals = heap.totals(warm_up=0)
            rows.append({
                **run, "label": label, "component": None,
                **{column: getattr(totals, column) for column in HEAP_TOTAL_COLUMNS},
            })

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows, columns=[
        "run_id", "run_date", "k4h_release_date", "k4h_release", "label", "component",
        *SUMMARY_COLUMNS,
    ]))

build_event_size_trend

build_event_size_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load what each event of every podio output file takes on disk across run_dirs, from the runs' _event_size.json.

One row per run, config, file and collection of its events (:data:~k4bench.analysis.event_size.FRAME_PARAMETERS among them), with its type and its compressed_kb and uncompressed_kb per event, and one per run, config and file with collection NaN for the whole event, with the file's size on disk (file_mb) and events besides; columns :data:EVENT_SIZE_TREND_COLUMNS and x_date. Each record is read on its own, so one that is malformed drops no other config of its run. None when no run has a podio output file (runs from before the runners measured their outputs have none).

Source code in k4bench/analysis/trend.py
def build_event_size_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load what each event of every podio output file takes on disk across
    *run_dirs*, from the runs' ``_event_size.json``.

    One row per run, config, file and collection of its events
    (:data:`~k4bench.analysis.event_size.FRAME_PARAMETERS` among them), with
    its ``type`` and its ``compressed_kb`` and ``uncompressed_kb`` per event,
    and one per run, config and file with ``collection`` ``NaN`` for the
    whole event, with the file's size on disk (``file_mb``) and ``events``
    besides; columns :data:`EVENT_SIZE_TREND_COLUMNS` and ``x_date``. Each
    record is read on its own, so one that is malformed drops no other config
    of its run. ``None`` when no run has a podio output file (runs from before
    the runners measured their outputs have none).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir() or not (paths := labelled_files(run_dir, EVENT_SIZE_SUFFIX)):
            continue
        meta = parse_run_dir(run_dir)
        run = {
            "run_id":           run_dir.name,
            "run_date":         meta["run_date"],
            "k4h_release_date": meta["k4h_release_date"],
            "k4h_release":      meta["k4h_release"],
        }
        for path, label in paths:
            try:
                files = read_event_size(path)
            except (OSError, ValueError) as exc:
                _log.debug("build_event_size_trend: skipping '%s': %s", path, exc)
                continue
            for output in files:
                if not output.has_events:
                    continue
                key = {**run, "label": label, "file": output.name}
                per_event = output.per_event()
                rows.extend(
                    {**key, "collection": collection, **values}
                    for collection, values in per_event[
                        ["type", "compressed_kb", "uncompressed_kb"]
                    ].to_dict("index").items()
                )
                rows.append({
                    **key, "collection": None,
                    "compressed_kb": per_event["compressed_kb"].sum(),
                    "uncompressed_kb": per_event["uncompressed_kb"].sum(),
                    "file_mb": output.bytes / KB**2, "events": output.events,
                })

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows, columns=list(EVENT_SIZE_TREND_COLUMNS)))

build_algorithm_timing_trend

build_algorithm_timing_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load each k4run job's algorithms' per-event time across run_dirs.

One row per run, config and algorithm (:meth:~k4bench.analysis.components.ComponentTiming.algorithms) that ran in an event, with the :func:build_event_timing_trend statistics of its own time in each event it ran in, the warm-up event 0 left out: columns run_id, run_date, k4h_release_date, k4h_release, label, component, n_events, mean_time_s, median_time_s, p95_time_s, std_time_s, trimmed_mean_time_s (absent below enough events) and x_date. Each component file is read on its own, so one that is malformed drops no other config of its run. None when no run has component files (ddsim runs have none).

Source code in k4bench/analysis/trend.py
def build_algorithm_timing_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load each k4run job's algorithms' per-event time across *run_dirs*.

    One row per run, config and algorithm
    (:meth:`~k4bench.analysis.components.ComponentTiming.algorithms`) that
    ran in an event, with the :func:`build_event_timing_trend` statistics of
    its own time in each event it ran in, the warm-up event 0 left out:
    columns ``run_id``, ``run_date``, ``k4h_release_date``, ``k4h_release``,
    ``label``, ``component``, ``n_events``, ``mean_time_s``,
    ``median_time_s``, ``p95_time_s``, ``std_time_s``,
    ``trimmed_mean_time_s`` (absent below enough events) and ``x_date``.
    Each component file is read on its own, so one that is malformed drops
    no other config of its run. ``None`` when no run has component files
    (ddsim runs have none).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        timings, unreadable = load_each_component_timing(run_dir)
        for label, reason in unreadable.items():
            _log.debug("build_algorithm_timing_trend: skipping '%s' of '%s': %s", label, run_dir, reason)

        run = {
            "run_id":           run_dir.name,
            "run_date":         meta["run_date"],
            "k4h_release_date": meta["k4h_release_date"],
            "k4h_release":      meta["k4h_release"],
        }
        for label, timing in timings.items():
            wall = timing.execute["wall_s"]
            wall = wall[wall.index != 0]
            for component in timing.algorithms():
                stats = _time_stats(wall[component])
                if stats:
                    rows.append({**run, "label": label, "component": component, **stats})

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows))

build_event_timing_trend

build_event_timing_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load per-event timing and memory summary across run_dirs.

For each run directory, event timing is loaded for all available configs. Per config per run, summary statistics are computed (event 0 excluded): mean_time_s, median_time_s, p95_time_s, trimmed_mean_time_s, mean_rss_mb, median_rss_mb, p95_rss_mb, max_rss_mb

trimmed_mean_time_s (:func:upper_trimmed_mean) is a view of the typical event, unmoved by how heavy this run's slow tail happened to be. It is absent (NaN) for a config with too few events to support it.

Where the event file records anonymous RSS, rss_anon_slope_mb_per_event is the :func:theil_sen_slope of the valid (non-negative) post-warm-up rss_anon_end_mb samples against their event numbers, in MB/event. It indicates memory growth across the run, which a leak or other accumulating state can cause; it is not proof of either. It is absent (NaN) below :data:MIN_SLOPE_EVENTS valid samples.

Returns a long-form DataFrame with those columns plus run_id, run_date, k4h_release_date, k4h_release, label or None if no data could be loaded. run_id lets callers join each row with its run's reliability verdict (see k4bench.results.reliability_evidence).

Source code in k4bench/analysis/trend.py
def build_event_timing_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load per-event timing and memory summary across *run_dirs*.

    For each run directory, event timing is loaded for all available configs.
    Per config per run, summary statistics are computed (event 0 excluded):
        mean_time_s, median_time_s, p95_time_s, trimmed_mean_time_s,
        mean_rss_mb, median_rss_mb, p95_rss_mb, max_rss_mb

    ``trimmed_mean_time_s`` (:func:`upper_trimmed_mean`) is a view of the
    typical event, unmoved by how heavy this run's slow tail happened to be. It
    is absent (NaN) for a config with too few events to support it.

    Where the event file records anonymous RSS, ``rss_anon_slope_mb_per_event``
    is the :func:`theil_sen_slope` of the valid (non-negative) post-warm-up
    ``rss_anon_end_mb`` samples against their event numbers, in MB/event. It
    indicates memory growth across the run, which a leak or other accumulating
    state can cause; it is not proof of either. It is absent (NaN) below
    :data:`MIN_SLOPE_EVENTS` valid samples.

    Returns a long-form DataFrame with those columns plus
        run_id, run_date, k4h_release_date, k4h_release, label
    or ``None`` if no data could be loaded. ``run_id`` lets callers join each row
    with its run's reliability verdict (see ``k4bench.results.reliability_evidence``).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        meta = parse_run_dir(run_dir)
        try:
            event_data = load_event_timing(run_dir)
        except EXPECTED_LOAD_ERRORS as exc:
            _log.debug("build_event_timing_trend: skipping '%s': %s", run_dir, exc)
            continue
        except Exception:
            _log.exception("build_event_timing_trend: error loading '%s'", run_dir)
            continue

        for label, df_ev in event_data.items():
            # Exclude event 0 (warmup)
            df_ev = df_ev[df_ev["event_number"] != 0]
            if df_ev.empty:
                continue
            row: dict = {
                "run_id":           run_dir.name,
                "run_date":         meta["run_date"],
                "k4h_release_date": meta["k4h_release_date"],
                "k4h_release":      meta["k4h_release"],
                "label":            label,
            }
            if "event_time_s" in df_ev.columns:
                row |= _time_stats(df_ev["event_time_s"])
            if "rss_end_mb" in df_ev.columns:
                r = df_ev["rss_end_mb"].dropna()
                nr = len(r)
                if nr:
                    row["n_events_rss"]  = nr
                    row["mean_rss_mb"]   = float(r.mean())
                    row["median_rss_mb"] = float(r.median())
                    row["p95_rss_mb"]    = float(r.quantile(0.95))
                    row["max_rss_mb"]    = float(r.max())
                    row["std_rss_mb"]    = float(r.std()) if nr > 1 else 0.0
            if "rss_anon_end_mb" in df_ev.columns:
                r = df_ev["rss_anon_end_mb"].dropna()
                r = r[r >= 0]  # Negative samples indicate a failed /proc read.
                nr = len(r)
                if nr:
                    row["n_events_rss_anon"] = nr
                    row["mean_rss_anon_mb"] = float(r.mean())
                    row["median_rss_anon_mb"] = float(r.median())
                    row["std_rss_anon_mb"] = float(r.std()) if nr > 1 else 0.0
                # Slope against the event number, so a dropped sample leaves a
                # gap in x rather than pulling later events one step earlier.
                slope = theil_sen_slope(df_ev.loc[r.index, "event_number"], r)
                if slope is not None:
                    row["rss_anon_slope_mb_per_event"] = slope
            if "rss_file_end_mb" in df_ev.columns:
                r = df_ev["rss_file_end_mb"].dropna()
                r = r[r >= 0]
                nr = len(r)
                if nr:
                    row["n_events_rss_file"] = nr
                    row["mean_rss_file_mb"] = float(r.mean())
                    row["std_rss_file_mb"] = float(r.std()) if nr > 1 else 0.0
            rows.append(row)

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows))

build_machine_info_trend

build_machine_info_trend(run_dirs: tuple[str, ...]) -> DataFrame | None

Load per-run machine load / memory metrics across run_dirs.

Unlike the other trend builders there is no per-config dimension: each run directory has a single machine_info.json describing the physical machine that executed the benchmark. One row per run captures the load average, available RAM, swap, frequency and throttle counters, so callers can see how the host's condition varied across nightly releases (e.g. a day where the machine was under unusually high load).

Returns a DataFrame with one row per run plus run_id, run_date, k4h_release_date, k4h_release and x_date columns, or None if no machine info could be loaded. run_id lets callers join the machine condition of a run with its per-config results (e.g. to attach a reliability verdict to each run).

Source code in k4bench/analysis/trend.py
def build_machine_info_trend(run_dirs: tuple[str, ...]) -> pd.DataFrame | None:
    """Load per-run machine load / memory metrics across *run_dirs*.

    Unlike the other trend builders there is no per-config dimension: each run
    directory has a single ``machine_info.json`` describing the physical machine
    that executed the benchmark. One row per run captures the load average,
    available RAM, swap, frequency and throttle counters, so callers can see how
    the host's condition varied across nightly releases (e.g. a day where the
    machine was under unusually high load).

    Returns a DataFrame with one row per run plus ``run_id``, ``run_date``,
    ``k4h_release_date``, ``k4h_release`` and ``x_date`` columns, or ``None``
    if no machine info could be loaded. ``run_id`` lets callers join the
    machine condition of a run with its per-config results (e.g. to attach a
    reliability verdict to each run).
    """
    if not run_dirs:
        return None

    rows: list[dict] = []
    for d in run_dirs:
        run_dir = Path(d)
        if not run_dir.is_dir():
            continue
        mi = load_machine_info(str(run_dir))
        if not mi:
            continue
        meta = parse_run_dir(run_dir)
        rows.append({
            "run_id":                  run_dir.name,
            "run_date":                meta["run_date"],
            "k4h_release_date":        meta["k4h_release_date"],
            "k4h_release":             meta["k4h_release"],
            "hostname":                mi.get("hostname"),
            "cpu_logical_cores":       mi.get("cpu_logical_cores"),
            "cpu_physical_cores":      mi.get("cpu_physical_cores"),
            "load_avg_1m_start":       mi.get("load_avg_1m_start"),
            "load_avg_5m_start":       mi.get("load_avg_5m_start"),
            "load_avg_1m_end":         mi.get("load_avg_1m_end"),
            "load_avg_5m_end":         mi.get("load_avg_5m_end"),
            "ram_total_gb":            mi.get("ram_total_gb"),
            "ram_available_gb_start":  mi.get("ram_available_gb_start"),
            "ram_available_gb_end":    mi.get("ram_available_gb_end"),
            "swap_used_gb_start":      mi.get("swap_used_gb_start"),
            "swap_in_pages":           mi.get("swap_in_pages"),
            "swap_out_pages":          mi.get("swap_out_pages"),
            "cpu_freq_mhz_start":      mi.get("cpu_freq_mhz_start"),
            "thermal_throttle_events": mi.get("thermal_throttle_events"),
        })

    if not rows:
        return None
    return _finalize_dates(pd.DataFrame(rows))