Skip to content

Commit 4376c72

Browse files
committed
feat: add Markdown block report generation for CLI
Generally intended to resemble the original reports that were removed from DVSim as closely as possible. Note the TODOs - currently the progress table is missing because this information is not being computed nor captured correctly here. Also the stage/overall totals in the result table are not correct, but this is because the SimCfg calculations are not correct, instead of the Markdown presentation logic being incorrect. Signed-off-by: Alex Jones <alex.jones@lowrisc.org>
1 parent 73ce0bc commit 4376c72

2 files changed

Lines changed: 224 additions & 4 deletions

File tree

src/dvsim/sim/data.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ def empty(self) -> bool:
156156
v is None for v in self.model_dump(exclude_unset=True, exclude={"code"}).values()
157157
)
158158

159+
def flattened(self) -> dict[str, float | None]:
160+
"""Convert the coverage metrics to a flattened dictionary.
161+
162+
This dictionary will contain all the stored metrics, and a computed "total" average item.
163+
"""
164+
average = self.average
165+
items = {} if average is None else {"total": average}
166+
if self.code:
167+
items.update(self.code.model_dump(exclude_none=True))
168+
items.update(self.model_dump(exclude={"code"}))
169+
return items
170+
159171

160172
class FlowResults(BaseModel):
161173
"""Flow results data."""

src/dvsim/sim/report.py

Lines changed: 212 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44

55
"""Generate reports."""
66

7-
from collections.abc import Callable
7+
from collections import defaultdict
8+
from collections.abc import Callable, Collection, Iterable
9+
from datetime import datetime
810
from pathlib import Path
9-
from typing import Protocol, TypeAlias
11+
from typing import Any, Protocol, TypeAlias
12+
13+
from tabulate import tabulate
1014

1115
from dvsim.logging import log
16+
from dvsim.report.data import IPMeta
1217
from dvsim.sim.data import SimFlowResults, SimResultsSummary
1318
from dvsim.templates.render import render_static, render_template
19+
from dvsim.utils import TS_FORMAT_LONG
1420

1521
__all__ = (
1622
"HtmlReportRenderer",
@@ -22,6 +28,18 @@
2228
"write_report",
2329
)
2430

31+
32+
def _plural(item: str, n: int | Collection[Any], suffix: str = "s") -> str:
33+
if not isinstance(n, int):
34+
n = len(n)
35+
return item if n == 1 else item + suffix
36+
37+
38+
def _indent_by_levels(lines: Iterable[tuple[int, str]], indent_spaces: int = 4) -> str:
39+
"""Format per-line indentation of (0-indexed level, msg) log messages."""
40+
return "\n".join(" " * lvl * indent_spaces + msg for lvl, msg in lines)
41+
42+
2543
# Report rendering returns mappings of relative report paths to (string) contents.
2644
ReportArtifacts: TypeAlias = dict[str, str]
2745

@@ -131,6 +149,9 @@ class MarkdownReportRenderer:
131149

132150
format_name = "markdown"
133151

152+
MAX_TESTS_PER_BUCKET = 5
153+
MAX_RESEEDS_PER_BUCKETED_TEST = 2
154+
134155
def render(self, summary: SimResultsSummary, outdir: Path | None = None) -> ReportArtifacts:
135156
"""Render a Markdown report of the sim flow results."""
136157
if outdir is not None:
@@ -150,8 +171,195 @@ def render(self, summary: SimResultsSummary, outdir: Path | None = None) -> Repo
150171

151172
def render_block(self, results: SimFlowResults) -> ReportArtifacts:
152173
"""Render a Markdown report of the sim flow results for a given block/flow."""
153-
_results = results
154-
return {"report.md": "TODO: Markdown block report"}
174+
# Generate block result metadata information
175+
report_md = self.render_metadata(results.block, results.timestamp, results.build_seed)
176+
testplan_ref = (results.testplan_ref or "").strip()
177+
if len(results.stages) > 0 and testplan_ref:
178+
report_md += f"\n### [Testplan]({testplan_ref})"
179+
report_md += f"\n### Simulator: {results.tool.name.upper()}"
180+
181+
# Record a summary of the simulation results, coverage, and failure buckets, if applicable.
182+
result_summary = self.render_block_results(results)
183+
if result_summary:
184+
report_md += "\n\n" + result_summary
185+
186+
return {"report.md": report_md}
187+
188+
def render_metadata(
189+
self,
190+
scope: IPMeta,
191+
timestamp: datetime,
192+
seed: int | None,
193+
title: str = "Simulation Results",
194+
) -> str:
195+
"""Generate a Markdown string summary of the result metadata.
196+
197+
Args:
198+
scope: The scope (block/top) to generate metadata from.
199+
timestamp: The timestamp metadata info to include.
200+
seed: The build seed, if one was used in this run.
201+
title: The title to use as a suffix (to "NAME %s"). Defaults to "Simulation Results".
202+
203+
"""
204+
name = scope.variant_name(sep="/")
205+
report_md = f"## {name.upper()} {title}"
206+
report_md += f"\n### {timestamp.strftime(TS_FORMAT_LONG)}"
207+
208+
revision = (scope.revision_info or "").strip()
209+
if not revision:
210+
revision = f"Github Revision: [`{scope.commit_short}`]({scope.url})"
211+
report_md += f"\n### {revision}"
212+
report_md += f"\n### Branch: {scope.branch}"
213+
214+
if seed is not None:
215+
report_md += f"\n### Build randomization enabled with --build-seed {seed}"
216+
217+
return report_md
218+
219+
def render_block_results(self, results: SimFlowResults) -> str:
220+
"""Generate a Markdown string covering the results, coverage and failure buckets."""
221+
report_md = self.render_result_table(results) if results.total else "No results to display."
222+
223+
# TODO: need to optionally generate a progress table if `--map-full-testplan` was set.
224+
# This can be passed through and set when instantiating the markdown renderer, but
225+
# right now we don't record the correct information for testplan progress in the sim
226+
# results, so we leave this incomplete for now.
227+
228+
if results.coverage:
229+
coverage_table = self.render_coverage_table(results)
230+
if coverage_table:
231+
report_md += "\n\n" + coverage_table
232+
233+
if results.failed_jobs.buckets:
234+
bucket_summary = self.render_bucket_summary(results)
235+
if bucket_summary:
236+
report_md += "\n\n" + bucket_summary
237+
238+
return report_md
239+
240+
def render_result_table(self, results: SimFlowResults) -> str:
241+
"""Generate a Markdown string containing a table of the testplan results."""
242+
column_info = [
243+
("Stage", "center"),
244+
("Name", "center"),
245+
("Tests", "left"),
246+
("Max Job Runtime", "center"),
247+
("Simulated Time", "center"),
248+
("Passing", "center"),
249+
("Total", "center"),
250+
("Pass Rate", "center"),
251+
]
252+
table = []
253+
hidden_names = ("n.a.", "unmapped")
254+
255+
for stage_key, stage in results.stages.items():
256+
# Coalesce result information to default values if necessary
257+
stage_name = "" if stage_key.lower() in hidden_names else stage_key
258+
259+
for tp_key, tp in stage.testpoints.items():
260+
tp_name = "" if tp_key.lower() in hidden_names else tp_key
261+
for test_name, result in tp.tests.items():
262+
job_runtime = "" if result.max_time is None else f"{result.max_time:.3f}s"
263+
sim_time = "" if result.sim_time is None else f"{result.sim_time:.3f}us"
264+
pass_rate = "-- %" if result.total == 0 else f"{result.percent:.2f} %"
265+
266+
row = [
267+
stage_name,
268+
tp_name,
269+
test_name,
270+
job_runtime,
271+
sim_time,
272+
result.passed,
273+
result.total,
274+
pass_rate,
275+
]
276+
table.append(row)
277+
278+
pass_rate = "-- %" if stage.total == 0 else f"{stage.percent:.2f} %"
279+
# TODO: note the calculated stage totals are currently not correct.
280+
table.append(
281+
[stage_name, None, "**TOTAL**", None, None, stage.passed, stage.total, pass_rate]
282+
)
283+
284+
# TODO: note the calculated overall totals are currently not correct.
285+
pass_rate = "-- %" if results.total == 0 else f"{results.percent:.2f} %"
286+
table.append(
287+
[None, None, "**TOTAL**", None, None, results.passed, results.total, pass_rate]
288+
)
289+
290+
if not table:
291+
return ""
292+
293+
return "### Test Results\n\n" + tabulate(
294+
table,
295+
headers=[c[0] for c in column_info],
296+
tablefmt="pipe",
297+
colalign=[c[1] for c in column_info],
298+
)
299+
300+
def render_coverage_table(self, results: SimFlowResults) -> str:
301+
"""Generate a Markdown string containing a table of the coverage results."""
302+
if results.coverage is None:
303+
return ""
304+
305+
cov_results = {
306+
k.upper().replace("_", "/"): f"{v:.2f} %"
307+
for k, v in results.coverage.flattened().items()
308+
if v is not None
309+
}
310+
if not cov_results and not results.cov_report_page:
311+
return ""
312+
313+
report_md = "## Coverage Results"
314+
if results.cov_report_page:
315+
report_md += f"\n### [Coverage Dashboard]({results.cov_report_page})"
316+
if cov_results:
317+
colalign = ("center",) * len(cov_results)
318+
report_md += "\n\n" + tabulate(
319+
[cov_results], headers="keys", tablefmt="pipe", colalign=colalign
320+
)
321+
322+
return report_md
323+
324+
def render_bucket_summary(self, results: SimFlowResults) -> str:
325+
"""Generate a Markdown string with a summary of the buckets (failures/killed)."""
326+
lines = [(0, "## Failure Buckets")]
327+
328+
for bucket, tests in sorted(
329+
results.failed_jobs.buckets.items(),
330+
key=lambda kv: len(kv[1]),
331+
reverse=True,
332+
):
333+
lines.append((0, f"* `{bucket}` has {len(tests)} {_plural('failure', tests)}:"))
334+
335+
grouped_tests = defaultdict(list)
336+
for job in tests:
337+
grouped_tests[job.name].append(job)
338+
339+
displayed = list(grouped_tests.items())[: self.MAX_TESTS_PER_BUCKET]
340+
for name, reseeds in displayed:
341+
lines.append(
342+
(1, f"* Test {name} has {len(reseeds)} {_plural('failure', reseeds)}.")
343+
)
344+
345+
for failure in reseeds[: self.MAX_RESEEDS_PER_BUCKETED_TEST]:
346+
lines.append((2, f"* {failure.qual_name}\\"))
347+
line_context = "Log" if failure.line is None else f"Line {failure.line}, in log"
348+
lines.append((2, f" {line_context} {failure.log_path}"))
349+
if failure.log_context:
350+
lines.append((0, ""))
351+
lines.extend((4, line.rstrip()) for line in failure.log_context)
352+
lines.append((0, ""))
353+
354+
extra = len(reseeds) - self.MAX_RESEEDS_PER_BUCKETED_TEST
355+
if extra > 0:
356+
lines.append((2, f"* ... and {extra} more {_plural('failure', extra)}."))
357+
358+
extra = len(grouped_tests) - self.MAX_TESTS_PER_BUCKET
359+
if extra > 0:
360+
lines.append((2, f"* ... and {extra} more {_plural('test', extra)}."))
361+
362+
return _indent_by_levels(lines)
155363

156364
def render_summary(self, summary: SimResultsSummary) -> ReportArtifacts:
157365
"""Render a Markdown report of a summary of the sim flow results (overall)."""

0 commit comments

Comments
 (0)