diff --git a/python/hyperion_diagnostics/report.py b/python/hyperion_diagnostics/report.py index d7d5963..3462f43 100644 --- a/python/hyperion_diagnostics/report.py +++ b/python/hyperion_diagnostics/report.py @@ -14,6 +14,48 @@ import numpy as np +def _format_markdown_value(value: Any) -> str: + if isinstance(value, float): + return f"{value:.4f}" + return str(value) + + +def _render_configuration(config: dict[str, Any]) -> list[str]: + lines = ["## Configuration"] + for key, value in config.items(): + lines.append(f"- **{key}:** {value}") + return lines + + +def _render_summary_table(summary_stats: dict[str, dict[str, float]]) -> list[str]: + lines = ["## Parameter Summary", ""] + headers = None + for param, stats in summary_stats.items(): + if headers is None: + headers = list(stats.keys()) + lines.append("| Parameter | " + " | ".join(headers) + " |") + lines.append("|" + "|".join(["---"] * (len(headers) + 1)) + "|") + vals = " | ".join(_format_markdown_value(stats.get(h, "N/A")) for h in headers) + lines.append(f"| {param} | {vals} |") + return lines + + +def _render_convergence_metrics(metrics: dict[str, float]) -> list[str]: + lines = ["## Convergence Metrics"] + for key, value in metrics.items(): + lines.append(f"- **{key}:** {_format_markdown_value(value)}") + return lines + + +def _render_bullet_section(title: str, items: list[str], *, prefix: str = "") -> list[str]: + if not items: + return [] + lines = ["", f"## {title}"] + for item in items: + lines.append(f"- {prefix}{item}") + return lines + + @dataclass class DiagnosticsReport: """Структурированный отчёт: модель, метод, конфиг, summary, convergence, варнинги, выводы.""" @@ -50,48 +92,17 @@ def to_markdown(self) -> str: f"**Method:** {self.inference_method}", f"**Timestamp:** {self.timestamp}", "", - "## Configuration", + *_render_configuration(self.config), ] - for k, v in self.config.items(): - lines.append(f"- **{k}:** {v}") lines.append("") - lines.append("## Parameter Summary") - lines.append("") - - if self.summary_stats: - headers = None - for param, stats in self.summary_stats.items(): - if headers is None: - headers = list(stats.keys()) - lines.append("| Parameter | " + " | ".join(headers) + " |") - lines.append("|" + "|".join(["---"] * (len(headers) + 1)) + "|") - vals = " | ".join( - f"{stats.get(h, 'N/A'):.4f}" if isinstance(stats.get(h), float) - else str(stats.get(h, 'N/A')) - for h in headers - ) - lines.append(f"| {param} | {vals} |") + lines.extend(_render_summary_table(self.summary_stats)) lines.append("") - lines.append("## Convergence Metrics") - for k, v in self.convergence_metrics.items(): - if isinstance(v, float): - lines.append(f"- **{k}:** {v:.4f}") - else: - lines.append(f"- **{k}:** {v}") - - if self.warnings: - lines.append("") - lines.append("## Warnings") - for w in self.warnings: - lines.append(f"- ⚠ {w}") - - if self.conclusions: - lines.append("") - lines.append("## Conclusions") - for c in self.conclusions: - lines.append(f"- {c}") + lines.extend(_render_convergence_metrics(self.convergence_metrics)) + + lines.extend(_render_bullet_section("Warnings", self.warnings, prefix="⚠ ")) + lines.extend(_render_bullet_section("Conclusions", self.conclusions)) return "\n".join(lines) diff --git a/python/tests/test_diagnostics/test_report_rendering.py b/python/tests/test_diagnostics/test_report_rendering.py new file mode 100644 index 0000000..fe9a2b1 --- /dev/null +++ b/python/tests/test_diagnostics/test_report_rendering.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from hyperion_diagnostics.report import DiagnosticsReport + + +def test_report_markdown_preserves_section_order() -> None: + report = DiagnosticsReport( + model_name="ordered_model", + inference_method="nuts", + timestamp="2026-05-09T10:00:00", + config={"num_samples": 100}, + summary_stats={"mu": {"mean": 0.0, "std": 1.0}}, + convergence_metrics={"mu/ess": 120.0}, + warnings=["Low BFMI"], + conclusions=["Review diagnostics."], + ) + + markdown = report.to_markdown() + + assert markdown.index("## Configuration") < markdown.index("## Parameter Summary") + assert markdown.index("## Parameter Summary") < markdown.index("## Convergence Metrics") + assert markdown.index("## Convergence Metrics") < markdown.index("## Warnings") + assert markdown.index("## Warnings") < markdown.index("## Conclusions") + + +def test_report_markdown_omits_empty_optional_sections() -> None: + report = DiagnosticsReport( + model_name="minimal_model", + inference_method="laplace", + timestamp="2026-05-09T10:00:00", + ) + + markdown = report.to_markdown() + + assert "## Warnings" not in markdown + assert "## Conclusions" not in markdown + + +def test_report_markdown_formats_numeric_and_text_values() -> None: + report = DiagnosticsReport( + model_name="format_model", + inference_method="smc", + timestamp="2026-05-09T10:00:00", + summary_stats={"theta": {"mean": 1.23456, "status": "ok"}}, + convergence_metrics={"particles": 256, "ess": 101.12345}, + ) + + markdown = report.to_markdown() + + assert "| theta | 1.2346 | ok |" in markdown + assert "- **particles:** 256" in markdown + assert "- **ess:** 101.1235" in markdown