phony-pgdl API
The generator layer: it defines generators and produces values. This page is the Rust API reference. For the language, see The PGDL Language.
Invoking generators
fn generate_one(def: &GeneratorDef, registry: &Registry, seed: u64, index: u64)
-> Result<Value, GenError>;
fn generate(def: &GeneratorDef, registry: &Registry, root_seed: u64, count: usize)
-> Result<Vec<Value>, GenError>;generate_oneproduces one value for invocationindex, underseed. It first validatesdef.paramsagainst the resolved generator's manifest (unknown/mistyped/missing-required →BadParams, before any value is drawn), then rollsnullable, runs the generator, and appliestransform.generateis the unit of generation — call a generatorcounttimes underroot_seed. Each invocationigets its own derived seed and ordinali, andgenerateenforces theuniquemodifier: values are de-duplicated by a type-tagged key (string"1"never collides with number1), collisions re-roll with a varied seed, and nulls fromnullableare exempt — they neither enter the dedup set nor burn retries.
A generator is pure: it knows nothing about tables, rows, or relationships, and its output may be a scalar, object, or array (nested, keys optional). To build structured or coherent values, compose generators (see Composition).
The envelope
Every generator invocation is a GeneratorDef:
struct GeneratorDef {
use_ref: String, // serialized as "use" — e.g. "@phony/core:logic.int_between"
params: Value,
constraints: Value,
modifiers: Modifiers,
}
struct Modifiers {
unique: Option<bool>, // distinct across a run of invocations
nullable: Option<f64>, // probability the value is null
transform: Option<String>, // a PEL expression over `value`
}compile_generator(pgdl: &Value) -> Result<GeneratorDef, String> maps a PGDL per-type definition ({ "type": "logic", "algorithm": …, "params": … }) — or a { "use": "@scope:name" } reference — onto this envelope.
A generator's manifest describes its accepted parameters so an envelope can be checked without the implementation:
struct Manifest { name: String, version: String, summary: String,
params: BTreeMap<String, ParamSpec>, output: String, deterministic: bool }
fn validate(def: &GeneratorDef, registry: &Registry) -> Result<(), Vec<String>>;The registry
An open lookup from namespaced reference to generator, plus the asset provider and reference instant used during generation.
impl Registry {
fn new() -> Self; // empty
fn with_builtins() -> Self; // all @phony/core:* generators
fn register(&mut self, g: Arc<dyn Generator>) -> Option<Arc<dyn Generator>>;
fn resolve(&self, reference: &str) -> Option<&Arc<dyn Generator>>;
fn names(&self) -> Vec<&str>;
fn with_assets(self, assets: Arc<dyn AssetProvider>) -> Self;
fn set_assets(&mut self, assets: Arc<dyn AssetProvider>);
fn with_reference_time(self, epoch: i64) -> Self;
fn set_reference_time(&mut self, epoch: i64);
}Custom generators
Implement Generator and register it under a namespaced name. execute must be a pure function of ctx.seed, params, and constraints.
trait Generator: Send + Sync {
fn manifest(&self) -> &Manifest;
fn execute(&self, ctx: &GenContext, params: &Value, constraints: &Value)
-> Result<Value, GenError>;
}
struct GenContext<'a> {
seed: u64, // derived for this exact invocation
index: u64, // invocation ordinal (sequence/cycle key off it)
registry: &'a Registry,
assets: &'a dyn AssetProvider,
depth: u32, // composition-recursion bound
bindings: Option<&'a Bindings<'a>>, // the generator's own local scope
reference_time: i64,
}
struct Bindings<'a> { values: &'a serde_json::Map<String, Value> }bindings is the generator's own scope — its params and any let results. There is no table, row, or sibling field. Draw randomness only from SplitMix64::new(ctx.seed) — never the wall clock or a global RNG.
Composition
A generator authored as pure data ({ name, params, let, body }) — see Composition & packages.
fn compile_composition(def: &Value) -> Result<Arc<dyn Generator>, String>;
fn register_composition(registry: &mut Registry, def: &Value) -> Result<(), String>;In a structured output, every string leaf gets its own seed salted by the leaf's JSON path ("a", "items.0.b", …) — so two sibling fields with the same template still produce independent draws.
Packages
Load a .phony package — its data-defined generators and locale assets — into a registry + LocaleAssets:
fn load_package(registry: &mut Registry, assets: &mut LocaleAssets, doc: &Value)
-> Result<LoadReport, String>;
fn load_package_dir(registry: &mut Registry, assets: &mut LocaleAssets, dir: impl AsRef<Path>)
-> Result<LoadReport, String>;PEL
mod pel {
fn render_template(src: &str, ctx: &GenContext) -> Result<String, GenError>;
fn eval_expression(src: &str, ctx: &GenContext, bound: Option<Value>) -> Result<Value, GenError>;
fn referenced_names_in_template(src: &str) -> Vec<String>;
fn referenced_names_in_expr(src: &str) -> Vec<String>;
}render_template renders a {{ … }} template; eval_expression evaluates one expression (with an optional bound value, used by transform). References resolve against ctx.bindings. Every function call is arity-checked (wrong argument counts return GenError::BadParams, never a panic); string literals support the \n \t \\ \" \' escapes, and object literals ({ k: expr }) are expressions. The referenced_names_* helpers power a composition's topological let ordering. The full function set is in Expressions (PEL).
Assets & locales
trait AssetProvider: Send + Sync {
fn model(&self, name: &str) -> Option<Arc<NgramModel>>;
fn list(&self, name: &str) -> Option<Arc<Value>>;
}In-memory:
let assets = InMemoryAssets::new()
.with_model("person.first_names", model)
.with_list("geo.cities", json!(["Istanbul", "Ankara"]));Locale-resolving — packages contribute (asset, locale) data; resolution walks a chain (most-specific first) with a * fallback:
let assets = LocaleAssets::default()
.contribute_list("coin.flip", "tr_TR", json!(["Yazı", "Tura"]))
.contribute_list("coin.flip", "en_US", json!(["Heads", "Tails"]))
.contribute_list("http.method", LOCALE_INDEPENDENT, json!(["GET", "POST"]))
.with_chain(vec!["tr_TR".into(), "base".into()]);Switching the chain switches the resolved data; the generator is unchanged. See Locales & the contribution model.
Determinism helpers
fn derive_seed(root_seed: u64, key: &str, index: u64) -> u64;
fn fnv1a64(s: &str) -> u64; // the portable string hash (pub — ports reimplement it)
const DEFAULT_REFERENCE_TIME: i64; // 2024-01-01T00:00:00Zderive_seed is the exact recipe a non-Rust runtime reproduces:
fn derive_seed(root, key, index) = mix64(root ^ fnv1a64(key) ^ index.wrapping_mul(0x9E3779B97F4A7C15))key is a stable sub-identity (a let name, a column; empty "" at the top), FNV-1a/64 hashed; index is the invocation ordinal, spread across the word by a multiply with the golden-ratio constant; mix64 is the SplitMix64 finalizer. Because the terms are XOR-combined by name, adding or reordering fields never shifts the others. See How generation works.
The frozen known-answer vectors for all of this — SplitMix64, fnv1a64, derive_seed, PEL semantics, end-to-end generator runs, and the n-gram pipeline — live in the conformance corpus at phony-core/conformance/vectors.json; any runtime port must reproduce every vector byte-for-byte.
Errors
enum GenError {
Unknown(String), // no such generator/asset/reference
BadParams(String), // params didn't satisfy the generator
}Bad input is always a GenError, never a panic: every PEL function checks its argument count (wrong arity, an unknown function, divide/modulo by zero → BadParams), and generator params are validated against the manifest.