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.
- faultforge_cli/__init__.py +0 -0
- faultforge_cli/encoded_memory/__init__.py +9 -0
- faultforge_cli/encoded_memory/commands.py +713 -0
- faultforge_cli/encoded_memory/plots.py +395 -0
- faultforge_cli/encoded_memory/results.py +212 -0
- faultforge_cli/logging.py +122 -0
- faultforge_cli/main.py +24 -0
- faultforge_cli-0.2.0.dist-info/METADATA +21 -0
- faultforge_cli-0.2.0.dist-info/RECORD +11 -0
- faultforge_cli-0.2.0.dist-info/WHEEL +4 -0
- faultforge_cli-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Figure-building for the `compare`/`heatmap` commands.
|
|
2
|
+
|
|
3
|
+
See `results` for the vocabulary (configuration, bit error rate, split key) and
|
|
4
|
+
the `compare` pipeline this is the final step of. Figures are built via
|
|
5
|
+
`matplotlib`'s `Figure` object-oriented API directly (never
|
|
6
|
+
`matplotlib.pyplot`), so this module never touches `pyplot`'s global "current
|
|
7
|
+
figure" state; only `commands` imports `pyplot`, for `plt.show()`.
|
|
8
|
+
|
|
9
|
+
`matplotlib` is loosely typed - several of its calls hand back `Any` - so
|
|
10
|
+
extracted axes are pinned to `Axes` with `assert isinstance` at the boundary,
|
|
11
|
+
which keeps editor tooling useful in the rest of the function.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import matplotlib
|
|
20
|
+
import numpy as np
|
|
21
|
+
from faultforge import Fingerprint
|
|
22
|
+
from faultforge.dtype import EncodingDtype
|
|
23
|
+
from faultforge.experiments.encoded_memory import (
|
|
24
|
+
DetailedResult,
|
|
25
|
+
ReliabilityMetric,
|
|
26
|
+
SavedResult,
|
|
27
|
+
)
|
|
28
|
+
from faultforge_cli.encoded_memory.results import (
|
|
29
|
+
Configuration,
|
|
30
|
+
bit_position_histogram,
|
|
31
|
+
configuration_points,
|
|
32
|
+
)
|
|
33
|
+
from matplotlib.axes import Axes
|
|
34
|
+
from matplotlib.colors import LogNorm
|
|
35
|
+
from matplotlib.figure import Figure
|
|
36
|
+
from matplotlib.lines import Line2D
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_MARKERS = [
|
|
41
|
+
"o",
|
|
42
|
+
"v",
|
|
43
|
+
"^",
|
|
44
|
+
"<",
|
|
45
|
+
">",
|
|
46
|
+
"s",
|
|
47
|
+
"p",
|
|
48
|
+
"P",
|
|
49
|
+
"*",
|
|
50
|
+
"h",
|
|
51
|
+
"+",
|
|
52
|
+
"X",
|
|
53
|
+
"D",
|
|
54
|
+
]
|
|
55
|
+
"""A fixed marker cycle so lines stay distinguishable without relying on color alone."""
|
|
56
|
+
|
|
57
|
+
_COLORS = [
|
|
58
|
+
"#1f77b4",
|
|
59
|
+
"#ff7f0e",
|
|
60
|
+
"#2ca02c",
|
|
61
|
+
"#d62728",
|
|
62
|
+
"#9467bd",
|
|
63
|
+
"#8c564b",
|
|
64
|
+
"#e377c2",
|
|
65
|
+
"#7f7f7f",
|
|
66
|
+
"#bcbd22",
|
|
67
|
+
"#17becf",
|
|
68
|
+
]
|
|
69
|
+
"""Matplotlib's default ("tab10") color cycle, assigned explicitly per label
|
|
70
|
+
(see `build_compare_figure`) rather than left to each `Axes`'s own color
|
|
71
|
+
cycler - the cycler resets per `Axes`, so two configurations plotted into
|
|
72
|
+
different, otherwise-empty grid cells would both land on its first color."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GroupBy(enum.StrEnum):
|
|
76
|
+
"""A fingerprint-derived split key to fan a `compare` grid out by.
|
|
77
|
+
|
|
78
|
+
Distinct from a configuration (see `results`): this decides which grid
|
|
79
|
+
*cell* a line lands in, not which line results belong to.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
Model = "model"
|
|
83
|
+
Dtype = "dtype"
|
|
84
|
+
Dataset = "dataset"
|
|
85
|
+
Metric = "metric"
|
|
86
|
+
Ungrouped = "none"
|
|
87
|
+
"""No splitting - a single row/column. `None` can't be a member name (it's
|
|
88
|
+
a reserved keyword), but the CLI-facing value stays "none"."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def group_key(group_by: GroupBy, fingerprint: Fingerprint) -> str | None:
|
|
92
|
+
"""The grid cell value `fingerprint` belongs to under `group_by`.
|
|
93
|
+
|
|
94
|
+
`None` for `GroupBy.Ungrouped`.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: For `GroupBy.Dataset` on a bundle with no `dataset`
|
|
98
|
+
scalar (e.g. an `ImageNet` bundle).
|
|
99
|
+
"""
|
|
100
|
+
match group_by:
|
|
101
|
+
case GroupBy.Ungrouped:
|
|
102
|
+
return None
|
|
103
|
+
case GroupBy.Dtype:
|
|
104
|
+
return str(fingerprint.scalars["dtype"])
|
|
105
|
+
case GroupBy.Metric:
|
|
106
|
+
return str(fingerprint.scalars["reliability_metric"])
|
|
107
|
+
case GroupBy.Model:
|
|
108
|
+
return str(fingerprint.children["bundle"][0].scalars["model"])
|
|
109
|
+
case GroupBy.Dataset:
|
|
110
|
+
bundle = fingerprint.children["bundle"][0]
|
|
111
|
+
dataset = bundle.scalars.get("dataset")
|
|
112
|
+
if dataset is None:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{bundle.kind!r} bundle has no 'dataset' scalar to group by"
|
|
115
|
+
)
|
|
116
|
+
return str(dataset)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _reliability_metric(configuration: Configuration) -> ReliabilityMetric:
|
|
120
|
+
_, first = configuration.results[0]
|
|
121
|
+
return first.reliability_metric()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cell(axes: Any, row: int, col: int) -> Axes:
|
|
125
|
+
"""One grid cell out of `Figure.subplots`' loosely-typed 2D array."""
|
|
126
|
+
ax = axes[row][col]
|
|
127
|
+
assert isinstance(ax, Axes)
|
|
128
|
+
return ax
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_compare_figure(
|
|
132
|
+
configurations: Sequence[Configuration],
|
|
133
|
+
*,
|
|
134
|
+
row_by: GroupBy = GroupBy.Ungrouped,
|
|
135
|
+
col_by: GroupBy = GroupBy.Ungrouped,
|
|
136
|
+
percentile: float | None = None,
|
|
137
|
+
log_x: bool = False,
|
|
138
|
+
) -> Figure:
|
|
139
|
+
"""Step 4 of the `compare` pipeline: one subplot per `(row_by, col_by)`
|
|
140
|
+
cell, one overlaid line per configuration within its cell.
|
|
141
|
+
|
|
142
|
+
Cell values are discovered in first-seen order across `configurations`,
|
|
143
|
+
sizing the grid automatically. A label recurring across several
|
|
144
|
+
configurations (e.g. the same encoder compared across several model/dtype
|
|
145
|
+
combinations) keeps the same marker/color wherever it appears, and only
|
|
146
|
+
contributes one entry to the shared figure-level legend.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If `configurations` is empty, if they don't all share a
|
|
150
|
+
`reliability_metric` (unlike model/dataset/dtype, it changes what
|
|
151
|
+
the y-axis numbers mean, not just what's being compared), or if
|
|
152
|
+
`row_by`/`col_by` raises (see `group_key`).
|
|
153
|
+
"""
|
|
154
|
+
if not configurations:
|
|
155
|
+
raise ValueError("no configurations to plot")
|
|
156
|
+
|
|
157
|
+
metrics = {_reliability_metric(config) for config in configurations}
|
|
158
|
+
if len(metrics) > 1:
|
|
159
|
+
names = sorted(metric.value for metric in metrics)
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"all configurations must share a reliability metric to be "
|
|
162
|
+
f"compared, got {names}"
|
|
163
|
+
)
|
|
164
|
+
metric = next(iter(metrics))
|
|
165
|
+
|
|
166
|
+
row_values: list[str | None] = []
|
|
167
|
+
col_values: list[str | None] = []
|
|
168
|
+
cells: list[tuple[str | None, str | None]] = []
|
|
169
|
+
for config in configurations:
|
|
170
|
+
fingerprint = config.results[0][1].fingerprint
|
|
171
|
+
row_value = group_key(row_by, fingerprint)
|
|
172
|
+
col_value = group_key(col_by, fingerprint)
|
|
173
|
+
cells.append((row_value, col_value))
|
|
174
|
+
if row_value not in row_values:
|
|
175
|
+
row_values.append(row_value)
|
|
176
|
+
if col_value not in col_values:
|
|
177
|
+
col_values.append(col_value)
|
|
178
|
+
|
|
179
|
+
n_rows = len(row_values)
|
|
180
|
+
n_cols = len(col_values)
|
|
181
|
+
|
|
182
|
+
labels_in_order: list[str] = []
|
|
183
|
+
for config in configurations:
|
|
184
|
+
if config.label not in labels_in_order:
|
|
185
|
+
labels_in_order.append(config.label)
|
|
186
|
+
marker_by_label = {
|
|
187
|
+
label: _MARKERS[index % len(_MARKERS)]
|
|
188
|
+
for index, label in enumerate(labels_in_order)
|
|
189
|
+
}
|
|
190
|
+
color_by_label = {
|
|
191
|
+
label: _COLORS[index % len(_COLORS)]
|
|
192
|
+
for index, label in enumerate(labels_in_order)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fig = Figure(figsize=(4 * n_cols + 2, 3 * n_rows + 1))
|
|
196
|
+
fig.set_layout_engine("constrained")
|
|
197
|
+
axes = fig.subplots(n_rows, n_cols, squeeze=False, sharex=True, sharey=True)
|
|
198
|
+
|
|
199
|
+
handles_by_label: dict[str, Line2D] = {}
|
|
200
|
+
for config, (row_value, col_value) in zip(configurations, cells, strict=True):
|
|
201
|
+
ax = _cell(axes, row_values.index(row_value), col_values.index(col_value))
|
|
202
|
+
|
|
203
|
+
points = configuration_points(config, percentile=percentile)
|
|
204
|
+
xs = [x for x, _ in points]
|
|
205
|
+
ys = [y for _, y in points]
|
|
206
|
+
|
|
207
|
+
(line,) = ax.plot(
|
|
208
|
+
xs,
|
|
209
|
+
ys,
|
|
210
|
+
label=config.label,
|
|
211
|
+
marker=marker_by_label[config.label],
|
|
212
|
+
color=color_by_label[config.label],
|
|
213
|
+
)
|
|
214
|
+
handles_by_label.setdefault(config.label, line)
|
|
215
|
+
|
|
216
|
+
if log_x:
|
|
217
|
+
ax.set_xscale("log")
|
|
218
|
+
ax.grid(True)
|
|
219
|
+
|
|
220
|
+
# Label each row/column of the grid with its split-key value (the key name
|
|
221
|
+
# itself is carried by the axis titles below).
|
|
222
|
+
for row_index, row_value in enumerate(row_values):
|
|
223
|
+
if row_value is not None:
|
|
224
|
+
_cell(axes, row_index, 0).set_ylabel(row_value)
|
|
225
|
+
for col_index, col_value in enumerate(col_values):
|
|
226
|
+
if col_value is not None:
|
|
227
|
+
_cell(axes, 0, col_index).set_title(col_value)
|
|
228
|
+
|
|
229
|
+
score_label = "Mean" if percentile is None else f"{percentile:g}th Percentile"
|
|
230
|
+
fig.supxlabel("Bit Error Rate")
|
|
231
|
+
fig.supylabel(f"{score_label} {metric.score_name()} [%]")
|
|
232
|
+
|
|
233
|
+
fig.legend(
|
|
234
|
+
handles_by_label.values(),
|
|
235
|
+
handles_by_label.keys(),
|
|
236
|
+
loc="outside upper left",
|
|
237
|
+
frameon=False,
|
|
238
|
+
ncols=2,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return fig
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _residual_faults(run_bitmask: Sequence[int]) -> int:
|
|
245
|
+
"""Total bits that differ from golden post-decode, across one run.
|
|
246
|
+
|
|
247
|
+
The count of bits actually flipped after decoding, which can be less than
|
|
248
|
+
the injected fault count if the encoding masked some of them.
|
|
249
|
+
"""
|
|
250
|
+
return sum(value.bit_count() for value in run_bitmask)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _histogram_range(values: Sequence[float]) -> tuple[float, float]:
|
|
254
|
+
"""A valid (non-zero-width) range for `numpy.histogram2d`."""
|
|
255
|
+
low = min(values)
|
|
256
|
+
high = max(values)
|
|
257
|
+
return (low, high) if low != high else (low, low + 1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _colorbar_ticks(vmax: float) -> list[int]:
|
|
261
|
+
"""Positive integer occurrence counts up to `vmax`, in the standard
|
|
262
|
+
"1-2-5" preferred-number sequence (1, 2, 5, 10, 20, 50, ...) - the same
|
|
263
|
+
convention `LogLocator(subs=[1, 2, 5])` uses for log-scaled axes.
|
|
264
|
+
|
|
265
|
+
Explicit rather than left to `LogNorm`'s default locator: occurrence
|
|
266
|
+
counts are always positive integers, but when a panel's real range is
|
|
267
|
+
narrow (e.g. every visible cell has exactly one occurrence), that locator
|
|
268
|
+
invents fractional, decade-boundary ticks outside the real data (like
|
|
269
|
+
`10^-1`, i.e. 0.1 occurrences) to pad out a nonsingular range - which
|
|
270
|
+
reads as a negative value and doesn't correspond to anything real.
|
|
271
|
+
"""
|
|
272
|
+
ticks: list[int] = []
|
|
273
|
+
magnitude = 1
|
|
274
|
+
while magnitude <= vmax:
|
|
275
|
+
for multiplier in (1, 2, 5):
|
|
276
|
+
tick = magnitude * multiplier
|
|
277
|
+
if tick <= vmax:
|
|
278
|
+
ticks.append(tick)
|
|
279
|
+
magnitude *= 10
|
|
280
|
+
return ticks if ticks else [1]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def build_heatmap_figure(
|
|
284
|
+
results: Sequence[SavedResult],
|
|
285
|
+
*,
|
|
286
|
+
bins: int = 50,
|
|
287
|
+
min_score: float | None = None,
|
|
288
|
+
max_score: float | None = None,
|
|
289
|
+
max_total_faults: int | None = None,
|
|
290
|
+
skip_multi_bit_faults: bool = False,
|
|
291
|
+
) -> Figure:
|
|
292
|
+
"""A figure of per-run score density against faulty bit position (built
|
|
293
|
+
from `bit_position_histogram`) - shows which bit positions correlate with
|
|
294
|
+
reliability loss.
|
|
295
|
+
|
|
296
|
+
Bins with no data are filled with the colormap's minimum color rather
|
|
297
|
+
than left transparent - `LogNorm` treats a zero count as invalid and
|
|
298
|
+
masks it, which would otherwise show through as plain white.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If `results` is empty, any entry isn't a `DetailedResult`
|
|
302
|
+
(a heatmap fundamentally needs per-run `bitmask` data), they don't
|
|
303
|
+
all share a `dtype`/`reliability_metric` (both affect axis
|
|
304
|
+
semantics/bounds, not just cosmetics), or no runs remain after
|
|
305
|
+
filtering.
|
|
306
|
+
"""
|
|
307
|
+
if not results:
|
|
308
|
+
raise ValueError("no results to plot")
|
|
309
|
+
|
|
310
|
+
for result in results:
|
|
311
|
+
if not isinstance(result.result, DetailedResult):
|
|
312
|
+
raise ValueError(
|
|
313
|
+
"heatmap requires results recorded with --compare-bitwise "
|
|
314
|
+
"(a per-run bitmask)"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
metrics = {result.reliability_metric() for result in results}
|
|
318
|
+
if len(metrics) > 1:
|
|
319
|
+
names = sorted(metric.value for metric in metrics)
|
|
320
|
+
raise ValueError(f"all results must share a reliability metric, got {names}")
|
|
321
|
+
metric = next(iter(metrics))
|
|
322
|
+
|
|
323
|
+
dtypes = {result.fingerprint.scalars["dtype"] for result in results}
|
|
324
|
+
if len(dtypes) > 1:
|
|
325
|
+
raise ValueError(f"all results must share a dtype, got {sorted(dtypes)}")
|
|
326
|
+
bit_width = EncodingDtype(next(iter(dtypes))).bit_count()
|
|
327
|
+
|
|
328
|
+
scores: list[float] = []
|
|
329
|
+
positions: list[int] = []
|
|
330
|
+
weights: list[int] = []
|
|
331
|
+
|
|
332
|
+
for result in results:
|
|
333
|
+
assert isinstance(result.result, DetailedResult)
|
|
334
|
+
for run, score in zip(result.result.results, result.scores(), strict=True):
|
|
335
|
+
if min_score is not None and score < min_score:
|
|
336
|
+
continue
|
|
337
|
+
if max_score is not None and score > max_score:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
if max_total_faults is not None:
|
|
341
|
+
residual = _residual_faults(run.bitmask)
|
|
342
|
+
if residual > max_total_faults:
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
histogram = bit_position_histogram(
|
|
346
|
+
run.bitmask, skip_multi_bit=skip_multi_bit_faults
|
|
347
|
+
)
|
|
348
|
+
for position, count in histogram.items():
|
|
349
|
+
scores.append(score)
|
|
350
|
+
positions.append(position)
|
|
351
|
+
weights.append(count)
|
|
352
|
+
|
|
353
|
+
if not scores:
|
|
354
|
+
raise ValueError("no runs left after filtering")
|
|
355
|
+
|
|
356
|
+
score_range = _histogram_range(scores)
|
|
357
|
+
x_edges = np.histogram_bin_edges([], bins=bins, range=score_range)
|
|
358
|
+
|
|
359
|
+
counts, _, y_edges = np.histogram2d(
|
|
360
|
+
scores,
|
|
361
|
+
positions,
|
|
362
|
+
bins=[x_edges, bit_width],
|
|
363
|
+
range=[score_range, (-0.5, bit_width - 0.5)],
|
|
364
|
+
weights=weights,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
cmap = matplotlib.colormaps["plasma"]
|
|
368
|
+
background = cmap(0.0)
|
|
369
|
+
# `vmin` is deliberately below the smallest real occurrence count (1),
|
|
370
|
+
# not equal to it - `LogNorm` maps `vmin` itself to the very bottom of
|
|
371
|
+
# the colormap, which is also `background` above; if a real count of 1
|
|
372
|
+
# used exactly `vmin`, it would render identically to an empty cell.
|
|
373
|
+
norm = LogNorm(vmin=0.5, vmax=counts.max())
|
|
374
|
+
|
|
375
|
+
fig = Figure(figsize=(8, 5))
|
|
376
|
+
fig.set_layout_engine("constrained")
|
|
377
|
+
mosaic = fig.subplot_mosaic([["main", "cbar"]], width_ratios=[0.95, 0.05])
|
|
378
|
+
ax = mosaic["main"]
|
|
379
|
+
colorbar_ax = mosaic["cbar"]
|
|
380
|
+
assert isinstance(ax, Axes)
|
|
381
|
+
assert isinstance(colorbar_ax, Axes)
|
|
382
|
+
ax.set_facecolor(background)
|
|
383
|
+
|
|
384
|
+
image = ax.pcolormesh(x_edges, y_edges, counts.T, cmap=cmap, norm=norm)
|
|
385
|
+
ax.set_xlabel(f"{metric.score_name()} [%]")
|
|
386
|
+
ax.set_ylabel("Bit Position")
|
|
387
|
+
|
|
388
|
+
colorbar = fig.colorbar(image, cax=colorbar_ax, ticks=_colorbar_ticks(counts.max()))
|
|
389
|
+
# `LogNorm` colorbars auto-attach sub-decade minor ticks (e.g. `6x10^-1`)
|
|
390
|
+
# independent of the `ticks=` override above - turn them off so only the
|
|
391
|
+
# explicit, always-meaningful integer ticks show.
|
|
392
|
+
colorbar.minorticks_off()
|
|
393
|
+
colorbar_ax.set_ylabel("Occurrences")
|
|
394
|
+
|
|
395
|
+
return fig
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Turning saved `encoded_memory` result files into comparable configurations.
|
|
2
|
+
|
|
3
|
+
This is the data-wrangling half of the `compare`/`heatmap` commands (the
|
|
4
|
+
figure-drawing half lives in `plots`). It has no `matplotlib` dependency, so
|
|
5
|
+
it can be exercised without a plotting backend.
|
|
6
|
+
|
|
7
|
+
Vocabulary used across this module and `plots`:
|
|
8
|
+
|
|
9
|
+
- **configuration** - a set of loaded results that are identical in every
|
|
10
|
+
fingerprint field *except* the injected fault count. They only differ in bit
|
|
11
|
+
error rate, so they describe "the same thing measured at several rates" and
|
|
12
|
+
are drawn as a single line. This is the unit `compare` compares. Grouping is
|
|
13
|
+
driven entirely by fingerprint equality, never by how files are laid out on
|
|
14
|
+
disk.
|
|
15
|
+
- **bit error rate** - `faults / total_bits`; the x-axis of `compare`. The one
|
|
16
|
+
quantity allowed to vary within a configuration.
|
|
17
|
+
- **split key** (`plots.GroupBy`) - a separate concept: a fingerprint-derived
|
|
18
|
+
key (dtype, model, ...) that `compare` uses to fan a single chart out into a
|
|
19
|
+
*grid* of rows and/or columns via `row_by`/`col_by`. Splitting the grid is
|
|
20
|
+
unrelated to which configuration a result belongs to.
|
|
21
|
+
|
|
22
|
+
The `compare` pipeline, end to end:
|
|
23
|
+
|
|
24
|
+
1. `commands.compare` parses each path argument into a path plus an optional
|
|
25
|
+
`=LABEL` override.
|
|
26
|
+
2. `load_results` discovers and loads every file under those paths into one
|
|
27
|
+
flat pool of `(path, result)` pairs, skipping anything that fails to load.
|
|
28
|
+
3. `build_configurations` clusters that pool into `Configuration`s and names
|
|
29
|
+
each one.
|
|
30
|
+
4. `plots.build_compare_figure` places each configuration in its grid cell and
|
|
31
|
+
draws its line.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
from faultforge import Fingerprint
|
|
41
|
+
from faultforge.experiments.encoded_memory import SavedResult
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_result_files(path: Path) -> list[Path]:
|
|
47
|
+
"""Resolve `path` to the result file(s) it names.
|
|
48
|
+
|
|
49
|
+
A file resolves to itself; a directory resolves to every regular file
|
|
50
|
+
found recursively within it, sorted for determinism.
|
|
51
|
+
"""
|
|
52
|
+
resolved = path.expanduser()
|
|
53
|
+
if resolved.is_dir():
|
|
54
|
+
return sorted(p for p in resolved.rglob("*") if p.is_file())
|
|
55
|
+
return [resolved]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_results(paths: Sequence[Path]) -> list[tuple[Path, SavedResult]]:
|
|
59
|
+
"""Step 2 of the `compare` pipeline: load every result file found across
|
|
60
|
+
all of `paths` (recursing into any directories) into one flat pool of
|
|
61
|
+
`(path, result)` pairs.
|
|
62
|
+
|
|
63
|
+
Skips (with a `logger.warning`) any file that isn't a valid saved
|
|
64
|
+
result - e.g. a stray non-result file living alongside real ones in a
|
|
65
|
+
directory - rather than failing the whole load.
|
|
66
|
+
"""
|
|
67
|
+
loaded: list[tuple[Path, SavedResult]] = []
|
|
68
|
+
for path in paths:
|
|
69
|
+
for file in discover_result_files(path):
|
|
70
|
+
try:
|
|
71
|
+
loaded.append((file, SavedResult.load(file)))
|
|
72
|
+
except Exception as error:
|
|
73
|
+
logger.warning(
|
|
74
|
+
f"Failed to load a result from {file} - skipping\n-> {error}"
|
|
75
|
+
)
|
|
76
|
+
return loaded
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_VARYING_SCALAR = "faults"
|
|
80
|
+
"""The one `Fingerprint` scalar clustering ignores: it's expected to vary
|
|
81
|
+
within a configuration, since it's the x-axis being compared (bit error rate)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, frozen=True)
|
|
85
|
+
class Configuration:
|
|
86
|
+
"""One line on a `compare` chart: results that match on everything except
|
|
87
|
+
bit error rate, plus the legend label to draw them under.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
label: str
|
|
91
|
+
results: list[tuple[Path, SavedResult]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fingerprint_without_faults(fingerprint: Fingerprint) -> Fingerprint:
|
|
95
|
+
return fingerprint.model_copy(
|
|
96
|
+
update={
|
|
97
|
+
"scalars": {
|
|
98
|
+
key: value
|
|
99
|
+
for key, value in fingerprint.scalars.items()
|
|
100
|
+
if key != _VARYING_SCALAR
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cluster_by_fingerprint(
|
|
107
|
+
loaded: Sequence[tuple[Path, SavedResult]],
|
|
108
|
+
) -> list[list[tuple[Path, SavedResult]]]:
|
|
109
|
+
"""Group entries whose fingerprints agree on everything except `faults`.
|
|
110
|
+
|
|
111
|
+
Driven entirely by `Fingerprint` structural equality (via
|
|
112
|
+
`Fingerprint.diff`), not by how the caller organized files on disk.
|
|
113
|
+
"""
|
|
114
|
+
clusters: list[tuple[Fingerprint, list[tuple[Path, SavedResult]]]] = []
|
|
115
|
+
for path, result in loaded:
|
|
116
|
+
key = _fingerprint_without_faults(result.fingerprint)
|
|
117
|
+
for representative, entries in clusters:
|
|
118
|
+
if not representative.diff(key):
|
|
119
|
+
entries.append((path, result))
|
|
120
|
+
break
|
|
121
|
+
else:
|
|
122
|
+
clusters.append((key, [(path, result)]))
|
|
123
|
+
return [entries for _, entries in clusters]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_configurations(
|
|
127
|
+
loaded: Sequence[tuple[Path, SavedResult]],
|
|
128
|
+
*,
|
|
129
|
+
label_overrides: Mapping[Path, str],
|
|
130
|
+
) -> list[Configuration]:
|
|
131
|
+
"""Step 3 of the `compare` pipeline: cluster `loaded` into configurations
|
|
132
|
+
and label each one.
|
|
133
|
+
|
|
134
|
+
A configuration's label is whichever `label_overrides` entry applies to
|
|
135
|
+
one of its files - warning (and keeping the first one seen) if more than
|
|
136
|
+
one distinct override applies to the same configuration, since that means
|
|
137
|
+
the caller gave conflicting names to what turned out to be one line. Falls
|
|
138
|
+
back to the stem of the configuration's first (sorted) file when no
|
|
139
|
+
override applies.
|
|
140
|
+
"""
|
|
141
|
+
configurations: list[Configuration] = []
|
|
142
|
+
for cluster in _cluster_by_fingerprint(loaded):
|
|
143
|
+
cluster_sorted = sorted(cluster, key=lambda entry: entry[0])
|
|
144
|
+
|
|
145
|
+
label: str | None = None
|
|
146
|
+
for path, _ in cluster_sorted:
|
|
147
|
+
override = label_overrides.get(path)
|
|
148
|
+
if override is None:
|
|
149
|
+
continue
|
|
150
|
+
if label is not None and label != override:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"conflicting labels {label!r} and {override!r} apply to "
|
|
153
|
+
"the same configuration; keeping the first"
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
label = override
|
|
157
|
+
|
|
158
|
+
if label is None:
|
|
159
|
+
label = cluster_sorted[0][0].stem
|
|
160
|
+
|
|
161
|
+
configurations.append(Configuration(label=label, results=cluster_sorted))
|
|
162
|
+
return configurations
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def configuration_points(
|
|
166
|
+
configuration: Configuration, *, percentile: float | None
|
|
167
|
+
) -> list[tuple[float, float]]:
|
|
168
|
+
"""Flatten every run across every file in `configuration` into
|
|
169
|
+
`(bit_error_rate, score)` points, one per distinct bit error rate.
|
|
170
|
+
|
|
171
|
+
Runs recorded at the same bit error rate (whether from the same file or
|
|
172
|
+
different files within the configuration) are pooled and reduced to
|
|
173
|
+
`percentile` (or the mean, if `None`). Sorted by bit error rate.
|
|
174
|
+
"""
|
|
175
|
+
scores_by_rate: dict[float, list[float]] = {}
|
|
176
|
+
for _, result in configuration.results:
|
|
177
|
+
rate = result.bit_error_rate()
|
|
178
|
+
scores_by_rate.setdefault(rate, []).extend(result.scores())
|
|
179
|
+
|
|
180
|
+
def reduce(scores: list[float]) -> float:
|
|
181
|
+
if percentile is None:
|
|
182
|
+
return float(np.mean(scores))
|
|
183
|
+
return float(np.percentile(scores, percentile))
|
|
184
|
+
|
|
185
|
+
return sorted((rate, reduce(scores)) for rate, scores in scores_by_rate.items())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def bit_position_histogram(
|
|
189
|
+
bitmask: Sequence[int], *, skip_multi_bit: bool = False
|
|
190
|
+
) -> dict[int, int]:
|
|
191
|
+
"""Count how many `bitmask` elements had each bit position flipped.
|
|
192
|
+
|
|
193
|
+
Maps bit position (0-indexed from the LSB) to how many elements had that
|
|
194
|
+
position differ from the golden value, by decomposing each stored xor value
|
|
195
|
+
bit-by-bit. This is the "which bit index gets hit" view the `heatmap`
|
|
196
|
+
command's bottom panel is built from.
|
|
197
|
+
|
|
198
|
+
Pass `skip_multi_bit=True` to exclude elements with more than one faulty
|
|
199
|
+
bit, isolating cases where a single bit flip's position is unambiguous.
|
|
200
|
+
"""
|
|
201
|
+
histogram: dict[int, int] = {}
|
|
202
|
+
for value in bitmask:
|
|
203
|
+
if skip_multi_bit and value.bit_count() > 1:
|
|
204
|
+
continue
|
|
205
|
+
position = 0
|
|
206
|
+
remaining = value
|
|
207
|
+
while remaining:
|
|
208
|
+
if remaining & 1:
|
|
209
|
+
histogram[position] = histogram.get(position, 0) + 1
|
|
210
|
+
remaining >>= 1
|
|
211
|
+
position += 1
|
|
212
|
+
return histogram
|