API reference¶
Auto-generated from docstrings across the datagen and pipeline packages.
Configuration¶
config ¶
Configuration models for synthetic ERP/CRM data generation.
All generation behavior (volume, RNG seed, and "messiness" ratios) is
centralized here as a validated pydantic model so that a run is fully
described by a single, serializable object. This makes datasets reproducible
and lets configs be checked into version control (as YAML) for repeatable
demos and tests.
GenerationConfig ¶
MessinessConfig ¶
Bases: BaseModel
Ratios (0.0-1.0) controlling how much "bad data" is injected.
Each ratio is applied independently and per export target (ERP vs. CRM), so the two system exports diverge from each other realistically instead of being corrupted identically.
Canonical entities¶
identities ¶
Canonical ("ground truth") entity models.
These represent the single source of truth for each business entity before it is projected into system-specific representations (ERP CSV vs. CRM JSON) and before cosmetic messiness (bad date formats, missing values, encoding issues) is layered on at export time.
Structural discrepancies that a reconciliation pipeline must detect --
records existing in only one system, or a payment amount that legitimately
differs between systems -- are decided here, since they require knowledge of
both systems at once. Cosmetic messiness is applied later, per system, in
datagen.messiness.
Shared utilities¶
rng ¶
Seeded random-number-generator utilities.
To keep dataset generation fully reproducible while still letting each
component (customers, invoices, payments, per-system messiness, ...) draw
independent random streams, we derive child generators from a single master
seed via numpy.random.SeedSequence. This avoids the common pitfall
of reusing one global RNG (which makes output order-sensitive) while still
guaranteeing that a given master seed always reproduces the same dataset.
seeded_uuid4 ¶
Draw a random-looking UUID (version 4 bit layout) from a seeded RNG.
Using uuid.uuid4() directly would draw from OS randomness and break
reproducibility; this instead derives the 128 bits from the caller's
seeded generator so that a given master seed always yields identical
entity IDs.
Source code in src/datagen/rng.py
spawn_rngs ¶
Create one independent, reproducible RNG per name from a master seed.
Source code in src/datagen/rng.py
keys ¶
Business-facing key formatting.
ERP and CRM systems virtually never agree on ID formatting even when they
refer to the same underlying entity (one uses zero-padded numeric codes,
another prefixes differently, etc). These helpers derive both systems'
business keys from the same underlying sequence_number so a
reconciliation pipeline can be built to match them (e.g. by normalizing
digits) instead of relying on the internal correlation UUIDs, which are
never exported.
Generators¶
customers ¶
Faker-based synthetic customer generation.
generate_customers ¶
Generate count unique, deterministic customers for the given seed.
Source code in src/datagen/generators/customers.py
invoices ¶
mimesis-based synthetic invoice generation.
Structural cross-system discrepancies (an invoice existing in only one of ERP/CRM) are decided here since a reconciliation pipeline needs to be able to detect exactly this kind of divergence.
generate_invoices ¶
Generate invoices for each customer, per config volume and messiness settings.
Source code in src/datagen/generators/invoices.py
payments ¶
Synthetic payment generation.
Amount drift between ERP and CRM (representing real-world discrepancies such as bank fees or FX rounding) is decided here since it requires knowledge of both systems' views of the same payment.
generate_payments ¶
Generate payments against a subset of invoices, per config settings.
Source code in src/datagen/generators/payments.py
Messiness injectors¶
dates ¶
Inject inconsistent or invalid date formatting into date columns.
corrupt_dates ¶
Rewrite a random subset of cells in columns to an inconsistent or invalid date format.
Source code in src/datagen/messiness/dates.py
encoding ¶
Inject mixed-encoding / mojibake corruption into text columns.
Simulates the classic real-world bug where UTF-8 encoded text is misinterpreted as a single-byte codepage (e.g. CP-1252/Latin-1), which is a frequent source of "dirty" data when files are exchanged between systems that don't agree on encoding.
corrupt_encoding ¶
Corrupt a random subset of cells in columns with mojibake-style encoding artifacts.
Source code in src/datagen/messiness/encoding.py
missing ¶
Inject missing/null-like values into otherwise-populated columns.
inject_missing ¶
Replace a random subset of cells in columns with a missing-value marker.
Source code in src/datagen/messiness/missing.py
duplicates ¶
Inject near-duplicate rows into an export.
Real-world duplicate rows are rarely byte-identical (extra whitespace, case differences, re-keyed entries) so a random subset of columns on each duplicate row is lightly perturbed rather than copied verbatim.
add_duplicates ¶
Append near-duplicates of a random subset of rows, then shuffle row order.
exclude_columns should list ID/foreign-key columns that must not be
perturbed: corrupting a join key would silently orphan the duplicated
record (e.g. detaching an invoice from its customer) instead of
producing a realistic, detectable duplicate.
Source code in src/datagen/messiness/duplicates.py
mismatches ¶
Apply structural cross-system mismatches at export time.
Whether a payment amount should legitimately drift between ERP and CRM is
decided during generation (see datagen.generators.payments); this
module is where that decision is actually materialized into the ERP export's
amount column, since the CRM export always shows the canonical/true amount.
apply_amount_drift ¶
Add drift_column into amount_column and drop the now-internal drift column.
Source code in src/datagen/messiness/mismatches.py
Exporters¶
erp_csv ¶
ERP-style CSV export (legacy flat, upper-snake-case field names).
export_erp_csv ¶
Write ERP-style customers/invoices/payments CSVs under output_dir/erp.
Source code in src/datagen/exporters/erp_csv.py
crm_json ¶
CRM-style nested JSON export (camelCase field names, API-response shape).
export_crm_json ¶
Write a CRM-style nested customers.json under output_dir/crm.
Source code in src/datagen/exporters/crm_json.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | |
Ground truth¶
ground_truth ¶
Ground-truth reconciliation mapping.
Optionally emitted alongside a generated dataset, this file records the actual cross-system relationships the generator created -- which invoices/payments exist in both systems, which are orphaned in only one, and which payments have a legitimate ERP/CRM amount mismatch. A reconciliation pipeline's output can be scored against this file to measure precision and recall, since it captures answers that are otherwise undiscoverable purely by comparing the messy exports.
build_ground_truth ¶
Build a JSON-serializable ground-truth reconciliation mapping.
Source code in src/datagen/ground_truth.py
datagen CLI¶
cli ¶
Command-line interface for generating synthetic ERP/CRM datasets.
generate ¶
generate(
seed=None,
customers=None,
min_invoices=None,
max_invoices=None,
payment_coverage=None,
output_dir=None,
config_file=None,
with_ground_truth=None,
missing_value_ratio=None,
bad_date_ratio=None,
encoding_issue_ratio=None,
duplicate_ratio=None,
orphan_ratio=None,
amount_drift_ratio=None,
)
Generate a full synthetic ERP + CRM dataset (customers, invoices, payments).
Without --config, generation uses GenerationConfig
defaults. Any other flag that is explicitly passed always overrides the
base config (whether that base came from defaults or --config), so
--config base.yaml --output-dir out2/ behaves as expected.
Source code in src/datagen/cli.py
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | |
show_config ¶
Print the fully-resolved GenerationConfig as YAML, without generating anything.
Source code in src/datagen/cli.py
Pipeline models¶
models ¶
Target clean-entity schemas, quarantine entries, and the crosswalk.
CleanCustomer / CleanInvoice / CleanPayment describe the post-cleaning
record shape: one instance per source-system record (an ERP-side and a
CRM-side customer are each validated into this same schema, tagged by
source_system), validated before the record is allowed into entity
resolution. A field is required here only if reconciliation cannot function
without it (per CLAUDE.md's "Cleaning vs. quarantine" missing-values rule);
everything else stays optional so a record with a gap still participates in
resolution using whatever signal it does have.
CrosswalkEntry ¶
Bases: BaseModel
One row per matched, orphan, or ambiguous entity -- never just the matched ones.
DuplicateMergeLog ¶
Bases: BaseModel
Record of an intra-system near-duplicate cluster collapsed into one canonical row.
ReasonCode ¶
Bases: StrEnum
Quarantine reason codes, one per failure mode in CLAUDE.md's "Resolution outcomes".
Ingestion¶
ingest ¶
Raw ingestion of ERP CSV and CRM nested-JSON exports.
Reads files into polars DataFrames with their original column names and
all-Utf8 dtypes -- no cleaning or type coercion happens here (see
pipeline.cleaners for that). Keeping ingestion a pure I/O + flattening step
means messy values (bad date formats, missing-value markers, mojibake)
survive intact for the cleaning stage to handle explicitly, per CLAUDE.md's
"Cleaning vs. quarantine" rule.
ingest_all ¶
Read both the ERP and CRM exports under data_dir into raw, all-string tables.
Source code in src/pipeline/ingest.py
read_crm_tables ¶
Read crm/customers.json and flatten it into three flat, all-string DataFrames.
Source code in src/pipeline/ingest.py
read_erp_tables ¶
Read erp/customers.csv, erp/invoices.csv, and erp/payments.csv.
Source code in src/pipeline/ingest.py
Cleaners¶
duplicates ¶
Collapse intra-system near-duplicate rows, before validation or resolution.
datagen's duplicate injection (see src/datagen/messiness/duplicates.py)
never perturbs a duplicated row's ID/foreign-key columns -- only a random
other string column, by whitespace-padding it or upper-casing it. The CRM
JSON exporter is the one exception: since it nests rows into a tree keyed
by that same business key, it re-keys each duplicate with a -DUPn suffix
first so it doesn't collide (see datagen.exporters.crm_json._dedupe_join_key).
Stripping that suffix before grouping means both the flat ERP CSVs and the
nested CRM JSON collapse the same way. This is a separate, earlier problem
than cross-system entity resolution in pipeline.resolve: collapsing here
first means resolution never has to match against duplicated noise.
collapse_duplicates ¶
Collapse rows sharing the same key_column value into one canonical row.
Rows with a null/missing key are left untouched -- they can't be grouped by business key and are handled later as missing-required-field failures.
Source code in src/pipeline/cleaners/duplicates.py
missing ¶
Normalize missing-value markers to null.
datagen's missing-value injection (see src/datagen/messiness/missing.py)
replaces cells with one of a fixed set of markers: a true null, an empty
string, or a sentinel like "N/A". Recognizing all of them here means
downstream per-field required/optional handling only ever has to deal with
"present" vs. "null" -- not a grab bag of sentinel spellings.
clean_missing_markers ¶
Normalize missing-value markers to null across columns.
Source code in src/pipeline/cleaners/missing.py
normalize_missing_value ¶
Map a known missing-value marker to None; pass any other value through.
dates ¶
Parse messy date strings to ISO-8601, or flag them as unparseable.
datagen's date corruption (see src/datagen/messiness/dates.py) rewrites
a clean ISO date into one of a fixed set of alternate formats, a Unix
timestamp, or a known invalid placeholder ("0000-00-00", "TBD",
etc.). Trying every one of those formats -- not just a summarized subset --
before giving up is what makes this "cleaning" rather than a validation gate;
see CLAUDE.md's "Cleaning vs. quarantine" rule.
Assumes generic missing-value markers (see pipeline.cleaners.missing) have
already been normalized to null: a null input here means "legitimately
absent", not "invalid" -- only a non-null value that fails every format is
flagged as invalid.
clean_date_column ¶
Parse column to ISO-8601 strings in place; add a {column}_invalid flag column.
A row is flagged invalid when its raw value was non-null but failed to parse under any known format -- that's the signal a later validation stage uses to decide whether to quarantine (see CLAUDE.md's "unparseable_date" reason code).
Source code in src/pipeline/cleaners/dates.py
parse_date ¶
Parse a single date string, trying every format the generator can produce.
Returns None for an unparseable string, including known invalid
placeholders like "0000-00-00" or "TBD" (neither matches any
known format, so no special-casing is needed for them).
Source code in src/pipeline/cleaners/dates.py
encoding ¶
Repair UTF-8-misread-as-CP-1252 mojibake, or flag it as unrecoverable.
datagen's encoding corruption (see src/datagen/messiness/encoding.py)
inserts accented characters into otherwise-ASCII text, then encodes it as
UTF-8 and decodes those bytes as CP-1252. Reversing that round trip
(encode as CP-1252, decode as UTF-8) repairs it exactly; running the same
round trip on text that was never corrupted either no-ops (pure ASCII) or
raises (genuinely non-CP-1252-encodable text), so it's a safe repair
attempt to always make.
clean_encoding_column ¶
Repair mojibake in column in place; add a {column}_invalid flag column.
A row is flagged invalid when its raw value was non-null but the mojibake repair was unrecoverable -- the signal a later validation stage uses to decide whether to quarantine (see CLAUDE.md's "unrecoverable_encoding" reason code).
Source code in src/pipeline/cleaners/encoding.py
repair_mojibake ¶
Attempt to reverse CP-1252/UTF-8 mojibake in text.
Returns the repaired text, the original text unchanged if it wasn't
mojibake to begin with, or None if it's unrecoverable (the text
already contains the Unicode replacement character, or repairing it
would produce one).
Source code in src/pipeline/cleaners/encoding.py
Schema validation¶
validate ¶
Wire the field-level cleaners into schema-validated records or quarantine entries.
For each source-specific raw table, cleans it (see pipeline.cleaners) and
validates every row into the matching Clean* model from pipeline.models,
or a QuarantineEntry when it can't be. A row is quarantined for the most
specific reason available: a flagged unparseable date or unrecoverable
encoding failure from the cleaning stage takes priority over a generic
schema-validation failure, since it points at the actual root cause.
Entity resolution¶
resolve ¶
Entity resolution: match ERP and CRM records into a canonical crosswalk.
Per CLAUDE.md's hard constraint, matching never relies on business-key
format shortcuts (CUST_ID vs customerId are cosmetically different
encodings of the same sequence number only because this is a synthetic
dataset -- a real migration would not have that convenient correlation).
Instead:
- Customers are matched on
full_namesimilarity (rapidfuzz) combined with normalized exact email/phone comparison into one confidence score per candidate pair.datagen.identities.CanonicalCustomerhas noexists_in_erp/exists_in_crmsplit (unlike invoices/payments), so a genuine customer orphan is not a designed feature of the generator -- but it is still handled correctly here rather than assumed away, since a real pipeline can't rely on that. - Invoices are matched within an already-resolved customer's own
invoices only, on exact (
issue_date,currency,amount) equality. There is no legitimate cross-system date lag or amount drift for invoices (only payments drift), so this is an exact join, not a fuzzy score, scoped to the matched customer pair. - Payments are matched within an already-resolved invoice's own
payments, on exact
payment_dateequality only. Amount is deliberately drifted for some ERP payments (erp_amount_drift) to simulate bank fees/FX rounding, so amount is never part of the match gate here -- flagging that drift is report.py's job, using the crosswalk this module produces to look the matched pair's amounts back up.
Every entity resolves to exactly one of four ResolutionOutcomes (see
CLAUDE.md's "Resolution outcomes"), decided per-record via mutual top-match
agreement to avoid many-to-one collisions:
- Matched -- a record's unique top-scoring candidate (score >=
threshold) also has it as its own unique top-scoring candidate: one
CrosswalkEntrywith both keys populated. - Orphan -- zero candidates score >= threshold: a
CrosswalkEntrywith the missing sidenull. Never quarantined. - Ambiguous -- the record has a candidate >= threshold but the mutual
top-match agreement above doesn't hold (multiple tied top candidates on
either side, or a non-reciprocal top pick): every record on both sides of
that tie is quarantined with
ReasonCode.AMBIGUOUS_MATCH, rather than guessing which one is correct. - Invalid -- handled upstream by
validate.py; this module never sees those records.
The customer fuzzy-match confidence threshold below was calibrated per
CLAUDE.md by sweeping eval/score.py data/raw --threshold <value> (500
customers, --with-ground-truth) from 0.1 to 1.0 in increments of 0.05:
combined invoice+payment F1 is flat at its maximum (86.6%) across the
entire 0.1-0.95 range and only drops (to 85.4%) at 0.99-1.0, where an
exact-match-only gate starts rejecting genuine matches with a merely
near-identical name. 0.75 sits well inside that plateau, away from that
edge.
PairResolution
dataclass
¶
Result of resolving one side's records (A) against the other (B).
resolve_customers ¶
Match ERP/CRM customers on name + email/phone confidence.
Returns (crosswalk_entries, quarantine_entries) covering every input
customer exactly once (matched, orphan, or ambiguous -- never dropped).
Source code in src/pipeline/resolve.py
resolve_invoices ¶
Match ERP/CRM invoices, scoped to each already-resolved customer pair.
An invoice whose customer wasn't matched (orphan or ambiguous on the customer side) has no counterpart to compare against, so it resolves straight to orphan -- there's nothing to be ambiguous about without a resolved parent.
Source code in src/pipeline/resolve.py
resolve_payments ¶
Match ERP/CRM payments, scoped to each already-resolved invoice pair.
Matches on payment_date equality only -- amount is deliberately
drifted for some ERP payments and is never a match gate here (see
module docstring); report.py flags drift by looking the matched pair's
amounts back up via the crosswalk this returns.
Source code in src/pipeline/resolve.py
Crosswalk persistence¶
crosswalk ¶
Persist the crosswalk table as JSON, with an idempotent upsert.
The crosswalk is keyed on (entity_type, erp_key, crm_key) (see
CrosswalkEntry) rather than on canonical_id, since resolve.py mints a
fresh UUID on every run. Re-running the pipeline on unchanged input must not
duplicate records or reassign existing canonical IDs, so upsert_crosswalk
uses a reject-on-conflict strategy: when an incoming entry's key already
exists on disk, the existing entry (and its canonical_id) is kept as-is
and the incoming one is discarded; only genuinely new keys are appended.
Known limitation (deliberate, not silently worked around): because the key
includes the exact erp_key/crm_key shape, a previously-orphaned record
whose missing side later gets resolved (e.g. a corrected export fixes a
one-sided invoice) has a different key on the next run -- (entity_type,
"INV-1", None) vs. (entity_type, "INV-1", "IN-1") are different keys --
and is written as a brand-new entry with a new canonical_id, rather than
resolving forward onto the stale orphan entry, which is left in place.
upsert_crosswalk ¶
Merge new_entries into the crosswalk at path, keyed as described above.
Source code in src/pipeline/crosswalk.py
Quarantine log¶
quarantine ¶
Persist quarantined records to quarantine_log.json.
Every row that fails cleaning/validation, or resolves to the ambiguous
outcome, must land here -- never be silently dropped (see CLAUDE.md's "Zero
silent failures" rule). original_data may retain full PII since this file
is the designated place for it; PII masking only applies to console/summary
output (see pipeline.cli), not this file.
Postgres target schema¶
postgres ¶
Load pipeline results into the Postgres target schema (see schema.sql).
This is an additional persistence layer alongside the JSON crosswalk and
quarantine log, not a replacement -- pipeline.crosswalk and
pipeline.quarantine remain the source of truth that reconcile run reads
back on the next invocation. Mirrors their exact idempotency semantics:
customers/invoices/paymentsare upserted on(source_system, business_key)-- each run's clean projection replaces the previous one for that key.crosswalkuses the same reject-on-conflict strategy aspipeline.crosswalk.upsert_crosswalk: acanonical_idis never reassigned once(entity_type, erp_key, crm_key)already exists.quarantine_logis replaced in full on every run, matchingpipeline.quarantine.write_quarantine_log's overwrite (not upsert) behavior -- it reflects only the most recent run's failures.
Callers should open the connection as a context manager so a failure partway through a load rolls back instead of leaving a half-written run:
create_schema ¶
load_crosswalk ¶
Upsert entries, preserving the existing canonical_id on a key collision.
Source code in src/pipeline/postgres.py
load_pipeline_result ¶
Create the schema (if needed) and load a full pipeline run into Postgres.
Does not commit -- open conn as a context manager (with psycopg.connect(dsn) as
conn:) so the whole run commits or rolls back atomically.
Source code in src/pipeline/postgres.py
replace_quarantine ¶
Replace the entire quarantine_log table with entries.
Source code in src/pipeline/postgres.py
Reconciliation report¶
report ¶
Build the Markdown reconciliation report from pipeline outputs.
Precision/recall against ground_truth.json is never computed here -- per
CLAUDE.md's ground_truth.json eval-only rule, only eval/score.py (or
tests) may read that file. When available, the caller passes in an
already-computed PrecisionRecall to embed in the report; this module
never imports or references ground_truth.json itself.
IngestCounts
dataclass
¶
IngestCounts(
erp_customers,
erp_invoices,
erp_payments,
crm_customers,
crm_invoices,
crm_payments,
)
Raw row counts per source system, before cleaning/validation.
PrecisionRecall
dataclass
¶
Computed by eval/score.py against ground_truth.json -- never by this module.
compute_payment_drift ¶
Flag matched payment pairs whose amounts disagree beyond the rounding epsilon.
Source code in src/pipeline/report.py
Orchestration¶
orchestrate ¶
End-to-end pipeline orchestration: ingest -> dedupe -> validate -> resolve.
Both eval/score.py and pipeline.cli need to run this exact same sequence
of stages over a data directory, so it's factored out here once rather than
duplicated between them.
run_pipeline ¶
Run ingestion, intra-system dedup, cleaning/validation, and resolution over data_dir.
customer_match_threshold is exposed here (rather than hardcoded) so
eval/score.py can sweep values for offline calibration.
Source code in src/pipeline/orchestrate.py
reconcile CLI¶
cli ¶
Command-line interface for running the ERP/CRM reconciliation pipeline.
This CLI never reads ground_truth.json -- per CLAUDE.md's eval-only rule,
that file is only ever consulted by eval/score.py or tests. Precision/
recall is therefore never part of this CLI's report; run eval/score.py
separately against a dataset generated with --with-ground-truth for that.
run ¶
run(
data_dir,
crosswalk_path=Path("data/processed/crosswalk.json"),
quarantine_path=Path(
"data/processed/quarantine_log.json"
),
duplicate_log_path=Path(
"data/processed/duplicate_log.json"
),
report_path=Path("RECONCILIATION_REPORT.md"),
customer_threshold=CUSTOMER_MATCH_THRESHOLD,
postgres_dsn=None,
)
Run ingest -> dedupe -> clean -> resolve over DATA_DIR and write the crosswalk/report.
Source code in src/pipeline/cli.py
Evaluation against ground truth¶
score ¶
Score a pipeline run's crosswalk against ground_truth.json.
This is the ONLY module allowed to read ground_truth.json -- per
CLAUDE.md's hard constraint, no module under src/pipeline/ may import,
read, or reference it. This script runs the pipeline itself (via
pipeline.orchestrate.run_pipeline) and scores the resulting crosswalk
after the fact; the pipeline never consults the answer key at runtime.
ground_truth.json only covers invoices and payments -- customers have no
exists_in_erp/exists_in_crm split in the generator, so there's nothing
to score customer resolution against.
Usage::
uv run python eval/score.py path/to/data_dir
uv run python eval/score.py path/to/data_dir --threshold 0.8
The --threshold flag sweeps resolve_customers' confidence threshold for
offline calibration (see pipeline.resolve.CUSTOMER_MATCH_THRESHOLD's
docstring) -- it does not read ground_truth.json any differently, it just
re-runs the pipeline with a different threshold before scoring.
score_crosswalk ¶
Combined precision/recall across invoices and payments (all ground_truth.json covers).