pelagos-decoder 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.
pelagos/utils.py ADDED
@@ -0,0 +1,995 @@
1
+ # (C) Copyright IBM 2025, 2026
2
+ # Modified 2026 by AMD to report ROCm device information.
3
+ #
4
+ # This code is licensed under the Apache License, Version 2.0. You may
5
+ # obtain a copy of this license in the LICENSE file in the root directory
6
+ # of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
7
+ #
8
+ # Any modifications or derivative works of this code must retain this
9
+ # copyright notice, and modified files need to carry a notice indicating
10
+ # that they have been altered from the originals.
11
+
12
+ """Utility functions for Relay-BP-S decoding.
13
+
14
+ This module provides utility functions for quantum error correction decoding,
15
+ including error prior computation, gamma sampling, and matrix validation.
16
+ These functions support the main Relay-BP algorithm implementation.
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import logging
21
+ import torch
22
+ import numpy as np
23
+ import time
24
+ from typing import Any, Dict, Optional, Tuple, Union, List
25
+ from pathlib import Path
26
+ from scipy.sparse import csc_matrix, csr_matrix, coo_matrix
27
+ from scipy.sparse.csgraph import connected_components
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ def bitpack_errors(errors: torch.Tensor, bits_per_word: int = 32) -> torch.Tensor:
33
+ """Pack error bits into words for memory efficiency.
34
+
35
+ Args:
36
+ errors: [B, V] binary tensor (0/1)
37
+ bits_per_word: number of bits per packed word
38
+
39
+ Returns:
40
+ [B, (V + bits_per_word - 1) // bits_per_word] packed tensor
41
+ """
42
+ B, V = errors.shape
43
+ words_per_batch = (V + bits_per_word - 1) // bits_per_word
44
+
45
+ # Reshape to [B, words_per_batch, bits_per_word]
46
+ padded_errors = torch.zeros(
47
+ B, words_per_batch * bits_per_word, dtype=errors.dtype, device=errors.device
48
+ )
49
+ padded_errors[:, :V] = errors
50
+
51
+ # Reshape and pack
52
+ packed = padded_errors.view(B, words_per_batch, bits_per_word)
53
+
54
+ # Convert to int32 words (CUDA supports lshift on int32)
55
+ result = torch.zeros(B, words_per_batch, dtype=torch.int32, device=errors.device)
56
+
57
+ for i in range(bits_per_word):
58
+ result |= packed[:, :, i].to(torch.int32) << i
59
+
60
+ return result
61
+
62
+
63
+ def bitunpack_errors(packed: torch.Tensor, V: int, bits_per_word: int = 32) -> torch.Tensor:
64
+ """Unpack error bits from words.
65
+
66
+ Args:
67
+ packed: [B, words_per_batch] packed tensor
68
+ V: number of variables
69
+ bits_per_word: number of bits per packed word
70
+
71
+ Returns:
72
+ [B, V] binary tensor (0/1)
73
+ """
74
+ B, words_per_batch = packed.shape
75
+
76
+ # Convert to binary representation
77
+ bits = torch.zeros(B, words_per_batch, bits_per_word, dtype=torch.uint8, device=packed.device)
78
+
79
+ for i in range(bits_per_word):
80
+ bits[:, :, i] = ((packed >> i) & 1).to(torch.uint8)
81
+
82
+ # Flatten and truncate to V
83
+ errors = bits.view(B, -1)[:, :V]
84
+
85
+ return errors
86
+
87
+
88
+ def compute_log_prior_ratios(error_priors: np.ndarray) -> np.ndarray:
89
+ """Compute log prior ratios from error probabilities.
90
+
91
+ This function converts error probabilities to log-likelihood ratios used
92
+ in belief propagation, corresponding to the log_prior_ratios computation
93
+ in Rust min_sum.rs.
94
+
95
+ Args:
96
+ error_priors: [V] error probabilities in (0, 0.5) for each qubit
97
+
98
+ Returns:
99
+ [V] log prior ratios log((1-p)/p) for belief propagation
100
+ """
101
+ # Clip probabilities to avoid numerical issues
102
+ p = np.clip(error_priors, 1e-10, 0.5 - 1e-10)
103
+
104
+ # Compute log prior ratios
105
+ log_ratios = np.log((1 - p) / p)
106
+
107
+ return log_ratios.astype(np.float32)
108
+
109
+
110
+ def compute_decoding_weights(
111
+ hard_dec: torch.Tensor, log_prior_ratios: torch.Tensor
112
+ ) -> torch.Tensor:
113
+ """Compute decoding weights for solution selection.
114
+
115
+ Args:
116
+ hard_dec: [B, V] hard decisions (0/1)
117
+ log_prior_ratios: [V] log prior ratios
118
+
119
+ Returns:
120
+ [B] decoding weights (lower is better)
121
+ """
122
+ # Weight = sum of log prior ratios for error bits
123
+ weights = torch.sum(hard_dec.float() * log_prior_ratios.unsqueeze(0), dim=1)
124
+
125
+ return weights
126
+
127
+
128
+ def sample_gamma_uniform(
129
+ rows: int, V: int, gamma_min: float, gamma_max: float, device: str, seed: Optional[int] = None
130
+ ) -> torch.Tensor:
131
+ """Sample gamma values from uniform distribution for disordered memory.
132
+
133
+ This function samples memory strength values for the disordered memory phase
134
+ of Relay-BP, corresponding to the gamma sampling in Rust relay.rs.
135
+
136
+ Args:
137
+ rows: number of gamma rows to draw (1 for gamma_draw="broadcast", the batch size
138
+ for "per_row")
139
+ V: number of variables (qubits)
140
+ gamma_min: minimum gamma value for memory strength
141
+ gamma_max: maximum gamma value for memory strength
142
+ device: target GPU device
143
+ seed: random seed for reproducibility. Seeds a generator private to this call;
144
+ the global RNG state is neither used nor changed.
145
+
146
+ Returns:
147
+ [rows, V] gamma values for memory mixing
148
+ """
149
+ gen = None
150
+ if seed is not None:
151
+ gen = torch.Generator(device=device)
152
+ gen.manual_seed(seed)
153
+
154
+ # Sample uniform random values
155
+ uniform = torch.rand(rows, V, device=device, generator=gen)
156
+
157
+ # Scale to [gamma_min, gamma_max]
158
+ gamma = gamma_min + (gamma_max - gamma_min) * uniform
159
+
160
+ return gamma
161
+
162
+
163
+ def sample_gamma_scalar(B: int, V: int, gamma_value: float, device: str) -> torch.Tensor:
164
+ """Sample scalar gamma values for ordered memory phase.
165
+
166
+ This function creates uniform gamma values for the ordered memory phase
167
+ of Relay-BP, corresponding to the gamma0 parameter in Rust relay.rs.
168
+
169
+ Args:
170
+ B: batch size
171
+ V: number of variables (qubits)
172
+ gamma_value: uniform gamma value for memory strength
173
+ device: target GPU device
174
+
175
+ Returns:
176
+ [B, V] gamma values (all identical)
177
+ """
178
+ return torch.full((B, V), gamma_value, device=device)
179
+
180
+
181
+ def sample_gamma_per_variable(
182
+ rows: int,
183
+ V: int,
184
+ gamma_intervals: torch.Tensor,
185
+ device: str,
186
+ seed: Optional[int] = None,
187
+ ) -> torch.Tensor:
188
+ """Sample gamma values from per-variable uniform intervals.
189
+
190
+ Each variable v has its own
191
+ interval [low_v, high_v] and we sample:
192
+ gamma[r, v] ~ Uniform(low_v, high_v)
193
+ independently for each row r.
194
+
195
+ Args:
196
+ rows: number of gamma rows to draw (1 for gamma_draw="broadcast", the batch size
197
+ for "per_row")
198
+ V: number of variables (qubits)
199
+ gamma_intervals: [V, 2] tensor with [:,0]=low, [:,1]=high bounds
200
+ device: target GPU device
201
+ seed: random seed for reproducibility. Seeds a generator private to this call;
202
+ the global RNG state is neither used nor changed.
203
+
204
+ Returns:
205
+ [rows, V] gamma values for memory mixing
206
+ """
207
+ gen = None
208
+ if seed is not None:
209
+ gen = torch.Generator(device=device)
210
+ gen.manual_seed(seed)
211
+
212
+ if gamma_intervals.ndim != 2 or gamma_intervals.shape[0] != V or gamma_intervals.shape[1] != 2:
213
+ raise ValueError(
214
+ f"gamma_intervals must have shape (V={V}, 2), got {tuple(gamma_intervals.shape)}"
215
+ )
216
+
217
+ # [rows, V] uniform samples in [0, 1]
218
+ uniform = torch.rand(rows, V, device=device, generator=gen)
219
+
220
+ # Broadcast per-variable intervals: low, high are [1, V]
221
+ low = gamma_intervals[:, 0].unsqueeze(0)
222
+ high = gamma_intervals[:, 1].unsqueeze(0)
223
+
224
+ gamma = low + (high - low) * uniform
225
+ return gamma
226
+
227
+
228
+ GAMMA_DRAW_MODES = ("broadcast", "per_row")
229
+
230
+
231
+ def validate_error_priors(error_priors: np.ndarray) -> np.ndarray:
232
+ """Validate and clip error priors.
233
+
234
+ Args:
235
+ error_priors: [V] error probabilities
236
+
237
+ Returns:
238
+ [V] validated and clipped error probabilities
239
+ """
240
+ # Check shape
241
+ if error_priors.ndim != 1:
242
+ raise ValueError("error_priors must be 1D array")
243
+
244
+ # Check range and clip
245
+ clipped = np.clip(error_priors, 1e-10, 0.5 - 1e-10)
246
+
247
+ # Warn if clipping occurred
248
+ if np.any(clipped != error_priors):
249
+ print("Warning: error_priors were clipped to avoid numerical issues")
250
+
251
+ return clipped
252
+
253
+
254
+ def validate_csr_matrix(H_csr) -> bool:
255
+ """Validate CSR matrix format and properties; coerce CSR if needed."""
256
+ from scipy.sparse import csr_matrix, issparse
257
+
258
+ if not issparse(H_csr):
259
+ raise ValueError("Input must be a scipy.sparse matrix (any format)")
260
+
261
+ # Coerce to CSR if it's e.g. csr_array/coo/csc/etc.
262
+ if not isinstance(H_csr, csr_matrix):
263
+ H_csr = H_csr.tocsr()
264
+
265
+ if H_csr.shape[0] == 0 or H_csr.shape[1] == 0:
266
+ raise ValueError("Matrix cannot be empty")
267
+ if H_csr.nnz == 0:
268
+ raise ValueError("Matrix cannot have zero non-zero elements")
269
+ if not np.all((H_csr.data == 0) | (H_csr.data == 1)):
270
+ print("Warning: Matrix contains non-binary values, will be treated as binary")
271
+
272
+ return True
273
+
274
+
275
+ def get_device_info(device: str) -> dict:
276
+ """Get device information for debugging.
277
+
278
+ Args:
279
+ device: device string ("cuda" or "rocm")
280
+
281
+ Returns:
282
+ Dictionary with device information
283
+ """
284
+ if device == "cuda":
285
+ if torch.cuda.is_available():
286
+ return {
287
+ "device": device,
288
+ "available": True,
289
+ "name": torch.cuda.get_device_name(),
290
+ "memory_total": torch.cuda.get_device_properties(0).total_memory,
291
+ "memory_allocated": torch.cuda.memory_allocated(),
292
+ "memory_reserved": torch.cuda.memory_reserved(),
293
+ }
294
+ else:
295
+ return {"device": device, "available": False}
296
+
297
+ elif device == "rocm":
298
+ if not torch.version.hip or not torch.cuda.is_available():
299
+ return {"device": device, "available": False}
300
+
301
+ # PyTorch exposes HIP devices through the torch.cuda API.
302
+ device_index = torch.cuda.current_device()
303
+ properties = torch.cuda.get_device_properties(device_index)
304
+ return {
305
+ "device": device,
306
+ "available": True,
307
+ "name": properties.name,
308
+ "memory_total": properties.total_memory,
309
+ "memory_allocated": torch.cuda.memory_allocated(device_index),
310
+ "memory_reserved": torch.cuda.memory_reserved(device_index),
311
+ "hip_version": torch.version.hip,
312
+ }
313
+
314
+ else:
315
+ raise ValueError(f"Unsupported device: {device}")
316
+
317
+
318
+ def format_memory_size(bytes: int) -> str:
319
+ """Format memory size in human-readable format.
320
+
321
+ Args:
322
+ bytes: memory size in bytes
323
+
324
+ Returns:
325
+ Formatted string
326
+ """
327
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
328
+ if bytes < 1024.0:
329
+ return f"{bytes:.1f} {unit}"
330
+ bytes /= 1024.0
331
+ return f"{bytes:.1f} PB"
332
+
333
+
334
+ def _deg_stats(arr: np.ndarray) -> Tuple[int, float, int]:
335
+ if arr.size == 0:
336
+ return 0, 0.0, 0
337
+ return int(arr.min()), float(arr.mean()), int(arr.max())
338
+
339
+
340
+ def _hist_small_ints(deg: np.ndarray, bins=(2, 3, 4, 5, 6, 8, 12, 16, 24, 32)) -> Dict[str, int]:
341
+ if deg.size == 0:
342
+ return {}
343
+ out = {}
344
+ prev = 0
345
+ for b in bins:
346
+ out[f"≤{b}"] = int((deg <= b).sum() - prev)
347
+ prev = (deg <= b).sum()
348
+ out[f">{bins[-1]}"] = int((deg > bins[-1]).sum())
349
+ return out
350
+
351
+
352
+ def _rank_gf2_via_reduction(H_csr: csr_matrix) -> int:
353
+ """
354
+ Compute rank over GF(2) via sparse row-reduction.
355
+ WARNING: potentially expensive for very large matrices; enable only if you need it.
356
+ """
357
+ # convert to COO for simple row-ops, then to dense bit rows per pivot band
358
+ H = H_csr.copy().astype(np.uint8)
359
+ M, N = H.shape
360
+ pivcol = 0
361
+ rank = 0
362
+ indptr, indices = H.indptr, H.indices
363
+ # Build list of sets for quick XOR row ops
364
+ rows = [set(indices[indptr[i] : indptr[i + 1]]) for i in range(M)]
365
+ while pivcol < N and rank < M:
366
+ # find a row with a 1 in pivcol at or below 'rank'
367
+ pivot = -1
368
+ for r in range(rank, M):
369
+ if pivcol in rows[r]:
370
+ pivot = r
371
+ break
372
+ if pivot == -1:
373
+ pivcol += 1
374
+ continue
375
+ # swap rows (as sets)
376
+ if pivot != rank:
377
+ rows[rank], rows[pivot] = rows[pivot], rows[rank]
378
+ # eliminate below
379
+ for r in range(rank + 1, M):
380
+ if pivcol in rows[r]:
381
+ rows[r] ^= rows[rank] # symmetric diff = XOR over GF(2)
382
+ rank += 1
383
+ pivcol += 1
384
+ return rank
385
+
386
+
387
+ def describe_check_matrices(
388
+ check_matrices,
389
+ *,
390
+ dem: Optional[Any] = None, # expects .num_detectors / .num_observables if given
391
+ rounds: Optional[int] = None,
392
+ compute_components: bool = False, # compute connected components of Tanner graph
393
+ compute_rank: bool = False, # GF(2) rank of H (can be expensive)
394
+ file=None,
395
+ return_dict: bool = False,
396
+ ) -> Dict[str, Any]:
397
+ """
398
+ Derive & print detailed stats from real matrices only (no name heuristics).
399
+ """
400
+ H = check_matrices.check_matrix # usually CSC
401
+ O = check_matrices.observables_matrix # usually CSC
402
+ pri = np.asarray(check_matrices.error_priors)
403
+
404
+ # Ensure both CSR and CSC forms
405
+ H_csr: csr_matrix = H if isinstance(H, csr_matrix) else H.tocsr(copy=False)
406
+ H_csc: csc_matrix = H if isinstance(H, csc_matrix) else H.tocsc(copy=False)
407
+
408
+ M, N = H_csr.shape
409
+ E = int(H_csr.nnz)
410
+ density = (E / (M * N)) if (M and N) else 0.0
411
+
412
+ # Tanner degrees
413
+ row_deg = np.diff(H_csr.indptr) # errors per check
414
+ col_deg = np.diff(H_csc.indptr) # checks per error
415
+ rmin, rmean, rmax = _deg_stats(row_deg)
416
+ cmin, cmean, cmax = _deg_stats(col_deg)
417
+ is_row_reg = rmin == rmax and M > 0
418
+ is_col_reg = cmin == cmax and N > 0
419
+ regularity = (
420
+ f"(d_v={cmin}, d_c={rmin})-regular"
421
+ if (is_row_reg and is_col_reg)
422
+ else ("row-regular" if is_row_reg else ("column-regular" if is_col_reg else None))
423
+ )
424
+ col_hist = _hist_small_ints(col_deg)
425
+ row_hist = _hist_small_ints(row_deg)
426
+
427
+ # Observables matrix stats
428
+ O_rows, O_cols = O.shape
429
+ O_nnz = int(O.nnz)
430
+ O_density = (O_nnz / (O_rows * O_cols)) if (O_rows and O_cols) else 0.0
431
+
432
+ # Priors
433
+ finite_mask = np.isfinite(pri)
434
+ pri_stats = {
435
+ "length": int(pri.size),
436
+ "finite_fraction": float(finite_mask.mean() if pri.size else 1.0),
437
+ "min": float(np.min(pri[finite_mask])) if finite_mask.any() else float("nan"),
438
+ "mean": float(np.mean(pri[finite_mask])) if finite_mask.any() else float("nan"),
439
+ "max": float(np.max(pri[finite_mask])) if finite_mask.any() else float("nan"),
440
+ }
441
+
442
+ # Per-cycle detectors from DEM if provided
443
+ dets_total = getattr(dem, "num_detectors", None) if dem is not None else None
444
+ per_cycle_detectors = (dets_total // rounds) if (dets_total is not None and rounds) else None
445
+
446
+ # Optional: connected components of Tanner graph (bipartite graph with M+N nodes)
447
+ comp_info = None
448
+ if compute_components and E > 0:
449
+ # build bipartite adjacency: blocks [0:M) checks, [M:M+N) variables
450
+ H_coo: coo_matrix = H_csr.tocoo(copy=False)
451
+ rows = H_coo.row
452
+ cols = H_coo.col + M
453
+ data = np.ones_like(rows, dtype=np.uint8)
454
+ # symmetric adjacency
455
+ i_idx = np.concatenate([rows, cols])
456
+ j_idx = np.concatenate([cols, rows])
457
+ A = coo_matrix(
458
+ (np.ones_like(i_idx, dtype=np.uint8), (i_idx, j_idx)), shape=(M + N, M + N)
459
+ ).tocsr()
460
+ n_comp, labels = connected_components(A, directed=False, return_labels=True)
461
+ comp_sizes = np.bincount(labels)
462
+ comp_info = {
463
+ "num_components": int(n_comp),
464
+ "component_sizes_desc": np.sort(comp_sizes)[::-1].tolist(),
465
+ }
466
+
467
+ # Optional: GF(2) rank and k ≈ N - rank(H)
468
+ rank_info = None
469
+ if compute_rank and E > 0:
470
+ rank_h = _rank_gf2_via_reduction(H_csr.astype(np.uint8))
471
+ k_est = int(N - rank_h)
472
+ rank_info = {"rank_gf2": int(rank_h), "k_estimate": k_est}
473
+
474
+ # ----- printing -----
475
+ pf = print if file is None else (lambda *a, **k: print(*a, **k, file=file))
476
+
477
+ pf(f"H (checks × errors): {M} × {N}")
478
+ pf(f" nonzeros (E): {E} | density: {density:.9f}")
479
+ pf(f" avg column weight (checks per error): {cmean:.2f}")
480
+ pf(f" avg row weight (errors per check): {rmean:.2f}")
481
+ pf(f" column weight min/mean/max: {cmin} / {cmean:.2f} / {cmax}")
482
+ pf(f" row weight min/mean/max: {rmin} / {rmean:.2f} / {rmax}")
483
+ if regularity:
484
+ pf(f" regularity: {regularity}")
485
+ pf(f" column weight hist: {col_hist}")
486
+ pf(f" row weight hist: {row_hist}")
487
+
488
+ pf(f"O (observables × errors): {O_rows} × {O_cols} (nnz={O_nnz}, density={O_density:.9f})")
489
+
490
+ if rounds is not None:
491
+ if per_cycle_detectors is not None:
492
+ pf(
493
+ f"per-cycle detectors: {per_cycle_detectors} (from DEM: {dets_total} / rounds: {rounds})"
494
+ )
495
+ else:
496
+ pf(f"per-cycle detectors: unavailable (need DEM with num_detectors)")
497
+
498
+ if rank_info:
499
+ pf(f"rank_GF2(H) = {rank_info['rank_gf2']} => k_est ≈ {rank_info['k_estimate']}")
500
+
501
+ if comp_info:
502
+ pf(
503
+ f"Tanner graph connected components: {comp_info['num_components']} "
504
+ f"(largest sizes: {comp_info['component_sizes_desc'][:5]})"
505
+ )
506
+
507
+ pf(
508
+ f"priors: N={pri_stats['length']}, finite={pri_stats['finite_fraction'] * 100:.1f}% | "
509
+ f"min/mean/max={pri_stats['min']}/{pri_stats['mean']}/{pri_stats['max']}"
510
+ )
511
+
512
+ out = {
513
+ "H": {
514
+ "shape": (M, N),
515
+ "nonzeros": E,
516
+ "density": density,
517
+ "avg_column_weight": cmean,
518
+ "avg_row_weight": rmean,
519
+ "column_weight_stats": {"min": cmin, "mean": cmean, "max": cmax},
520
+ "row_weight_stats": {"min": rmin, "mean": rmean, "max": rmax},
521
+ "column_weight_hist": col_hist,
522
+ "row_weight_hist": row_hist,
523
+ "regularity": regularity,
524
+ },
525
+ "O": {"shape": (O_rows, O_cols), "nonzeros": O_nnz, "density": O_density},
526
+ "priors": pri_stats,
527
+ "rounds": rounds,
528
+ "per_cycle_detectors": per_cycle_detectors,
529
+ "rank": rank_info,
530
+ "components": comp_info,
531
+ }
532
+ return out if return_dict else out
533
+
534
+
535
+ def warmup_decoder_options_overrides(options: dict) -> dict:
536
+ """Iteration-limit overrides that turn any relay-bp config into a warmup config."""
537
+ is_relay = (options.get("num_sets") or 0) > 0
538
+ return {
539
+ "pre_iter": 1,
540
+ "num_sets": 1 if is_relay else 0,
541
+ "set_max_iter": 1 if is_relay else 0,
542
+ "stop_nconv": 1,
543
+ }
544
+
545
+
546
+ def warmup_build_and_decode(
547
+ *,
548
+ check_matrices: Any,
549
+ dem: Any,
550
+ backend: str,
551
+ algo: str,
552
+ perf: str,
553
+ dtype: str,
554
+ pre_iter: int,
555
+ num_sets: int,
556
+ set_max_iter: int,
557
+ stop_nconv: int,
558
+ gamma0: float | None,
559
+ gamma_dist_interval: tuple[float, float] | None,
560
+ alpha: float | None,
561
+ beta: float | None,
562
+ device: Union[str, List[int], int] = [0],
563
+ seed: int = 0,
564
+ repeats: int = 3,
565
+ batch: int = 8,
566
+ parallel: bool = True,
567
+ ) -> None:
568
+ """
569
+ Build a fresh decoder with iteration limits forced to 1 and run warmup decodes.
570
+ Prints start/end and per-repeat timing.
571
+ """
572
+ sampler = dem.compile_sampler(seed=seed)
573
+
574
+ if backend == "triton":
575
+ from .adapter import RelayDecoder as _RelayDecoder
576
+ from .adapter import ObservableDecoderRunner as _ObservableDecoderRunner
577
+
578
+ _plain = (num_sets == 0) or (algo == "plain")
579
+
580
+ # Build Min-sum and Relay config dicts mirroring the Rust bindings, but
581
+ # with iteration limits reduced to 1 for warmup.
582
+ # Note: Preserve alpha value even when _plain=True (num_sets=0) to ensure
583
+ # warmup decoder uses same configuration as actual decoder for autotuning cache.
584
+ msl_config = {
585
+ "error_priors": check_matrices.error_priors,
586
+ "alpha": None if (algo == "plain") else (alpha if alpha not in (None, 0.0) else 0.0),
587
+ "gamma0": (0.0 if _plain else (gamma0 or 0.0)),
588
+ }
589
+ relay_config = {
590
+ "pre_iter": 1,
591
+ "num_sets": 0 if _plain else num_sets,
592
+ "set_max_iter": 0 if _plain else 1,
593
+ "gamma_dist_interval": (0.0, 0.0) if _plain else (gamma_dist_interval or (0.0, 0.0)),
594
+ "stop_nconv": 1 if _plain else max(1, int(stop_nconv)),
595
+ "logging": False,
596
+ "seed": int(seed or 0),
597
+ }
598
+ backend_config = {
599
+ "dtype": dtype,
600
+ "perf": perf,
601
+ "device": device,
602
+ "algo": ("plain" if _plain else "relay"),
603
+ }
604
+
605
+ dec = _RelayDecoder(
606
+ check_matrices.check_matrix,
607
+ msl_config=msl_config,
608
+ relay_config=relay_config,
609
+ data_type="float64" if dtype == "fp64" else "float32",
610
+ backend_config=backend_config,
611
+ )
612
+
613
+ observable_decoder = _ObservableDecoderRunner(
614
+ dec,
615
+ check_matrices.observables_matrix,
616
+ include_decode_result=True,
617
+ )
618
+ else:
619
+ import relay_bp as _rb
620
+
621
+ suffix = {"fp16": "F16", "fp32": "F32", "fp64": "F64"}.get(dtype)
622
+ if suffix is None:
623
+ raise ValueError("Unsupported dtype for rust warmup: " + str(dtype))
624
+ if algo == "plain":
625
+ minsum_name = f"MinSumBPDecoder{suffix}"
626
+ if not hasattr(_rb, minsum_name):
627
+ raise ValueError(f"Requested Rust plain decoder '{minsum_name}' not available")
628
+ MinSum = getattr(_rb, minsum_name)
629
+ dec = MinSum(
630
+ check_matrices.check_matrix,
631
+ error_priors=check_matrices.error_priors,
632
+ max_iter=1,
633
+ alpha=None,
634
+ gamma0=None,
635
+ )
636
+ else:
637
+ relay_name = f"RelayDecoder{suffix}"
638
+ if not hasattr(_rb, relay_name):
639
+ raise ValueError(f"Requested Rust relay decoder '{relay_name}' not available")
640
+ Relay = getattr(_rb, relay_name)
641
+ dec = Relay(
642
+ check_matrices.check_matrix,
643
+ error_priors=check_matrices.error_priors,
644
+ gamma0=(gamma0 or 0.0),
645
+ pre_iter=1,
646
+ num_sets=max(1, int(num_sets)),
647
+ set_max_iter=1,
648
+ gamma_dist_interval=(gamma_dist_interval or (0.0, 0.0)),
649
+ stop_nconv=max(1, int(stop_nconv)),
650
+ seed=int(seed or 0),
651
+ alpha=(alpha if alpha not in (None, 0.0) else 0.0),
652
+ beta=beta,
653
+ )
654
+ from relay_bp import ObservableDecoderRunner as _ObservableDecoderRunner
655
+
656
+ observable_decoder = _ObservableDecoderRunner(
657
+ dec,
658
+ check_matrices.observables_matrix,
659
+ include_decode_result=True,
660
+ )
661
+
662
+ total_start = time.perf_counter()
663
+ print(
664
+ f"[warmup] starting (repeats={int(repeats)}, batch={int(batch)}, backend={backend}, algo={algo}, perf={perf}, dtype={dtype})"
665
+ )
666
+ for i in range(int(repeats)):
667
+ rep_start = time.perf_counter()
668
+ _det, _obs, _warm_errors = sampler.sample(int(batch), return_errors=True)
669
+ _ = observable_decoder.from_errors_decode_observables_detailed_batch(
670
+ _warm_errors.astype(np.uint8, copy=False), parallel=parallel
671
+ )
672
+ rep_end = time.perf_counter()
673
+ ms = (rep_end - rep_start) * 1e3
674
+ print(f"[warmup] repeat {i + 1}/{int(repeats)} took {ms:.3f} ms")
675
+ total_end = time.perf_counter()
676
+ total_ms = (total_end - total_start) * 1e3
677
+ print(f"[warmup] finished in {total_ms:.3f} ms")
678
+
679
+
680
+ def load_explicit_gammas(
681
+ explicit_gammas: Union[str, np.ndarray, None],
682
+ num_variables: int,
683
+ backend: str = "triton",
684
+ num_sets: Optional[int] = None,
685
+ mode: str = "relay",
686
+ ) -> Optional[np.ndarray]:
687
+ """Load and validate explicit gamma values for Relay-BP.
688
+
689
+ This function loads explicit gamma values from a file or numpy array,
690
+ validates the shape, and converts to the appropriate dtype for the backend.
691
+
692
+ Args:
693
+ explicit_gammas: File path (str), numpy array, or None
694
+ num_variables: Number of variables (V) in the code (for validation)
695
+ backend: Backend type ("rust" or "triton") - determines dtype conversion
696
+ num_sets: Number of relay sets (for warning if K < num_sets)
697
+ mode: Decoding mode ("relay" or "plain") - only used for warnings
698
+
699
+ Returns:
700
+ numpy array of shape (K, V) with dtype float64 (rust) or float32 (triton),
701
+ or None if explicit_gammas is None
702
+
703
+ Raises:
704
+ FileNotFoundError: If file path doesn't exist
705
+ ValueError: If file format is unsupported or shape is invalid
706
+ TypeError: If explicit_gammas is not str, np.ndarray, or None
707
+ """
708
+ if explicit_gammas is None:
709
+ return None
710
+
711
+ explicit_gammas_array = None
712
+
713
+ if isinstance(explicit_gammas, str):
714
+ # Load from file
715
+ gamma_path = Path(explicit_gammas)
716
+ if not gamma_path.exists():
717
+ raise FileNotFoundError(f"Explicit gammas file not found: {explicit_gammas}")
718
+
719
+ if gamma_path.suffix == ".npy":
720
+ explicit_gammas_array = np.load(gamma_path)
721
+ elif gamma_path.suffix == ".npz":
722
+ # For .npz, try common keys or use first array
723
+ data = np.load(gamma_path)
724
+ if "gammas" in data:
725
+ explicit_gammas_array = data["gammas"]
726
+ elif "arr_0" in data:
727
+ explicit_gammas_array = data["arr_0"]
728
+ else:
729
+ keys = list(data.keys())
730
+ if keys:
731
+ explicit_gammas_array = data[keys[0]]
732
+ else:
733
+ raise ValueError(f"No arrays found in .npz file: {explicit_gammas}")
734
+ data.close()
735
+ elif gamma_path.suffix == ".csv":
736
+ try:
737
+ import pandas as pd
738
+
739
+ explicit_gammas_array = pd.read_csv(gamma_path, header=None).values
740
+ except ImportError:
741
+ raise ImportError(
742
+ "pandas is required to load .csv files. Install with: pip install pandas"
743
+ )
744
+ else:
745
+ raise ValueError(
746
+ f"Unsupported file format for explicit gammas: {gamma_path.suffix}. Use .npy, .npz, or .csv"
747
+ )
748
+
749
+ log.info(
750
+ f"Loaded explicit gammas from '{explicit_gammas}': shape {explicit_gammas_array.shape}"
751
+ )
752
+ elif isinstance(explicit_gammas, np.ndarray):
753
+ explicit_gammas_array = explicit_gammas
754
+ else:
755
+ raise TypeError(
756
+ f"explicit_gammas must be a file path (str) or numpy array, got {type(explicit_gammas)}"
757
+ )
758
+
759
+ # Validate shape: must be (K, V) where V matches number of variables
760
+ if explicit_gammas_array.ndim != 2:
761
+ raise ValueError(
762
+ f"explicit_gammas must be 2D array (K, V), got shape {explicit_gammas_array.shape}"
763
+ )
764
+ if explicit_gammas_array.shape[1] != num_variables:
765
+ raise ValueError(
766
+ f"explicit_gammas must have V={num_variables} columns (number of variables), "
767
+ f"got {explicit_gammas_array.shape[1]}"
768
+ )
769
+
770
+ K = explicit_gammas_array.shape[0]
771
+ if mode == "relay" and num_sets is not None and num_sets > 0 and K < num_sets:
772
+ log.warning(
773
+ f"Number of gamma sets ({K}) is less than num_sets ({num_sets}). "
774
+ f"Gammas will cycle (relay leg l uses row (l + 1) % {K})."
775
+ )
776
+
777
+ # Convert to appropriate dtype for backend
778
+ if backend == "rust":
779
+ explicit_gammas_array = explicit_gammas_array.astype(np.float64)
780
+ else:
781
+ explicit_gammas_array = explicit_gammas_array.astype(np.float32)
782
+
783
+ return explicit_gammas_array
784
+
785
+
786
+ def load_gamma_dist_by_error(
787
+ gamma_dist_by_error: Union[str, np.ndarray, None],
788
+ num_variables: int,
789
+ ) -> Optional[np.ndarray]:
790
+ """Load and validate per-variable gamma intervals for Relay-BP.
791
+
792
+ Returns a (V, 2) array of [low, high] intervals per variable.
793
+ """
794
+ if gamma_dist_by_error is None:
795
+ return None
796
+
797
+ arr: Optional[np.ndarray] = None
798
+
799
+ if isinstance(gamma_dist_by_error, str):
800
+ gamma_path = Path(gamma_dist_by_error)
801
+ if not gamma_path.exists():
802
+ raise FileNotFoundError(f"gamma_dist_by_error file not found: {gamma_dist_by_error}")
803
+
804
+ if gamma_path.suffix == ".npy":
805
+ arr = np.load(gamma_path)
806
+ elif gamma_path.suffix == ".npz":
807
+ data = np.load(gamma_path)
808
+ if "gammas" in data:
809
+ arr = data["gammas"]
810
+ elif "arr_0" in data:
811
+ arr = data["arr_0"]
812
+ else:
813
+ keys = list(data.keys())
814
+ if keys:
815
+ arr = data[keys[0]]
816
+ else:
817
+ raise ValueError(f"No arrays found in .npz file: {gamma_dist_by_error}")
818
+ data.close()
819
+ elif gamma_path.suffix == ".csv":
820
+ try:
821
+ import pandas as pd
822
+
823
+ arr = pd.read_csv(gamma_path, header=None).values
824
+ except ImportError:
825
+ raise ImportError(
826
+ "pandas is required to load .csv files for gamma_dist_by_error. "
827
+ "Install with: pip install pandas"
828
+ )
829
+ else:
830
+ raise ValueError(
831
+ f"Unsupported file format for gamma_dist_by_error: {gamma_path.suffix}. "
832
+ "Use .npy, .npz, or .csv"
833
+ )
834
+
835
+ log.info(f"Loaded gamma_dist_by_error from '{gamma_dist_by_error}': shape {arr.shape}")
836
+ elif isinstance(gamma_dist_by_error, np.ndarray):
837
+ arr = gamma_dist_by_error
838
+ else:
839
+ raise TypeError(
840
+ f"gamma_dist_by_error must be a file path (str) or numpy array, got {type(gamma_dist_by_error)}"
841
+ )
842
+
843
+ # Validate shape: (V, 2)
844
+ if arr.ndim != 2 or arr.shape[0] != num_variables or arr.shape[1] != 2:
845
+ raise ValueError(
846
+ f"gamma_dist_by_error must have shape (V={num_variables}, 2), got {arr.shape}"
847
+ )
848
+ if np.any(arr[:, 0] > arr[:, 1]):
849
+ raise ValueError("gamma_dist_by_error: lower bounds must be <= upper bounds in each row")
850
+
851
+ # Use float32 for compatibility with Triton; Rust bindings will up-cast as needed.
852
+ return arr.astype(np.float32)
853
+
854
+
855
+ def format_batch_per_worker(batch: int, num_workers: int) -> Optional[str]:
856
+ """Format batch distribution per worker as 'X' or 'X-Y'.
857
+
858
+ This helper is used to report how a batch of size B is split across
859
+ a given number of workers (GPUs or CPU cores), without encoding any
860
+ backend-specific logic. It returns:
861
+
862
+ - None if num_workers <= 0
863
+ - 'X' if B is evenly divisible by num_workers
864
+ - 'X-Y' if not divisible, indicating some workers process X and some X+1
865
+ """
866
+ if num_workers <= 0:
867
+ return None
868
+ base = batch // num_workers
869
+ remainder = batch % num_workers
870
+ if remainder == 0:
871
+ return str(base)
872
+ return f"{base}-{base + 1}"
873
+
874
+
875
+ def format_batch_per_device_from_decoder(batch: int, decoder) -> Optional[str]:
876
+ """Infer device count from decoder (GPUs) and format batch per device."""
877
+ if decoder is None:
878
+ return None
879
+ try:
880
+ if hasattr(decoder, "_dec") and hasattr(decoder._dec, "num_gpus"):
881
+ num_gpus = int(decoder._dec.num_gpus)
882
+ else:
883
+ num_gpus = 1
884
+ except Exception:
885
+ num_gpus = 1
886
+ return format_batch_per_worker(batch, num_gpus)
887
+
888
+
889
+ def has_rust() -> bool:
890
+ """Return True when the Rust ``relay_bp`` reference decoder is importable.
891
+
892
+ The Rust implementation is an optional dependency. Callers gate on this
893
+ rather than catching ImportError at each use site.
894
+ """
895
+ try:
896
+ import relay_bp # noqa: F401
897
+ except Exception:
898
+ return False
899
+ return True
900
+
901
+
902
+ def compare_with_rust(
903
+ H_csr,
904
+ error_priors: np.ndarray,
905
+ syndromes: np.ndarray,
906
+ **decoder_kwargs,
907
+ ) -> dict:
908
+ """Decode ``syndromes`` with both backends and compare the results.
909
+
910
+ Both decoders receive the same check matrix, priors and configuration, so a
911
+ disagreement reflects a real difference rather than a setup skew.
912
+
913
+ Pass every parameter that affects gamma sampling explicitly — in particular
914
+ ``gamma_dist_interval`` alongside ``gamma0`` and ``seed``. If one is left to
915
+ a default the two implementations may pick different disordered memory
916
+ strengths, and the comparison will report a mismatch that is a
917
+ configuration difference rather than a decoder disagreement.
918
+
919
+ Args:
920
+ H_csr: Check matrix (C x V) in CSR format.
921
+ error_priors: [V] error probabilities in (0, 0.5).
922
+ syndromes: [B, C] uint8 syndromes. The Triton decoder requires a
923
+ torch tensor on its device; a numpy array is accepted here and
924
+ converted for the Rust side.
925
+ **decoder_kwargs: Forwarded to both decoders. ``data_type`` selects the
926
+ Rust precision variant ("float16" / "float32" / "float64").
927
+
928
+ Returns:
929
+ A dict with ``agree``, ``mismatched_shots``, ``total_shots``,
930
+ ``triton_e_hat`` and ``rust_e_hat``.
931
+
932
+ Raises:
933
+ RuntimeError: if the Rust decoder is not installed, or the installed
934
+ build lacks the precision variant requested.
935
+ ValueError: on an unsupported ``data_type``.
936
+ """
937
+ if not has_rust():
938
+ raise RuntimeError(
939
+ "compare_with_rust requires the Rust reference decoder. "
940
+ "Install it with the 'rust' extra."
941
+ )
942
+
943
+ import relay_bp as _rb
944
+
945
+ from .decoder import RelayBPDecoder as _TritonDecoder
946
+
947
+ dtype = str(decoder_kwargs.get("data_type", "float32"))
948
+ suffix = {"float16": "F16", "float32": "F32", "float64": "F64"}.get(dtype)
949
+ if suffix is None:
950
+ raise ValueError(f"unsupported data_type for comparison: {dtype!r}")
951
+
952
+ rust_cls_name = f"RelayDecoder{suffix}"
953
+ if not hasattr(_rb, rust_cls_name):
954
+ raise RuntimeError(f"the installed relay_bp has no {rust_cls_name}")
955
+
956
+ triton_out = _TritonDecoder(
957
+ H_csr=H_csr, error_priors=error_priors, **decoder_kwargs
958
+ ).decode(syndromes)
959
+ _triton_raw = triton_out["errors"] if isinstance(triton_out, dict) else triton_out
960
+ # The Triton decoder returns device tensors; bring them to host for comparison.
961
+ triton_e_hat = (
962
+ _triton_raw.cpu().numpy() if hasattr(_triton_raw, "cpu") else np.asarray(_triton_raw)
963
+ )
964
+
965
+ _RUST_KEYS = (
966
+ "pre_iter",
967
+ "num_sets",
968
+ "set_max_iter",
969
+ "gamma0",
970
+ "gamma_dist_interval",
971
+ "stop_nconv",
972
+ "normalized_min_sum_alpha",
973
+ "offset_min_sum_beta",
974
+ "seed",
975
+ )
976
+ rust_kwargs = {k: v for k, v in decoder_kwargs.items() if k in _RUST_KEYS}
977
+ rust_decoder = getattr(_rb, rust_cls_name)(
978
+ H_csr, error_priors=error_priors, **rust_kwargs
979
+ )
980
+ # The Rust decoder is a CPU implementation and needs host-side numpy, while
981
+ # the Triton decoder needs a device tensor. Accept either input form.
982
+ if hasattr(syndromes, "cpu"):
983
+ syndromes_np = syndromes.cpu().numpy()
984
+ else:
985
+ syndromes_np = np.asarray(syndromes)
986
+ rust_e_hat = np.asarray(rust_decoder.decode_batch(syndromes_np))
987
+
988
+ mismatched = int(np.count_nonzero((triton_e_hat != rust_e_hat).any(axis=-1)))
989
+ return {
990
+ "agree": mismatched == 0,
991
+ "mismatched_shots": mismatched,
992
+ "total_shots": int(triton_e_hat.shape[0]),
993
+ "triton_e_hat": triton_e_hat,
994
+ "rust_e_hat": rust_e_hat,
995
+ }