Determinism¶
Byte-exact reproducibility is a first-class invariant. A (global_seed, shard_id, sample_idx, emitter_idx, rx_idx, stage) tuple uniquely identifies an RNG draw; re-running with the same config and seeds produces byte-identical IQ and structurally identical metadata.
This page is normative. Implementations must follow this seed-derivation flow exactly.
Canonical attestation statements¶
Layer 14 extends the byte-identity boundary to signed provenance. Before SigstoreDSSEAdapter signs or accepts an in-toto Statement v1, it requires the supplied UTF-8 payload bytes to equal their RFC 8785 canonical JSON representation. Whitespace, key-order, or other semantically equivalent alternate encodings are rejected. The resulting signature therefore binds one statement byte representation, not only one parsed JSON value. See Attestation for the identity-policy and signature checks.
Why determinism matters¶
Need |
Property |
|---|---|
Distributed generation |
Spark workers can re-run failed shards without coordination |
Partial recomputation |
Resume after interruption skips already-written shards |
Bug investigation |
Reproduce a single sample on a laptop from |
Differential testing |
Compare two implementation revisions byte-by-byte over the same dataset |
CI smoke tests |
Release gating on bit-identical reproduction of a baseline dataset |
The only operations we accept as non-deterministic:
LLM completions (Phase 2). Prompts are deterministic; the LLM is not. We measure stability via PAES on the verifier subset.
GPU floating-point reductions. Sionna RT / PHY realizations may differ across devices due to backend reduction order; we treat small amplitude differences as below the relevant noise floor. For strict CI checks, use the deterministic controls provided by the active backend.
Everything else is byte-exact.
Public surface¶
The rfgen.rng module exports exactly three callables:
from rfgen.rng import derive_rng, seed_for, torch_to_numpy_rng
derive_rng(parent, *path) splits a parent torch.Generator into a child generator deterministically. seed_for(global_seed, shard_id, sample_idx, ...) collapses the canonical hierarchy into a 64-bit integer. torch_to_numpy_rng(rng) bridges a torch generator into a local NumPy generator without touching global state.
The shipped planner class used by orchestration is SeedSchedule, defined in rfgen.rng and re-exported from rfgen.orchestration for backward compatibility. Earlier draft helpers (derive_shard_seed, derive_sample_seed, split_for_layer, _hash_int64) are not part of the contract.
derive_rng¶
def derive_rng(parent: torch.Generator, *path: int | str) -> torch.Generator
Returns a fresh torch.Generator whose seed is a deterministic function of parent.initial_seed() and path. Identical (parent_seed, path) inputs always return generators that emit identical streams. The child runs on the same device as parent (CPU or CUDA).
Path components may be int or str and are mixed freely. Strings are canonicalized to a stable 64-bit prefix before entering the split (see How the split works).
seed_for¶
def seed_for(
global_seed: int,
shard_id: str,
sample_idx: int,
emitter_idx: int = 0,
rx_idx: int = 0,
stage: str | int = 0,
) -> int
Returns a deterministic non-negative 63-bit integer (suitable for any signed-int64 seed slot) derived from the canonical 5-component hierarchy (shard_id, sample_idx, emitter_idx, rx_idx, stage) rooted at global_seed. The 3-argument call shape seed_for(global_seed, shard_id, sample_idx) still works because emitter_idx, rx_idx, and stage default to 0.
stage accepts either a string (e.g., "tx", "channel", "post_sum") or an integer; both are canonicalized to a 64-bit prefix before the split.
torch_to_numpy_rng¶
def torch_to_numpy_rng(rng: torch.Generator) -> np.random.Generator
Returns a fresh local numpy.random.Generator deterministically derived from rng. The helper draws one seed from the inclusive range [0, 2**63 - 1] by combining two torch.randint(low=0, high=2**32, ...) 32-bit words and clearing the top bit. This preserves the framework-wide signed-int64 seed width while keeping NumPy and SciPy consumers on the same per-sample RNG path as torch-backed code.
The seed hierarchy¶
global_seed (int, from config.run.seed)
↓
shard_id (str) ← content-addressed shard identifier
↓
sample_idx (int) ← 0-based index within the shard
↓
emitter_idx (int) ← 0-based emitter index within the sample
↓
rx_idx (int) ← 0-based receiver index within the sample
↓
stage (str | int) ← pipeline stage tag: "tx", "channel", "post_sum", ...
↓
seed_for(...) → int63 ← consumed by torch.Generator / numpy.Generator
Every layer that draws randomness gets its own independent stream. Two draws with different (emitter_idx, rx_idx, stage) paths cannot collide.
How the split works¶
The hierarchical split is delegated to numpy.random.SeedSequence.spawn: the canonical, tested, collision-free hierarchical split shipped by NumPy. The collision-free guarantee inherits from NumPy’s documented SeedSequence.spawn design (see the NumPy _seed_sequence.pyx source and its public docs).
import numpy as np
from rfgen.rng import seed_for
seed = seed_for(
global_seed=1337,
shard_id="cfg-abcdef0123456789-shard-000042",
sample_idx=17,
emitter_idx=2,
rx_idx=0,
stage="channel",
)
# seed is a non-negative int that fits in a signed 64-bit slot
hashlib.sha256 is used only to canonicalize string-valued inputs (e.g., shard_id, a string-valued stage) into a stable 64-bit integer prefix consumed by SeedSequence. It is never used to perform the split itself.
Two draft helpers from earlier revisions are gone: there is no BLAKE2b-based _hash_int64, and there are no derive_shard_seed / derive_sample_seed / split_for_layer functions. All of that responsibility now sits behind seed_for and SeedSequence.spawn.
Global-state isolation¶
The rfgen.rng module never reads from random or numpy.random global state, and never seeds them either. Every RNG it produces is a local object: a torch.Generator from derive_rng, or an integer seed from seed_for that the caller passes into a freshly constructed RNG.
A contract test snapshots both global states (Python stdlib random.getstate() and numpy.random.get_state()) before and after derive_rng / seed_for calls and asserts they are unchanged.
Collision contract¶
seed_for is collision-free across the documented hierarchy in the practical regime the framework operates in. The contract test draws N = 10**6 random paths from the hierarchy (shard_id, sample_idx, emitter_idx, rx_idx, stage), with each component sampled uniformly from a documented integer range, calls seed_for(...) for each, and asserts len(set(seeds)) == N (zero collisions in 1M draws).
The test is gated behind @pytest.mark.slow and runs in nightly CI rather than on every PR. The stronger >2^32 claim is not asserted by exhaustive enumeration; it cites NumPy’s SeedSequence documentation.
Round-trip contract¶
Running twice from seed_for(...) produces byte-identical 1024-sample float32 IQ tensors:
from rfgen.rng import seed_for
import torch
def draw_iq(seed: int) -> torch.Tensor:
g = torch.Generator().manual_seed(seed)
return torch.randn(1024, generator=g, dtype=torch.float32)
s = seed_for(global_seed=1337, shard_id="shard-0", sample_idx=0)
assert torch.equal(draw_iq(s), draw_iq(s))
This holds modulo the documented backend nondeterminism caveats for Sionna and GPU reductions (see GPU non-determinism caveat below).
Per-layer RNG flow¶
A complete sample lifecycle, showing every RNG creation:
from rfgen.rng import derive_rng, seed_for
def generate_sample(global_seed: int, shard_id: str, sample_idx: int,
config: GenerationConfig) -> Record:
sample_seed = seed_for(global_seed, shard_id, sample_idx)
sample_rng = torch.Generator().manual_seed(sample_seed)
component_signals = []
for slot_idx in range(num_emitters):
slot_rng = derive_rng(sample_rng, "slot", slot_idx)
# Per-slot emitter selection, device draw, waveform synthesis, and
# placement all branch off the slot-local child stream.
emitter_rng = derive_rng(slot_rng, "emit")
tx_rng = derive_rng(slot_rng, "tx")
freq_rng = derive_rng(slot_rng, "freq")
time_rng = derive_rng(slot_rng, "time")
signal = compose_one_slot(
emitter_rng=emitter_rng,
tx_rng=tx_rng,
freq_rng=freq_rng,
time_rng=time_rng,
)
for rx_idx in range(num_rx):
channel_rng = derive_rng(slot_rng, "channel", rx_idx)
signal = channel.apply(signal, rng=channel_rng, params=...)
component_signals.append(signal)
# Sum + per-receiver post-sum channel stages
for rx_idx in range(num_rx):
rx_rng = derive_rng(sample_rng, "rx", rx_idx)
scene_prop_rng = derive_rng(rx_rng, "scene_prop")
post_sum_rng = derive_rng(rx_rng, "post_sum")
scene_signal = apply_rx_post_sum(
component_signals,
scene_prop_rng=scene_prop_rng,
post_sum_rng=post_sum_rng,
)
# Labeling: pure, no RNG
labels = labeler.label(scene_signal, config.scene)
return Record(iq=scene_signal.iq, ..., labels=labels)
Key invariants:
Every child stream is named and positional: slot-local work hangs off
derive_rng(sample_rng, "slot", slot_idx), then branches again by"emit","tx","freq","time", and"channel", rx_idx.Post-sum receiver work hangs off
derive_rng(sample_rng, "rx", rx_idx), then branches into"scene_prop"and"post_sum".Re-ordering emitters within a scene would break determinism because
slot_idxis positional. The composer stabilizes that order after placement.Labeler is pure: same
(scene_signal, scene)produces same labels, no RNG.
RNG type per layer¶
Layer |
RNG type |
Why |
|---|---|---|
Scene composer |
|
NumPy is faster for the scalar / small-array draws (slot count, class samples, placements), and the child-derivation path keeps the parent torch stream position unchanged |
Emitter |
|
Most emitter waveforms run on torch (TorchSig); torch RNG matches |
Channel |
|
Sionna accepts an int seed; we pass it through |
Labeler |
none |
Pure function |
A layer that needs a NumPy Generator but must not advance the parent torch stream derives a child first, then seeds NumPy from that child’s initial_seed():
import numpy as np
scene_rng = derive_rng(sample_rng, "scene")
np_rng = np.random.default_rng(int(scene_rng.initial_seed()))
Use torch_to_numpy_rng(...) only for the consuming bridge case, where advancing an already-derived torch generator is part of the contract:
channel_rng = derive_rng(sample_rng, "channel", emitter_idx)
np_rng = torch_to_numpy_rng(channel_rng)
What breaks determinism¶
These pitfalls produce non-deterministic output despite correct seeding. The framework guards against each:
Pitfall |
Guard |
|---|---|
Python |
Use |
|
Python 3.7+ guarantees insertion order; we still sort dict iteration in seed-sensitive paths |
Multi-threaded RNG sharing |
Each layer creates its own RNG; never share |
GPU floating-point reductions |
Set |
|
Banned in |
|
Banned; lint check rejects |
Hash-based code with |
Set |
Reading from |
The |
Reproducibility contract¶
Run-manifest provenance¶
rfgen.manifest defines the versioned run-manifest contract used to bind a
reproduction attempt to its inputs. It is separate from the per-asset CBOR
manifest described in Storage Layout. A major
one run manifest is an RFC 8785 canonical JSON envelope with this shape:
{
"schema": {"name": "rfgen.run-manifest", "major": 1, "minor": 0},
"payload": {"...": "strict major-one fields"},
"extensions": {"...": "optional forward-compatible JSON values"}
}
RunManifestPayloadV1 fields
root_seed, seed_schedule_id, resolved_config_canonical_json, and
resolved_config_sha256 identify the deterministic input path. The
resolved configuration is UTF-8 RFC 8785 JSON bytes, represented as a JSON
string on the wire; its digest is SHA-256 of those exact bytes. The payload
also records the run and dataset UUIDs, UTC creation and update timestamps,
source revision, sorted package and plugin provenance, asset provenance,
ordered output objects, optional annotation, audit, and parent revisions, and
the manifest lifecycle status.
manifest_sha256 is SHA-256 of RFC 8785 canonical bytes for the
payload with only manifest_sha256 omitted. Thus a reader recomputes the
digest after validating all known fields, rather than hashing the received
whitespace or object-member order. Package and plugin entries are ordered by
(normalized_name, version, entry_point); output objects are ordered by the
UTF-8 bytes of key. Invalid ordering, digest mismatch, noncanonical resolved
configuration, invalid SPDX asset licensing, and invalid lifecycle values are
validation failures.
Major-one payload fields are closed. parse_manifest rejects unknown payload fields and an unsupported major
version, while preserving unknown envelope members and extensions as JSON
semantic trees for a newer producer. It preserves value kind, array order, and
object members, not insignificant JSON whitespace or member order. A newer
minor version requires that preservation; callers that set
preserve_unknown=False reject it and any forward-compatible envelope data.
RFC 8785 values outside the supported JSON number domain also fail parsing.
VerificationReportV1 records
the revision, requested verification mode, UTC start/end times, ordered
VerificationCheckV1 rows,
and exact pass/fail/skip counts. Rows sort by (scope, key, code) and use the
closed CheckCode and CheckStatus vocabularies. The packaged JSON Schemas
for the run manifest and verification report let non-Python consumers validate
the wire structure.
ManifestVerifier is
read-only. METADATA validates the revision and schema while marking object
checks as not requested. CHECKSUMS streams every declared object in 8 MiB
chunks and records every missing or digest-mismatched object. REPRODUCE
includes those checks and asks an injected generation-runtime capability to
regenerate the 32 sample IDs with the smallest
sha256(run_id || sample_id) values. Each reproduction result must provide
fresh-process evidence; integer IQ is exact, while floating IQ requires both
maximum absolute and relative-RMS error at most 1e-6. Metadata, resolved
config, asset and plugin hashes, and sample IDs compare exactly. A missing
requested capability fails closed rather than being treated as a skip.
When callers configure a persisted DSSE bundle, the verifier only loads it and
uses the Layer 14 SigstoreDSSEAdapter with the RUN_MANIFEST_V1 policy. It
does not sign, write a bundle, or republish the manifest.
Layers 12 and 13 define canonical provenance and local publication. Layer 15 defines the typed verification service; it does not define a generation CLI. Text annotations (Phase 2) are not bit-identical because LLM sampling is non-deterministic; the prompts are.
GPU non-determinism caveat¶
Sionna RT and Sionna PHY use GPU operators that may produce non-deterministic results due to floating-point reduction order:
parallel reductions and matrix multiplies on GPU produce ULP-level differences across runs
cuDNNalgorithm autotuning means kernel choice varies
For strict reproducibility:
import torch
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
This forces deterministic kernels at a 10 to 30 percent runtime cost. The framework enables this in CI and in the Spark image’s smoke-test mode but leaves it off in bulk-generation mode (the amplitude tolerance is below the relevant noise floor).
See Also¶
Reference / API / Core Types: the
RecordandSignaltypes whose IQ tensors must round-trip byte-identically.Reference / API / Manifest: typed run-manifest parsing, canonicalization, and report contracts.
Reference / API / Manifest Verification: read-only verification modes and injected capability boundaries.
Reference / Storage Layout § Naming and idempotency: how
shard_idis computed.Reference / Metrics § Reproducibility CI smoke test: the byte-diff release gate.
Reference / Algorithms / Fingerprint Math § Per-device parameter draw: how device fingerprint draws consume
derive_rng.Reference / Quality / Contract Tests: the global-state-isolation and 1M-draw collision tests.