A friendly, ground-up explanation of how gen3-metadata-simulator works —
written for someone new to the codebase (and to Gen3). If you can read Python
and have seen JSON, you have enough to follow along.
TL;DR: we read a Gen3 data dictionary (a big JSON file describing node types and how they link), figure out a safe order to fill them in, then write one JSON file per node full of fake-but-valid records that point at each other correctly. Optionally, an LLM makes the values realistic instead of random.
Gen3 is a platform for hosting research data. Each Gen3 commons has a data dictionary (a "schema") that defines:
- node types — like
subject,sample,demographic,lipidomics_assay. Think of each as a table. - properties — the columns of each table (e.g.
subjecthaspatient_id,consent_codes, …), each with a type and sometimes constraints. - links — foreign keys between nodes (e.g. a
samplebelongs to asubject). This makes the whole dictionary a graph: nodes connected by links.
To test or demo a commons you need example data that (a) matches the dictionary
and (b) links together correctly (every sample points at a subject that
actually exists). Writing that by hand is painful. This tool generates it.
Input: one bundled Gen3 JSON schema (see
examples/jsonschema/acdc_schema_v1.1.5.json).
Output: one <node>.json per node + a DataImportOrder.txt, matching the
layout in examples/metadata/AusDiab_Simulated/.
Everything the tool does is this pipeline. Follow it top to bottom:
schema.json
│ (1) LOAD + RESOLVE schema.py → resolved node schemas
▼
ordering.generation_order() ordering.py → parents-before-children list
│ (2) ORDER
▼
MetadataGenerator.generate() generator.py → records per node
│ (3) GENERATE ── per record: links + property values
▼
validation.self_validate() validation.py → must be ZERO errors
│ (4) VALIDATE
▼
writers.write_outputs() writers.py → <node>.json + DataImportOrder.txt
(5) WRITE
If validation finds any error, we refuse to write and exit non-zero. The output is only ever written if it's provably valid.
The raw schema uses $ref to share common definitions (e.g. every node reuses
_definitions.yaml#/ubiquitous_properties for type/submitter_id/…). Those
references have to be "inlined" before we can read a node's real shape. We let
the gen3-validator library do this:
loader = SchemaLoader(schema_path).load() # runs ResolveSchema under the hood
loader.validate_is_gen3_schema() # sanity-check it's really a Gen3 dict
loader.node_schema("demographic") # the fully-resolved demographic node
loader.submittable_nodes() # the node names we will generatesubmittable_nodes() deliberately excludes helper keys (_definitions.yaml,
_settings.yaml, …) and program (which is administrative, never generated).
A child can't reference a parent that doesn't exist yet, so we must generate parents before children. The links form a directed graph; we run a topological sort (Kahn's algorithm) over the link edges:
order = generation_order(loader.resolver, set(loader.submittable_nodes()))
# e.g. ['project', 'subject', 'clinical_descriptor', 'sample', 'lipidomics_assay', ...]Gotcha we handle:
gen3-validator's own ordering forcescore_metadata_collectionto the very end, but file nodes link to it — so it has to come before them. We compute our own sort instead of using theirs. (See the comment block at the top ofordering.py.)Since 0.5.0 this is mostly moot:
core_metadata_collectionis excluded by default (DEFAULT_EXCLUDED_NODESingenerator.py). Removing a node from the generatable set before the sort cascades everywhere — no records, no output file, noDataImportOrder.txtentry, and_resolve_linkomits its optional links because the target is no longer generatable. The CLI exposes this as--exclude-node/--include-node(effective set: defaults + excludes - includes). A required link to an excluded node still emits a<node>_simulatedplaceholder, with a warning.
This is the heart of the tool. For each node, in order, we build num_records
records. Each record is a dict. Walking MetadataGenerator._make_record(node):
- Start with
type(the node name) and a uniquesubmitter_idlikedemographic_a745ba6d-eaee-419b-ba6c-ac4ae82d2fef— the node name plus a GUID drawn from the generator's seeded RNG (notuuid.uuid4(), so--seedstays reproducible). Gen3's sheepdog enforces a case-insensitive unique constraint on (project_id, submitter_id); the pre-0.5.0 two-random-words scheme had only 3249 combinations per node and produced real duplicate-key rejections at 100 records/node. - Work out which keys to emit: declared properties minus system properties (see "Key concepts" below for why).
- For each key:
- if it's a link → emit
{"submitter_id": "<a real parent's id>"}(or{"code": "<project code>"}for links to the project). The parent is picked from theGeneratedRecordRegistry, which holds everything generated so far — and because parents come first, there's always one to point at. Picking is multiplicity-aware:many_to_*links sample uniformly with replacement, while exclusiveone_to_one/one_to_manylinks claim each parent at most once per child node (sheepdog rejects a target that "already has" a child of that type). Every node generates the samenum_records, so there are always exactly enough parents to claim. - otherwise it's a data property → ask the value provider for a value (see section 3).
- if it's a link → emit
- Record it in the registry (so children can later link to it) and return it.
project is special: it's a single object (not a list), keyed by code instead
of submitter_id.
We flatten every record into one list and hand it to
gen3_validator.validate.validate_list_dict(records, resolved_schema). It
checks each record against its node's JSON Schema (Draft-4) and returns a list
of failures — empty means everything is valid. We bail if it's non-empty.
Schema validation is per-record, so it cannot see a link whose ref points
nowhere. validation.check_links closes that gap: every emitted link ref must
name a submitter_id (or project code) that was actually generated, and
every required link to a generated target must be present. Both generate
and the validate command run it, and generation refuses to write a batch
that fails either check. Intentional placeholders (simulated_program,
<node>_simulated for excluded required targets) are not failures.
project.json is written as a single object; every other node as a JSON array.
DataImportOrder.txt is the order list, one node name per line — exactly the
sequence Gen3 expects for submission.
Steps above decide which fields to fill and who links to whom. A
ValueProvider decides the actual value of each non-link property. This
is a pluggable strategy (providers/base.py):
class ValueProvider(ABC):
def value(self, req: ValueRequest) -> Any: ... # produce one value
def warmup(self, requests) -> None: ... # optional pre-pass (default: no-op)A ValueRequest is a little bundle describing one property: its node, name,
description, json_type, enum, regex pattern, format, numeric
minimum/maximum. The generator builds one and never cares which provider is
plugged in — that's the whole point of the interface.
There are two providers, plus a decorator:
Not a strategy of its own — a wrapper the CLI puts around whichever provider is
selected when --set NAME=VALUE flags are given. It returns the pinned
constant for overridden (node, property) pairs (resolved and type-coerced by
overrides.py against the schema) and delegates everything else, including
warmup(), from which overridden requests are filtered so the LLM provider
never spends API calls on fields whose value is fixed.
Schema-driven randomness, all from one seeded random.Random (so --seed
makes runs reproducible):
| property | value |
|---|---|
| enum | a random allowed value |
| integer / number | a bounded random number (respects minimum/maximum) |
| boolean | random True/False |
string with a pattern |
a string matching the regex, via rstr |
| plain string | a readable two-word token like focometer_quinch |
| array | [] (or --array-size sampled items) |
It's fast and dependency-light, but the values are nonsense — a bmi_baseline
might be 41.7, a date might be 3170-94-14 (regex-valid, but month 94 isn't
real).
The headline feature: use a lightweight LLM's domain knowledge to make values believable while keeping them schema-valid. It doesn't call the model per-record (that'd be slow and expensive). Instead:
warmup()runs once before generation. It collects every numeric/date/ text field, asks the model for a compact spec per field (in parallel batches — independent API calls run concurrently, with a live progress counter), and caches them to.cache/distributions.json. Fields already in the cache (unchanged) are skipped — see "Cache invalidation" below.value()then just samples from the cached spec — no network, fully reproducible under a seed.
The cleverness is routing. providers/classify.py::field_kind(req) sorts every
field into one of four buckets, and each bucket is handled differently:
| kind | example field | what the LLM provides | how a value is made |
|---|---|---|---|
numeric |
bmi_baseline, month_birth |
mean, stddev, min/max limits, unit | gauss(mean, stddev) then clamp to limits; round if integer |
date |
baseline_date, intended_release_date |
a plausible earliest..latest window |
pick a real calendar date in range (dates.py), render to the field's pattern, verify it matches |
text |
assay_description |
a pool of realistic example strings | rng.choice of the pool |
other |
sex (enum), sample_source (UBERON pattern), md5sum |
— | fall back to RandomValueProvider |
Two concrete wins:
month_birthis an integer. The LLM saysmin=1, max=12, so a generated month is always a real month — not just "any integer ≥ 0".baseline_datemust match^[0-9]{4}-[0-9]{2}-[0-9]{2}$. We generate an actualdatetime.datein the LLM's plausible window, so the month is 1–12 and the day is valid; then we render itYYYY-MM-DDand double-check it matches the regex. No more3170-94-14.
Anything the LLM didn't cover (or any non-LLM kind) quietly falls back to the random provider, so generation can never fail for lack of a spec.
warmup() doesn't talk to a model vendor directly — it talks to a SpecSource,
an interface with one method
estimate(requests, text_pool_size) -> {key: FieldSpec}. This indirection is
what makes the provider both multi-vendor and testable offline:
AnthropicSpecSourceandOpenAISpecSource— the real ones. They share a base (_ChunkedSpecSource) that handles batching (~20 fields/call), the prompt, and mapping the reply intoFieldSpecs; each subclass differs only in one method,_call_model— Anthropic usesclient.messages.parse(...), OpenAI usesclient.chat.completions.parse(...). Both force valid JSON via the same Pydantic schema (structured output). The SDK client is injectable, so tests pass a fake instead of hitting the network.- A fake source (in the tests) — returns canned specs. Every test runs with no network, no API key, no cost.
Adding another vendor is just another _ChunkedSpecSource subclass.
A FieldSpec is just a frozen dataclass holding the per-kind fields
(mean/stddev/min/max/unit, or earliest/latest, or examples). SpecCache
loads/saves the whole table to JSON.
The cache would be a trap if it never noticed the schema changed: edit a field's type and you'd keep getting stale (or randomly-fallen-back) values. To avoid that, every cache entry stores an md5 fingerprint of that field's JSON schema alongside the spec:
"demographic/month_birth": {
"fingerprint": "63f969cda984d46534b3905cc3f20e47",
"spec": {"kind": "numeric", "mean": 6.5, "stddev": 3.4, "minimum": 1, "maximum": 12, "unit": "month"}
}The fingerprint is computed in generator._build_request from the resolved
property schema (type, enum, pattern, bounds, description — anything that affects
generation). On each run, warmup() compares it against the cached one:
- unchanged (fingerprint matches) → reuse the cached spec, no API call;
- changed (a property's type/bounds were edited) → the md5 differs, so just that field is re-estimated and its entry overwritten;
- new field → no entry yet, so it's estimated;
- removed field → its stale entry is simply ignored.
So pointing the tool at an edited schema refreshes only the affected fields — not
the whole table. Need everything regenerated regardless? Pass --refresh-llm to
ignore the cache and re-estimate every field. Old cache files written before
fingerprinting still load (their entries just lack a fingerprint, so they rebuild
once on the next run).
.env carries three settings; the key itself is never stored in the repo or
in .env — only a path to a key file:
.env → LLM_PROVIDER=anthropic|openai (which vendor)
LLM_MODEL=claude-haiku-4-5 (which model)
LLM_API_KEY_FILE=/path/to/keyfile (a PATH, gitignored)
keyfile (outside the repo) → sk-... (the actual key)
load_llm_config() reads these (CLI --llm-provider / --llm-model override
.env), follows the key-file path, and returns an LLMConfig(provider, model, api_key). The CLI picks AnthropicSpecSource or OpenAISpecSource from
provider. Anything missing or invalid (unknown vendor, no model, missing/empty
key file) raises a clear ConfigError instead of a confusing 401 later.
Putting it together. Say we're generating a demographic record with the LLM
provider:
demographiclinks toclinical_descriptor. The registry already has clinical_descriptor records (it came earlier in the order), so the link becomes{"submitter_id": "clinical_descriptor_<guid>"}— a real generated parent id.sexis an enum →field_kindsaysother→ random pick:"female".month_birthis an integer →numeric→ cached spec{mean 6.5, std 3.4, min 1, max 12}→gaussthen clamp →4.bmi_baselineis a number →numeric→{mean 27, std 5, min 12, max 60}→26.81….baseline_datematches a date pattern →date→ real date in1990–2020renderedYYYY-MM-DD→"2004-08-17".id,state,project_id, … are system properties → not emitted.
Result: a record that reads like a real participant and passes validation.
- System properties are dropped. Gen3 nodes set
additionalProperties: false, and fields likeid,state,created_datetimeare assigned by the server. We emit declared properties minussystemProperties.typeandsubmitter_idsurvive (they come from the shared "ubiquitous properties", not fromsystemProperties). - Referential integrity is free because of the topological order: when we generate node X, every node X links to already exists in the registry.
programis never generated (it's not submittable). Theproject's requiredprogramslink is filled with a synthesized placeholder so the output still validates.- Subgroup links are flattened. A file node can link to several parents at
once via a
subgroup;links.py::extract_linksreturns oneLinkSpecper member so every foreign key is emitted. - Patterns matter for strings. A string with a regex
pattern(UBERON, ORCID, md5sum, dates) is generated to match it. The random provider usesrstr; the LLM date path generates a real date and verifies the match. - Determinism. All randomness flows through a single seeded
random.Random. Same--seed⇒ identical output (after warmup, for the LLM provider, since the cache is fixed). --setconstants bypass generation, not validation. An overridden property never reaches the provider (or the LLM warmup), but the constant is type-coerced up front and the output still runs through self-validation — a constant that violates a stringpatternmakes the run refuse to write.
poetry install
# random values
poetry run gen3-metadata-simulator generate -s examples/jsonschema/acdc_schema_v1.1.5.json -n 30 --seed 1
# realistic values (needs .env → LLM_API_KEY_FILE; see docs/usage.md)
poetry run gen3-metadata-simulator generate -s examples/jsonschema/acdc_schema_v1.1.5.json \
--provider llm --llm-model claude-haiku-4-5 -n 5 --seed 1Runs are quiet by default. Add --verbose to see milestones (including the LLM
warmup cache breakdown: fields reused vs re-estimated, and API calls made), or
--debug for per-item detail and full tracebacks. Logging uses the standard
logging module, one logger per module (logging.getLogger(__name__)); the CLI
just sets the level via configure_logging.
poetry run python3 -m pytest -qTests are fully offline — the LLM tests inject a fake SpecSource or mock
the Anthropic client, so no key or network is needed. The most important test is
the round-trip (test_roundtrip.py and test_roundtrip_llm.py): generate →
validate → assert zero errors. If you change generation, that's the test to
watch.
Want values from, say, a CSV of real distributions instead of an LLM? Implement the interface and plug it into the CLI:
from gen3_metadata_simulator.providers.base import ValueProvider
class CsvValueProvider(ValueProvider):
def value(self, req): ... # return one value for req
# warmup() is optional — override if you need a pre-passNothing in generator.py, writers.py, or validation.py changes — they only
know the interface.
The LLM provider doesn't care who produces specs, only that they implement
SpecSource.estimate(...). To use a different model vendor or a local file,
write a new SpecSource and pass it to LLMValueProvider.
| File | Responsibility |
|---|---|
cli.py |
Typer CLI: generate and validate commands, flag parsing, provider wiring |
schema.py |
Load + resolve the schema; expose resolved nodes and the submittable set |
ordering.py |
Topological sort → generation/import order |
links.py |
Read a node's links, flattening subgroups → LinkSpecs |
registry.py |
Remember generated records so children can link to real parents |
generator.py |
The record factory + the per-run orchestration |
validation.py |
Run gen3_validator and summarize failures |
writers.py |
Write <node>.json files and DataImportOrder.txt |
config.py |
Load the LLM API key via the LLM_API_KEY_FILE indirection |
providers/base.py |
ValueProvider interface + ValueRequest |
providers/random_provider.py |
Random, schema-driven values (default) |
providers/constant.py |
ConstantValueProvider — pins --set properties, delegates the rest |
overrides.py |
Parse --set NAME=VALUE and resolve/coerce against the schema |
providers/classify.py |
field_kind — route a field to numeric/date/text/other |
providers/specs.py |
FieldSpec, SpecCache, SpecSource, AnthropicSpecSource |
providers/dates.py |
Real-calendar-date generation rendered to a pattern |
providers/llm_provider.py |
LLMValueProvider — ties specs + dates + random together |
errors.py |
Typed exceptions (InvalidGen3SchemaError, ConfigError, …) |
For every CLI flag and option, see usage.md.
- node — a type in the Gen3 dictionary (≈ a table), e.g.
sample. - record — one generated instance of a node (≈ a row).
- link — a foreign key from one node to another; rendered as a nested
{"submitter_id": ...}(or{"code": ...}for the project). - submitter_id — a record's human-readable unique id; how other records refer to it.
- resolved schema — the schema after all
$refs have been inlined. - topological order — an ordering where every node comes after the nodes it depends on (its link targets).
- spec /
FieldSpec— the LLM's hint for one field (distribution + limits, or a date window, or example strings). - warmup — the one-time pass that fills the spec cache before generation.