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.
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.
- 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.
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 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":
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.
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:
--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":
count=3 count=6
Braylen Braylen
Amiri Amiri
Andre Andre
Izaiah
Zachariah
Saulcount 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:
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}}:
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):
Female Male Female Male Female Male
But count up all 100 rows and the split is exact to the record:
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:
| Mask | For 2 / 4 / 5 values, expands to |
|---|---|
60 | 60,40 |
,40 | 60,40 |
,10,10 | 40,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>
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>
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
- Text —
percentin full, including partial lists. - Hierarchical dependencies — proportions inside a subset.
- Unique values — the other whole-run layout that recomputes when
countchanges. - Large outputs — how exact proportions hold up while streaming.