How generation works
This page is the technical "how": what happens between a PDL schema and the rows that come out, and how to actually run it.
The pipeline
PDL schema (JSON)
│ 1. compile per-type defs → uniform envelopes; entities/scenarios parsed
▼
Schema (generators + entities + scenarios)
│ 2. order entities by FK; fields by intra-row dependency (topological)
▼
for each entity, for each row:
│ 3. derive seed per cell: derive_seed(root_seed, field_path, row)
│ 4. resolve registry → generator → value (refs memoized per row)
│ 5. modifiers nullable · transform · uniqueness
▼
Tables { "User": [ {…}, … ], "Order": [ … ] }1. Compilation
A PDL generator ({ "type": "logic", "algorithm": "int_between", … }) is compiled to the uniform envelope ({ use, params, constraints, modifiers }). type(+algorithm) becomes a namespaced use like @phony/core:logic.int_between; the right keys are gathered into params; unique/nullable/transform lift into modifiers.
This means inline field generators and schema generators are the same thing after compilation — there's one execution path.
2. Ordering
- Entities are sorted so a table is generated after any table its foreign keys point to. Self-references don't create a cross-entity edge.
- Fields within an entity are sorted topologically by intra-row dependencies: a
computed/template field that readssubtotalis generated aftersubtotal. A cycle is a hard error.
3. Seed derivation (why it's reproducible)
Every cell gets its own seed, derived deterministically:
seed(cell) = mix( root_seed XOR fnv1a64(field_path) XOR rotate(row) )root_seed— the seed you pass to the run.field_path— e.g."User.email", hashed with FNV-1a (64-bit).row— the zero-based row index.mix— a SplitMix64 finalizer (a fast avalanche).
Because each cell's seed is independent, changing one field's definition doesn't shift every other field, and a PHP/JS/Python runtime reproduces the exact same draws from the same inputs. There is no ambient randomness.
References derive a sub-seed from the cell seed and the reference name; inline generators derive one per occurrence — so two {{number}}s differ, but two references to first_name agree (memoized).
4. Row consistency
While a row is being built, resolved references are cached for that row. So:
{{first_name}}insideemail= the row'sfirst_namefield.- A Linked generator referenced as
location.cityandlocation.countryresolves to one record, so the columns always agree.
The frozen clock
"Now" never comes from the system clock. It comes from an injected reference instant. now(), today(), age(), and relative ranges (-1year, now+7days, today-30days) all resolve against it:
let registry = Registry::with_builtins()
.with_reference_time(1_704_067_200); // 2024-01-01T00:00:00ZSo a schema that says "created in the last year" produces the same dates every run. Output is a pure function of (schema, seed, reference-time).
Uniqueness
A unique field is de-duplicated across the entity's rows: if a value collides with one already used, the generator re-rolls (varied seed, up to 200 tries) before failing with a clear error. Composed unique values need entropy that actually varies on a re-roll (an inline number, an id) — a pure-reference template can't be made unique on its own.
Running a schema
Today the engine is a Rust library (phony-core); a CLI and language libraries (PHP, JS, Python) are on the way. The current API:
use std::sync::Arc;
use phony_pdl::{Dataset, InMemoryAssets, Registry};
use ngram_core::NgramModel;
// 1. assets — models/lists the generators look up by name
let assets = InMemoryAssets::new()
.with_model("person.first_names", NgramModel::train_words(&["mehmet","ayse"], 3));
// 2. registry — built-in generators + assets + (optionally) a reference instant
let registry = Registry::with_builtins()
.with_assets(Arc::new(assets))
.with_reference_time(1_704_067_200);
// 3. compile the schema and generate a scenario
let dataset = Dataset::from_pdl(&schema_json)?;
let tables = dataset.generate_scenario(®istry, /* seed */ 2026, "dev")?;Smaller entry points:
| Function | Generates |
|---|---|
generate_one(def, registry, seed, field, row) | one value from a standalone generator |
generate_field(schema, name, registry, seed, scope, row) | one named generator (refs resolve) |
Dataset::generate_table(registry, seed, entity, count, parents) | one table |
Dataset::generate_scenario(registry, seed, scenario) | all tables in a scenario |
The full Rust API is documented in The engine → phony-pdl API. The runtime story across CLI / OSS libraries / Cloud is in Spec → Execution Model.