From 1ffad800ef3277077d7aeb6b18c1bf22db606c1b Mon Sep 17 00:00:00 2001 From: Marty Bradley Date: Wed, 8 Jul 2026 08:39:13 -0400 Subject: [PATCH] Add govkit-synthetic-data skill (Faker-based scenario test data generator) Generate synthetic test data for a GovKit feature package from its Gherkin scenarios: a seeded, standalone Python Faker generator script plus committed data files and a MANIFEST coverage map tracing every dataset back to the scenario it exercises. - Tool-agnostic: the generator/project boundary is data files (CSV/JSONL/SQL), so the target stack can be anything; only the generator is Python. - Deterministic and regenerable (single seed, stdlib + faker only), with a synthetic marker on every record and guardrails against inventing business rules or touching real production data. - Includes SKILL.md, references/faker-patterns.md, and a standalone scripts/generator_template.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../skills/govkit-synthetic-data/SKILL.md | 157 ++++++++++++++++ .../references/faker-patterns.md | 110 ++++++++++++ .../scripts/generator_template.py | 169 ++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 plugins/govkit/skills/govkit-synthetic-data/SKILL.md create mode 100644 plugins/govkit/skills/govkit-synthetic-data/references/faker-patterns.md create mode 100644 plugins/govkit/skills/govkit-synthetic-data/scripts/generator_template.py diff --git a/plugins/govkit/skills/govkit-synthetic-data/SKILL.md b/plugins/govkit/skills/govkit-synthetic-data/SKILL.md new file mode 100644 index 0000000..3e7eb70 --- /dev/null +++ b/plugins/govkit/skills/govkit-synthetic-data/SKILL.md @@ -0,0 +1,157 @@ +--- +name: govkit-synthetic-data +description: > + Generate synthetic test data for a GovKit feature package using Python Faker — + a seeded, repeatable generator script plus committed data files derived from the + feature's Gherkin scenarios. Use whenever the user asks to "generate test data", + "create synthetic data", "seed data", "fixtures", "fake data", "sample records", + or "load-test data" for a feature, work item, or acceptance.feature — even if + they don't say GovKit or Faker. Also use after a feature package passes readiness + and the team needs data to exercise the scenarios. Works for any target stack; + only the generator itself is Python. +--- + +# GovKit Synthetic Data Skill + +## Purpose + +Turn an approved feature package into the synthetic data its scenarios need: a seeded Python Faker script committed next to the feature, plus the generated data files, plus a manifest that maps every dataset back to the scenario it exercises. + +The script is the deliverable, not just the data. Data files rot; a seeded script regenerates them identically in CI, survives schema tweaks with a one-line edit, and shows reviewers exactly how every value was produced. Faker output is inherently synthetic — no value is ever copied from a real person or record — which is what makes this safe in a government context. + +## Tool-agnostic design + +Like the other GovKit skills, this one runs regardless of team tooling. The target project may be C#, Java, Go, or anything else — that does not matter, because the boundary between the generator and the project is **data files, not code**. The generator needs only `python` and `faker`; the project consumes CSV, JSONL, or SQL like it would consume any other test asset. + +## Key terms + +- **Feature package** — the repo files that carry the approved spec: `/features//` containing `acceptance.feature`, `nfrs.md`, `eval_criteria.yaml`. +- **Data profile** — the entity/field/constraint model extracted from the package before any data is generated (see process step 2). +- **Coverage map** — the manifest table linking each generated dataset to the scenario(s) it serves and the case type (happy, boundary, negative, volume). + +## Scope + +Use it for: + +- Generating scenario-derived test data for a feature package +- Producing seeded, regenerable Faker scripts committed to the repo +- Volume/load datasets when NFRs specify throughput or scale thresholds +- Refreshing or extending data after the feature package changes + +Do not use it for: + +- Inventing business rules, valid ranges, or field formats the package doesn't state (ask the human — same guardrail as the rest of GovKit) +- Anonymizing or masking real production data (that is a different discipline; this skill never touches real records) +- Validating the feature package itself (use the readiness skill) +- Writing step definitions or implementation code + +## Inputs + +Read the feature package: + +```text +/features// + acceptance.feature (required — the source of scenarios and coverage) + nfrs.md (volumes, performance thresholds → volume datasets) + eval_criteria.yaml (distributions, thresholds → data shape constraints) +``` + +If `acceptance.feature` is missing, stop and say so — without scenarios there is nothing to derive coverage from. If the user has no feature package at all and just wants bulk data from a schema they describe, you can still help: skip to step 3 with their schema as the data profile, and say clearly that coverage is schema-driven, not scenario-derived. + +## Process + +### 1. Parse the feature package + +Read `acceptance.feature`. From each scenario, extract: + +- **Entities and fields** — nouns in Given/When/Then steps, columns in data tables and Examples tables +- **Constraints** — explicit values, ranges, formats, and states the steps assert +- **Relationships** — references between entities ("the citizen's application", "cases assigned to the reviewer") + +From `nfrs.md`, extract volume requirements (e.g., "handles 10,000 concurrent applications" → a volume dataset of that size). From `eval_criteria.yaml`, extract any distribution or threshold that constrains data shape (e.g., "95% of records complete" → generate 5% with missing optional fields). + +### 2. Build and confirm the data profile + +Write out the profile before generating anything: each entity, its fields, the type and Faker provider for each field, constraints, and cross-entity relationships. + +Where the package is silent — a field's valid range, an ID format, a state machine — do not guess. List the gaps and ask the human, or record an explicit assumption in the manifest if the human has delegated that call. Invented business rules in test data are worse than none: they encode a wrong spec into every test that uses the data. + +Consult `references/faker-patterns.md` for provider selection, locale, custom providers for domain formats (case numbers, agency codes), relational integrity, and edge-case injection techniques. + +### 3. Plan coverage from the scenarios + +For each scenario, decide which datasets it needs. Default coverage per scenario: + +| Case type | What it is | Cardinality | +|---|---|---| +| Happy | Values squarely inside every constraint | Zero, one, and many records (empty set, single record, and a batch) | +| Boundary | Values at the exact edges the steps assert (min, max, length limits, date cutoffs) | One record per boundary | +| Negative | Values that violate one constraint at a time, each labeled with which constraint it breaks | One record per violated constraint | +| Volume | NFR-driven scale (only when NFRs specify it) | The NFR's number | + +The zero/one/many spread on happy-path data matters because collection-handling bugs cluster at exactly those cardinalities. Negative records violate one rule each so a failing test points at one rule. + +Pick output formats by consumer, not by habit: CSV for tabular/bulk-load data, JSONL for API and document-shaped records, SQL inserts when the feature seeds a relational database, and Gherkin Examples tables when the team wants data pasted directly back into `acceptance.feature`. Ask if unclear; CSV + JSONL is the safe default. + +### 4. Write the generator script + +Create `/features//test_data/generate_test_data.py`, starting from `scripts/generator_template.py` (copy it, don't reference it — the committed script must stand alone). Requirements the template already handles, preserve them: + +- **Deterministic**: single seed constant, set via `Faker.seed()` AND `random.seed()`, overridable by `--seed`. Same seed → byte-identical output. +- **Standalone**: stdlib + `faker` only. No project imports, no other packages. +- **Self-describing**: `--help` works; header comment names the work item, seed, and faker version. +- **Format writers**: CSV, JSONL, SQL insert emitters included; use only the ones this feature needs. + +Every record carries a `synthetic` marker appropriate to the format (a `_synthetic: true` field, a CSV column, or a SQL comment header) so a stray file can never be mistaken for real data. + +### 5. Run, verify, and write the manifest + +Run the script twice; diff the outputs. Byte-identical or it's not done — nondeterminism here silently breaks test reproducibility later. Then spot-check: constraints hold, negative records violate exactly the rule they claim, volumes match. + +Commit alongside the data a `MANIFEST.md` containing: + +```markdown +# Synthetic Test Data — +Generated by generate_test_data.py | seed: | faker: | python: +ALL DATA IS SYNTHETIC (Python Faker). No real records were used or referenced. + +## Coverage map +| Dataset file | Scenario(s) | Case type | Records | Notes | +|---|---|---|---|---| + +## Assumptions + + +## Regenerate +python generate_test_data.py --seed +``` + +## Output layout + +```text +/features//test_data/ + generate_test_data.py + MANIFEST.md + data/ + _happy.csv / .jsonl + _boundary.jsonl + _negative.jsonl + _volume.csv (only when NFR-driven) + seed_.sql (only when SQL requested) +``` + +## Guardrails + +Do not: + +- Invent business rules, formats, or ranges the package doesn't state +- Use, copy, or derive values from real production data +- Emit data without the synthetic marker and manifest +- Leave the generator nondeterministic or dependent on the target project's code + +Always: + +- Trace every dataset to a scenario (or to a stated NFR/schema request) +- Record every assumption in the manifest with an owner +- Verify determinism by regenerating and diffing before calling it done +- Keep the script runnable with nothing but `pip install faker` diff --git a/plugins/govkit/skills/govkit-synthetic-data/references/faker-patterns.md b/plugins/govkit/skills/govkit-synthetic-data/references/faker-patterns.md new file mode 100644 index 0000000..051ea68 --- /dev/null +++ b/plugins/govkit/skills/govkit-synthetic-data/references/faker-patterns.md @@ -0,0 +1,110 @@ +# Faker Patterns for GovKit Synthetic Data + +Provider selection, domain formats, relational integrity, and edge-case injection. Read this while building the data profile (process step 2) and writing record builders (step 4). + +## Contents + +- Common providers by field type +- Determinism rules +- Domain formats and custom providers +- Weighted and state-dependent values +- Relational integrity across entities +- Injecting boundary and negative values +- Locale + +## Common providers by field type + +| Field | Provider | Note | +|---|---|---| +| Person name | `fake.name()`, `first_name()`, `last_name()` | | +| Email | `fake.email()` | domain is fake; use `fake.company_email()` for org-style | +| Phone | `fake.phone_number()` | format varies; normalize if the spec fixes a format | +| Street address | `fake.street_address()`, `fake.address()` | `address()` contains newlines — avoid in CSV | +| City/State/ZIP | `fake.city()`, `fake.state_abbr()`, `fake.zipcode()` | | +| SSN-format ID | `fake.ssn()` | synthetic by construction; fine per project ruling, keep the `_synthetic` marker | +| EIN | `fake.ein()` | | +| Date in range | `fake.date_between(start_date="-2y", end_date="today")` | always bound both ends | +| Timestamp | `fake.date_time_between(...)` | serialize ISO-8601 | +| UUID | `fake.uuid4()` | deterministic under `Faker.seed()` | +| Money | `round(random.uniform(lo, hi), 2)` | prefer `random` over `fake.pydecimal` for readable control | +| Free text | `fake.sentence()`, `fake.paragraph()` | cap length if the spec has field limits | +| Company/agency | `fake.company()` | see custom providers for real-format agency codes | + +## Determinism rules + +The whole value of the committed script is identical regeneration. These are the ways it silently breaks: + +1. Seed both generators: `Faker.seed(n)` then `random.seed(n)` — Faker's class-level seed does not cover direct `random.*` calls. +2. Never call `datetime.now()` / `date.today()` in record builders — "today" changes daily. `fake.date_between(end_date="today")` has the same problem: prefer explicit end dates (`end_date=date(2026, 7, 7)`) frozen at authoring time. +3. Generation order is part of determinism — build datasets in a fixed order; don't generate from dict/set iteration whose order isn't guaranteed. +4. `json.dumps(..., sort_keys=True)` and fixed CSV column order keep byte-identical diffs. +5. Record the faker version in the manifest; provider output can change between major versions. If CI regenerates, pin `faker==X.Y.Z`. + +## Domain formats and custom providers + +Feature packages often use domain identifiers Faker has no provider for (case numbers, permit IDs, agency codes). Use `fake.bothify` / `fake.numerify` with the format from the package: + +```python +case_number = fake.bothify("CASE-####-??").upper() # CASE-4821-XK +permit_id = fake.numerify("P-2026-%%%%%%") # P-2026-483920 +``` + +If the format isn't stated in the package, that's a data-profile gap — ask, don't invent (a plausible-looking wrong format will pass tests that should fail). + +For a reusable domain vocabulary, register a provider: + +```python +from faker.providers import DynamicProvider + +agency_provider = DynamicProvider( + provider_name="agency_code", + elements=["DMV", "DHS", "DOL", "HUD"], # from the package or the human +) +fake.add_provider(agency_provider) +fake.agency_code() +``` + +## Weighted and state-dependent values + +When eval criteria specify distributions ("95% complete", "80% approved"): + +```python +status = random.choices( + ["approved", "denied", "pending"], weights=[80, 15, 5], k=1)[0] +``` + +When one field constrains another (a denied application has a denial reason, an approved one doesn't), branch inside the builder so every record is internally consistent — scenario steps assert on combinations, not lone fields: + +```python +rec["status"] = random.choice(["approved", "denied"]) +rec["denial_reason"] = fake.sentence() if rec["status"] == "denied" else None +``` + +## Relational integrity across entities + +Generate parents first, then draw foreign keys from the actual parent IDs: + +```python +citizens = [build_citizen(fake) for _ in range(50)] +citizen_ids = [c["id"] for c in citizens] +applications = [build_application(fake, random.choice(citizen_ids)) + for _ in range(200)] +``` + +Never `fake.uuid4()` a foreign key independently — orphaned references make relational loads fail for reasons unrelated to the feature. If a scenario needs an orphan (a negative case), create it deliberately and label it `_violates="citizen_fk"`. + +## Injecting boundary and negative values + +Build a valid record first, then overwrite the one field under test — this keeps every other field valid, so the test isolates one rule: + +```python +rec = build_example_record(fake) +rec.update(_case="name_max_length", full_name="X" * 100) # boundary: exactly at limit +rec.update(_violates="name_max_length", full_name="X" * 101) # negative: one past it +``` + +Boundary values come from the scenarios' asserted limits, not from guesses. Typical families when the package states them: length limits (at, one under, one over), date cutoffs (day-of, day-before, day-after), numeric ranges (min, max, min-1, max+1), required fields (present vs `None` vs empty string — these are three different bugs). + +## Locale + +Default `en_US`. If the feature serves multilingual populations and scenarios say so, generate a labeled slice with other locales (`Faker(["en_US", "es_ES"])` picks randomly; seed still applies). Non-ASCII names are themselves a useful edge case for encoding bugs — include a few even in en_US data via e.g. `"José", "Nguyễn"` literals in boundary sets when text handling is in scope. diff --git a/plugins/govkit/skills/govkit-synthetic-data/scripts/generator_template.py b/plugins/govkit/skills/govkit-synthetic-data/scripts/generator_template.py new file mode 100644 index 0000000..b942dbf --- /dev/null +++ b/plugins/govkit/skills/govkit-synthetic-data/scripts/generator_template.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Synthetic test data generator — . + +Generated for the GovKit feature package at /features//. +ALL OUTPUT IS SYNTHETIC (Python Faker). No real records were used. + +Deterministic: the same --seed always produces byte-identical output. +Dependencies: python 3.9+ and `pip install faker` — nothing else. + +Usage: + python generate_test_data.py # default seed, all datasets + python generate_test_data.py --seed 42 + python generate_test_data.py --outdir data/ +""" + +import argparse +import csv +import json +import random +from datetime import date, datetime +from pathlib import Path + +from faker import Faker + +DEFAULT_SEED = 20260707 # fixed so regeneration is reproducible; override with --seed +LOCALE = "en_US" + + +# --------------------------------------------------------------------------- +# Determinism setup — seed BOTH faker and stdlib random, in this order. +# --------------------------------------------------------------------------- +def make_faker(seed: int) -> Faker: + Faker.seed(seed) + random.seed(seed) + return Faker(LOCALE) + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- +def _jsonable(value): + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + +def write_jsonl(records: list, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as f: + for rec in records: + f.write(json.dumps({k: _jsonable(v) for k, v in rec.items()}, + sort_keys=True) + "\n") + + +def write_csv(records: list, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not records: + # An intentionally empty dataset still gets written so loaders can + # detect the zero-cardinality case. Supply columns explicitly if a + # header row is required. + path.write_text("", encoding="utf-8") + return + fields = list(records[0].keys()) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fields, lineterminator="\n") + writer.writeheader() + for rec in records: + writer.writerow({k: _jsonable(v) for k, v in rec.items()}) + + +def write_sql(records: list, table: str, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"-- SYNTHETIC DATA (Python Faker) for table {table}. Not real records.", + "BEGIN;"] + for rec in records: + cols = ", ".join(rec.keys()) + vals = ", ".join(_sql_literal(v) for v in rec.values()) + lines.append(f"INSERT INTO {table} ({cols}) VALUES ({vals});") + lines.append("COMMIT;") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _sql_literal(value) -> str: + if value is None: + return "NULL" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, (date, datetime)): + return f"'{value.isoformat()}'" + return "'" + str(value).replace("'", "''") + "'" + + +# --------------------------------------------------------------------------- +# Record builders — REPLACE with entities from the feature package's +# data profile. Every record carries the synthetic marker. +# --------------------------------------------------------------------------- +def build_example_record(fake: Faker) -> dict: + """One happy-path record. Field set comes from the data profile — + every field traces to a Given/When/Then step or data table column.""" + return { + "_synthetic": True, + "id": fake.uuid4(), + "full_name": fake.name(), + "email": fake.email(), + # Dates frozen at authoring time — never "today"/"now", which would + # make regeneration drift day by day. Take the window from the spec. + "submitted_on": fake.date_between(start_date=date(2025, 7, 7), + end_date=date(2026, 7, 7)), + "status": random.choice(["received", "in_review", "approved", "denied"]), + } + + +def happy_records(fake: Faker, count: int) -> list: + return [build_example_record(fake) for _ in range(count)] + + +def boundary_records(fake: Faker) -> list: + """One record per boundary the scenarios assert (min/max/limits/cutoffs). + Tag each with _case so a failing test names the boundary it hit.""" + records = [] + # Example: earliest allowed submission date + rec = build_example_record(fake) + rec.update(_case="submitted_on_min", submitted_on=date(2025, 7, 7)) + records.append(rec) + return records + + +def negative_records(fake: Faker) -> list: + """One record per violated constraint — exactly one violation each, + named in _violates, so failures point at a single rule.""" + records = [] + rec = build_example_record(fake) + rec.update(_violates="email_format", email="not-an-email") + records.append(rec) + return records + + +# --------------------------------------------------------------------------- +# Main — zero/one/many happy sets, boundary, negative, optional volume +# --------------------------------------------------------------------------- +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--seed", type=int, default=DEFAULT_SEED) + parser.add_argument("--outdir", type=Path, default=Path("data")) + parser.add_argument("--volume", type=int, default=0, + help="record count for the NFR volume dataset (0 = skip)") + args = parser.parse_args() + + fake = make_faker(args.seed) + out = args.outdir + + # Zero / one / many — collection bugs cluster at these cardinalities. + write_jsonl([], out / "example_happy_zero.jsonl") + write_jsonl(happy_records(fake, 1), out / "example_happy_one.jsonl") + write_jsonl(happy_records(fake, 25), out / "example_happy_many.jsonl") + + write_jsonl(boundary_records(fake), out / "example_boundary.jsonl") + write_jsonl(negative_records(fake), out / "example_negative.jsonl") + + if args.volume: + write_csv(happy_records(fake, args.volume), out / "example_volume.csv") + + print(f"Generated synthetic datasets in {out}/ (seed={args.seed})") + + +if __name__ == "__main__": + main()