Skip to content

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 reads subtotal is generated after subtotal. 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}} inside email = the row's first_name field.
  • A Linked generator referenced as location.city and location.country resolves 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:

rust
let registry = Registry::with_builtins()
    .with_reference_time(1_704_067_200); // 2024-01-01T00:00:00Z

So 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:

rust
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(&registry, /* seed */ 2026, "dev")?;

Smaller entry points:

FunctionGenerates
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.

Phony Cloud — Documentation & Specification