Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions plugins/govkit/skills/govkit-synthetic-data/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<work-item-id>/` 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/<work-item-id>/
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/<work-item-id>/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 — <work-item-id>
Generated by generate_test_data.py | seed: <N> | faker: <version> | python: <version>
ALL DATA IS SYNTHETIC (Python Faker). No real records were used or referenced.

## Coverage map
| Dataset file | Scenario(s) | Case type | Records | Notes |
|---|---|---|---|---|

## Assumptions
<every rule not stated in the feature package, and who approved it>

## Regenerate
python generate_test_data.py --seed <N>
```

## Output layout

```text
/features/<work-item-id>/test_data/
generate_test_data.py
MANIFEST.md
data/
<entity>_happy.csv / .jsonl
<entity>_boundary.jsonl
<entity>_negative.jsonl
<entity>_volume.csv (only when NFR-driven)
seed_<db>.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`
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading