Skip to content

Commit 68dbb6b

Browse files
authored
Fix fp-stability log preservation and add local/inline reporting (#1537)
1 parent ed4ed34 commit 68dbb6b

5 files changed

Lines changed: 154 additions & 22 deletions

File tree

toolchain/mfc/cli/commands.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,12 @@
983983
default=False,
984984
dest="no_float_max",
985985
),
986+
Argument(
987+
name="force",
988+
help="Run a user case that exceeds the size feasibility guard anyway. Expect long runtimes: ~30x Verrou slowdown per run, times up to ~12 runs (trim passes with -N 1 and --no-* flags).",
989+
action=ArgAction.STORE_TRUE,
990+
default=False,
991+
),
986992
],
987993
examples=[
988994
Example("./mfc.sh fp-stability", "Auto-discover binaries and run the built-in suite"),
@@ -994,6 +1000,10 @@
9941000
Example("./mfc.sh fp-stability -N 10", "Run 10 random-rounding samples per case"),
9951001
Example("./mfc.sh fp-stability --no-vprec --no-cancellation", "Skip VPREC sweep and cancellation detection"),
9961002
Example("./mfc.sh fp-stability --no-cancellation --no-float-max", "Skip analysis passes"),
1003+
Example(
1004+
"./mfc.sh fp-stability big_case.py --force -N 1 --no-vprec --no-float-proxy",
1005+
"Run a case beyond the size guard, trimming passes to keep it tractable",
1006+
),
9971007
],
9981008
key_options=[
9991009
("--sim-binary PATH", "Serial simulation binary (debug, no-MPI)"),

toolchain/mfc/fp_stability.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
One run with --check-max-float=yes; reports locations where a
2727
double->float conversion would overflow to +/-Inf.
2828
29-
Logs are saved to fp-stability-logs/ and uploaded as CI artifacts.
30-
On GitHub Actions: a step summary table and ::warning:: file annotations
31-
are emitted automatically so failing source lines appear in the PR diff.
29+
Per-case logs (verrou.log, sim.out, pre.log, .inp, cancel_gen.txt) are saved to
30+
fp-stability-logs/<case>/ and the markdown report to fp-stability-logs/summary.md;
31+
CI uploads the directory as an artifact. On GitHub Actions, the report is also
32+
appended to the step summary and file annotations are emitted so failing source
33+
lines appear in the PR diff.
3234
3335
Requires:
3436
- Verrou-enabled Valgrind at $VERROU_HOME/bin/valgrind
@@ -43,8 +45,9 @@
4345
./mfc.sh fp-stability --sim-binary PATH --pre-binary PATH
4446
4547
A user case .py is run as a single serial CPU process under Verrou, so it must be
46-
a small, short proxy (a feasibility guard rejects large grids / long runs); output
47-
is forced to serial .dat I/O and the files to diff are auto-detected.
48+
a small, short proxy (a feasibility guard rejects large grids / long runs; --force
49+
overrides it, at proportionally long runtimes); output is forced to serial .dat
50+
I/O and the files to diff are auto-detected.
4851
"""
4952

5053
import math
@@ -371,11 +374,29 @@ def _blank_result(name: str) -> dict:
371374
}
372375

373376

377+
def _preserve_logs(work_dir: str, dest_dir: str) -> None:
378+
"""Copy a case's small text artifacts (*.log, *.out, *.inp, cancel_gen.txt)
379+
from its scratch work_dir into fp-stability-logs/<case>/, mirroring the
380+
run-dir layout, before the work_dir is deleted. Field-data .dat files are
381+
skipped (they can be large for a user case)."""
382+
keep = (".log", ".out", ".inp", ".txt")
383+
if os.path.isdir(dest_dir):
384+
shutil.rmtree(dest_dir) # stale logs from a previous invocation
385+
for root, _dirs, files in os.walk(work_dir):
386+
rel = os.path.relpath(root, work_dir)
387+
for fn in files:
388+
if fn.endswith(keep):
389+
target = os.path.normpath(os.path.join(dest_dir, rel))
390+
os.makedirs(target, exist_ok=True)
391+
shutil.copy2(os.path.join(root, fn), target)
392+
393+
374394
def _run_case(
375395
case: dict,
376396
verrou_bin: str,
377397
sim_bin: str,
378398
pp_bin: str,
399+
log_dir: str,
379400
n_samples: int,
380401
run_float: bool,
381402
run_vprec: bool,
@@ -476,6 +497,13 @@ def _run_case(
476497
if locs:
477498
worst = max(bits.values()) if bits else 0
478499
cons.print(f" cancellation: {len(locs)} site(s), worst loses >= {worst / math.log2(10):.0f} of ~16 digits")
500+
ranked = sorted(locs, key=lambda s: (-bits.get(s, 0), s))
501+
for path, line in ranked[:5]:
502+
lost = bits.get((path, line), 0) / math.log2(10)
503+
macro = " [dim](fypp-expanded)[/dim]" if (path, line) in result["cancellation_macro"] else ""
504+
cons.print(f" >= {lost:.0f} digits lost {path}:{line}{macro}")
505+
if len(ranked) > 5:
506+
cons.print(f" [dim]...and {len(ranked) - 5} more; see fp-stability-logs/summary.md[/dim]")
479507
n_macro = len(result["cancellation_macro"])
480508
if n_macro:
481509
cons.print(f" [dim]{n_macro} inside fypp expansions - line maps to multiple instances[/dim]")
@@ -495,12 +523,22 @@ def _run_case(
495523
result["float_max_locs"] = locs
496524
if locs:
497525
cons.print(f" [bold yellow]float-max[/bold yellow]: {len(locs)} overflow site(s)")
526+
for path, line in locs[:5]:
527+
cons.print(f" {path}:{line}")
528+
if len(locs) > 5:
529+
cons.print(f" [dim]...and {len(locs) - 5} more; see fp-stability-logs/summary.md[/dim]")
498530
else:
499531
cons.print(" float-max: no overflows")
500532
except Exception as exc:
501533
cons.print(f" [bold yellow]float-max check error[/bold yellow]: {exc}")
502534

503535
finally:
536+
# best-effort, like the rmtree below: a failed log copy must not replace
537+
# the case's real outcome (this runs even when the try block is raising)
538+
try:
539+
_preserve_logs(work_dir, os.path.join(log_dir, name))
540+
except OSError as exc:
541+
cons.print(f" [bold yellow]could not preserve logs[/bold yellow]: {exc}")
504542
shutil.rmtree(work_dir, ignore_errors=True)
505543
cons.unindent()
506544
cons.print()
@@ -532,12 +570,21 @@ def _load_user_case(input_path: str) -> dict:
532570
cells = (m + 1) * (n + 1) * (p + 1)
533571
t_stop = int(params.get("t_step_stop", 0) or 0)
534572
work = cells * max(t_stop, 1)
535-
if cells > FP_CASE_MAX_CELLS:
536-
raise MFCException(f"case has {cells:,} cells - too large for Verrou (~30x slowdown, run many times). " f"Use a coarsened proxy (<= {FP_CASE_MAX_CELLS:,} cells).")
537-
if work > FP_CASE_MAX_WORK:
538-
raise MFCException(
539-
f"case is ~{work:,} cell-steps ({cells:,} cells x {t_stop} time steps) - too slow under "
540-
f"Verrou (~30x, run many times). Reduce m/n/p or t_step_stop (target <= {FP_CASE_MAX_WORK:,} cell-steps)."
573+
if not ARG("force"):
574+
if cells > FP_CASE_MAX_CELLS:
575+
raise MFCException(
576+
f"case has {cells:,} cells - too large for Verrou (~30x slowdown, run many times). " f"Use a coarsened proxy (<= {FP_CASE_MAX_CELLS:,} cells), or pass --force to run anyway."
577+
)
578+
if work > FP_CASE_MAX_WORK:
579+
raise MFCException(
580+
f"case is ~{work:,} cell-steps ({cells:,} cells x {t_stop} time steps) - too slow under "
581+
f"Verrou (~30x, run many times). Reduce m/n/p or t_step_stop (target <= {FP_CASE_MAX_WORK:,} cell-steps), or pass --force to run anyway."
582+
)
583+
elif cells > FP_CASE_MAX_CELLS or work > FP_CASE_MAX_WORK:
584+
cons.print(
585+
f" [bold yellow]--force[/bold yellow]: case is ~{work:,} cell-steps "
586+
f"(guard is {FP_CASE_MAX_WORK:,}) - expect roughly {work / FP_CASE_MAX_WORK:.0f}x the usual per-run time, "
587+
"for every enabled pass (trim with -N 1 and --no-* flags)."
541588
)
542589
stem = os.path.splitext(os.path.basename(input_path))[0]
543590
if stem == "case": # examples/<name>/case.py - the dir name is more telling
@@ -622,6 +669,7 @@ def fp_stability():
622669
verrou_bin,
623670
sim_bin,
624671
pp_bin,
672+
log_dir,
625673
n_samples,
626674
run_float,
627675
run_vprec,
@@ -642,7 +690,8 @@ def fp_stability():
642690
mark = "[green]PASS[/green]" if r["passed"] else "[red]FAIL[/red]"
643691
cons.print(f" {mark} {r['name']}")
644692

645-
_emit_github_summary(results, n_samples)
693+
_emit_github_summary(results, n_samples, log_dir)
646694
_emit_github_annotations(results)
695+
cons.print(f" report: {os.path.join(log_dir, 'summary.md')}")
647696

648697
sys.exit(0 if n_fail == 0 else 1)

toolchain/mfc/fp_stability_metrics.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ def _macro_context_in_lines(lines: list, lineno: int) -> str:
113113
return None
114114

115115

116+
def _source_snippet(fname: str, lineno: int, context: int = 3) -> str:
117+
"""Return a line-numbered source excerpt around `lineno` (1-based), the marked
118+
line prefixed with '>', or '' if the file cannot be resolved or the line is
119+
out of range. Used to show the offending code inline in reports."""
120+
lines = _read_source_lines(fname)
121+
if not lines or not 1 <= lineno <= len(lines):
122+
return ""
123+
beg = max(1, lineno - context)
124+
end = min(len(lines), lineno + context)
125+
return "\n".join(f"{'>' if i == lineno else ' '}{i:5d} | {lines[i - 1].rstrip()}" for i in range(beg, end + 1))
126+
127+
116128
def _macro_context(fname: str, lineno: int) -> str:
117129
"""File-backed wrapper around _macro_context_in_lines; '' path safe."""
118130
lines = _read_source_lines(fname)

toolchain/mfc/fp_stability_report.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@
1111
MIN_SIG_BITS,
1212
VPREC_MANTISSA_BITS,
1313
_digits_left,
14+
_source_snippet,
1415
)
1516

1617

18+
def _snippet_md(fname: str, lineno: int) -> list:
19+
"""Markdown lines for a source excerpt nested under a list item (2-space
20+
indented fenced block), or [] if the source cannot be resolved."""
21+
snippet = _source_snippet(fname, lineno)
22+
if not snippet:
23+
return []
24+
return ["", " ```f90", *(" " + ln for ln in snippet.splitlines()), " ```", ""]
25+
26+
1727
def _emit_github_annotations(results: list):
1828
"""Emit GitHub annotations for FP cancellation sites.
1929
@@ -54,17 +64,15 @@ def _more_md(total: int, shown: int, noun: str) -> str:
5464
return f"- ...and {total - shown} more {noun}; see `fp-stability-logs/`"
5565

5666

57-
def _emit_github_summary(results: list, n_samples: int):
58-
"""Write a markdown results table to GITHUB_STEP_SUMMARY.
67+
def _emit_github_summary(results: list, n_samples: int, log_dir: str = None):
68+
"""Write the markdown results report.
5969
60-
Visible directly in the Actions run UI without downloading artifacts.
70+
Always written to <log_dir>/summary.md (when log_dir is given), so local runs
71+
get the same report CI shows; additionally appended to GITHUB_STEP_SUMMARY when
72+
set, where it is visible in the Actions run UI without downloading artifacts.
6173
Includes: pass/fail, max_dev, float proxy, VPREC sweep (failing levels),
6274
and catastrophic-cancellation source locations for any failing cases.
6375
"""
64-
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
65-
if not summary_path:
66-
return
67-
6876
n_pass = sum(1 for r in results if r["passed"])
6977
n_fail = len(results) - n_pass
7078

@@ -104,7 +112,9 @@ def _emit_github_summary(results: list, n_samples: int):
104112
for r in cases_with_cancel:
105113
site_bits = r.get("cancellation_bits") or {}
106114
macro_sites = r.get("cancellation_macro") or {}
107-
sites = [{"where": f"{fname}:{lineno}", "bits": site_bits.get((fname, lineno), 0), "macro": macro_sites.get((fname, lineno))} for fname, lineno in r["cancellation_locs"]]
115+
sites = [
116+
{"where": f"{fname}:{lineno}", "loc": (fname, lineno), "bits": site_bits.get((fname, lineno), 0), "macro": macro_sites.get((fname, lineno))} for fname, lineno in r["cancellation_locs"]
117+
]
108118
ordered = sorted(sites, key=lambda e: (-e["bits"], e["where"]))
109119
if ordered:
110120
w = ordered[0]
@@ -113,6 +123,7 @@ def _emit_github_summary(results: list, n_samples: int):
113123
lost = e["bits"] / math.log2(10)
114124
ambiguous = f" - _{e['macro']}-expanded, may represent multiple instances_" if e["macro"] else ""
115125
md.append(f"- **>= {lost:.0f} digits lost** (~{_digits_left(e['bits']):.0f} of 16 left) - `{e['where']}`{ambiguous}")
126+
md.extend(_snippet_md(*e["loc"]))
116127
footer = _more_md(len(ordered), 15, "site(s)")
117128
if footer:
118129
md.append(footer)
@@ -149,10 +160,18 @@ def _emit_github_summary(results: list, n_samples: int):
149160
md.append(f"**`{r['name']}`** - {len(r['float_max_locs'])} site(s)\n")
150161
for fname, lineno in r["float_max_locs"][:10]:
151162
md.append(f"- `{fname}:{lineno}`")
163+
md.extend(_snippet_md(fname, lineno))
152164
footer = _more_md(len(r["float_max_locs"]), 10, "site(s)")
153165
if footer:
154166
md.append(footer)
155167
md.append("")
156168

157-
with open(summary_path, "a") as f:
158-
f.write("\n".join(md) + "\n")
169+
text = "\n".join(md) + "\n"
170+
if log_dir:
171+
os.makedirs(log_dir, exist_ok=True)
172+
with open(os.path.join(log_dir, "summary.md"), "w") as f:
173+
f.write(text)
174+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
175+
if summary_path:
176+
with open(summary_path, "a") as f:
177+
f.write(text)

toolchain/mfc/test_fp_stability.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ def test_sig_bits_is_scale_free():
8080
assert abs(_sig_bits(1e-9, 1.0) - _sig_bits(1e-4, 1e5)) < 1e-9
8181

8282

83+
def test_source_snippet_marks_line_with_context(tmp_path):
84+
from mfc.fp_stability_metrics import _source_snippet
85+
86+
f = tmp_path / "m_x.fpp"
87+
f.write_text("".join(f"line{i}\n" for i in range(1, 11)))
88+
rows = _source_snippet(str(f), 5, context=2).splitlines()
89+
assert len(rows) == 5 # lines 3..7
90+
assert rows[2].startswith(">") and "line5" in rows[2]
91+
assert "line3" in rows[0] and "line7" in rows[-1]
92+
# unresolvable file or out-of-range line must degrade to '' (no snippet)
93+
assert _source_snippet(str(tmp_path / "nope.fpp"), 5) == ""
94+
assert _source_snippet(str(f), 99) == ""
95+
96+
8397
def test_sig_bits_zero_scale_is_safe():
8498
# a zero/degenerate field scale must not divide-by-zero; report full precision
8599
assert _sig_bits(1e-12, 0.0) == 53.0
@@ -99,6 +113,34 @@ def _emit_to_tmp(results, tmp_path, monkeypatch):
99113
return out.read_text()
100114

101115

116+
def test_emit_summary_writes_local_summary_md_without_ci_env(tmp_path, monkeypatch):
117+
# outside GitHub Actions the same report must land in fp-stability-logs/summary.md
118+
from mfc import fp_stability_report as report
119+
from mfc.fp_stability import _blank_result
120+
121+
monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
122+
report._emit_github_summary([_blank_result("x")], 5, log_dir=str(tmp_path))
123+
assert "0 passed, 1 failed" in (tmp_path / "summary.md").read_text()
124+
125+
126+
def test_preserve_logs_keeps_text_artifacts_skips_field_data(tmp_path):
127+
# logs/.inp/cancel_gen.txt must survive the work_dir rmtree; bulky .dat must not
128+
from mfc.fp_stability import _preserve_logs
129+
130+
work = tmp_path / "work"
131+
(work / "run_00").mkdir(parents=True)
132+
(work / "pre.log").write_text("pre")
133+
(work / "simulation.inp").write_text("inp")
134+
(work / "run_00" / "verrou.log").write_text("v")
135+
(work / "run_00" / "cons.1.00.000050.dat").write_text("data")
136+
dest = tmp_path / "logs" / "case"
137+
_preserve_logs(str(work), str(dest))
138+
assert (dest / "pre.log").is_file()
139+
assert (dest / "simulation.inp").is_file()
140+
assert (dest / "run_00" / "verrou.log").is_file()
141+
assert not (dest / "run_00" / "cons.1.00.000050.dat").exists()
142+
143+
102144
def test_emit_summary_survives_blank_result(tmp_path, monkeypatch):
103145
# the dict produced on the per-case error path must not KeyError the emitter
104146
from mfc.fp_stability import _blank_result

0 commit comments

Comments
 (0)