Skip to main content

Determinism & proportions

Two properties make TDC's data trustworthy: the same seed reproduces the same data byte for byte, and every share comes out at its exact proportion. The promise is precise about what has to match — the same config, the same seed, the same core version and the same output mode. Any of those four changing is license for the bytes to change; the language you run it from is not, which is why five implementations agree. A config that asks for today's date adds a fifth condition — the clock. This page covers three attributes together — seed, count, and percent — because they interact: count decides how many records you get, seed decides which records, and percent pins their proportions.

note

The example outputs below are illustrative — the exact names and numbers can differ by core version, but the properties they show (reproducibility, prefixes, exact counts) always hold.

Three runs of the same config, 60 rows each.
  • Athe first run, on one seed
  • Ba second run on the same seed — identical value for value
  • Ca different seed: the same shape of data, but none of the same numbers

seed — reproducible randomness

Test data should look random but stay reproducible: run the same config tomorrow and you get the same records. Without that, a bug report and a snapshot test have nothing to stand on. Plain "random" can't give it to you — every run is a fresh set. seed can: the same seed and the same config always produce exactly the same output.

The same output, whichever engine runs

TDC has three engines and picks one from your config — the fast streaming one by default, the exact on-disk one for uniqueness, the small in-RAM one under mode="memory" and behind the object API. They all produce the same values from the same seed. A row's value is derived from (seed, column name, row number), so it does not depend on which engine computed it, on what the columns beside it drew, or on how many threads wrote the file.

That is worth stating plainly because it used to be false: the engines drew in different orders, and one object could answer differently depending on whether you called toString() or iterate(). They agree now, and every shared fixture is checked on all three. See Large outputs for how the engine gets chosen — useful for speed and memory, and not something your data depends on.

seed is set on <env>. Its value is any string — a hash, a word, a number written as text — and internally the cyrb128 algorithm normalizes it to a 128-bit key. The CLI flag --seed and the API { seed } option both override the attribute.

<env count="4" seed="demo" local="en">
<sequence name="Name"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="Code"><gen type="number" value="1000..9999"/></sequence>
</env>

Both the name (from template) and the code (from number) look random. Run the config twice in a row and the output is identical, byte for byte:

./run demo.tdc — two consecutive runs
run 1              run 2
Braylen #2004      Braylen #2004
Amiri #2900        Amiri #2900
Andre #2771        Andre #2771
Izaiah #5951       Izaiah #5951

Nothing drifts. Same seed, same config, same result — that's determinism.

Change the seed → a different, equally stable set

Swap the seed for another word and you get a different set that's just as reproducible. Same config, only now seed="alpha":

./run demo.tdc (seed=alpha)
Ryland #1695
Leonidas #8152
Jakobe #8337
Jase #3363

That's how you keep several independent but reproducible datasets side by side: seed="demo" for one test, seed="alpha" for another, each one stable across runs.

Remove the seed → fresh every time

With no seed at all, TDC picks a random one per run and the output is new every time. Do that when you want fresh sample data and don't care about reproducing a specific output — but know that you're giving up the ability to point at a particular result later.

Cross-language guarantee

The PRNG (cyrb128 + sfc32) was chosen so that the same seed and config give identical results in every implementation. That portability is one of TDC's core promises.

The clock is the fifth input

value="today", value="now", person.b_day, and a date generator given no bounds read the clock while the run is happening. The seed pins which rows you get; it does not pin what "today" is. A config that uses any of them reproduces within the day and drifts after it:

./run people.tdc — the same seed, a year apart
--now 2026-04-23      --now 2027-04-23
Robert 1988-08-21     Robert 1989-08-21
John 2005-06-13       John 2006-06-13
James 1977-06-16      James 1978-06-16

The names are identical — a name comes from the seed alone. The birthdays moved, because the age window is measured back from today.

Write the clock down and the drift stops:

./run people.tdc --seed demo --now 2026-04-23

Two runs with the same --now are identical byte for byte; two runs with a different --now are not. The library API takes the same instant as milliseconds since the epoch — now in TypeScript's TdcOptions, now= on Python's TDC, Options.now(long) in Java, Options.NowMillis in C#, Options.now_millis in Rust. The accepted syntax and the rest of the flag are on --now.

count — how many records

count is how many times the block is rendered. It's set on <env>, defaults to 10, and is overridden by the CLI flag --count or the API { count } option. The value is a positive integer written as a string.

<env count="1000" seed="demo" local="en">
...
</env>
# Override from the CLI:
./run config.tdc --count 50

The important property: a short run is an honest prefix of a long run. Most generators — number, an unweighted template, counter, regex — compute each row's value from its row number and the seed, never from the total. So the first three rows of count="3" are exactly the first three rows of count="6":

./run demo.tdc --count 3 vs --count 6
count=3        count=6
Braylen        Braylen
Amiri          Amiri
Andre          Andre
             Izaiah
             Zachariah
             Saul

count doesn't shift the data; it just continues the same series. So you can debug on count="3" knowing those first records will be identical at count="1000".

The exception: whole-run layouts

Generators that lay their values out across the entire run get recomputed when count changes, so their columns are not a prefix. Four features work this way: exact proportions (percent on text and on <mix>, via the Hamilton method), uniqueness (uniq), a weighted template pack, and the list lengths of repeat="min..max" — a range there is a quota over the run, not a die rolled per row, so 200 rows of repeat="1..4" come out 50 / 50 / 50 / 50 and 201 rows come out 51 / 50 / 50 / 50. The name and place packs carry a frequency per value, and TDC turns those into exact quotas over the whole count with the same machinery percent uses. So person.male.firstName reshuffles when count changes, while an unweighted list like location.country stays a prefix. Counting against the full count is precisely what makes the proportions come out even and uniqueness hold at any size.

You can watch the recomputation happen. With percent="34,33,33" on three values, a run of count="4" and a run of count="8" share no prefix at all:

./run grade.tdc — percent layout is recomputed
count=4:   C A A B
count=8:   A C A B A B B C

The first four rows differ, because the layout was rebalanced for the new total. Positional generators (number, an unweighted template, counter, regex) would still be a prefix here; the proportion, uniqueness, weighted-pack and repeat-length machinery reshuffles.

The practical rule: a small run tells you the shape, not the rows. Debug on count="3" to check the format, the proportions and that the fields agree — but if any of the four features above is in the config, don't expect row 3 of the small run to be row 3 of the big one.

Built-ins that depend on the total

Built-in sequences that need the size of the whole run also change with count_total (the total number of rows) and _count (the current row number). Here's the same config rendered as ${{_count}}/${{_total}}: ${{Name}}:

./run demo.tdc --count 3 vs --count 6
count=3            count=6
1/3: Braylen       1/6: Braylen
2/3: Amiri         2/6: Amiri
3/3: Andre         3/6: Andre
                 4/6: Izaiah
                 5/6: Zachariah
                 6/6: Saul

_total correctly reports 3 in one run and 6 in the other — by definition it describes the whole run.

percent — exact proportions

Add percent to a text generator (or to a <mix>) and the shares land exactly, laid out by the Hamilton (largest-remainder) method: the number of times each value occurs is guaranteed to match the percentages you gave. The only randomness left is in the order of the rows.

<sequence name="Gender">
<gen type="text" value="Male,Female" percent="60"/>
</sequence>

The first rows come out interleaved (the order depends on the seed):

./run gender.tdc — first rows
Female
Male
Female
Male
Female
Male

But count up all 100 rows and the split is exact to the record:

./run gender.tdc (count=100)
Male     60
Female   40

Exactly 60 and 40 — not "about 60%". That's the Hamilton method: it distributes precisely and leaves the randomness in the row order alone.

Partial lists — percent can be shorter than the values

The list above is just percent="60", yet there are two values. A list shorter than the value list gets expanded: the filled positions pin their own percent, and the empty ones split whatever is left up to 100 evenly between them. That covers most real cases with very little typing:

MaskFor 2 / 4 / 5 values, expands to
6060,40
,4060,40
,10,1040,40,10,10
,,25,,18.75,18.75,25,18.75,18.75
46,46,13.5,13.5,13.5,13.5

The rules: if every position is filled, the numbers have to sum to 100. If any are empty positions, the filled numbers have to sum to no more than 100, and the rest is shared out across the blanks.

Shares that don't divide evenly

Three equal grades over 100 rows comes to 33.33% each, but "a third of 100" isn't a whole number. Hamilton hands the leftover record to the share with the largest remainder, so the total is still exactly count:

<sequence name="Grade"><gen type="text" value="A,B,C" percent=",,"/></sequence>
./run grade.tdc (count=100)
A   34
B   33
C   33

34 + 33 + 33 = 100 — no record is lost or double-counted. Explicit shares are taken just as literally:

<sequence name="Tier"><gen type="text" value="gold,silver,bronze" percent="50,30,20"/></sequence>
./run tier.tdc (count=100)
gold      50
silver    30
bronze    20

Small counts — the sum still holds

At a small count the shares round off, but their total always equals count. With percent="50,50" and count="3" you get either 2 + 1 or 1 + 2 (which value gets the extra row depends on the seed) — never 1 + 1 or 2 + 2. The proportion is approximate; the count is never wrong.

Inside a subset — percent with a parent

When the sequence has a parent, the percentages are measured within the filtered subset, not across the whole run. A 70/30 split of active users is 70/30 of that parent's rows, computed independently for each group. That's the foundation of hierarchical dependencies.

On <mix>

percent also drives <mix>, where the list length is checked against the number of nested <case> branches instead of against a value list. Leave percent off and the cases are distributed evenly.

See also