Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions python/fast_mlsirm/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@
from .report_exact_values import exact_value_disclosure


MAX_REPORT_METRICS = 512
MAX_REPORT_ROWS = 50_000
MAX_REPORT_COLUMNS = 64
MAX_REPORT_CELLS = 250_000
MAX_REPORT_COVERAGE_ITEMS = 32


def render_diagnostics_report(
diagnostics_path: str | Path,
output_path: str | Path,
*,
title: str | None = None,
) -> Path:
"""Render saved diagnostics JSON as a standalone HTML report."""
"""Render saved diagnostics JSON as a standalone HTML report.

Both path operands are explicit local filesystem choices made by the
calling process. This library function is not a network endpoint or path
sandbox; a service accepting untrusted identifiers must map them to trusted
filesystem paths before calling this boundary.
"""

source = Path(diagnostics_path)
# 🛡️ Sentinel: Prevent JSON DoS via unbounded recursion or memory exhaustion by using bounded loader
Expand Down Expand Up @@ -203,6 +216,11 @@ def _render_dimensionality_report(payload: dict[str, Any]) -> list[str]:

def _metric_section(heading: str, metrics: dict[str, Any]) -> str | None:
"""Render a labelled metric-card grid, or ``None`` when there are no metrics."""
if len(metrics) > MAX_REPORT_METRICS:
raise ValueError(
f"report metrics exceed the {MAX_REPORT_METRICS}-entry limit"
)

cards = []
for key, value in metrics.items():
cards.append(
Expand Down Expand Up @@ -240,6 +258,7 @@ def _table_section(
so the complete untruncated full-precision source rows and their JSON and
CSV exports stay available without hover, JavaScript, or pointer input.
"""
_validate_report_rows(rows)
chart = _bar_chart(rows, chart_value) if chart_value else ""
heading_id = heading.lower().replace(" ", "-")
return "\n".join(
Expand Down Expand Up @@ -287,6 +306,11 @@ def _availability_section(

def _coverage_column(heading: str, items: list[str], *, muted: bool = False) -> str:
"""Render one titled pill list within the coverage grid."""
if len(items) > MAX_REPORT_COVERAGE_ITEMS:
raise ValueError(
f"report coverage exceeds the {MAX_REPORT_COVERAGE_ITEMS}-item limit"
)

class_name = "coverage-list coverage-list-muted" if muted else "coverage-list"
item_markup = "\n".join(f"<li>{escape(name)}</li>" for name in items)
return "\n".join(
Expand Down Expand Up @@ -404,15 +428,28 @@ def _table(rows: list[dict[str, Any]], *, label: str, limit: int = 12) -> str:


def _rows_from_columnar(section: Any) -> list[dict[str, Any]]:
"""Transpose a column-oriented diagnostics section into a list of row dicts."""
"""Transpose a bounded column-oriented diagnostics section into row dicts."""
if not isinstance(section, dict) or not section:
return []

columns = list(section)
if len(columns) > MAX_REPORT_COLUMNS:
raise ValueError(
f"report table columns exceed the {MAX_REPORT_COLUMNS}-column limit"
)

lengths = [_value_length(section[column]) for column in columns]
row_count = max(lengths) if lengths else 0
if row_count == 0:
return []
if row_count > MAX_REPORT_ROWS:
raise ValueError(
f"report table rows exceed the {MAX_REPORT_ROWS}-row limit"
)
if row_count * len(columns) > MAX_REPORT_CELLS:
raise ValueError(
f"report table cells exceed the {MAX_REPORT_CELLS}-cell limit"
)

rows = []
for index in range(row_count):
Expand All @@ -423,6 +460,28 @@ def _rows_from_columnar(section: Any) -> list[dict[str, Any]]:
return rows


def _validate_report_rows(rows: list[dict[str, Any]]) -> None:
"""Reject row-oriented diagnostics that exceed report rendering budgets."""
if len(rows) > MAX_REPORT_ROWS:
raise ValueError(
f"report table rows exceed the {MAX_REPORT_ROWS}-row limit"
)

columns: set[str] = set()
cell_count = 0
for row in rows:
columns.update(row)
if len(columns) > MAX_REPORT_COLUMNS:
raise ValueError(
f"report table columns exceed the {MAX_REPORT_COLUMNS}-column limit"
)
cell_count += len(row)
if cell_count > MAX_REPORT_CELLS:
raise ValueError(
f"report table cells exceed the {MAX_REPORT_CELLS}-cell limit"
)


def _columns(rows: list[dict[str, Any]]) -> list[str]:
"""Return the union of row keys in first-seen order for table columns."""
ordered = []
Expand Down Expand Up @@ -701,6 +760,12 @@ def _css() -> str:
grid-template-columns: minmax(104px, 180px) 1fr minmax(64px, auto);
gap: 10px;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
}

.bar-row:hover {
background: var(--hover-bg);
}

.bar-label,
Expand Down
32 changes: 29 additions & 3 deletions python/fast_mlsirm/report_exact_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
#: Text used in the exact-value table when a cell has no recorded value.
MISSING_VALUE_TEXT = "missing"

#: Resource ceilings for the fully materialized exact-value table/exports.
MAX_EXACT_VALUE_ROWS = 50_000
MAX_EXACT_VALUE_COLUMNS = 64
MAX_EXACT_VALUE_CELLS = 250_000


def ordered_column_names(source_rows: list[dict[str, Any]]) -> list[str]:
"""Return every column name across ``source_rows`` in first-seen order.
Expand All @@ -51,6 +56,27 @@ def ordered_column_names(source_rows: list[dict[str, Any]]) -> list[str]:
return list(column_names)


def _validated_column_names(source_rows: list[dict[str, Any]]) -> list[str]:
"""Return report columns after bounding fully materialized exact-value cells."""
if len(source_rows) > MAX_EXACT_VALUE_ROWS:
raise ValueError(
f"report table rows exceed the {MAX_EXACT_VALUE_ROWS}-row limit"
)

column_names = ordered_column_names(source_rows)
if len(column_names) > MAX_EXACT_VALUE_COLUMNS:
raise ValueError(
f"report table columns exceed the {MAX_EXACT_VALUE_COLUMNS}-column limit"
)

cell_count = len(source_rows) * len(column_names)
if cell_count > MAX_EXACT_VALUE_CELLS:
raise ValueError(
f"report table cells exceed the {MAX_EXACT_VALUE_CELLS}-cell limit"
)
return column_names


def exact_value_text(cell_value: Any) -> str:
"""Return the full-precision display text for one table cell.

Expand Down Expand Up @@ -95,7 +121,7 @@ def exact_value_json(source_rows: list[dict[str, Any]]) -> str:
as ``null`` and non-finite floats as their explicit text names, so the
export round-trips the finite chart/table source without numeric drift.
"""
column_names = ordered_column_names(source_rows)
column_names = _validated_column_names(source_rows)
export_rows = [
{
column_name: _portable_export_value(source_row.get(column_name))
Expand All @@ -115,7 +141,7 @@ def exact_value_csv(source_rows: list[dict[str, Any]]) -> str:
full-precision text of :func:`exact_value_text`, and missing cells stay
as explicitly empty fields under their named column.
"""
column_names = ordered_column_names(source_rows)
column_names = _validated_column_names(source_rows)
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
csv_writer.writerow(column_names)
Expand Down Expand Up @@ -151,7 +177,7 @@ def exact_value_disclosure(
if not source_rows:
return ""

column_names = ordered_column_names(source_rows)
column_names = _validated_column_names(source_rows)
disclosure_id = _disclosure_dom_id(section_label)

header_cells = "".join(
Expand Down
5 changes: 5 additions & 0 deletions tests/test_report_numeric_motion_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def test_rendered_report_uses_tabular_numerals_without_opacity_transitions(tmp_p
bar_row_rule = _rule_body(style, ".bar-row")
assert "transition:" not in bar_row_rule
assert "opacity:" not in bar_row_rule
assert "padding: 4px 8px;" in bar_row_rule
assert "border-radius: 4px;" in bar_row_rule

bar_row_hover_rule = _rule_body(style, ".bar-row:hover")
assert "background: var(--hover-bg);" in bar_row_hover_rule

table_row_rule = _rule_body(style, "tbody tr")
assert "transition: background-color 0.15s ease-in-out;" in table_row_rule
Expand Down
107 changes: 107 additions & 0 deletions tests/test_report_resource_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Resource-bound contracts for diagnostics HTML rendering."""

from __future__ import annotations

import json

import pytest

from fast_mlsirm.report import render_diagnostics_report


_MAX_REPORT_METRICS = 512
_MAX_REPORT_ROWS = 50_000
_MAX_REPORT_COLUMNS = 64
_MAX_REPORT_CELLS = 250_000


def _render_payload(tmp_path, payload: dict[str, object]) -> None:
"""Persist ``payload`` and render it through the public report boundary."""
source = tmp_path / "fit_diagnostics.json"
output = tmp_path / "report.html"
source.write_text(json.dumps(payload), encoding="utf-8")
render_diagnostics_report(source, output)


def test_report_rejects_excessive_metric_card_count(tmp_path) -> None:
"""A bounded input file cannot amplify into an unbounded metric-card report."""
payload = {
"model_fit": {
f"metric_{index}": float(index)
for index in range(_MAX_REPORT_METRICS + 1)
}
}

with pytest.raises(ValueError, match="report metrics exceed the 512-entry limit"):
_render_payload(tmp_path, payload)

assert not (tmp_path / "report.html").exists()


def test_report_rejects_excessive_columnar_row_count(tmp_path) -> None:
"""Columnar diagnostics fail before materializing an oversized row table."""
payload = {
"model_fit": {"loglik": -1.0},
"itemfit": {"item_id": list(range(_MAX_REPORT_ROWS + 1))},
}

with pytest.raises(ValueError, match="report table rows exceed the 50000-row limit"):
_render_payload(tmp_path, payload)

assert not (tmp_path / "report.html").exists()


def test_report_rejects_excessive_columnar_column_count(tmp_path) -> None:
"""Columnar diagnostics reject pathological width before row expansion."""
payload = {
"model_fit": {"loglik": -1.0},
"itemfit": {
f"column_{index}": [float(index)]
for index in range(_MAX_REPORT_COLUMNS + 1)
},
}

with pytest.raises(ValueError, match="report table columns exceed the 64-column limit"):
_render_payload(tmp_path, payload)

assert not (tmp_path / "report.html").exists()


def test_report_rejects_excessive_columnar_cell_count(tmp_path) -> None:
"""Row x column amplification is bounded even below the individual limits."""
row_count = (_MAX_REPORT_CELLS // 32) + 1
values = list(range(row_count))
payload = {
"model_fit": {"loglik": -1.0},
"itemfit": {f"column_{index}": values for index in range(32)},
}

with pytest.raises(ValueError, match="report table cells exceed the 250000-cell limit"):
_render_payload(tmp_path, payload)

assert not (tmp_path / "report.html").exists()


def test_report_rejects_excessive_dimensionality_row_width(tmp_path) -> None:
"""Row-oriented dimensionality diagnostics share the same width guard."""
source = tmp_path / "dimension_diagnostics.json"
output = tmp_path / "report.html"
source.write_text(
json.dumps(
{
"best": {"latent_dim": 1.0},
"candidates": [
{
f"column_{index}": float(index)
for index in range(_MAX_REPORT_COLUMNS + 1)
}
],
}
),
encoding="utf-8",
)

with pytest.raises(ValueError, match="report table columns exceed the 64-column limit"):
render_diagnostics_report(source, output)

assert not output.exists()
33 changes: 33 additions & 0 deletions tests/test_report_sparse_row_resource_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Regression contract for sparse-row report cell amplification."""

from __future__ import annotations

import json

import pytest

from fast_mlsirm.report import render_diagnostics_report


def test_sparse_rows_count_materialized_union_width_against_cell_budget(tmp_path) -> None:
"""Sparse rows must not bypass the rendered report cell budget."""
column_count = 64
row_count = 4_000
candidates = [
{f"column_{index % column_count}": float(index)}
for index in range(row_count)
]
source = tmp_path / "dimension_diagnostics.json"
output = tmp_path / "report.html"
source.write_text(
json.dumps({"best": {"latent_dim": 1.0}, "candidates": candidates}),
encoding="utf-8",
)

with pytest.raises(
ValueError,
match="report table cells exceed the 250000-cell limit",
):
render_diagnostics_report(source, output)

assert not output.exists()
Loading