01 — Hello Harmonization¶
This is the first chapter. It introduces the framework's core model: two source columns are mapped to two target columns, run both through the Python API and the command-line tool. Later examples build on the pieces introduced here.
The model is small. You describe a mapping as a set of rules. Each rule
takes one or more source columns and produces a single target column by
running the source value through an ordered list of primitive operations —
small, named transformations like "look this code up in a table" or "carry it
across unchanged." A collection of rules is a RuleSet, which you build in
Python and save to a file called rules.json. You define the rules once there;
both the Python API and the command-line tool read that same rules.json, so
the mapping runs identically through either.
What it teaches¶
- The rule model. A rule maps source column(s) → one target column through an ordered list of primitive operations. This is the unit you compose everything else from.
- Two foundational primitives.
do_nothingcarries a column across unchanged (effectively a rename), andenum_to_enumlooks source codes up in a table to produce target labels. - The rules file drives both interfaces. A
RuleSetsaved torules.jsonis read unchanged by both the Python API and the CLI, so you write the rules once and both run the same thing.
The data¶
input.csv is a tiny participant roster with a clean name column and a coded
status column:
| participant_id | full_name | status_code |
|---|---|---|
| P001 | Ada Lovelace | A |
| P002 | Alan Turing | I |
| P003 | Grace Hopper | P |
| P004 | Katherine Johnson | A |
We want two harmonized columns out of it: a name (the same value, under the
canonical column name) and a status (the single-letter code expanded to a
readable label).
The harmonization choices (and why)¶
| Source | Target | Operations | Why this choice |
|---|---|---|---|
full_name |
name |
do_nothing |
The source is already clean; we only want it under the canonical column name. |
status_code |
status |
enum_to_enum |
Expand controlled codes to readable labels, with a visible fallback for anything unexpected. |
Rule 1 — carry a column across unchanged¶
full_name already holds exactly the value we want; it just needs to land in a
target column called name. The do_nothing primitive expresses precisely
that: "carry this across, no transformation." You could express the same thing
with an empty list of operations, but a do_nothing says the intent out loud —
this column is deliberately passed through — and shows up as a visible
operation in rules.json rather than an absence someone might mistake for an
oversight.
Rule 2 — map codes to labels¶
status_code uses single-letter codes (A/I/P) that the target schema
wants as full words. enum_to_enum is a lookup table: it maps each known code
to its label (A → active, I → inactive, P → pending). The interesting
decision is what happens to a code that isn't in the table. Two behaviours are
available:
- Strict — raise an error on any unknown code. Fail fast; good when the code set is guaranteed and a surprise should halt the run.
- Lenient + default — map unknown codes to a chosen fallback value and keep going.
This example chooses lenient with default="unknown". Real intake data
drifts — a new status code shows up that nobody warned you about — and we'd
rather surface that as a visible "unknown" than crash the whole batch or
emit a blank cell that could mean several things. (Chapter 05 explores the
strict-vs-lenient trade-off in depth.)
The reasoning for each rule is also recorded in its metadata.rationale, so it
survives into rules.json and travels with the rule.
The rules, serialized¶
A RuleSet is not only something you build in Python — it is saved to a file,
and that file is the mapping. It is what the Python API reloads, what the CLI
reads, and what you would commit, review, or hand to someone else. So it is
worth seeing what the rules you wrote above actually look like once written
out, because this file — not the Python that produced it — is the portable,
shareable artifact.
The same rule set serializes to two formats. JSON is the default; YAML is
offered because it is easier to read and edit by hand. RuleSet.save() and
load() (and the CLI's --rules) pick the format from the file extension
(.yaml/.yml for YAML, otherwise JSON), and both load to exactly the same
rules — the choice is purely about which you would rather look at.
Reading either one back, you can see the structure described above made
concrete: a list of rules, each with its sources, target, ordered
operations, and the metadata.rationale carried along. The enum_to_enum
lookup table, the strict: false / default: unknown choice — every decision
from the section above is right there in the file.
rules.json:
[
{
"sources": [
"full_name"
],
"target": "name",
"operations": [
{
"operation": "do_nothing"
}
],
"metadata": {
"rationale": "Source is already clean; carry it across under the canonical column name with no transformation."
}
},
{
"sources": [
"status_code"
],
"target": "status",
"operations": [
{
"operation": "enum_to_enum",
"mapping": [
{
"from": "A",
"to": "active"
},
{
"from": "I",
"to": "inactive"
},
{
"from": "P",
"to": "pending"
}
],
"strict": false,
"default": "unknown"
}
],
"metadata": {
"rationale": "Codes are controlled but data entry is imperfect; map known codes and surface anything unexpected as 'unknown' rather than failing the batch or emitting a blank."
}
}
]
rules.yaml:
- sources: [full_name]
target: name
operations:
- {operation: do_nothing}
metadata: {rationale: Source is already clean; carry it across under the canonical
column name with no transformation.}
- sources: [status_code]
target: status
operations:
- operation: enum_to_enum
mapping:
- {from: A, to: active}
- {from: I, to: inactive}
- {from: P, to: pending}
strict: false
default: unknown
metadata: {rationale: Codes are controlled but data entry is imperfect; map known
codes and surface anything unexpected as 'unknown' rather than failing the batch
or emitting a blank.}
Running it¶
build_rules.py is the only place the rules are defined. Running it writes both
rules.json and rules.yaml, and the other interfaces read those files:
# 0. (Re)generate rules.json AND rules.yaml from build_rules.py:
../../../harmonization-framework/venv/bin/python build_rules.py
# 1. Python API — harmonizes and checks the result against expected_output.csv:
../../../harmonization-framework/venv/bin/python run_python.py
# 2. Same, but loading the YAML rules instead — same output:
../../../harmonization-framework/venv/bin/python run_yaml.py
# 3. CLI — no Python needed (accepts rules.json or rules.yaml):
bash run_cli.sh
Because they all read the same rules, the mapping behaves the same way whichever
you use. There is one intentional difference in output shape: the
Python API returns every input column plus the new targets, while the CLI
returns only the target columns (add --include-metadata to also get the
source dataset / original_id columns). Same rules, same values — the
interfaces just differ in how much of the input they echo back.
Expected output¶
The Python API produces the full output — all input columns, the two new targets, and metadata:
participant_id,full_name,status_code,name,status,source dataset,original_id
P001,Ada Lovelace,A,Ada Lovelace,active,hello,0
P002,Alan Turing,I,Alan Turing,inactive,hello,1
P003,Grace Hopper,P,Grace Hopper,pending,hello,2
P004,Katherine Johnson,A,Katherine Johnson,active,hello,3
The CLI emits only the targets (plus metadata when --include-metadata is
passed):
name,status,source dataset,original_id
Ada Lovelace,active,hello,0
Alan Turing,inactive,hello,1
Grace Hopper,pending,hello,2
Katherine Johnson,active,hello,3
run_python.py asserts its output against the committed expected_output.csv,
so the example checks itself.
Generated files (not committed)¶
output.csv, output_cli.csv, and replay.log are produced when you run the
example. The committed artifacts are input.csv, rules.json, and
expected_output.csv.
Data files¶
Rendered from the committed CSV files in this example.
input.csv¶
| participant_id | full_name | status_code |
|---|---|---|
| P001 | Ada Lovelace | A |
| P002 | Alan Turing | I |
| P003 | Grace Hopper | P |
| P004 | Katherine Johnson | A |
expected_output.csv¶
| participant_id | full_name | status_code | name | status | source dataset | original_id |
|---|---|---|---|---|---|---|
| P001 | Ada Lovelace | A | Ada Lovelace | active | hello | 0 |
| P002 | Alan Turing | I | Alan Turing | inactive | hello | 1 |
| P003 | Grace Hopper | P | Grace Hopper | pending | hello | 2 |
| P004 | Katherine Johnson | A | Katherine Johnson | active | hello | 3 |