Skip to main content

Hierarchical dependencies

An ordinary fake-data library fills every field independently. TDC ties sequences together in a parent → child relationship: a child's values are generated only on the rows where the parent took a given value, and any percentages inside the child are measured against the size of that filtered subset, not against the whole count.

That is what lets one declarative config model a real dependent distribution: male names only for men, a diagnosis that follows from sex, children with no spouse.

note

Example outputs below are illustrative. The exact values a generator emits can change from one core version or seed to the next; what the feature guarantees is the counts and the structural rules — which rows get filled and which stay empty.

40 real rows. The parent takes A or B; each parent value has its own child alphabet, and no row ever mixes them.
  • rows where the parent chose A — the child below is always 1, 2, or 3
  • rows where the parent chose B — the child below is always 7 or 8

The problem: independent fields make impossible pairs

To see why parent matters, look at what happens without it. Two text sequences, country and city, declared independently:

<env count="8" seed="demo">
<sequence name="Country"><gen type="text" value="Russia,France" percent="50,50"/></sequence>
<sequence name="City"><gen type="text" value="Moscow,Paris" percent="50,50"/></sequence>
</env>
<block><line><data>${{Country}}: ${{City}}</data></line></block>
./run demo.tdc
Russia: Moscow
Russia: Moscow
France: Paris
France: Paris
Russia: Moscow
France: Paris
Russia: Paris
France: Moscow

Each field rolls its own dice. The last two rows are the problem: Russia: Paris and France: Moscow — Paris in Russia, Moscow in France. For a test that checks that the city belongs to its country, that data is garbage, and the more rows you generate, the more impossible pairs you get.

The fix: parent

Give each country its own city generator, filtered with parent="Country.Value". Now a city is chosen only on the rows where the country matches:

<env count="8" seed="demo">
<sequence name="Country"><gen type="text" value="Russia,France" percent="50,50"/></sequence>
<sequence name="CityRU" parent="Country.Russia"><gen type="text" value="Moscow,Kazan"/></sequence>
<sequence name="CityFR" parent="Country.France"><gen type="text" value="Paris,Lyon"/></sequence>
</env>
<block><line><data>${{Country}}: ${{CityRU}}${{CityFR}}</data></line></block>
./run demo.tdc
Russia: Moscow
Russia: Moscow
France: Lyon
France: Lyon
Russia: Kazan
France: Paris
Russia: Kazan
France: Paris

The Country column is unchanged (same seed), but now the city always fits: Russia gets only Moscow/Kazan, France only Paris/Lyon. The impossible pairs are gone. On any given row exactly one of the two city generators is active and the other is empty, so ${{CityRU}}${{CityFR}} yields a single city.

How it works

  1. Declare the parent with a distribution (text with percent, template, or any type).
  2. Declare the child with parent="Parent.Value".
  3. The child materializes only on rows where Parent == Value. On every other row its value is undefined — an empty string in interpolation.
  4. Any percentages inside the child are measured against the number of active rows — the filtered subset.
The value compared is the one the parent DREW

parent="Parent.Value" is answered before the formatting layer runs, so it compares against the value the parent's generator produced — not the one the row prints. A parent written <gen type="text" value="alpha,beta" case="upper"/> prints ALPHA, and the child that follows it must still say parent="Kind.alpha". Writing the printed spelling stops the run rather than silently matching nothing: filters on parent value "Kind.ALPHA", which the parent never produces. The same holds for mask=.

Names by gender

The classic case: men need male names, women need female names. One shared name generator can't do it — it doesn't know the row's gender. Two name generators, each under its own parent, can:

<env count="8" seed="demo">
<sequence name="Gender"><gen type="text" value="Male,Female" percent="60,40"/></sequence>
<sequence name="MaleName" parent="Gender.Male"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="FemaleName" parent="Gender.Female"><gen type="template" value="person.female.firstName"/></sequence>
</env>
<block><line><data>${{Gender}}: ${{MaleName}}${{FemaleName}}</data></line></block>
./run demo.tdc
Female: Elizabeth
Male: William
Male: James
Male: John
Male: Michael
Male: Robert
Female: Patricia
Female: Mary
  • Gender at count="8" yields 5 Male + 3 Female (60/40 of eight, rounded by the largest-remainder method — see percent). At count="100" it would be exactly 60 + 40.
  • MaleName is filled only on male rows (the person.male.firstName template draws from the male dictionary); on female rows it's empty.
  • FemaleName works symmetrically: it's filled only on female rows.
  • Concatenating ${{MaleName}}${{FemaleName}} leaves exactly one name per row, and it always matches the gender — never Male: Patricia.

Probability within a subset

"30% of them are fans" — 30% of whom? If only Russians can be fans and you take 30% of everyone, the real share of Russian fans comes out halved. Put the percent on the child and it applies to the filtered rows:

<env count="100" seed="demo">
<sequence name="Country"><gen type="text" value="RU,US" percent="50,50"/></sequence>
<sequence name="FootballFan" parent="Country.RU"><gen type="text" value="Yes,No" percent="30,70"/></sequence>
</env>
<block><line><data>${{Country}} -> [${{FootballFan}}]</data></line></block>
./run demo.tdc (first 10 rows)
RU -> [Yes]
RU -> [Yes]
US -> []
RU -> [No]
RU -> [No]
US -> []
US -> []
RU -> [No]
RU -> [No]
RU -> [No]

On US rows FootballFan is empty — the generator never fires there. Counting all 100 rows, column by column:

WhatResult
Country50 RU + 50 US
FootballFan among RU rows15 Yes + 35 No
FootballFan among US rows50 empty

The 30/70 split was measured against the 50 RU rows — "30% of Russians are fans", not "30 of all 100" (which would be 30 Yes).

Variation — a different split, same subset

Change nothing but the child's percentages, to percent="50,50":

<sequence name="FootballFan" parent="Country.RU"><gen type="text" value="Yes,No" percent="50,50"/></sequence>
./run demo.tdc (counts over 100 rows)
FootballFan among RU rows:  25 Yes + 25 No
FootballFan among US rows:  50 empty

You now get exactly 25 Yes + 25 No among the same 50 RU rows — half of the subset, not half of count. The US rows stay empty. This is why the percentage lives on the child: it always scales to the parent-filtered slice.

Declaration order

A parent must be declared before its child in the document. Declare them the other way around and rendering fails immediately:

<env count="8" seed="demo">
<sequence name="City" parent="Country.Russia"><gen type="text" value="Moscow,Kazan"/></sequence>
<sequence name="Country"><gen type="text" value="Russia,France" percent="50,50"/></sequence>
</env>
./run demo.tdc
error[TDC035]: parent sequence "Country" is not declared before this sequence
--> demo.tdc:3:35
|
3 |     <sequence name="City" parent="Country.Russia">…
|                                   ^^^^^^^^^^^^^^
|
note: Parent sequences must be declared earlier in the same <env>. Forward references and cycles are not supported.

The error names the offending line and column. Cyclic dependencies and forward references aren't supported — the dependency graph is resolved top to bottom, so parents always come first.

parent without a value

parent="Parent" — with no dot and no value — means "any row where the parent has a value at all", regardless of which value. You rarely need it at the first level (a top-level parent always has a value), but it's the right tool for deeper chains, where a mid-level sequence is itself filtered and you want a grandchild only where that mid-level one fired.

Here Country picks the US rows, USCity fills only those rows, and USZip should appear wherever there is a US city, no matter which one — so it uses the valueless form:

<env count="8" seed="demo">
<sequence name="Country"><gen type="text" value="US,UK" percent="50,50"/></sequence>
<sequence name="USCity" parent="Country.US"><gen type="text" value="New York,Chicago"/></sequence>
<sequence name="USZip" parent="USCity"><gen type="regex" value="[0-9]{5}"/></sequence>
</env>
<block><line><data>${{Country}} | ${{USCity}} ${{USZip}}</data></line></block>
./run demo.tdc
US | Chicago 78249
US | New York 90094
UK |
UK |
US | Chicago 17025
UK |
US | New York 41341
UK |

USZip fires on every US row and nowhere else — parent="USCity.New York" would have restricted it to New York alone. The valueless form says "inherit the parent's filter, don't add my own", which is exactly what you want for a field that hangs off whatever the parent produced.

missing= on the parent does not stop the child

"Any row where the parent has a value at all" is read as any row the parent's own filter let through — not as "any row where the parent came out non-empty". A parent that blanks some of its rows with missing= still counts those rows as its own, so the child fills them:

P=[] C=[child]
P=[b] C=[child]
P=[] C=[child]

If the child must disappear together with the blanks, say so with a condition — if="P" is true exactly when P produced something — rather than relying on the valueless parent=.

Interaction with if

if expressions evaluate against the current row's values. On a filtered-out row the child's value is undefined, which counts as empty — and empty is false. So a condition on a child column automatically excludes the rows where that child never fired, without an explicit parent check:

<env count="8" seed="demo">
<sequence name="Country"><gen type="text" value="RU,US" percent="50,50"/></sequence>
<sequence name="FootballFan" parent="Country.RU"><gen type="text" value="Yes,No" percent="30,70"/></sequence>
</env>
<block><line>
<data>${{Country}} ${{FootballFan}}</data>
<data if="FootballFan == Yes"> BUY</data>
</line></block>
./run demo.tdc
RU Yes BUY
RU No
US
US
RU No
US
RU No
US

The BUY tag is printed only where FootballFan == Yes. US rows have an empty FootballFan, which the comparison reads as false, so they come out without it — and you never had to write Country == RU in the condition.

A tree in the data, not in the config

parent relates one sequence to another. A different job comes up almost as often: a record that points at another record of the same kind. An employee whose manager is an employee, a comment replying to a comment, a category inside a category.

That is a column, not a construct — and the whole difficulty is one word: cycles. A manager chain that loops will hang whatever renders it, and no amount of redrawing fixes it, because the problem is in the shape rather than in any one value.

The fix is arithmetic, and it needs nothing new. Point every record at a lower id than its own:

<env count="10" seed="tree" local="en">
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Back"><gen type="number" value="1..4"/></sequence>
<sequence name="ParentId"><compute><result>
<choose>
<when>
<test><less_than><subtract><to_number><field name="Id"/></to_number><to_number><field name="Back"/></to_number></subtract><int v="1"/></less_than></test>
<then>
<choose>
<when><test><equals><to_number><field name="Id"/></to_number><int v="1"/></equals></test><then><int v="0"/></then></when>
<otherwise><int v="1"/></otherwise>
</choose>
</then>
</when>
<otherwise><subtract><to_number><field name="Id"/></to_number><to_number><field name="Back"/></to_number></subtract></otherwise>
</choose>
</result></compute></sequence>
<sequence name="Author"><gen type="template" value="person.lastName"/></sequence>
</env>
<block>
<line><data>${{Id}},${{ParentId}},${{Author}}</data></line>
</block>
./run tree.tdc
1,0,Smith
2,1,Jones
3,1,Miller
4,1,Garcia
5,3,Davis
6,5,Williams
7,5,Brown
8,7,Johnson
9,7,Martinez
10,7,Rodriguez

Read it as id, parent_id, author. Back is how far up the tree this record attaches — one to four rows above itself — and the two <choose> branches handle the top of the file: record 1 gets 0, the root, and anything that would reach past it attaches to the root instead.

What that guarantees, by construction rather than by luck:

  • No cycles. Every arrow points at a smaller number, so following them always ends.
  • One root. Only record 1 has no parent.
  • A live shape. Record 1 has three children here and record 2 has none, because Back is drawn per row. Widen it to 1..20 for a flat, wide tree; narrow it to 1..2 for a deep, thin one.

The same column works for an org chart, a threaded comment tree, a bill of materials, or nested categories. What the records say is a separate question — comment text is just text.paragraph and does not have to make conversational sense for the tree to be a valid tree.

See also