faultforge-cli 0.2.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,713 @@
1
+ """The `encoded-memory` CLI commands (recording and plotting)."""
2
+
3
+ import enum
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import matplotlib.pyplot as plt
9
+ import torch
10
+ import typer
11
+ from matplotlib.backends.registry import BackendFilter, backend_registry
12
+ from matplotlib.figure import Figure
13
+ from faultforge import DEFAULT_BATCH_SIZE, is_compressed
14
+ from faultforge.encoding import (
15
+ CepEncoder,
16
+ CepScheme,
17
+ Encoder,
18
+ EncoderSequence,
19
+ IdentityEncoder,
20
+ MsetEncoder,
21
+ SecdedEncoder,
22
+ )
23
+ from faultforge.experiment import (
24
+ AdditionalRuns,
25
+ MaxRuns,
26
+ SaveConfig,
27
+ Stability,
28
+ StopCondition,
29
+ )
30
+ from faultforge.experiments.encoded_memory import (
31
+ DetailedResult,
32
+ EncodedFaultInjection,
33
+ ReliabilityMetric,
34
+ discard_bitmasks_in_file,
35
+ )
36
+ from faultforge.fingerprint import FingerprintError
37
+ from faultforge.loading import (
38
+ Cifar,
39
+ CifarDataset,
40
+ CifarModel,
41
+ ImageNet,
42
+ ImageNetModel,
43
+ ModelBundle,
44
+ )
45
+ from faultforge.progress import Progress
46
+ from faultforge_cli.encoded_memory.plots import (
47
+ GroupBy,
48
+ build_compare_figure,
49
+ build_heatmap_figure,
50
+ )
51
+ from faultforge_cli.encoded_memory.results import (
52
+ build_configurations,
53
+ discover_result_files,
54
+ load_results,
55
+ )
56
+
57
+ app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+ class DatasetChoice(enum.StrEnum):
62
+ Cifar10 = "cifar10"
63
+ Cifar100 = "cifar100"
64
+ ImageNet = "imagenet"
65
+
66
+
67
+ def _init_model_bundle(
68
+ dataset: DatasetChoice,
69
+ model: str | None,
70
+ imagenet_root: str | None,
71
+ batch_size: int,
72
+ preload_batches: bool,
73
+ device: str,
74
+ ) -> ModelBundle:
75
+ """Build the `ModelBundle` for the given CLI choices and load the model/dataset from it."""
76
+ if model is None:
77
+ raise typer.BadParameter("A --model must be specified.", param_hint="--model")
78
+
79
+ bundle: ModelBundle
80
+ match dataset:
81
+ case DatasetChoice.Cifar10 | DatasetChoice.Cifar100:
82
+ try:
83
+ cifar_model = CifarModel(model)
84
+ except ValueError as error:
85
+ choices = ", ".join(m.value for m in CifarModel)
86
+ raise typer.BadParameter(
87
+ f"Unknown model {model!r} for dataset {dataset.value}. Choices: {choices}",
88
+ param_hint="--model",
89
+ ) from error
90
+ bundle = Cifar(model=cifar_model, dataset=CifarDataset(dataset.value))
91
+ case DatasetChoice.ImageNet:
92
+ if imagenet_root is None:
93
+ raise typer.BadParameter(
94
+ "--imagenet-root is required when --dataset imagenet.",
95
+ param_hint="--imagenet-root",
96
+ )
97
+ try:
98
+ imagenet_model = ImageNetModel(model)
99
+ except ValueError as error:
100
+ choices = ", ".join(m.value for m in ImageNetModel)
101
+ raise typer.BadParameter(
102
+ f"Unknown model {model!r} for dataset imagenet. Choices: {choices}",
103
+ param_hint="--model",
104
+ ) from error
105
+ bundle = ImageNet(kind=imagenet_model, root=imagenet_root)
106
+
107
+ return bundle
108
+
109
+
110
+ def _resolve_encoder(
111
+ *, mset: bool, cep: bool, cep_scheme: CepScheme, secded: int | None
112
+ ) -> Encoder:
113
+ match (mset, cep):
114
+ case (True, False):
115
+ head = MsetEncoder()
116
+ case (False, True):
117
+ head = CepEncoder(cep_scheme)
118
+ case (True, True):
119
+ raise typer.BadParameter("Using MSET and CEP together is not allowed.")
120
+ case (False, False):
121
+ head = None
122
+
123
+ match (head, secded):
124
+ case (None, None):
125
+ return IdentityEncoder()
126
+ case (_, None):
127
+ # Asserts to help out the type checker.
128
+ assert head is not None
129
+ return head
130
+ case (None, _):
131
+ assert secded is not None
132
+ return SecdedEncoder(secded)
133
+ case (_, _):
134
+ assert secded is not None
135
+ assert head is not None
136
+ return EncoderSequence([head], SecdedEncoder(secded))
137
+
138
+
139
+ @app.command()
140
+ def list_models(
141
+ dataset: Annotated[
142
+ DatasetChoice, typer.Option(help="Which dataset to use")
143
+ ] = DatasetChoice.ImageNet,
144
+ ) -> None:
145
+ """List all available models for the given dataset."""
146
+ match dataset:
147
+ case DatasetChoice.Cifar10 | DatasetChoice.Cifar100:
148
+ models = [model.value for model in CifarModel]
149
+ case DatasetChoice.ImageNet:
150
+ models = [model.value for model in ImageNetModel]
151
+
152
+ for model in models:
153
+ typer.echo(model)
154
+
155
+
156
+ @app.command(
157
+ no_args_is_help=True,
158
+ )
159
+ def record(
160
+ model: Annotated[
161
+ str,
162
+ typer.Option(
163
+ help="Which model to use. Choices depend on the dataset. The the list-models command can be used to see available models.",
164
+ rich_help_panel="Model Setup",
165
+ ),
166
+ ],
167
+ dataset: Annotated[
168
+ DatasetChoice,
169
+ typer.Option(
170
+ help="Which dataset to use",
171
+ rich_help_panel="Model Setup",
172
+ ),
173
+ ] = DatasetChoice.ImageNet,
174
+ imagenet_root: Annotated[
175
+ str | None,
176
+ typer.Option(
177
+ help="Path to a local directory containing ILSVRC2012_devkit_t12.tar.gz "
178
+ "and ILSVRC2012_img_val.tar. Required when --dataset is imagenet.",
179
+ rich_help_panel="Model Setup",
180
+ ),
181
+ ] = None,
182
+ batch_size: Annotated[
183
+ int,
184
+ typer.Option(
185
+ help="The batch size of the dataset",
186
+ rich_help_panel="Model Setup",
187
+ ),
188
+ ] = DEFAULT_BATCH_SIZE,
189
+ preload_batches: Annotated[
190
+ bool,
191
+ typer.Option(
192
+ help="Preload all batches into memory before starting the experiment. Otherwise, batches are loaded from disk on-demand. This should be set if there is enough memory as it is much faster.",
193
+ rich_help_panel="Model Setup",
194
+ ),
195
+ ] = True,
196
+ batch_limit: Annotated[
197
+ int | None,
198
+ typer.Option(
199
+ help="Use only the first N batches of the dataset",
200
+ rich_help_panel="Model Setup",
201
+ ),
202
+ ] = None,
203
+ f16: Annotated[
204
+ bool,
205
+ typer.Option(
206
+ help="Load and run the model in float16 (half precision) instead of "
207
+ "float32. Halves the encoded bit-width per parameter, changing what "
208
+ "--faults/--bit-error-rate mean numerically. Some PyTorch CPU "
209
+ "kernels have incomplete float16 support for certain architectures; "
210
+ "if you hit a 'not implemented for Half' error, try a CUDA "
211
+ "--device instead.",
212
+ rich_help_panel="Model Setup",
213
+ ),
214
+ ] = False,
215
+ reliability_metric: Annotated[
216
+ ReliabilityMetric,
217
+ typer.Option(
218
+ help="Which metric to use for reliability measurements",
219
+ rich_help_panel="Model Setup",
220
+ ),
221
+ ] = ReliabilityMetric.Accuracy,
222
+ bit_error_rate: Annotated[
223
+ float | None,
224
+ typer.Option(
225
+ "--bit-error-rate",
226
+ "--ber",
227
+ min=0.0,
228
+ max=1.0,
229
+ help="The bit error rate to use. Mutually exclusive with --faults.",
230
+ rich_help_panel="Fault Injection",
231
+ ),
232
+ ] = None,
233
+ faults: Annotated[
234
+ int | None,
235
+ typer.Option(
236
+ min=0,
237
+ help="The number of faults to inject. Mutually exclusive with --bit-error-rate.",
238
+ rich_help_panel="Fault Injection",
239
+ ),
240
+ ] = None,
241
+ compare_bitwise: Annotated[
242
+ bool,
243
+ typer.Option(
244
+ help="Additionally record a bitwise comparison of each run's faulty "
245
+ "parameters against the golden model's, keeping the nonzero xor "
246
+ "results. Increases the size of recorded results.",
247
+ rich_help_panel="Fault Injection",
248
+ ),
249
+ ] = False,
250
+ fault_summary: Annotated[
251
+ bool,
252
+ typer.Option(
253
+ help="Print a per-run fault-injection summary (bits flipped, BER, and "
254
+ "- if --compare-bitwise is also set - affected/masked counts and a "
255
+ "faulty-bit histogram) after each run.",
256
+ rich_help_panel="Fault Injection",
257
+ ),
258
+ ] = False,
259
+ secded: Annotated[
260
+ int | None,
261
+ typer.Option(
262
+ min=1,
263
+ help="Use a SECDED encoding with n data bits. Multiples of 8 perform better.",
264
+ rich_help_panel="Encoding Settings",
265
+ ),
266
+ ] = None,
267
+ mset: Annotated[
268
+ bool,
269
+ typer.Option(
270
+ help="Use an MSET encoding. Chunking is determined by the data type.",
271
+ rich_help_panel="Encoding Settings",
272
+ ),
273
+ ] = False,
274
+ cep: Annotated[
275
+ bool,
276
+ typer.Option(
277
+ help="Use a CEP encoding.",
278
+ rich_help_panel="Encoding Settings",
279
+ ),
280
+ ] = False,
281
+ cep_scheme: Annotated[
282
+ CepScheme,
283
+ typer.Option(
284
+ help="Determines the chunking for CEP encoding. Maps to the number of data bits per parity bit in a chunk.",
285
+ rich_help_panel="Encoding Settings",
286
+ ),
287
+ ] = CepScheme.D3P1,
288
+ golden_is_encoded: Annotated[
289
+ bool,
290
+ typer.Option(
291
+ help="Use the non-faulty encoded model as the golden model. By default the unencoded model is used.",
292
+ rich_help_panel="Encoding Settings",
293
+ ),
294
+ ] = False,
295
+ output: Annotated[
296
+ Path | None,
297
+ typer.Option(
298
+ help="A file path to save results to.",
299
+ rich_help_panel="Recording Settings",
300
+ ),
301
+ ] = None,
302
+ autosave: Annotated[
303
+ float | None,
304
+ typer.Option(
305
+ help="Save after N seconds have passed. Ignored if --output is not set. Only saves at the end of the experiment or when interrupted by default.",
306
+ rich_help_panel="Recording Settings",
307
+ ),
308
+ ] = None,
309
+ compress: Annotated[
310
+ bool,
311
+ typer.Option(
312
+ help="Save --output zstd-compressed. Only controls the format of a "
313
+ "newly created file; if --output already exists, its existing "
314
+ "on-disk format (compressed or not) is kept regardless of this flag.",
315
+ rich_help_panel="Recording Settings",
316
+ ),
317
+ ] = False,
318
+ overwrite: Annotated[
319
+ bool,
320
+ typer.Option(
321
+ help="If --output already exists but was recorded with a different configuration, discard it and start fresh instead of aborting.",
322
+ rich_help_panel="Recording Settings",
323
+ ),
324
+ ] = False,
325
+ runs: Annotated[
326
+ int | None,
327
+ typer.Option(
328
+ help="Run the experiment N additional times on top of any existing results (e.g. loaded via --output). Incompatible with --min-runs and --stability-threshold. Can be combined with --max-runs.",
329
+ rich_help_panel="Recording Settings",
330
+ ),
331
+ ] = None,
332
+ max_runs: Annotated[
333
+ int | None,
334
+ typer.Option(
335
+ help="Stop once the results contain N runs in total, including any already loaded via --output. Can be combined with --runs or --stability-threshold.",
336
+ rich_help_panel="Recording Settings",
337
+ ),
338
+ ] = None,
339
+ min_runs: Annotated[
340
+ int | None,
341
+ typer.Option(
342
+ help="Make sure the results contain at least N runs.",
343
+ rich_help_panel="Recording Settings",
344
+ ),
345
+ ] = None,
346
+ stability_threshold: Annotated[
347
+ float | None,
348
+ typer.Option(
349
+ min=0.0,
350
+ max=100.0,
351
+ help="Run until the mean has a margin of error smaller or equal to N% of the mean value at 95% confidence.",
352
+ rich_help_panel="Recording Settings",
353
+ ),
354
+ ] = None,
355
+ device: Annotated[
356
+ str,
357
+ typer.Option(
358
+ help="Which device to use. PyTorch device string.",
359
+ rich_help_panel="Misc Settings",
360
+ ),
361
+ ] = "cpu",
362
+ ) -> None:
363
+ """Run an encoded memory fault injection experiment and record the results."""
364
+ bundle = _init_model_bundle(
365
+ dataset, model, imagenet_root, batch_size, preload_batches, device
366
+ )
367
+
368
+ encoder = _resolve_encoder(
369
+ mset=mset,
370
+ cep=cep,
371
+ cep_scheme=cep_scheme,
372
+ secded=secded,
373
+ )
374
+
375
+ if stability_threshold is not None and runs is not None:
376
+ raise typer.BadParameter("Cannot specify both --stability-threshold and --runs")
377
+ if runs is not None and min_runs is not None:
378
+ raise typer.BadParameter("Cannot specify both --runs and --min-runs")
379
+
380
+ match (faults, bit_error_rate):
381
+ case (None, None):
382
+ faults_ = 0
383
+ case (None, _):
384
+ assert bit_error_rate is not None
385
+ # Need to make sure it's a float as only floats are treated as BER.
386
+ faults_ = float(bit_error_rate)
387
+ case (_, None):
388
+ assert faults is not None
389
+ faults_ = faults
390
+ case _:
391
+ raise typer.BadParameter(
392
+ "Only one of --faults or --bit-error-rate can be specified"
393
+ )
394
+
395
+ dtype = torch.float16 if f16 else torch.float32
396
+
397
+ experiment = EncodedFaultInjection(
398
+ bundle,
399
+ encoder,
400
+ reliability_metric,
401
+ golden_is_encoded=golden_is_encoded,
402
+ faults=faults_,
403
+ compare_bitwise=compare_bitwise,
404
+ fault_summary=fault_summary,
405
+ preload_dataset=preload_batches,
406
+ dataset_batch_limit=batch_limit,
407
+ batch_size=batch_size,
408
+ device=device,
409
+ dtype=dtype,
410
+ progress=Progress(),
411
+ )
412
+ stop_conditions: list[StopCondition] = []
413
+
414
+ save_config: SaveConfig | None = None
415
+ output_exists = False
416
+ if output is not None:
417
+ output = Path(output).expanduser()
418
+ output_exists = output.exists()
419
+ if not output.parent.exists():
420
+ logger.info(f"Creating output parent directory {output.parent}")
421
+ output.parent.mkdir(parents=True)
422
+ else:
423
+ logger.debug(f"Output parent directory {output.parent} already exists")
424
+
425
+ effective_compressed = is_compressed(output) if output_exists else compress
426
+ save_config = SaveConfig(
427
+ path=output, interval_seconds=autosave, compressed=effective_compressed
428
+ )
429
+
430
+ if stability_threshold is not None:
431
+ if min_runs is None:
432
+ min_samples = 0
433
+ else:
434
+ min_samples = min_runs
435
+ stop_conditions.append(
436
+ Stability(min_samples=min_samples, threshold=stability_threshold)
437
+ )
438
+
439
+ if runs is not None:
440
+ stop_conditions.append(AdditionalRuns(runs))
441
+
442
+ if max_runs is not None:
443
+ stop_conditions.append(MaxRuns(max_runs))
444
+
445
+ if output is not None and output_exists:
446
+ try:
447
+ experiment.load_from(output)
448
+ except FingerprintError as error:
449
+ if not overwrite:
450
+ logger.error(str(error))
451
+ raise typer.Exit(1) from None
452
+ logger.warning(
453
+ f"{output} was recorded with a different configuration and will be overwritten:\n{error}"
454
+ )
455
+
456
+ experiment.run_loop(stop_conditions=stop_conditions, save_config=save_config)
457
+
458
+
459
+ @app.command()
460
+ def discard_bitmasks(
461
+ path: Annotated[
462
+ Path,
463
+ typer.Argument(
464
+ help="Path to a result file previously recorded with --compare-bitwise."
465
+ ),
466
+ ],
467
+ ) -> None:
468
+ """Discard any recorded bitmasks from a saved result file, shrinking it.
469
+
470
+ Operates directly on the saved file; unlike `record`, this doesn't need to
471
+ reload the model or dataset that produced it.
472
+ """
473
+ discard_bitmasks_in_file(path)
474
+
475
+
476
+ def _split_path_argument(raw: str) -> tuple[Path, str | None]:
477
+ """Splits `raw` on the first literal '=', separating a path from an
478
+ optional legend-label override. Safe since paths never contain '=' on
479
+ this project's Linux-first environment."""
480
+ path_str, sep, label = raw.partition("=")
481
+ return Path(path_str).expanduser(), (label if sep else None)
482
+
483
+
484
+ def _show_or_save(fig: Figure, output: Path | None) -> None:
485
+ if output is not None:
486
+ fig.savefig(output)
487
+ return
488
+
489
+ # `plt.show()` silently does nothing on a non-interactive backend (e.g.
490
+ # the "agg" fallback matplotlib picks when no GUI toolkit like PyQt or
491
+ # tkinter is importable) - check first so a missing --output doesn't look
492
+ # like the command did nothing.
493
+ backend = plt.get_backend()
494
+ interactive_backends = {
495
+ name.lower()
496
+ for name in backend_registry.list_builtin(BackendFilter.INTERACTIVE)
497
+ }
498
+ if backend.lower() not in interactive_backends:
499
+ logger.error(
500
+ f"no --output given and matplotlib has no interactive backend "
501
+ f"available (using {backend!r}). Install a GUI toolkit matplotlib "
502
+ "can use (e.g. PyQt6, or a working tkinter), or pass --output to "
503
+ "save the figure to a file instead."
504
+ )
505
+ raise typer.Exit(1)
506
+
507
+ # `fig` was built via the `Figure` OO API directly (see `plots`), so
508
+ # pyplot never tracked it the way a `plt.figure()`-created figure would
509
+ # be - `plt.show()` only displays figures pyplot is tracking, so without
510
+ # this it would silently do nothing. Passing an untracked `Figure` as
511
+ # `num` makes `plt.figure()` adopt it instead of creating a new one.
512
+ plt.figure(fig)
513
+ plt.show()
514
+
515
+
516
+ @app.command(no_args_is_help=True)
517
+ def compare(
518
+ paths: Annotated[
519
+ list[str],
520
+ typer.Argument(
521
+ help="Result file(s) to compare, or director(ies) recursively "
522
+ "containing them. Results are automatically clustered into "
523
+ "configurations by matching everything in their fingerprint "
524
+ "except the bit error rate, so e.g. several bit error rates of "
525
+ "the same model/encoder/dtype become one line regardless of "
526
+ "which file or directory they came from. Append '=LABEL' to a "
527
+ "path to override the legend label for every file found under "
528
+ "it; otherwise a configuration defaults to the stem of its "
529
+ "first (sorted) file.",
530
+ ),
531
+ ],
532
+ row_by: Annotated[
533
+ GroupBy,
534
+ typer.Option(
535
+ help="Facet the grid into rows by this fingerprint-derived key.",
536
+ rich_help_panel="Grouping",
537
+ ),
538
+ ] = GroupBy.Ungrouped,
539
+ col_by: Annotated[
540
+ GroupBy,
541
+ typer.Option(
542
+ help="Facet the grid into columns by this fingerprint-derived key.",
543
+ rich_help_panel="Grouping",
544
+ ),
545
+ ] = GroupBy.Ungrouped,
546
+ percentile: Annotated[
547
+ float | None,
548
+ typer.Option(
549
+ min=0.0,
550
+ max=100.0,
551
+ help="Plot this percentile of scores per point instead of the mean.",
552
+ rich_help_panel="Display",
553
+ ),
554
+ ] = None,
555
+ log_x: Annotated[
556
+ bool,
557
+ typer.Option(
558
+ "--log-x",
559
+ help="Use a logarithmic x-axis (bit error rate).",
560
+ rich_help_panel="Display",
561
+ ),
562
+ ] = False,
563
+ output: Annotated[
564
+ Path | None,
565
+ typer.Option(
566
+ help="Save the figure to this path instead of opening an "
567
+ "interactive window.",
568
+ rich_help_panel="Output",
569
+ ),
570
+ ] = None,
571
+ ) -> None:
572
+ """Plot reliability score vs bit error rate, one line per configuration.
573
+
574
+ Each PATH is a result file or a directory of them. Everything found is
575
+ pooled and automatically clustered into configurations: results that match
576
+ on every fingerprint field except bit error rate become one line, no matter
577
+ which file or directory they came from. So the usual workflow is to record
578
+ several bit error rates of one setup, then point compare at them to get a
579
+ single line summarizing it.
580
+
581
+ Labels: a configuration's legend label defaults to the stem of its first
582
+ file, which is rarely meaningful. Append '=LABEL' to a PATH to name
583
+ everything found under it. The reason to keep each configuration's runs in
584
+ their own directory is so a single 'dir=LABEL' names the whole line at once:
585
+
586
+ compare runs/secded=SECDED-8 runs/identity=Identity
587
+
588
+ Grid: --row-by / --col-by split the chart into a grid of subplots by a
589
+ fingerprint key (dtype, model, ...). For example --row-by dtype puts f32
590
+ results in the top row and f16 in the next, and the remaining differences
591
+ within a cell become its lines. Omit both for a single chart.
592
+ """
593
+ label_overrides: dict[Path, str] = {}
594
+ source_paths: list[Path] = []
595
+ for raw in paths:
596
+ path, label = _split_path_argument(raw)
597
+ source_paths.append(path)
598
+ if label is not None:
599
+ for file in discover_result_files(path):
600
+ label_overrides[file] = label
601
+
602
+ loaded = load_results(source_paths)
603
+ if not loaded:
604
+ logger.error("no result files found")
605
+ raise typer.Exit(1)
606
+
607
+ configurations = build_configurations(loaded, label_overrides=label_overrides)
608
+
609
+ try:
610
+ fig = build_compare_figure(
611
+ configurations,
612
+ row_by=row_by,
613
+ col_by=col_by,
614
+ percentile=percentile,
615
+ log_x=log_x,
616
+ )
617
+ except ValueError as error:
618
+ logger.error(str(error))
619
+ raise typer.Exit(1) from None
620
+
621
+ _show_or_save(fig, output)
622
+
623
+
624
+ @app.command(no_args_is_help=True)
625
+ def heatmap(
626
+ paths: Annotated[
627
+ list[Path],
628
+ typer.Argument(
629
+ help="Result file(s) to plot, or director(ies) recursively "
630
+ "containing them. All merged into one figure. Requires results "
631
+ "recorded with --compare-bitwise.",
632
+ ),
633
+ ],
634
+ bins: Annotated[
635
+ int,
636
+ typer.Option(
637
+ min=1,
638
+ help="Number of bins along the score axis.",
639
+ rich_help_panel="Display",
640
+ ),
641
+ ] = 50,
642
+ min_score: Annotated[
643
+ float | None,
644
+ typer.Option(
645
+ help="Discard runs scoring below this value.",
646
+ rich_help_panel="Filtering",
647
+ ),
648
+ ] = None,
649
+ max_score: Annotated[
650
+ float | None,
651
+ typer.Option(
652
+ help="Discard runs scoring above this value.",
653
+ rich_help_panel="Filtering",
654
+ ),
655
+ ] = None,
656
+ max_total_faults: Annotated[
657
+ int | None,
658
+ typer.Option(
659
+ min=0,
660
+ help="Discard runs with more than this many total residual "
661
+ "(post-decode) faults, before decomposing into bit positions.",
662
+ rich_help_panel="Filtering",
663
+ ),
664
+ ] = None,
665
+ skip_multi_bit_faults: Annotated[
666
+ bool,
667
+ typer.Option(
668
+ help="Exclude elements with more than one faulty bit.",
669
+ rich_help_panel="Filtering",
670
+ ),
671
+ ] = False,
672
+ output: Annotated[
673
+ Path | None,
674
+ typer.Option(
675
+ help="Save the figure to this path instead of opening an "
676
+ "interactive window.",
677
+ rich_help_panel="Output",
678
+ ),
679
+ ] = None,
680
+ ) -> None:
681
+ """Plot per-run score density against faulty bit position."""
682
+ loaded = load_results(paths)
683
+ if not loaded:
684
+ logger.error("no result files found")
685
+ raise typer.Exit(1)
686
+
687
+ results = [result for _, result in loaded]
688
+ if not all(isinstance(result.result, DetailedResult) for result in results):
689
+ offenders = [
690
+ str(path)
691
+ for path, result in loaded
692
+ if not isinstance(result.result, DetailedResult)
693
+ ]
694
+ logger.error(
695
+ "heatmap requires results recorded with --compare-bitwise; "
696
+ f"missing bitmasks in: {', '.join(offenders)}"
697
+ )
698
+ raise typer.Exit(1) from None
699
+
700
+ try:
701
+ fig = build_heatmap_figure(
702
+ results,
703
+ bins=bins,
704
+ min_score=min_score,
705
+ max_score=max_score,
706
+ max_total_faults=max_total_faults,
707
+ skip_multi_bit_faults=skip_multi_bit_faults,
708
+ )
709
+ except ValueError as error:
710
+ logger.error(str(error))
711
+ raise typer.Exit(1) from None
712
+
713
+ _show_or_save(fig, output)