← board

Does NilPy's random seed itself at import?

The fork

compiler/builtin/pylib.pas, above PyRandState:

"The state starts at a fixed value rather than a clock reading: a program that never calls seed() then reproduces exactly, which is what makes a failure reportable. CPython seeds from entropy instead, and a program that DEPENDS on the difference is depending on the stream, which neither implementation promises."

Measured consequence, on pinned v388:

import random
for i in range(8):
    print(random.randint(1, 100))

prints 1 80 73 48 91 14 77 100 on every run, forever. (The leading 1 is not a separate defect: the first draw from the fixed state is a constant that happens to be ≡ 0 mod 100 and mod 5. With a different range the first value is ordinary — randint(0, 1000000) opens with 793785. The bug ticket originally claimed two defects; there is one.)

Why it is not clear-cut

For the current behaviour. A failing run reproduces from the source alone, with no seed to capture — genuinely valuable, and it is why the choice was made.

Against it, and this is the part the comment does not cover. The charter is "if code works on CPython, it must work on NilPy". A program that shuffles a deck, samples a corpus, jitters a retry, or picks a random port is not "depending on the stream" — it depends on not getting the same answer twice, which CPython guarantees by seeding at import and NilPy currently denies. That class of working CPython program is broken here, silently, and the failure looks like a logic bug in the user's code.

The comment's defence answers a different objection (nobody may rely on the exact sequence — true, and not what is at stake).

Options

  1. Seed from entropy at first use; keep random.seed(n) for determinism. CPython's own contract. A program that wants reproducibility writes the one line CPython programs already write. builtin.pas's Randomize already has the per-arch clock_gettime entropy, with a documented bare-target fallback, so the mechanism exists and would be extracted rather than duplicated. Cost: a NilPy failure involving randomness no longer reproduces from source alone.
  2. Seed from entropy, but honour an env var / flag (PXX_RANDOM_SEED=n) that pins it. Keeps both properties; costs one small mechanism and a place to document it.
  3. Keep the fixed seed, and document the divergence in devdocs/dev/nilpy-semantics-divergences.md. Cheapest, and it makes the behaviour findable — but that document is for things a working CPython program cannot observe, and this one observes it immediately, so the entry would be the first of a different kind.

Recommendation

Option 2, falling back to 1 if the flag is not worth the mechanism. The upward-compatibility rule is the stronger of the two claims, and it is the one stated as a charter; debuggability is a real but recoverable loss, and option 2 does not even lose it. Option 3 puts an observable behavioural difference in a document defined as being for unobservable ones.


RULED 2026-08-31 — option 1, seed from entropy

Owner: "the random seed question is trivial, we follow cpython in behavior... instructions are clear." They are: the charter is one-directional — code that works on CPython must work here — and a program that shuffles a deck or picks a random port depends on not getting the same answer twice, which is a guarantee CPython makes and we currently deny.

Why option 1 and not this ticket's own option 2

The recommendation above was option 2 — entropy plus a PXX_RANDOM_SEED=n override, on the grounds that it loses nothing. Overruled, on a measurement the ticket did not make.

Option 2 protects debuggability. Nothing is relying on it:

So entropy seeding breaks no test, and option 1's stated cost — a failure involving randomness no longer reproducing from source alone — lands on ad-hoc debugging, where the remedy is the one line of random.seed(n) a CPython programmer already writes. CPython itself ships PYTHONHASHSEED and no equivalent for random, so option 1 is also the closer match to the thing we are compatible with. A second mechanism, with a second place to document it, in the name of a property nothing uses, is not worth it — and it is cheap to add later if it ever bites.

What to build

Extract the entropy source builtin.pas's Randomize already has (per-arch clock_gettime, with its documented bare-target fallback) and seed PyRandState from it at module init. Extract, do not duplicate — the mechanism exists. random.seed(n) keeps working unchanged and remains the way to pin a stream.

Replace the comment above PyRandState in compiler/builtin/pylib.pas in the same commit. It currently argues for the fixed seed, and its defence — "a program that DEPENDS on the difference is depending on the stream" — answers a different objection: nobody may rely on the exact sequence, which is true and is not what was at stake. Leaving it in place would leave the repo arguing with itself.

Note on why this was a ticket

Correct to escalate, over-priced at 60. The fixed seed was a documented decision, not a slip, and this repo does not let an agent overrule one of those as a bug fix. What would have shrunk it: the charter it collides with is unambiguous and quoted in this very ticket, and the test-suite cost — the only thing that could have made it genuinely hard — took two greps to rule out. Escalate the conflict; price it after checking the cost.

Unblocks

[[bug-b-nilpy-random-is-never-seeded-and-its-first-draw-is-the-low-bound]] (blocked/, parked on this answer). Its own finding stands and is not re-opened: the leading 1 from randint(1, 100) was not a second defect — it is the fixed state's first draw happening to be ≡ 0 mod 100.

Ruled 2026-08-31 by the owner; test-dependency measurement by frank-user.

CORRECTION 2026-08-31 — my census undercounted, and the reason matters

frankB, implementing this, found that test_nilpy_from_import_binds_provided_names does draw a value:

from random import seed, randint
...
seed(1)
print(randint(5, 5))

I had put it in the "import mechanics only, never draws" group. Three tests draw, not two.

Why the census missed it, precisely: I grepped random\.seed, which matches only the dotted spelling. This file uses from random import seed, randint and then a bare seed(1) — so the pattern structurally could not match the very spelling the file exists to test. A from-import test, invisible to a dotted-form grep. The instrument could not fail for the population it was aimed at, which is this repo's own recurring shape.

The conclusion is unchanged and for a better reason than I gave. The file is safe under entropy seeding because it seeds explicitly and because randint(5, 5) is degenerate — one possible value — not because it never draws. Stated correctly: all three drawing tests seed explicitly, which is what makes the import-time default irrelevant to them.

The open item is closed, negative

I flagged that I had not checked whether pydiff.py or the fuzz harness generate random-using programs. They do not. tools/pydiff.py states the rule for its own corpus in a comment near line 199 — "Keep it deterministic (no time, no randomness, and SORT anything set-derived — set order is unspecified and CPython's own varies per run)" — spot-checked here. fuzz.sh mutates Pascal sources, whose Randomize/RandSeed surface this change does not alter behaviourally.

Landed

4fa9f66e5 (frankB) — PXXEntropy64 extracted from builtin.pas's Randomize (interface-declared; Randomize is now one line over it, no second syscall table), pylib.pas seeds PyRandState in the unit's initialization, no env var. Seeding is unconditional at init rather than lazy on first draw: CPython is not conditional either, and a have-I-been-seeded flag would have to get the seed() interaction right for no gain.

A third stale comment was corrected that neither of us knew about: the riscv32 note in the moved block said "Randomize is the only caller, so a program that never randomizes never issues it" — now false, since pylib's init issues clock_gettime64 at startup for any program pulling pylib. It still cannot reach a bare ESP boot, because pylib uses builtin and builtin does not compile there, but that is the reason and it had never been written down.