Python Part II · Module 26 · Regression Automation
Seeds and Reproducibility
A seed is the whole reason a constrained-random regression finds anything: the same test at a hundred seeds is a hundred different stimulus patterns. It is also the one value that makes a failure actionable, because without it the failure cannot be reproduced and becomes, by Chapter 25.7's route, something nobody believes. Those two jobs pull in opposite directions — a sweep wants seeds spread as widely as possible, and reproducibility wants every one of them written down — and the usual one-line expression for generating them quietly takes a position on a question most people have never measured.
Intermediate18 min readPythonPart IIAutomationRegressionVerification Flow
Part II · Module 26 · Chapter 26.3 · Seeds and Reproducibility
1. What a Seed Has to Do
JOBS = [
("widen the search", "a hundred seeds are a hundred stimulus patterns"),
("pin a failure", "the same seed reproduces the same stimulus exactly"),
]
for what, why in JOBS:
print(f"{what:<20} {why}")
print()
print("these pull in opposite directions:")
print(" widening wants seeds nobody chose")
print(" pinning wants every seed written down")
print()
print("the resolution:", "choose them unpredictably ONCE, then record them forever")widen the search a hundred seeds are a hundred stimulus patterns
pin a failure the same seed reproduces the same stimulus exactly
these pull in opposite directions:
widening wants seeds nobody chose
pinning wants every seed written down
the resolution: choose them unpredictably ONCE, then record them foreverThe last line is the whole discipline, and Chapter 25.4 Section 5 stated half of it: "random" means generated once and recorded, never generated again on each run. At regression scale the other half appears — the seeds are a list, and a list is much easier to regenerate by accident than a single value.
2. The Annotation
seeds = [base + i for i in range(n)] # recorded, not re-randomisedbase, n = 1839274, 5
seeds = [base + i for i in range(n)]
print("seeds:", seeds)
print()
print("same base and n, same list:", seeds == [base + i for i in range(n)])
print("one line reproduces all of them from two numbers:", (base, n))
print("so the record needs:", "base and n — not the list")seeds: [1839274, 1839275, 1839276, 1839277, 1839278]
same base and n, same list: True
one line reproduces all of them from two numbers: (1839274, 5)
so the record needs: base and n — not the listThe comment carries the rule and the expression carries a compression: two numbers reproduce the whole sweep, so a regression record can store base and n instead of a hundred integers, and a reader can tell at a glance that the seeds were not cherry-picked.
The expression also takes a position: the seeds are consecutive. That is worth checking rather than assuming.
3. Are Consecutive Seeds Safe?
import random
base = 1839274
firsts = [random.Random(base + i).randrange(1000) for i in range(12)]
print("first draw from 12 consecutive seeds:")
print(" ", firsts)
print()
print("strictly increasing :", firsts == sorted(firsts))
print("adjacent pairs within 10 :", sum(1 for a, b in zip(firsts, firsts[1:]) if abs(a - b) < 10))
print("distinct values :", len(set(firsts)), "of", len(firsts))first draw from 12 consecutive seeds:
[760, 941, 128, 23, 522, 783, 382, 966, 469, 194, 371, 469]
strictly increasing : False
adjacent pairs within 10 : 0
distinct values : 11 of 12No visible structure. A wider check:
import random
base = 1839274
buckets = [0] * 10
for i in range(10000):
buckets[random.Random(base + i).randrange(10)] += 1
print("10,000 consecutive seeds, first draw bucketed into 10:")
print(" ", buckets)
print()
print("minimum :", min(buckets))
print("maximum :", max(buckets))
print("all within 10% of 1000:", all(900 <= b <= 1100 for b in buckets))10,000 consecutive seeds, first draw bucketed into 10:
[956, 962, 1005, 1055, 1031, 1013, 1053, 1019, 935, 971]
minimum : 935
maximum : 1055
all within 10% of 1000: TrueFor Python's Mersenne Twister, consecutive seeds are fine — ten thousand of them fill ten buckets within a few percent of uniform.
Now the same experiment against a deliberately weak generator, written out here so there is no doubt what it is:
def weak_lcg(seed):
"""A 16-bit linear congruential generator. Deliberately poor."""
return (seed * 1103515245 + 12345) & 0xFFFF
base = 1839274
firsts = [weak_lcg(base + i) % 1000 for i in range(12)]
print("first draw from 12 consecutive seeds:")
print(" ", firsts)
print()
print("adjacent differences:", [b - a for a, b in zip(firsts, firsts[1:])])
print("how many are exactly +77:", sum(1 for a, b in zip(firsts, firsts[1:]) if b - a == 77))first draw from 12 consecutive seeds:
[275, 352, 429, 506, 47, 124, 201, 742, 819, 896, 437, 514]
adjacent differences: [77, 77, 77, -459, 77, 77, 541, 77, 77, -459, 77]
how many are exactly +77: 8Eight of eleven adjacent pairs differ by exactly 77. The "sweep" is walking in a straight line, and whatever the stimulus is a function of, it is being sampled along one direction instead of scattered.
So neither folklore is right. "Consecutive seeds are fine" is true of Python's generator and measured here; "never use consecutive seeds" is superstition applied to a generator that does not have the problem. The useful statement is the uncomfortable one:
The answer depends on a generator you cannot inspect. Your simulator's constraint solver is not Python's
random, how it consumes the seed is not documented at this level, and no experiment you can run from outside settles it.
Nothing above says anything about any real simulator — none is installed here, and the LCG is a weak generator written in the example to make the failure visible. What the pair of measurements establishes is that the question is real, which is enough to justify a defence that costs nothing.
4. Two Cheap Defences
import hashlib
base, n = 1839274, 12
consecutive = [base + i for i in range(n)]
spaced = [base + i * 104729 for i in range(n)]
hashed = [int(hashlib.sha256(f"{base}:{i}".encode()).hexdigest()[:8], 16) for i in range(n)]
for label, seeds in (("consecutive", consecutive), ("spaced", spaced), ("hashed", hashed)):
print(f"{label:<12} first three: {seeds[:3]}")
print(f"{'':<12} reproducible from (base, n): {seeds == type(seeds)(seeds)}")
print()
print("all three are a pure function of (base, i):", True)
print("none of them consults the clock:", True)
print("hashed seeds fit in 32 bits:", all(s < 2**32 for s in hashed))consecutive first three: [1839274, 1839275, 1839276]
reproducible from (base, n): True
spaced first three: [1839274, 1944003, 2048732]
reproducible from (base, n): True
hashed first three: [1569488902, 3014295854, 2514021102]
reproducible from (base, n): True
all three are a pure function of (base, i): True
none of them consults the clock: True
hashed seeds fit in 32 bits: TrueSpacing multiplies the index by a large prime, so consecutive indices become distant seeds. It keeps the seeds human-sized and the arithmetic obvious.
Hashing turns (base, i) into something with no visible relationship to its neighbours, which is the strongest of the three and produces seeds nobody will recognise or type correctly.
Both remain a pure function of base and i, which is the property that actually matters: two numbers still reproduce the whole sweep, and a resumed regression regenerates exactly the same list. A defence that broke that would be worse than the problem.
Which to use is a judgement, and the honest ranking is: consecutive is fine until somebody shows otherwise, spacing costs one multiplication and removes the question, and hashing is for when seeds cross a tool boundary you do not control. What is not acceptable is choosing by habit and never writing down which you chose — the generator belongs in the record alongside base and n.
5. Base 0, Again
DEFAULT_BASE = 1839274
def seeds_wrong(base=None, n=3):
return [(base or DEFAULT_BASE) + i for i in range(n)]
def seeds_right(base=None, n=3):
if base is None:
base = DEFAULT_BASE
return [base + i for i in range(n)]
for base in (0, 7, None):
print(f"base={str(base):<6} `or` form -> {seeds_wrong(base)}")
print(f"{'':<11} `is None` -> {seeds_right(base)}")base=0 `or` form -> [1839274, 1839275, 1839276]
`is None` -> [0, 1, 2]
base=7 `or` form -> [7, 8, 9]
`is None` -> [7, 8, 9]
base=None `or` form -> [1839274, 1839275, 1839276]
`is None` -> [1839274, 1839275, 1839276]Chapter 25.4 Section 5's trap, now affecting a hundred seeds instead of one. base=0 is silently replaced, so a sweep somebody launched from base 0 to reproduce a reported range runs an entirely different range and reports that nothing failed.
The rule from that chapter holds exactly: or tests truthiness and is None tests absence, and 0 is a perfectly ordinary base.
6. Five Places a Seed Must Appear
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass(frozen=True)
class Job:
test: str
seed: int
@dataclass(frozen=True)
class Result:
test: str
seed: int
status: str
run_dir: Optional[str] = None
@property
def reproduce(self):
return f"flow run --test {self.test} --seed {self.seed} --waves"
job = Job("axi_burst", 1839276)
run_dir = Path("runs") / f"{job.test}_{job.seed}"
result = Result(job.test, job.seed, "FAIL", str(run_dir))
state = {job: "FAIL"}
PLACES = [
("the job", job.seed),
("the run directory", str(run_dir)),
("the result record", result.seed),
("the reproduce command", result.reproduce),
("the resume state key", next(iter(state)).seed),
]
for where, value in PLACES:
print(f"{where:<24} {value}")
print()
print("all five agree:", len({job.seed, result.seed, next(iter(state)).seed}) == 1)
print("and the directory name contains it:", str(job.seed) in str(run_dir))the job 1839276
the run directory runs/axi_burst_1839276
the result record 1839276
the reproduce command flow run --test axi_burst --seed 1839276 --waves
the resume state key 1839276
all five agree: True
and the directory name contains it: TrueFive places, one number, and every one of them is a separate opportunity to lose it.
Three of the five are worth a sentence. The run directory carries it because Chapter 23.7 Section 2 needs two runs of one test not to collide. The reproduce command carries it because Chapter 26.1 Section 7's second guarantee is otherwise unmeetable. The resume state key carries it because Chapter 26.8 identifies work by (test, seed) — and a resume that regenerates its seed list differently finds no matching keys and re-runs everything.
7. How Many Seeds
TESTS = 40
for seeds in (1, 5, 20, 100):
jobs = TESTS * seeds
print(f"{seeds:>4} seed(s) per test -> {jobs:>5} jobs")
print()
print("the cost is linear and the benefit is not:")
for seeds, found in ((1, 6), (5, 11), (20, 14), (100, 15)):
print(f" {seeds:>4} seeds: {found} distinct failures found, "
f"{found / seeds:.2f} per seed")
print()
print("those failure counts are illustrative, not measured here")
print("what IS general:", "cost is linear in seeds; distinct findings saturate") 1 seed(s) per test -> 40 jobs
5 seed(s) per test -> 200 jobs
20 seed(s) per test -> 800 jobs
100 seed(s) per test -> 4000 jobs
the cost is linear and the benefit is not:
1 seeds: 6 distinct failures found, 6.00 per seed
5 seeds: 11 distinct failures found, 2.20 per seed
20 seeds: 14 distinct failures found, 0.70 per seed
100 seeds: 15 distinct failures found, 0.15 per seed
those failure counts are illustrative, not measured here
what IS general: cost is linear in seeds; distinct findings saturateThe failure numbers are invented to show a shape — nobody can measure your design's saturation point from here. What is general is the shape itself: jobs grow linearly with seeds and distinct findings do not, so there is a point past which more seeds buy compute time and nothing else.
The practical consequences are two.
Different seed counts for different purposes. A smoke suite wants one seed per test and speed; a nightly wants enough to find seed-dependent bugs; a weekly soak wants many. Chapter 26.2's defaults.seeds with per-test overrides is exactly the mechanism.
Measure your own saturation. Count distinct (test, bucket) failures — Chapter 26.7's buckets — rather than raw failure counts, and plot it against seed count over a few weeks. The number where it flattens is your answer, and it is the only one that is not a guess.
8. Pinned Seeds: the Ones That Found Something
SWEEP = [1839274 + i for i in range(5)]
PINNED = {"axi_burst": [771002, 4410881], "mem_walk": [9]}
def seeds_for(test, sweep, pinned):
"""Pinned seeds always run; the sweep adds breadth on top."""
fixed = list(pinned.get(test, ()))
extra = [s for s in sweep if s not in fixed]
return fixed + extra
for test in ("axi_burst", "mem_walk", "axi_smoke"):
seeds = seeds_for(test, SWEEP, PINNED)
n_pinned = len(PINNED.get(test, ()))
print(f"{test:<11} {len(seeds)} seed(s): {n_pinned} pinned + {len(seeds) - n_pinned} swept")
print(f"{'':<11} {seeds}")
print()
print("pinned seeds run on EVERY regression:", True)
print("a pinned seed is :", "one that previously found a bug")
print("why it is pinned :", "to prove the fix stays fixed")axi_burst 7 seed(s): 2 pinned + 5 swept
[771002, 4410881, 1839274, 1839275, 1839276, 1839277, 1839278]
mem_walk 6 seed(s): 1 pinned + 5 swept
[9, 1839274, 1839275, 1839276, 1839277, 1839278]
axi_smoke 5 seed(s): 0 pinned + 5 swept
[1839274, 1839275, 1839276, 1839277, 1839278]
pinned seeds run on EVERY regression: True
a pinned seed is : one that previously found a bug
why it is pinned : to prove the fix stays fixedA sweep and a pin answer different questions, and a regression needs both.
The sweep looks for new bugs. Its seeds should be different every month — that is the point of widening — and no individual seed in it matters.
A pin re-runs a seed that already found one. Once a bug is fixed, the seed that exposed it is the cheapest possible regression test for that exact bug, and it must run every night regardless of what the sweep is doing. Those seeds are not reproducible from base and n; they are a curated list, and they belong in the suite file next to the test.
The ordering is deliberate: pinned seeds run first, so a regression cut short has already executed the known-interesting cases — Chapter 26.2 Section 4's argument about what a partial run should have covered, applied within one test.
SWEEP = [1839274 + i for i in range(3)]
PINNED = {"axi_burst": [771002, 1839275]}
fixed = PINNED["axi_burst"]
naive = fixed + SWEEP
deduped = fixed + [s for s in SWEEP if s not in fixed]
print("pinned :", fixed)
print("sweep :", SWEEP)
print()
print("naive concat:", naive, f"({len(naive)} jobs)")
print("deduplicated:", deduped, f"({len(deduped)} jobs)")
print("overlap :", sorted(set(fixed) & set(SWEEP)))
print()
print("what the duplicate would do:", "break Chapter 26.1's invariant, per 26.2 §6")pinned : [771002, 1839275]
sweep : [1839274, 1839275, 1839276]
naive concat: [771002, 1839275, 1839274, 1839275, 1839276] (5 jobs)
deduplicated: [771002, 1839275, 1839274, 1839276] (4 jobs)
overlap : [1839275]
what the duplicate would do: break Chapter 26.1's invariant, per 26.2 §6A pinned seed can collide with a swept one, and it will eventually: a sweep that walks consecutively from a moving base passes through every integer in its range sooner or later.
The duplicate is Chapter 26.2 Section 6's exactly — one (test, seed) job twice, total inflated by one, and the invariant failing with no job actually missing. Filtering the sweep against the pins is one comprehension and it is not optional.
9. The Sweep, Assembled
import hashlib
from dataclasses import dataclass
DEFAULT_BASE = 1839274
class SeedError(Exception):
"""A seed policy that cannot be reproduced."""
@dataclass(frozen=True)
class SeedPolicy:
base: int = DEFAULT_BASE
count: int = 1
mode: str = "consecutive"
def __post_init__(self):
if self.mode not in ("consecutive", "spaced", "hashed"):
raise SeedError(f"unknown seed mode {self.mode!r}")
if self.count < 1:
raise SeedError(f"count must be at least 1, got {self.count}")
if self.base < 0:
raise SeedError(f"base must not be negative, got {self.base}")
def seeds(self):
if self.mode == "consecutive":
return [self.base + i for i in range(self.count)]
if self.mode == "spaced":
return [self.base + i * 104729 for i in range(self.count)]
return [int(hashlib.sha256(f"{self.base}:{i}".encode()).hexdigest()[:8], 16)
for i in range(self.count)]
def resolve_base(base=None):
return (DEFAULT_BASE, "default") if base is None else (base, "given")
for mode in ("consecutive", "spaced", "hashed"):
policy = SeedPolicy(base=1839274, count=4, mode=mode)
print(f"{mode:<12} {policy.seeds()}")
print()
p = SeedPolicy(base=1839274, count=4)
print("reproducible from the policy alone:", p.seeds() == SeedPolicy(**vars(p)).seeds())
print("the record stores :", vars(p))
print()
for base in (0, None):
value, origin = resolve_base(base)
print(f"base={str(base):<6} -> {value} ({origin})")
for bad in ({"mode": "clever"}, {"count": 0}, {"base": -1}):
try:
SeedPolicy(**bad)
except SeedError as exc:
print("SeedError:", exc)consecutive [1839274, 1839275, 1839276, 1839277]
spaced [1839274, 1944003, 2048732, 2153461]
hashed [1569488902, 3014295854, 2514021102, 224613403]
reproducible from the policy alone: True
the record stores : {'base': 1839274, 'count': 4, 'mode': 'consecutive'}
base=0 -> 0 (given)
base=None -> 1839274 (default)
SeedError: unknown seed mode 'clever'
SeedError: count must be at least 1, got 0
SeedError: base must not be negative, got -1Four decisions, and the first is what makes the rest of the module work.
The policy is the record, not the seed list. Three fields reproduce any sweep, they fit in Chapter 24.6's environment record beside everything else, and a reader can see immediately that the seeds were generated rather than chosen.
The mode is stored. Section 4's ranking is a judgement, and the thing that makes it safe to change is recording which one a given regression used — otherwise Section 6's callout happens: the regeneration differs and the resume silently restarts.
base=0 survives and base=None defaults. Section 5, implemented rather than remembered.
Every invalid policy raises at construction. A typo'd mode would otherwise fall through to the hashed branch and produce a sweep nobody asked for, which is Chapter 25.1 Section 9's argument for validating a vocabulary.
10. Predict, Run, Observe
import hashlib
import random
B = 100
# A
print("A:", [B + i for i in range(4)])
# B
print("B:", [B + i * 104729 for i in range(3)])
# C
print("C:", [B + i for i in range(4)] == [B + i for i in range(4)])
# D
def weak(seed):
return (seed * 1103515245 + 12345) & 0xFFFF
draws = [weak(B + i) % 1000 for i in range(5)]
print("D:", draws[:4])
# E
print("E:", [b - a for a, b in zip(draws, draws[1:])])
# F
print("F:", len({random.Random(B + i).randrange(1000) for i in range(4)}))
# G
def or_form(base):
return base or 999
def none_form(base):
return 999 if base is None else base
print("G:", or_form(0), none_form(0))
# H
print("H:", int(hashlib.sha256(b"100:0").hexdigest()[:8], 16) < 2**32)The verified output:
A: [100, 101, 102, 103]
B: [100, 104829, 209558]
C: True
D: [965, 506, 583, 660]
E: [-459, 77, 77, -459]
F: 4
G: 999 0
H: TrueA — the annotation's expression, from a small base.
B — spacing turns consecutive indices into distant seeds.
C — all of them are pure functions of (base, i), so the list is reproducible.
D — the weak generator's first draws.
E — three of the four adjacent differences are exactly 77, with the wrap-around accounting for the rest. Section 3's structure, from a different base.
F — Python's generator gives four distinct values from four consecutive seeds.
G — or discards base 0; the explicit test keeps it.
H — a hashed seed truncated to eight hex digits fits in 32 bits, which most tools require.
11. Exercises
Exercise 1 — two jobs. Say what a seed does for a sweep and what it does for a failure, and why they conflict.
Exercise 2 — the resolution. State the discipline in one sentence.
Exercise 3 — two numbers. Show that base and n reproduce a whole sweep, and say what the record should therefore store.
Exercise 4 — measure your own. Run the ten-bucket uniformity check over ten thousand consecutive seeds of Python's generator.
Exercise 5 — a weak one. Reproduce the LCG result and report how many adjacent pairs differ by a constant.
Exercise 6 — the honest conclusion. Write two sentences explaining why neither piece of folklore is right.
Exercise 7 — what you cannot inspect. Say why no experiment you run from Python settles the question for your simulator.
Exercise 8 — spacing. Implement it and show consecutive indices producing distant seeds.
Exercise 9 — hashing. Implement it and confirm the seeds fit in 32 bits.
Exercise 10 — still pure. Say why a defence that broke reproducibility would be worse than the problem.
Exercise 11 — base 0. Show the or form discarding it, and say what the person reproducing a range concludes.
Exercise 12 — five places. List them for your flow and confirm all five carry the same number.
Exercise 13 — the silent restart. Describe how a changed seed generator makes a resume look like it worked.
Exercise 14 — saturation. Describe the experiment that would find your design's saturation point, and say what you would count.
Exercise 15 — per purpose. Choose seed counts for a smoke suite, a nightly and a weekly soak, and justify each.
Exercise 16 — the policy object. Implement SeedPolicy with all three modes and validation.
Exercise 17 — store the mode. Say what goes wrong if the mode is not recorded.
Exercise 18 — pins. List the seeds in your flow that previously found a bug. Say where they are recorded.
Exercise 19 — two questions. Distinguish what a sweep is for from what a pin is for, in one sentence each.
Exercise 20 — pins first. Say why a partial regression should have run the pinned seeds.
Exercise 21 — the collision. Construct a pin that a sweep passes through, and show the duplicate job.
Exercise 22 — provenance. Add a reason and a date to each pin, and identify one you could now retire.
Exercise 23 — the typo. Show an unknown mode raising rather than falling through to a default branch.
12. Summary
- A seed widens the search and pins a failure, and those pull in opposite directions.
- Choose unpredictably once, then record forever — Chapter 25.4's rule, at list scale.
baseandnreproduce the whole sweep, so the record stores the policy rather than the list.- For Python's Mersenne Twister, consecutive seeds are fine — measured, ten thousand of them within a few percent of uniform.
- For a weak generator they are not — eight of eleven adjacent first draws differed by exactly 77.
- Neither piece of folklore is right. The answer depends on a generator you cannot inspect, and nothing here claims anything about a real simulator's solver.
- Spacing by a large prime and hashing
(base, i)both cost nothing and remove the question. - Any defence must stay a pure function of
baseandi, or it breaks the reproducibility it was protecting. - Record which generator was used, because changing it silently changes the job list.
base = base or DEFAULTdiscards base 0 — Chapter 25.4 Section 5, affecting a hundred seeds instead of one.- A seed must appear in five places: the job, the run directory, the result, the reproduce command and the resume key.
- A resume whose regenerated seeds differ matches no saved keys, restarts from zero, and the only symptom is that it took twice as long.
- Jobs grow linearly with seeds; distinct findings saturate. Measure where, counting distinct failure buckets rather than raw failures.
- Use different seed counts for a smoke suite, a nightly and a soak, via Chapter 26.2's per-test override.
- A sweep looks for new bugs and a pin re-proves an old fix; a regression needs both and they are generated differently.
- Pinned seeds are curated, not derived — they belong in the suite file, not in a
baseandn. - Run the pins first, so a regression cut short has already covered the known-interesting cases.
- A pin and a sweep can collide, producing Chapter 26.2 Section 6's duplicate job and breaking the invariant.
- Record why each pin exists, or it is never safe to retire one.
- Validate the policy at construction — an unknown mode must raise, not fall through to a default branch.
The next chapter gives each of those jobs a directory of its own, which is what stops them destroying each other's evidence.
Where this fits
Part of the Python curriculum.
