Skip to content

Architecture

polspec is a small Python package over a Rust extension. The Python side owns the vocabulary — what a column can declare and what that means; the Rust side owns only the inner loop that fills arrays with values.

Modules

Module Responsibility
bound An inclusive [min, max], either end optionally open
check A named boolean expression, with SQL-style null handling
constants Default generation ranges
dtypes What each dtype can actually hold
distributions The distributions available, and each one's parameter aliases
spec ColSpec — one column's declaration, and everything it validates about itself
rules ColRule — conditional values, and the pass that applies them
foreign_key ForeignKey — declaration, and the pass that makes generated keys consistent
engine Turning a spec into the ColumnPlan the Rust extension takes, and finishing the result: gathering typed choices, casting temporal columns back
_ffi The only module that imports the Rust extension (lazily), building plans and re-raising its errors as GenerationError
errors The PolspecError hierarchy
constraints What both sides read: Domain (the values a column may hold) and Pass/order (which rewrite of a generated frame runs first)
validation inspect and validate over a TableSpec: every claim becomes a _Constraint that produces a Finding. The constraints/ package holds them by kind: _values (one value's domain, bounds, length, format, pattern, lifted over a List's elements), _rules, _table (composite keys, checks), _relations (foreign keys, hierarchy); report.py holds Finding and ValidationReport
tablespec TableSpec — a spec as an immutable value, with its declaration-time checks and structural operations
framespec FrameSpec — the metaclass that builds a TableSpec from a class body, and the facade forwarding every verb to it
generation generate, generate_batches and the file sinks, as functions over a TableSpec; composite.py separates a __unique_together__ group; seeds.py keys every pass's seed by name, as the engine keys columns
catspec CatSpec — a shared registry of enums and categoricals, as a value, plus the metaclass that builds one from a class body (the same split as tablespec/framespec)
registry Registry — a declared set of specs: resolving cross-spec keys, ordering parents before children, generate_all/validate_all, one file and one diagram for the set
serialization Spec files: a field registry (fields.py) that YAML, generated Python and the import datetime decision all derive from; the dtype codec table (dtypes.py); format versions and migrations (migrations.py)
profiler Inferring a spec from an existing DataFrame
report Rendering a spec, or a registry of them, as Markdown or Mermaid
cli The polspec command, one module per verb: _schema (infer, new), _data (validate, generate), _drift (diff, drift), _test; _io holds the readers, writers and spec loaders they share

The dependency direction is one-way: spec and tablespec know nothing about framespec, and report is not reachable from either the generation or validation path.

Generating

flowchart LR
    A["FrameSpec.generate(n, seed)"] --> B["_generate_random<br/>or _generate_cartesian"]
    B --> C["_plan_column: one<br/>ColumnPlan per column"]
    C --> D["Rust: generate_dataframe<br/>columns in parallel"]
    D --> E["_finish: gather typed choices,<br/>cast temporal columns back"]
    E --> F["order the passes<br/>by reads and writes"]
    F --> G["each pass: rules, foreign keys,<br/>composite-key repair"]
    G --> H[DataFrame]

Each column becomes a ColumnPlan — kind, nullability, exact bounds, domain size and weights, lengths, distribution — crossing into Rust once. Rust fills the columns in parallel, and within a column in 65,536-row chunks whose seeds come from the chunk index, so output is identical regardless of thread count.

For a fixed-width column a chunk is a unit of work, not a unit of storage. The values buffer and the validity bitmap are each allocated once at the column's full length, and a chunk fills its own disjoint slice of both — which is why the chunk size is a multiple of 8, so the bitmap divides on a byte boundary and no two threads touch the same byte. The column reaches Polars as a single chunk, so nothing downstream — the gather behind a choices domain, the cast behind a temporal dtype, a sink_* write — pays for a column split into hundreds of pieces.

String columns are the exception: a row's width is not known until it is drawn, and Polars backs them with view arrays, which merge by copying sixteen bytes of view per row. That costs more than the split it would remove, so a long string column stays chunked.

Rules and foreign keys are applied afterwards as vectorised passes over the finished frame, not row by row. Each pass declares the columns it reads and the ones it writes, and constraints.order runs them so no pass reads a column a later one rewrites: a rule keyed on a foreign-keyed column sees the parent's values, and a self-referencing key drawing from a foreign-keyed column draws from values that are actually there. That ordering is what makes generated data satisfy the same claims validation checks it against — which is why a spec whose passes cannot be ordered is refused at declaration rather than generated and then failed by its own spec.

Seeds are drawn per pass in declaration order, so which order they end up running in does not change the values any one of them samples.

A unique=True column never reaches a pass: the engine draws it without replacement in the first place (src/unique.rs), shuffling a materialised domain when the domain is barely bigger than the frame and rejecting against a set when it is roomy. A __unique_together__ group is a pass, because distinctness across columns can only be judged once they all exist: it resamples the rows repeating a combination, and reads every member so it runs after the rules and keys that settle them.

Validating

flowchart LR
    A["FrameSpec.inspect(df) / validate(df)"] --> B[Structural checks]
    B --> C["Build one _Constraint<br/>per declared claim"]
    C --> D["One Polars aggregation<br/>over the whole frame"]
    D --> E["Each constraint turns its<br/>result into a Finding"]
    E --> F["ValidationReport<br/>(what inspect returns)"]
    F -->|validate: findings| G[ValidationError carrying the report]
    F -->|validate: none| H["Drop / add / cast / reorder"]

Every claim a spec makes becomes a _Constraint that contributes aggregation expressions and turns the results back into a Finding: a code, a count, samples, code-specific details, and a lazy filter that locates the rows. They are collected first and evaluated together, so validating a wide table costs one scan rather than one per column. Foreign keys are the exception: each needs its own anti-join against a parent frame.

Adding a new kind of check means adding a class, not editing two distant loops.

The Python / Rust boundary

Python builds one ColumnPlan per column -- a #[pyclass] in src/plan.rs that validates itself at construction, so an unknown kind, a weight vector of the wrong length or a distribution parameter out of range is refused with a message naming the column before any sampling starts. polspec/_ffi.py is the only module that imports the extension, lazily: validation, spec files and the registry work without a built extension, and only generation asks for one.

Rust knows about kinds, not about polspec's vocabulary: int8 .. uint64, float32/float64, bool, string, and index. Date crosses as an int32 day count and Datetime/Duration/Time as an int64 in their own unit. Anything with a finite domain -- choices, an Enum, a capacity-limited Categorical -- crosses as index with the domain's size and weights; Rust returns UInt32 indices and Python gathers the typed values back, so a datetime or a bytes choice never passes through a string.

Bounds cross as a Limit: an i64, a u64 or an f64, whichever holds the Python value exactly, so Int64 and UInt64 bounds keep every bit.

Distribution parameter aliases live only in polspec/distributions.py and are resolved when a column is declared; src/dist.rs reads canonical keys and exports its table as distribution_params(), which a test compares with the Python one. Each column's seed is derived from the frame seed and the column name (sample.rs), so inserting a column never reshuffles its neighbours. src/sample.rs and src/unique.rs have no Python types and carry the unit tests cargo test runs; python/polspec/_polspec.pyi is the stub, and a test asserts its names match the module.

Tests

File Covers
test_roundtrip.py The property tying the two directions together: anything generate() produces, validate() accepts
test_declaration.py Declaration-time contracts that never reach generated data
test_generation.py Random and cartesian generation, dtype coverage, distributions, weights
test_rules.py ColRule: what a rule may declare and which rows it touches
test_serialization.py to_yaml/from_yaml and to_python, and what they warn about and drop
test_profiler.py from_dataframe inference
test_framespec.py The class body: inheritance, tags, __checks__, __unique_together__, validators
test_report.py Markdown data dictionaries and Mermaid diagrams
test_foreign_key.py ForeignKey declaration, persistence and generation
test_validation.py Validation behaviour and error reporting
test_inspect.py inspect(): findings as data, lazy failing rows, JSON
test_tablespec.py TableSpec as a value: construction, structural operations, the metaclass
test_expr.py The col() predicate language and its data form
test_serialization_format.py The field registry, format versions, migrations, unknown keys
test_registry.py Registry: resolution, ordering, generate_all/validate_all, files, discovery
test_errors.py The exception hierarchy
test_catspec.py Shared category registries: both declaration forms, and that they agree
test_streaming.py Batching and the file sinks
test_cli.py The command line, including running a generated test file under pytest
test_engine.py The Python / Rust boundary: typed plans, exact bounds, typed choices, per-column seeds, the stub
test_constraints.py What both sides share: Domain, and the pass ordering that lets rules and keys see each other's work
test_docs.py That the documentation points at things that exist: every exported name, link and nav entry, and the generated llms.txt
test_doc_examples.py That the documentation's Python examples run

The round-trip file carries xfail(strict=True) markers for known gaps, so a fix turns the marker into a failure rather than passing unnoticed. See Known limitations.