ml-reliability-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ """Serialization utilities for ML Reliability Toolkit reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import math
8
+ from dataclasses import fields, is_dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+
15
+ def _normalize_for_json(value: Any) -> Any:
16
+ """Convert supported values to JSON-compatible data.
17
+
18
+ Non-finite floats become None, which JSON serializes as null.
19
+ NumPy scalar values are converted to their Python equivalents.
20
+ The input objects are not modified.
21
+ """
22
+ if isinstance(value, np.generic):
23
+ return _normalize_for_json(value.item())
24
+
25
+ if value is None or isinstance(value, (str, bool, int)):
26
+ return value
27
+
28
+ if isinstance(value, float):
29
+ return value if math.isfinite(value) else None
30
+
31
+ if isinstance(value, dict):
32
+ return {str(key): _normalize_for_json(item) for key, item in value.items()}
33
+
34
+ if isinstance(value, (list, tuple)):
35
+ return [_normalize_for_json(item) for item in value]
36
+
37
+ if is_dataclass(value) and not isinstance(value, type):
38
+ return {
39
+ field.name: _normalize_for_json(getattr(value, field.name))
40
+ for field in fields(value)
41
+ }
42
+
43
+ raise TypeError(
44
+ f"Object of type {type(value).__name__} "
45
+ "is not supported by the reporting JSON serializer."
46
+ )
47
+
48
+
49
+ def report_to_dict(report: Any) -> dict[str, Any]:
50
+ """Convert a reliability report into a JSON-compatible dictionary."""
51
+ normalized = _normalize_for_json(report)
52
+
53
+ if not isinstance(normalized, dict):
54
+ raise TypeError("A reliability report must serialize to a dictionary.")
55
+
56
+ if hasattr(report, "invalid_score_checks"):
57
+ normalized["invalid_score_checks"] = [
58
+ check.check_id for check in report.invalid_score_checks
59
+ ]
60
+
61
+ return normalized
62
+
63
+
64
+ def write_json_report(
65
+ report: Any,
66
+ path: str | Path,
67
+ ) -> None:
68
+ """Write a reliability report as strict, indented JSON."""
69
+ output_path = Path(path)
70
+ data = report_to_dict(report)
71
+
72
+ with output_path.open("w", encoding="utf-8", newline="\n") as file:
73
+ json.dump(
74
+ data,
75
+ file,
76
+ indent=2,
77
+ ensure_ascii=False,
78
+ allow_nan=False,
79
+ )
80
+ file.write("\n")
81
+
82
+
83
+ def _format_console_number(value: Any, precision: int = 4) -> str:
84
+ """Format finite numbers consistently for console reports."""
85
+ if (
86
+ isinstance(value, (int, float))
87
+ and not isinstance(value, bool)
88
+ and math.isfinite(value)
89
+ ):
90
+ return f"{value:.{precision}f}"
91
+
92
+ return "N/A"
93
+
94
+
95
+ def format_console_report(report: Any) -> str:
96
+ """Format a reliability report as readable console text."""
97
+ critical_failures = [
98
+ check
99
+ for check in report.checks
100
+ if not check.passed and check.severity == "critical"
101
+ ]
102
+
103
+ lines = [
104
+ "=" * 60,
105
+ "ML RELIABILITY REPORT",
106
+ "=" * 60,
107
+ "",
108
+ "OVERALL ASSESSMENT",
109
+ "-" * 60,
110
+ f"Score: {_format_console_number(report.score, precision=2)}",
111
+ f"Grade: {report.grade}",
112
+ ]
113
+
114
+ if critical_failures:
115
+ lines.extend(
116
+ [
117
+ "",
118
+ f"CRITICAL ISSUES DETECTED: {len(critical_failures)}",
119
+ "Review critical failures before relying on this assessment.",
120
+ ]
121
+ )
122
+
123
+ lines.extend(
124
+ [
125
+ "",
126
+ "CHECK SUMMARY",
127
+ "-" * 60,
128
+ f"Total checks: {len(report.checks)}",
129
+ f"Passed: {len(report.passed_checks)}",
130
+ f"Failed: {len(report.failed_checks)}",
131
+ f"Warnings: {len(report.warnings)}",
132
+ "",
133
+ "CHECK DETAILS",
134
+ "-" * 60,
135
+ ]
136
+ )
137
+
138
+ if report.checks:
139
+ for check in report.checks:
140
+ status = "PASSED" if check.passed else "FAILED"
141
+
142
+ lines.extend(
143
+ [
144
+ f"[{status}] {check.title}",
145
+ f" Category: {check.category}",
146
+ f" Severity: {check.severity}",
147
+ f" Score: {_format_check_score(check.score)}",
148
+ f" Message: {check.message}",
149
+ ]
150
+ )
151
+ if not check.passed and check.recommendation:
152
+ lines.append(f" Recommendation: {check.recommendation}")
153
+
154
+ lines.append("")
155
+ else:
156
+ lines.append("No checks to display.")
157
+ lines.append("")
158
+
159
+ lines.extend(
160
+ [
161
+ "STRESS TEST SUMMARY",
162
+ "-" * 60,
163
+ f"Total stress tests: {len(report.stress_tests)}",
164
+ f"Successful: {len(report.successful_stress_tests)}",
165
+ f"Failed: {len(report.failed_stress_tests)}",
166
+ ]
167
+ )
168
+
169
+ worst_degradation = report.worst_stress_degradation
170
+
171
+ lines.append(
172
+ "Worst valid degradation: "
173
+ + (f"{worst_degradation:.4f}" if worst_degradation is not None else "N/A")
174
+ )
175
+
176
+ lines.append("")
177
+ lines.append("STRESS TEST DETAILS")
178
+ lines.append("-" * 60)
179
+
180
+ if report.stress_tests:
181
+ for result in report.stress_tests:
182
+ status = "COMPLETED" if result.passed else "FAILED"
183
+
184
+ lines.extend(
185
+ [
186
+ f"[{status}] {result.stress_type}",
187
+ (
188
+ f" Baseline metric: "
189
+ f"{_format_console_number(result.baseline_metric)}"
190
+ ),
191
+ (
192
+ f" Stressed metric: "
193
+ f"{_format_console_number(result.stressed_metric)}"
194
+ ),
195
+ f" Degradation: {_format_console_number(result.degradation)}",
196
+ "",
197
+ ]
198
+ )
199
+ else:
200
+ lines.append("No stress tests to display.")
201
+ lines.append("")
202
+
203
+ lines.append("=" * 60)
204
+
205
+ return "\n".join(lines)
206
+
207
+
208
+ def _escape_markdown_table_cell(value: Any) -> str:
209
+ """Escape text for safe use inside a Markdown table cell."""
210
+ text = str(value)
211
+ text = text.replace("\\", "\\\\")
212
+ text = text.replace("|", "\\|")
213
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
214
+ text = text.replace("\n", "<br>")
215
+ return text
216
+
217
+
218
+ def _escape_markdown_text(value: Any) -> str:
219
+ """Escape special Markdown syntax in ordinary text."""
220
+ text = str(value)
221
+
222
+ replacements = {
223
+ "\\": "&#92;",
224
+ "`": "&#96;",
225
+ "*": "&#42;",
226
+ "_": "&#95;",
227
+ "{": "&#123;",
228
+ "}": "&#125;",
229
+ "[": "&#91;",
230
+ "]": "&#93;",
231
+ "<": "&lt;",
232
+ ">": "&gt;",
233
+ "|": "\\|",
234
+ }
235
+
236
+ return "".join(replacements.get(char, char) for char in text)
237
+
238
+
239
+ def _format_check_score(value: Any) -> str:
240
+ """Format a check score while identifying invalid values."""
241
+ if value is None:
242
+ return "N/A"
243
+
244
+ if (
245
+ isinstance(value, bool)
246
+ or not isinstance(value, (int, float))
247
+ or not math.isfinite(value)
248
+ or not 0.0 <= value <= 100.0
249
+ ):
250
+ return "Invalid score"
251
+
252
+ return f"{value:.2f}"
253
+
254
+
255
+ def format_markdown_report(report: Any) -> str:
256
+ """Format a reliability report as a Markdown document."""
257
+ score = (
258
+ f"{report.score:.2f}"
259
+ if isinstance(report.score, (int, float))
260
+ and not isinstance(report.score, bool)
261
+ and math.isfinite(report.score)
262
+ else "N/A"
263
+ )
264
+
265
+ lines = [
266
+ "# ML Reliability Report",
267
+ "",
268
+ "## Overall Assessment",
269
+ "",
270
+ f"- **Score:** {score}",
271
+ f"- **Grade:** {report.grade}",
272
+ "",
273
+ "## Check Summary",
274
+ "",
275
+ f"- **Total checks:** {len(report.checks)}",
276
+ f"- **Passed:** {len(report.passed_checks)}",
277
+ f"- **Failed:** {len(report.failed_checks)}",
278
+ f"- **Warnings:** {len(report.warnings)}",
279
+ "",
280
+ "## Check Results",
281
+ "",
282
+ ]
283
+ critical_failures = [
284
+ check
285
+ for check in report.checks
286
+ if not check.passed and check.severity == "critical"
287
+ ]
288
+
289
+ if critical_failures:
290
+ warning_lines = [
291
+ f"**CRITICAL ISSUES DETECTED: {len(critical_failures)}**",
292
+ "",
293
+ "Review critical failures before relying on this assessment.",
294
+ "",
295
+ ]
296
+ lines[6:6] = warning_lines
297
+
298
+ if report.checks:
299
+ lines.extend(
300
+ [
301
+ "| Check | Category | Status | Severity | Score |",
302
+ "|---|---|---|---|---:|",
303
+ ]
304
+ )
305
+
306
+ for check in report.checks:
307
+ status = "Passed" if check.passed else "Failed"
308
+ check_score = _format_check_score(check.score)
309
+
310
+ lines.append(
311
+ f"| {_escape_markdown_table_cell(check.title)} "
312
+ f"| {_escape_markdown_table_cell(check.category)} "
313
+ f"| {status} "
314
+ f"| {_escape_markdown_table_cell(check.severity)} "
315
+ f"| {check_score} |"
316
+ )
317
+
318
+ lines.extend(
319
+ [
320
+ "",
321
+ f"**Message:** {_escape_markdown_text(check.message)}",
322
+ ]
323
+ )
324
+
325
+ if not check.passed and check.recommendation:
326
+ lines.append(
327
+ f"**Recommendation:** {_escape_markdown_text(check.recommendation)}"
328
+ )
329
+
330
+ lines.append("")
331
+ else:
332
+ lines.extend(["No checks to display.", ""])
333
+
334
+ lines.extend(
335
+ [
336
+ "## Stress Test Summary",
337
+ "",
338
+ f"- **Total stress tests:** {len(report.stress_tests)}",
339
+ f"- **Completed:** {len(report.successful_stress_tests)}",
340
+ f"- **Failed:** {len(report.failed_stress_tests)}",
341
+ ]
342
+ )
343
+
344
+ worst_degradation = report.worst_stress_degradation
345
+ degradation_text = (
346
+ f"{worst_degradation:.4f}" if worst_degradation is not None else "N/A"
347
+ )
348
+
349
+ lines.extend(
350
+ [
351
+ f"- **Worst valid degradation:** {degradation_text}",
352
+ "",
353
+ "## Stress Test Results",
354
+ "",
355
+ ]
356
+ )
357
+
358
+ if report.stress_tests:
359
+ lines.extend(
360
+ [
361
+ "| Stress Test | Status | Baseline | Stressed | Degradation |",
362
+ "|---|---|---:|---:|---:|",
363
+ ]
364
+ )
365
+
366
+ for result in report.stress_tests:
367
+ status = "Completed" if result.passed else "Failed"
368
+
369
+ def format_metric(value: Any) -> str:
370
+ if (
371
+ isinstance(value, (int, float))
372
+ and not isinstance(value, bool)
373
+ and math.isfinite(value)
374
+ ):
375
+ return f"{value:.4f}"
376
+ return "N/A"
377
+
378
+ lines.append(
379
+ f"| {_escape_markdown_table_cell(result.stress_type)} "
380
+ f"| {status} "
381
+ f"| {format_metric(result.baseline_metric)} "
382
+ f"| {format_metric(result.stressed_metric)} "
383
+ f"| {format_metric(result.degradation)} |"
384
+ )
385
+ else:
386
+ lines.append("No stress tests to display.")
387
+
388
+ lines.append("")
389
+ return "\n".join(lines)
390
+
391
+
392
+ def write_markdown_report(
393
+ report: Any,
394
+ path: str | Path,
395
+ ) -> None:
396
+ """Write a reliability report as a Markdown file."""
397
+ output_path = Path(path)
398
+ content = format_markdown_report(report)
399
+
400
+ with output_path.open(
401
+ "w",
402
+ encoding="utf-8",
403
+ newline="\n",
404
+ ) as file:
405
+ file.write(content)
406
+ file.write("\n")
407
+
408
+
409
+ def format_html_report(report: Any) -> str:
410
+ """Format a reliability report as a standalone HTML document."""
411
+
412
+ def escape(value: Any) -> str:
413
+ """Escape dynamic values before inserting them into HTML."""
414
+ return html.escape(str(value), quote=True)
415
+
416
+ def format_number(value: Any, precision: int = 2) -> str:
417
+ """Format finite numeric values, returning N/A otherwise."""
418
+ if (
419
+ isinstance(value, (int, float))
420
+ and not isinstance(value, bool)
421
+ and math.isfinite(value)
422
+ ):
423
+ return f"{value:.{precision}f}"
424
+ return "N/A"
425
+
426
+ score = format_number(report.score)
427
+
428
+ lines = [
429
+ "<!DOCTYPE html>",
430
+ '<html lang="en">',
431
+ "<head>",
432
+ ' <meta charset="UTF-8">',
433
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
434
+ " <title>ML Reliability Report</title>",
435
+ " <style>",
436
+ (
437
+ " body { font-family: Arial, sans-serif; max-width: 1100px; "
438
+ "margin: 2rem auto; padding: 0 1rem; color: #222; }"
439
+ ),
440
+ " h1, h2 { color: #17365d; }",
441
+ " .summary { display: flex; gap: 1rem; flex-wrap: wrap; }",
442
+ (
443
+ " .card { border: 1px solid #ddd; border-radius: 8px; "
444
+ "padding: 1rem; min-width: 140px; }"
445
+ ),
446
+ (
447
+ " table { width: 100%; border-collapse: collapse; "
448
+ "margin: 1rem 0 2rem; }"
449
+ ),
450
+ (
451
+ " th, td { border: 1px solid #ddd; padding: 0.65rem; "
452
+ "text-align: left; overflow-wrap: anywhere; }"
453
+ ),
454
+ " th { background: #f2f5f9; }",
455
+ " .passed { color: #176b35; }",
456
+ " .failed { color: #a32121; }",
457
+ " .details { margin: 0.75rem 0 1.25rem; }",
458
+ (
459
+ " @media (max-width: 600px) { "
460
+ "body { margin: 1rem auto; } table { font-size: 0.85rem; } }"
461
+ ),
462
+ " </style>",
463
+ "</head>",
464
+ "<body>",
465
+ " <h1>ML Reliability Report</h1>",
466
+ " <section aria-labelledby='assessment-heading'>",
467
+ " <h2 id='assessment-heading'>Overall Assessment</h2>",
468
+ " <div class='summary'>",
469
+ f" <div class='card'><strong>Score</strong><p>{score}</p></div>",
470
+ f" <div class='card'><strong>Grade</strong><p>{escape(report.grade)}</p></div>",
471
+ " </div>",
472
+ " </section>",
473
+ " <section aria-labelledby='checks-heading'>",
474
+ " <h2 id='checks-heading'>Check Summary</h2>",
475
+ (
476
+ f" <p>{len(report.checks)} checks | "
477
+ f"{len(report.passed_checks)} passed | "
478
+ f"{len(report.failed_checks)} failed | "
479
+ f"{len(report.warnings)} warnings</p>"
480
+ ),
481
+ " <h2>Check Results</h2>",
482
+ ]
483
+ critical_failures = [
484
+ check
485
+ for check in report.checks
486
+ if not check.passed and check.severity == "critical"
487
+ ]
488
+
489
+ if critical_failures:
490
+ lines.extend(
491
+ [
492
+ " <div class='critical-warning' role='alert'>",
493
+ (
494
+ f" <strong>CRITICAL ISSUES DETECTED: "
495
+ f"{len(critical_failures)}</strong>"
496
+ ),
497
+ (
498
+ " <p>Review critical failures before relying "
499
+ "on this assessment.</p>"
500
+ ),
501
+ " </div>",
502
+ ]
503
+ )
504
+
505
+ if report.checks:
506
+ lines.extend(
507
+ [
508
+ " <table>",
509
+ (
510
+ " <thead><tr><th>Check</th><th>Category</th>"
511
+ "<th>Status</th><th>Severity</th><th>Score</th></tr></thead>"
512
+ ),
513
+ " <tbody>",
514
+ ]
515
+ )
516
+
517
+ for check in report.checks:
518
+ status = "Passed" if check.passed else "Failed"
519
+ status_class = "passed" if check.passed else "failed"
520
+
521
+ lines.extend(
522
+ [
523
+ " <tr>",
524
+ f" <td>{escape(check.title)}</td>",
525
+ f" <td>{escape(check.category)}</td>",
526
+ f" <td class='{status_class}'>{status}</td>",
527
+ f" <td>{escape(check.severity)}</td>",
528
+ f" <td>{escape(_format_check_score(check.score))}</td>",
529
+ " </tr>",
530
+ " <tr><td colspan='5'>",
531
+ (
532
+ f" <div class='details'><strong>Message:</strong> "
533
+ f"{escape(check.message)}"
534
+ ),
535
+ ]
536
+ )
537
+
538
+ if not check.passed and check.recommendation:
539
+ lines.append(
540
+ f"<br><strong>Recommendation:</strong> "
541
+ f"{escape(check.recommendation)}"
542
+ )
543
+
544
+ lines.extend(
545
+ [
546
+ " </div>",
547
+ " </td></tr>",
548
+ ]
549
+ )
550
+
551
+ lines.extend(
552
+ [
553
+ " </tbody>",
554
+ " </table>",
555
+ ]
556
+ )
557
+ else:
558
+ lines.append(" <p>No checks to display.</p>")
559
+
560
+ lines.extend(
561
+ [
562
+ " </section>",
563
+ " <section aria-labelledby='stress-heading'>",
564
+ " <h2 id='stress-heading'>Stress Test Summary</h2>",
565
+ (
566
+ f" <p>{len(report.stress_tests)} stress tests | "
567
+ f"{len(report.successful_stress_tests)} completed | "
568
+ f"{len(report.failed_stress_tests)} failed</p>"
569
+ ),
570
+ " <h2>Stress Test Results</h2>",
571
+ ]
572
+ )
573
+
574
+ worst_degradation = report.worst_stress_degradation
575
+ lines.append(
576
+ " <p><strong>Worst valid degradation:</strong> "
577
+ f"{format_number(worst_degradation, 4)}</p>"
578
+ )
579
+
580
+ if report.stress_tests:
581
+ lines.extend(
582
+ [
583
+ " <table>",
584
+ (
585
+ " <thead><tr><th>Stress Test</th><th>Status</th>"
586
+ "<th>Baseline</th><th>Stressed</th><th>Degradation</th></tr></thead>"
587
+ ),
588
+ " <tbody>",
589
+ ]
590
+ )
591
+
592
+ for result in report.stress_tests:
593
+ status = "Completed" if result.passed else "Failed"
594
+ status_class = "passed" if result.passed else "failed"
595
+
596
+ lines.extend(
597
+ [
598
+ " <tr>",
599
+ f" <td>{escape(result.stress_type)}</td>",
600
+ f" <td class='{status_class}'>{status}</td>",
601
+ f" <td>{format_number(result.baseline_metric, 4)}</td>",
602
+ f" <td>{format_number(result.stressed_metric, 4)}</td>",
603
+ f" <td>{format_number(result.degradation, 4)}</td>",
604
+ " </tr>",
605
+ ]
606
+ )
607
+
608
+ lines.extend(
609
+ [
610
+ " </tbody>",
611
+ " </table>",
612
+ ]
613
+ )
614
+ else:
615
+ lines.append(" <p>No stress tests to display.</p>")
616
+
617
+ lines.extend(
618
+ [
619
+ " </section>",
620
+ "</body>",
621
+ "</html>",
622
+ ]
623
+ )
624
+
625
+ return "\n".join(lines)
626
+
627
+
628
+ def write_html_report(
629
+ report: Any,
630
+ path: str | Path,
631
+ ) -> None:
632
+ """Write a reliability report as a standalone HTML file."""
633
+ output_path = Path(path)
634
+ content = format_html_report(report)
635
+
636
+ with output_path.open(
637
+ "w",
638
+ encoding="utf-8",
639
+ newline="\n",
640
+ ) as file:
641
+ file.write(content)
642
+ file.write("\n")