Skip to content

Complete pipeline migration from R to Python - #2

Merged
pmayd merged 288 commits into
devfrom
migration
Aug 24, 2026
Merged

pmayd merged 288 commits into
devfrom
migration

Conversation

@pmayd

@pmayd pmayd commented Nov 22, 2025 •

Copy link
Copy Markdown
Collaborator

Migration: R to Python data pipeline (migration -> dev, PR #2)

Replaces the R implementation of the A4D medical tracker pipeline with a Python
one covering both arms (patient + product), plus deployment, state management,
and an R/Python comparison harness used to verify the migration cell by cell.

285 commits, 364 files changed. 981 tests, 86% coverage. R retired
2026-08-24 (r-archive/ deleted; reachable at
git show r-archive-removed^:r-archive/R/<file>).

This document is live until PR #2 merges, then frozen with the rest of
docs/archive/ at tag migration-archive-frozen.

This MR is ready to merge. Every ticket that gated it is closed: the
cell-by-cell R/Python triage over 254 trackers, the production verification
run, the dependency audit, the performance profile, R's retirement, and the
documentation overhaul. CI is green on the head commit. What remains open is
listed at the end and none of it blocks the merge.

Keep this document current: update it at the end of every working session,
alongside docs/wayfinder/map.md.


Treasure map

flowchart TD
  subgraph DONE["Shipped and verified"]
    A["Patient pipeline<br/>extract - clean - tables"]
    B["Product pipeline<br/>extract - clean - table"]
    C["Merged into one branch<br/>PR #6, 2026-08-09"]
    D["Cloud Run Job<br/>GCS + BigQuery + Drive"]
    E["Incremental processing<br/>tracker_metadata + MD5"]
    F["Production verification run<br/>verified vs BQ snapshot"]
    G["Perf profile<br/>6.6x patient speedup"]
    H["Dependency audit<br/>19 CVEs cleared"]
    I["Comparison harness<br/>4 stages, run-over-run deltas"]
    N["Merged headers propagated<br/>screening columns recovered"]
  end

  subgraph TRIAGE["R/Python triage - 57 of 65 tickets closed"]
    J["Product cleaned: COMPLETE<br/>20 unclassified, kept as signals"]
    K["Product raw: COMPLETE<br/>0 unclassified"]
    L["Patient cleaned: COMPLETE<br/>16 unclassified, all owned by open questions"]
    M["Patient raw: COMPLETE<br/>0 unclassified"]
    M2["Patient raw column divergence: DONE<br/>18,235 rows all accounted for"]
  end

  subgraph OPEN["Still open - none block the merge"]
    Q["34 - local checks match CI"]
    R["35 - Polars 2.0 deprecations"]
    T["16 - per-file log drill-down"]
    X["40 - source-defect findings Excel"]
    Y["41 - 2026 new Patient List fields"]
    Z["65 - logs table values naming R scripts"]
  end

  subgraph READY["Unblocked"]
    V["6 - promote migration to dev"]
  end

  subgraph BLOCKED["Blocked"]
    W["9 - golden-master snapshot tests"]
  end

  A --> C
  B --> C
  C --> D --> F
  C --> I --> TRIAGE
  L --> V --> W

  classDef done fill:#1a7f37,stroke:#116329,color:#fff
  classDef open fill:#1f6feb,stroke:#0b3d91,color:#fff
  classDef blocked fill:#6e7781,stroke:#424a53,color:#fff
  classDef ready fill:#8250df,stroke:#5a32a3,color:#fff
  class A,B,C,D,E,F,G,H,I,J,K,L,M,M2,N done
  class Q,R,T,X,Y,Z open
  class V ready
  class W blocked
Loading

Part 1 — The a4d package

What it does

Reads A4D clinic Excel trackers (one workbook per clinic per year, one sheet
per month) and produces analysis-ready BigQuery tables. Two independent arms
run over the same workbooks:

  • patient — the per-month patient sheets plus the Patient List sheet
  • product — the INV / stock section (insulin and supply movements)
GCS bucket                Excel trackers            per-tracker parquet          tables            BigQuery
a4dphase2_upload   -->    <clinic>/<year>_...  -->  patient_data_raw/       -->  patient_static    -->  tracker.*
Google Drive              .xlsx                     patient_data_cleaned/        patient_monthly
clinic_data.xlsx                                    product_data_raw/            patient_annual
                                                    product_data_cleaned/        product_data
                                                    logs/                        clinic_data_static
                                                                                 table_logs
                                                                                 table_errors
                                                                                 tracker_metadata

clinic_id is the tracker's parent folder name. The tracker year comes from
sheet names (Jan24 -> 2024) or the filename.

Module map

Module Purpose
extract/patient.py Excel -> raw patient parquet (openpyxl, multi-sheet, two-row header merge)
extract/product.py Excel -> raw product parquet (month sheets, stock section)
extract/wide_format.py Mandalay wide-format handling (column expansion 2020-21, cell splitting 2017-19)
clean/patient.py Type conversion, validation, transformations -> cleaned parquet
clean/product.py Product cleaning (steps 2.0-2.21), running balance, chronological sort
clean/schema.py / schema_product.py 83-column patient and 20-column product meta schemas
clean/converters.py Safe type conversion with ErrorCollector
clean/validators.py Allowed-value validation, canonical labels + alias map
clean/transformers.py Regimen extraction, BP splitting, FBG conversion
clean/date_parser.py Flexible date parsing (Excel serials, DD/MM/YYYY, month-year, typo rescue)
tables/*.py Aggregate cleaned parquets into the final tables (patient, product, clinic, logs, metadata)
pipeline/*.py Per-tracker and per-arm orchestration, parallel workers, result dataclasses
gcp/*.py GCS download/upload, BigQuery load, Drive download, production-run verification
reference/*.py Column synonyms, product categories, province validation (YAML in reference_data/)
validate/*.py Source-vs-output reconciliation
migration/compare.py R/Python comparison engine (Part 2) — retired, kept as the evidence record
state/*.py Incremental processing: manifest, MD5 filter, source resolution
config.py Pydantic settings from .env / A4D_* env vars
cli.py Typer CLI

Row-level data-quality problems never raise: ErrorCollector accumulates them
and they land in table_errors / table_logs with an error_code. Sentinels
for unusable values are numeric 999999, string "Undefined", date
"9999-09-09", each distinct from null, which means nothing was recorded.

Configuration

Everything is a Pydantic setting, overridable via .env or A4D_* env vars:

Setting Default
A4D_DATA_ROOT the local tracker directory
A4D_OUTPUT_DIR output (relative to data_root)
A4D_PROJECT_ID / A4D_DATASET a4dphase2 / tracker
A4D_DOWNLOAD_BUCKET / A4D_UPLOAD_BUCKET a4dphase2_upload / a4dphase2_output
A4D_MAX_WORKERS 4
A4D_ERROR_VAL_NUMERIC / _CHARACTER / _DATE 999999 / Undefined / 9999-09-09
A4D_MIN_TRACKER_YEAR / _MAX_TRACKER_YEAR 2017 / 2030

CLI

Commands are grouped by process, not by the object they act on:

uv run a4d run                    # full end-to-end: Drive + GCS download, both arms, tables, GCS + BigQuery upload
uv run a4d run patient            # patient arm only, local
uv run a4d run product            # product arm only, local

uv run a4d create tables          # rebuild all tables from existing cleaned parquets
uv run a4d create logs            # rebuild only the logs table from existing log files

uv run a4d upload tables          # -> BigQuery (--only patient|product|clinic|logs|errors|metadata)
uv run a4d upload output          # -> GCS

uv run a4d download trackers      # <- GCS
uv run a4d download clinic-data   # <- Google Drive

Key flags on run: --file (single tracker), --workers/-w, --skip-download,
--skip-upload, --skip-drive-download, --skip-patient, --skip-product,
--skip-tables, --incremental, --force.

--incremental skips trackers whose MD5 and completion state match the previous
run's manifest (BigQuery -> local parquet -> empty fallback); both arms see the
same filtered queue. --force wipes prior local outputs first and overrides
--incremental.

Usage scenarios

# 1. First-time setup
uv sync && just hooks

# 2. Debug one tracker end to end (no GCS, no upload)
just run-file "/path/to/2024_Mahosot Hospital A4D Tracker.xlsx"
just run-file-product "/path/to/2024_Mahosot Hospital A4D Tracker.xlsx"

# 3. Full local run over everything in data_root, both arms, no cloud
uv run a4d run --skip-download --skip-upload --skip-drive-download

# 4. Reprocess everything from scratch (what triage sessions use before comparing)
uv run a4d run patient --force
uv run a4d run product --force

# 5. Pull the current production trackers down, process locally, upload nothing
just run-download

# 6. Cheap daily-style run: only trackers that actually changed
uv run a4d run --incremental

# 7. Tables only, from parquets already on disk
just create-tables

# 8. Production -- see "Running in production" below

Development commands

just ci            # format-check + lint + type-check + test, the same set CI runs
just test          # unit tests (skips slow/integration)
just test-fast     # no coverage, fail fast
just test-all      # everything including slow + integration
just format / fix / lint / check
just sync / update / info / clean
just docker-build / docker-smoke / docker-push / docker-list / docker-clean
just job-settings  # current Cloud Run CPU/memory/timeout/parallelism

Running in production (GCP)

The pipeline is already deployed and running in Google Cloud as a Cloud Run
Job — a one-shot container that downloads trackers from GCS, processes both
arms, uploads output to GCS and loads it into BigQuery, then exits. It has been
executed for real against production and verified against a pre-run snapshot.

Everything lives in asia-southeast2 (Jakarta) — Artifact Registry, the
Cloud Run Job, both GCS buckets and the BigQuery dataset. This is a data
residency requirement: patient data must not be processed or stored in the EU.
Bucket and dataset locations are fixed at creation time.

Job a4d-pipeline (Cloud Run Job, asia-southeast2)
Image asia-southeast2-docker.pkg.dev/a4dphase2/a4d/pipeline:latest, also tagged per git SHA
Resources 8 vCPU, 8 GiB, 3600s task timeout, A4D_MAX_WORKERS=8
Service account a4d-pipeline@a4dphase2.iam.gserviceaccount.com — storage.objectViewer on a4dphase2_upload, storage.objectCreator on a4dphase2_output, bigquery.jobUser + bigquery.dataEditor project-level
Storage A4D_DATA_ROOT=/tmp/data, ephemeral in-container — nothing persists between executions
Base image python:3.14-slim, uv sync --frozen --no-dev, default CMD is a4d run
Scheduling Cloud Scheduler is not enabled yet; runs are triggered manually

Start, monitor, verify, roll back:

just backup-bq        # snapshot BigQuery tables (7-day expiry) -- the rollback point
just deploy           # build, push, point the job at the new image
just run-job          # trigger an execution
just logs-job         # stream logs from the running execution
just job-settings     # current CPU / memory / timeout / parallelism
just rollback abc1234 # revert the job to a previous git SHA
uv run python scripts/verify_production_run.py   # live BigQuery tables vs the snapshot

just deploy && just run-job   # redeploy + run after a code change

verify_production_run.py (+ src/a4d/gcp/verify.py, unit-tested) compares row
counts, distinct clinic counts and schema against the backup-bq snapshot. The
first combined patient+product production execution passed it cleanly — all
four tables grew, 51 -> 53 clinics, no anomalies.

Full instructions — one-time infrastructure setup (service account, IAM grants,
Artifact Registry, job creation), the three levels of local image testing before
deploying, and the optional Cloud Scheduler wiring — are in
SETUP.md.


Part 2 — The R/Python comparison harness

The single most important tool in this migration. It is how "is the Python
pipeline right?" was turned into a number that goes down each session.

Migration-only, deliberately not wired into a4d.cli: it has a defined
end of life at R's retirement (ticket 12). Engine in
src/a4d/migration/compare.py (pure, unit-tested), thin CLI in
scripts/compare_outputs.py.

How to run it

just compare-outputs \
  "/Volumes/USB SanDisk 3.2Gen1 Media/a4d/output_r" \
  "/Volumes/USB SanDisk 3.2Gen1 Media/a4d/output_python" \
  output/comparison

# equivalently
uv run python scripts/compare_outputs.py \
  --r-dir  ".../output_r" \
  --py-dir ".../output_python" \
  --output-dir output/comparison \
  --only-mismatches      # print only files with at least one measure flagged

It takes two existing output directories and diffs them — it never runs
either pipeline. The R side is a frozen baseline on the test-data drive; R is
not re-run (one deliberate exception, when the tracker set grew, framed as the
final capture before R is retired).

What it compares

Four stages, each producing its own workbook, so a divergence can be localised
to extraction vs. cleaning:

Stage Directory Row-alignment key
Patient (raw) patient_data_raw/ patient_id + sheet_name, ordinal tie-break
Patient (cleaned) patient_data_cleaned/ patient_id + sheet_name, ordinal tie-break
Product (raw) product_data_raw/ ordinal position within (clinic_id, sheet)
Product (cleaned) product_data_cleaned/ ordinal position within (clinic_id, sheet)

Product has no natural identity key — product_entry_date is null on many rows
and collapsed the join, so add_row_ordinal() computes a positional key at
comparison time instead (never stored: the frozen R baseline cannot be re-run
to pick up a new column).

Patient does have one, and keeps it: rows pair only when they are the same
patient on the same monthly sheet. The same ordinal breaks ties in the handful
of sheets that list a patient twice — by content, not position, since
cleaning reorders the copies. RowAlignment.IDENTITY vs
RowAlignment.POSITIONAL names which arm does which.

Seven measures per file, coarse to fine:

Measure What it answers
Shape match same row count? Structural only.
ID divergence patients/products present on only one side. Independent of the row key.
Column divergence columns only on one side, plus dtype differences.
Categorical divergence label values appearing on one side but never the other.
Totals divergence numeric columns whose column-sum differs beyond tolerance.
Row-key divergence rows that found no partner at all. Read this before Cell divergence — 0 cell mismatches can mean "everything agreed" or "nothing was paired".
Cell divergence matched rows diffed value by value.

Normalisation, before anything is called a mismatch

Representation differences are not divergences, and they used to drown
everything else. Three normalisers run per stage, on columns declared in the
STAGES table:

  • normalize_date_column — R stores unparsed Excel serials as strings, Python
    stores parsed dates. Both sides go through the pipeline's own
    parse_date_flexible. Product raw product_entry_date: 65,743 -> 91.
    Patient raw overall: 564,096 -> 46,788.
  • normalize_numeric_column — R and Python round float-to-string differently.
    Parse both back to float so the tolerance applies. On the patient raw
    stage the target columns come from each frame itself, not the cleaned
    schema, which cannot name the Patient List join's .static copies or a
    measurement column typed as a string. 434 mismatches -> 0.
  • normalize_whitespace_column — readxl's trim_ws=TRUE strips what openpyxl
    keeps, and represents an embedded line break as \r\n vs \n. On the
    patient raw stage its target columns come from each frame itself, for the
    same reason as the numeric list: the cleaned schema cannot name a raw-only
    column (dm_complications) and types as Float64 columns the raw stage
    still holds as text (insulin_injections, hba1c_updated). 40 mismatches
    -> 0.
  • normalize_boolean_literal_column — readxl writes an Excel boolean as
    FALSE, openpyxl as False. Only the exact literals are folded. Raw stage
    only; cleaning already canonicalizes both. 20 mismatches -> 0.

Cause classifiers

Every cell mismatch is run through a per-column registry and labelled with a
cause, or left unclassified. ~20 classifiers exist, each named after the
mechanism it identifies, e.g.:

r_extraction_gap, r_non_latin_header_miss, r_category_lookup_miss,
r_insulin_dedup_drop,
r_join_suffix_collision, r_ifelse_na_propagation, r_date_error_sentinel,
r_numeric_error_sentinel, row_order_divergence,
derived_running_total_row_order, buddhist_era_typo,
python_buddhist_era_converted, buddhist_era_conversion_row_order,
python_canonical_label, python_future_date_sentinel,
stray_date_zeroed, wide_format_fragment, r_drops_richtext_space,
python_trims_merged_subvalue, r_ymd_first_misparse,
python_rejects_beyond_tracker_year, r_unicode_sanitizer_rejects_accent.

unclassified is the number that matters. A classifier records that a
difference is understood, not that Python won. Where a difference is
genuinely undecidable, or the source file itself is corrupt, that is recorded
as the answer rather than papered over with a label.

What a run writes

output/comparison/2026-08-16T231826Z/          # one self-contained folder per run
├── compare_report_patient_data_raw.xlsx
├── compare_report_patient_data_cleaned.xlsx
├── compare_report_product_data_raw.xlsx
├── compare_report_product_data_cleaned.xlsx
└── snapshot_<stage>.json                      # per-column / per-cause counts

Each workbook carries summary sheets (per-column and per-cause mismatch counts,
files only in R, files only in Python) and one detail sheet per measure:
column_divergence, id_overlap, categorical_overlap, row_key_overlap,
totals, cell_mismatches. Excel rather than HTML on purpose — triage means
loading the result as a dataframe, filtering, sorting and adding columns.

The console prints the same summary plus a run-over-run delta (red/green)
against the previous run folder's snapshot for that stage, so a fix's effect is
visible by count without needing a classifier to prove it worked.

How it was actually used

The loop each session:

  1. just compare-outputs ... -> pick the largest unclassified population in
    the cell_mismatches sheet.
  2. Filter that column in Excel, look for the shape of the difference
    (R-null vs Python-has-value? sentinel? ordering?).
  3. Open the real source Excel workbook and read the cell. This is the step
    that mattered — a shape-matching heuristic is not a diagnosis. Nearly every
    real bug in the table below was found here, not in the report.
  4. Also read R's own source when the mechanism lives there. This was
    r-archive/ for the whole of the triage; the archive is now deleted, and the
    same files are reachable at git show r-archive-removed^:r-archive/R/<file>.
  5. Then either: fix Python (most sessions), or add a named classifier
    explaining the mechanism, or record it as an open question.
  6. Re-run the pipeline (a4d run patient --force) and the comparison; the
    delta shows the effect.
  7. Whatever did not converge is split into a new ticket rather than left
    sprawling.

Rules that made it work, learned the hard way:

  • Triage means deciding, not labelling. Two bars: explain the actual
    mechanism, and say what you checked.
  • Not 1:1 R parity. R can be wrong and often is. The arbiter is the source
    workbook, not R's output.
  • A corrupt source is a valid terminal answer — "this tracker needs human
    inspection" is a finding, not a failure.
  • Split rather than sprawl. 39 tickets exist because sessions ended by
    handing the residual forward with its numbers attached.

Ticket 32 exists to re-audit every classifier written before that bar was set.


Part 3 — Results

Real defects found and fixed

Triage was overwhelmingly a bug-hunt, not a labelling exercise. The ones that
changed production output:

Fix Effect
_fix_t1d_diagnosis_age recomputed from dates, discarding recorded ages 25,968 -> 4,807 mismatches
merge_headers left the 2022 template's Updated 2022 header unmapped recovered 7,165 blood-pressure / education dates
_apply_type_conversions split on the first space; dateutil then completed from today non-deterministic output, real dates destroyed
parse_date_flexible deleted the 4th letter of month names (March -> Marh) every full month name was unparseable
Date path knew 4 missing-value markers where the numeric path knew 11 sentinel-stamped date cells 6,186 -> 1,994
extract_regimen lowercased every unmatched value (NPH -> nph) live data corruption
validate_allowed_values picked the last of two identically-sanitising spellings now a loud config error; canonical labels declared in config
remove_header_rows missed rows blank except one formula-emptied cell row insertion/shift across product raw
read_patient_rows accepted a row on its ID alone, turning a stray list of patient IDs below the data block into records 24 invented patient-months, now 0
Inconsistent whitespace trimming across both arms recovered 72 rows of patient sex
find_data_start_row was O(n^2) on read-only worksheets 6.6x speedup, 145.8s -> 22.0s
clean_product_data crashed on pre-product-tracking trackers 4 trackers now yield empty schema-conformant output
run-pipeline aborted the whole run on one patient tracker failure soft-fail-and-continue, both arms
Columns with data under an empty header cell were dropped silently 17 sites recovered from the sheets that label them; 194 insulin-regimen rows back on one tracker
rename_columns kept only the first of several source columns sharing a canonical name 2,489 recorded screening values recovered across 27 trackers
Glucose recorded in mmol/L under an mg/dL header, and no analytical bounds on any FBG column 29 columns corrected, 5,673 readings flagged, 832 impossible readings rejected
A tracker changing shape mid-year was invisible tracker_layout_changed flags it: 37 trackers, 284 positions, incl. one clinic's baseline FBG silently dropped for 5 months
read_patient_rows returned openpyxl's datetime for a number typed into a date-formatted cell, which the numeric conversion then sentinelled 24 real systolic readings were reaching BigQuery as 999999; now recovered from the Excel serial
A header merged across a block never reached the sub-headers past a blank column, and blank-header recovery then filed a screening selection under observations complication-screening results and dates recovered (11 -> 0 and 31 -> 0 mismatches); merged spans read from the sheet XML, so no read-write workbook load

Also added: a balance_reconciliation error code that fires when the computed
closing stock contradicts the tracker's own recorded total (113 groups across
21 files), and a blank_header_with_data error code for columns holding real
values under an empty header cell — a clinician's slip that silently costs the
column (26 trackers, the clearest being 194 insulin-regimen rows in
2021_Kantha Bopha). Where the tracker's other month sheets unanimously label
that column, recover_blank_headers takes the name from them and the data
survives; where they don't, the site is reported for correction in the source
workbook, which is what the planned source-defect report (ticket 40) exists to
drive.

Alongside it, tracker_layout_changed flags any position whose meaning is not
stable across a tracker's month sheets — a tracker is one workbook for one
clinic-year and should not change shape partway through. Of the 359 disagreeing
positions, 75 are pure renames the synonym file already absorbs; the other 284
(37 trackers) change the canonical column. This is also why deriving a single
header set per tracker and applying it positionally was rejected: 2018_CDA's
April sheet is missing the Insulin Regimen column entirely, shifting seven
columns left for that month.

Where verification stands

Current baseline: output/comparison/2026-08-21T215325Z, 254 trackers.
Earlier counts on the wayfinder map were measured against smaller tracker sets
and should be read as historical.

Stage Mismatches Unclassified
Product (raw) 89 0
Product (cleaned) 22,706 20 (kept on purpose as signals)
Patient (cleaned) 114,284 16
Patient (raw) 26,171 0 (from 14,844)

All four stages are now triaged. Patient's cleaned stage has 99.99% of
its flagged cells explained; the 16 left are not unexplained but deliberately
withheld — 13 belong to the standing null-vs-Undefined question, 2 are
R-null against Python's 999999 where neither side published a reading, and 1
is R's inability to read a slash-separated month. Each is a decision about what
the pipeline should publish, not a difference nobody understands.


Still open

None of this blocks the merge. It is the work the wayfinder map carries
forward past this PR.

Frontier (takeable now)

Closed since this section was last written

  • 64 — the documentation overhaul, now closed. Every module outside
    src/a4d/migration/ explains itself in its own terms: src/ 174 R references
    -> 0, tests/ 46 -> 0, markdown 65 -> 0. The comparison package
    keeps its 179 + 42 and is marked historical in its own module docstring —
    R divergence is its subject, and it will never run again. docs/migration/
    moved to docs/archive/ with an index, each document corrected to the true
    current state before being frozen; docs/VALIDATION_SUMMARY.md deleted as a
    superseded 174-tracker verdict. New CI guard
    tests/test_docs_have_no_r_framing.py stops regressions. Suite 1,080
    passed
    , 86% coverage.

  • 12 — retire R from the workspace, now closed. r-archive/ is deleted: 156
    tracked files, 1.9M, gone from the working tree and recoverable through the
    annotated tag r-archive-removed (r-archive-removed^ is the last commit
    containing it). CLAUDE.md's "R Archive / do not modify" section is replaced
    by that recovery recipe; docs/CLAUDE.md, SETUP.md, pyproject.toml's ruff
    exclusion, .dockerignore and .gitignore all updated. Inlining the cited R
    excerpts first was rejected once the citation count was measured properly —
    35 across 12 modules, not 8 in one — which is what became ticket 64.

  • 44 — cleaned-stage FBG cells where R has nothing, now closed. It ends the
    patient cleaned-stage triage: 2,955 unclassified cells -> 16, every one of
    the 16 already owned by an open question.
    The ticket described one
    population and a file-level obstacle; scanning the whole column first found
    the decisive fact is row-level and much stronger. Neither pipeline reads
    the mmol glucose column from the workbook
    — both derive it from the mg cell
    beside it (convert_glucose_units in Python, fix_fbg's fbg/18 in R) — so
    an mmol divergence is an mg divergence restated, and all 2,935 cells, without
    exception, sat beside an mg cell that already carried a named cause.

    The reframing surfaced 110 cells running the other way that no ticket had
    described
    : Python null, R holding a value, each exactly mg_r / 18. R
    divides a number fix_fbg manufactured out of text (140 from "Lost follow
    up") or one far past the analytical ceiling (2013 mg/dL) and publishes the
    quotient as a measurement — 2023_CDA gets 111.8 mmol/L, above the level
    A4D's medical advisor called impossible. A further 28, also unlooked-at, run
    a third way: 2018 CDA writes 148 mg/dl (Mar-18), R's as.numeric fails
    on the whole string and Python reads it. Python is the correct side in all
    three. mmol_derived_from_mg_sibling names the cascade rather than
    re-deciding it, bounded by the ÷18 identity so it cannot claim an mmol cell
    carrying a reading its mg sibling does not account for.

    It also closed a standing tooling limitation. column_unit_swapped — read
    back from the run's own glucose_unit_swapped error records rather than
    restated in the comparison — lets the range test judge a reading against the
    unit the column holds rather than the one its name claims, which resolves
    the six 2020 Kantha Bopha cells round 10 could only write out in prose
    (fbg_baseline_mg unclassified 6 -> 0). Verified across two full runs
    against the real 254-tracker data: per_column counts byte-identical, the
    other three stage snapshots unchanged — nothing suppressed, only named.

  • 57 — patient cleaned triage, round 10, now closed. It ended the
    ten-round chain: 96 in-scope cells -> 19, all of them carrying written
    verdicts (13 the standing Undefined question, 6 a named tooling
    limitation), so there is no round 11. The last real Python defect was one
    character: R's extract_date_from_measurement makes the closing parenthesis
    optional and Python's did not, so 180(May-2017 lost its date. 24 cells
    recovered. What follows is the round-9/10 picture as it stood, which is where
    the 96 came from — 96 in-scope cells left after
    round 9, excluding the FBG population ticket 44 owns and the
    hospitalisation_date population ticket 39 owns. Unlike every round before
    it these share no mechanism: ~27 are 2017/2018 trackers where R lifts a date
    out of the measurement cell (8.53 (28/8/2017)) and Python has no equivalent
    path; 18 are one undecidable cell (25-Ma4-2025 — Mar or May, and R answers
    April); ~19 are glued digit groups (26/102022, 10/1023) where the missing
    separator's position is a guess; 14 are a numeric tail, two of whose FBG
    values sit inside the analytical bounds and should not have been
    sentinelled; and 13 are Undefined cells that belong to a standing open
    question rather than to triage.

    Round 9 closed the whole nine-column date family as one R mechanism, and
    it was established by installing lubridate and executing R rather than
    reading it. parse_dates deletes the fourth letter of any word of four
    or more letters — April-17 becomes Aprl-17 — and then walks a fixed order
    list ending in my and y that cannot fail loudly. So a spelled-out month
    collapses to 1 January of its year (88 cells), an unreadable month makes R
    read the day as the month — 9-Dce-20 becomes 2020-09-01 (143 cells) —
    and a day past 12 leaves R with no reading at all (107 cells). Every
    reproduction matched R's frozen output exactly. Python is the correct side of
    all three.

    The same investigation exposed three Python gaps, all real data loss, all
    fixed
    . Month spellings Python did not know: the Bahasa Malaysia Mac,
    Mei and Okt that the Malaysian clinics write in a column every other
    clinic writes in English, all twelve Thai abbreviations for Nakornping's
    trackers, plus the transposition Dce and the dropped letter in ug.
    Separator runs damaged by a stray keystroke — 26-05- 2007, 19-Jan_2023,
    02-Apr=-2026, 23/05//2025 — which R recovers because lubridate splits on
    any non-alphanumeric run where dateutil needs a well-formed separator. And
    zero-width characters pasted in from another application. Checked old parser
    against new over all 5,187 distinct raw date strings on the 254-tracker
    set: 32 changed, every one from the sentinel to a real date, and no
    already-parsing value altered its reading.

    The repair's narrowness was forced by measurement, not caution. The first
    version — lubridate's own rule, any non-alphanumeric run is a separator —
    turned 11-15 /01/2019, a range of two visit days, into a single date of
    2001-11-15. The repair now runs only when at most three numbers remain, which
    is also why 26/102022 and 10/1023 are deliberately still rejected.

    insulin_subtype's 11 cells were traced and deliberately left
    unclassified
    . R's derivation is a chain of ifelse(x == "Y", ...): an
    all-- row yields "" and becomes null, an all-null row yields NA and
    becomes Undefined. R's apparent distinction is NA propagation, not design,
    so it cannot be cited as evidence for what the output should say — which
    makes these cells part of a standing open question, not a residual.

  • 47 — four trackers where cleaning merged several patients into one ID, now
    closed.
    The pipeline was publishing KH_NPH02 — an identifier in no source
    workbook — with four different patients' September records filed under it.
    Cause: fix_patient_id inherited R's rule of truncating any malformed ID
    over 8 characters to its first 8, and 2023_NPH's Sep23 sheet types a
    stray H into four IDs its own Patient List and three other month sheets
    spell correctly. Truncation is now dropped — a divergence from R, taken
    deliberately: a malformed ID is recovered against the well-formed IDs the
    same tracker carries (edit distance 1, unique candidate only) and sentinelled
    to Undefined otherwise, so the pipeline never invents an identity. Measured
    by re-cleaning all 254 raw parquets: one file changes, four patients each
    regain their September row, and zero non-conforming IDs remain corpus-wide.
    The other three files of ticket 45's count turned out to be two correct
    merges (hyphen-vs-underscore spellings of one patient) and one unrepairable
    source defect (2026_NOGH's MM_NO97/98/99, written that way in its own
    Patient List, three patients still sharing Undefined).

Frontier, continued

  • 64 — rewrite every docstring and doc that explains the code by what R did.
    Spawned by 12 and the only frontier ticket on the route to the merge.
    Documentation that justifies a behaviour with "to match R" was fine as working
    documentation during the migration and is wrong now that R is gone: it
    explains the code by something that no longer exists. 35 citations across 12
    Python modules
    , including production cleaning code — not the 8 in
    compare.py that ticket 12's inventory claimed. Not a find-and-replace: round
    4 found _validate_dates claiming its future-date guard "matches R pipeline
    behavior" when R has no such guard at all, so a stale citation can also be
    wrong about R. Also has to decide the fate of src/a4d/migration/compare.py
    itself, whose subject is a pipeline no longer in this repo.
  • 34 — make the local pre-push checks match CI. CI was red for four days
    unnoticed because the locally-run check set was a strict subset.
  • 35 — 17 Polars 2.0 deprecation warnings. Each asks about a behaviour
    change; they need decisions, not silencing.
  • 40 — one Excel of every source-tracker defect, so the workbooks
    themselves can be corrected rather than guessed at in the pipeline.
  • 41 — the 2026 template's five new Patient List fields: in or out.
  • 16 — per-file log drill-down. Replaces LogViewerA4D's job. Not yet
    decided whether it gates rollout or is a nice-to-have.
  • 40 — one Excel of every source-tracker defect (file, sheet, patient, row,
    finding), so the workbooks themselves get corrected rather than the pipeline
    guessing. Inherits a backlog of already-verified findings; may turn out to be
    the same deliverable as 16.
  • 41 — the 2026 template's five new Patient List fields (Phone Number,
    Insurance Card Status, Current Insulin Regimen, BGM A4D, Insulin A4D).
    Neither pipeline carries them forward. Left for the data owner to decide
    after looking at the new trackers.

Blocked

  • 6 — promote migration into dev (this PR). Fully unblocked: every
    ticket that gated it is closed, including 64. Nothing on this map now stands
    between here and the merge.

Frontier, not blocking the merge

  • 65 — two values published into the BigQuery logs table still name R
    scripts
    : function_name="read_product_data_step1" (the only one of twenty
    that is not the emitting Python function) and script="script1"/"script3"
    in a column whose default is the stage name "clean". These are data, not
    comments, so ticket 64 recorded them rather than changing published output.
    Deliberately not wired as a merge blocker — inconsistency, not falsehood.
  • 9 — golden-master/snapshot regression tests. Deliberately deferred until
    after promotion.

Two production defects fixed by the classifier audit (ticket 32)

  • _validate_entry_dates exempted every parsed year >= 2400 so Thai
    Buddhist-era dates could flow through, which also let corrupt Excel serials
    (1,339,576 and 411,384) reach the product table as 5567-08-19 and
    3026-04-30. The exemption is now the tracker's own BE band, so all 22
    genuine BE dates survive and the 3 corrupt cells are sentinelled and logged
    under a new implausible_era_date error code.
  • a4d run published an errors table holding the patient arm only — the
    patient arm writes it from inside run_patient_pipeline and nothing wrote
    the product arm's. BigQuery's errors table has therefore never carried a
    product finding (e.g. zero of the 116 balance_reconciliation records).
    run now writes it once after both arms: 63,295 -> 97,326 records.

Buddhist-era dates are converted, not published as written (ticket 61)

Thai clinics keep their trackers in a Thai-locale Excel, so dates arrive with a
Buddhist-era year (BE = CE + 543) — the calendar the clinic uses, not an error
it made. The cleaned stage now shifts them to Gregorian on both arms and logs
each one under a new buddhist_era_converted code; the raw stage still keeps
what the workbook says.

  • 375 patient cells recovered. _validate_dates saw a year centuries ahead
    and clobbered every one with the 9999-09-09 sentinel, so these readings were
    destroyed rather than published oddly — and invisible to the comparison,
    because R sentinels them too.
  • 22 product rows converted. product_data no longer carries a stock
    movement dated 543 years in the future.
  • 6 cells that decode to no plausible year stay sentinelled (3035-03-01,
    5025-05-19), as do two hospitalisation_date cells whose note names a date
    range. All are reported as source defects instead.

A cleared row number no longer costs a whole month sheet (ticket 59)

find_data_start_row scanned column A for the first numeric cell. Where a
clinician clears a patient's row number, Excel keeps a whitespace-only string in
its place — so the scan started one row late, read the header rows from the
data, found no patient_id after harmonization, and skipped the sheet entirely.

  • 15 patients' October records recovered on 2022_Children's Hospital 2,
    whose Oct22 sheet was being dropped whole.
  • The rule is deliberately narrow: only a whitespace-only cell directly
    abutting
    the numeric block counts. Matching R's looser "first non-empty cell"
    rule was measured across all 254 trackers and rejected — it would start 14
    sheets at row 1 on a stray letter in A1.

A row whose patient ID is a broken formula is reported, not silently dropped
(ticket 59)

Rows whose ID cell holds #REF! were discarded with no error record. They are
still discarded — Undefined is a bucket rather than an identity, and keeping
them would pool many patients' measurements under one group key — but each
discard now emits the new excel_error_patient_id code so the workbook can be
repaired.

  • 120 rows across 9 trackers, the largest being 2026_Preah Kossamak's
    May26 sheet (98 rows, an entire month, carrying ages, FBG readings, HbA1c,
    weight, height and insulin regimens).

Patients whose ID is spelled two ways keep their demographics (ticket 58)

Both whole-tracker joins in extraction — the Patient List demographics and
the Annual sheet — attached on the raw patient_id, while the fix that makes
a respelled ID usable runs later, in cleaning. Where a month sheet spells a
patient differently from the Patient List in the same workbook, the join could
not match: the row kept its measurements and lost every static column. Both
joins now key on a derived normalized ID; the raw patient_id column is
untouched.

  • 680 rows / 6,863 Patient List cells recovered across 6 trackers, plus 8
    rows / 40 cells from the Annual join. dob, sex, province,
    t1d_diagnosis_date, hba1c_baseline, recruitment_date,
    patient_consent, lost_date and status_out among them.
  • 672 of those rows are the 2023 and 2024 Mahosot trackers, whose month
    sheets write LA-MH056 and up with a hyphen against a Patient List spelled
    LA_MH056 — 38 patients in 2024, 27 in 2023.
  • R has the same gap, so the comparison never showed it: both sides were null.
    Verified against R's own frozen cleaned output.
  • No fan-out risk: measured across all 192 Patient-List-bearing trackers,
    zero have two entries folding to one normalized key.
  • The 15 rows that still miss are source defects — 2023_NPH's stray H, and
    2024_Mandalay General's MM_YG013_MG, which that Patient List does not
    contain under any spelling.

Naming what that fix changed, on the stage that cannot see it (ticket 63)

The cause above recognises a cell by the month row's own ID still carrying the
hyphen — and cleaning normalizes exactly that, on both sides. So the fix took
the cleaned stage from 16 unexplained cells to 4,949. The comparison now
derives the affected identities per file from the run's own raw output and
carries them to the cleaned stage as a flag; production output is untouched.
Cleaned unclassified is back to 16, with the four-stage totals unchanged.

  • A column R lost file-wide is a different defect, and the naive wiring
    claimed 43 files instead of 6. 2024 Preah Kossamak reads zero
    recruitment_date in 863 rows (R's xml:space header defect) where 2024
    Mahosot reads 672 of 1,068. A new r_column_empty_in_file guard keeps 2,336
    cells off the wrong label.
  • Four existing causes were silently holding join-miss cells and handed
    back 671 recruitment_date, 677 fbg_baseline_mg, 224 each of edu_occ
    and edu_occ_updated, and 9 per blood-pressure column — every figure now
    matching the raw stage's own count for that column, computed a different way.
  • age is derived, so it got its own cause. _fix_age_from_dob overrides
    the sheet's Age cell with the D.O.B.-derived age; R has no D.O.B. for these
    patients (null in all 275 rows) and keeps the clinic's typed figure. 113
    cells; Python is the correct side.

Known and accepted

  • The frozen R baseline covers 254 trackers via a documented, reversible rename
    map; new clinics added after the last R run have no R counterpart and show as
    Python-only.
  • compare_columns deliberately flags every dtype difference, including
    harmless representation artifacts (R Float64 vs Python Int32 on integer
    columns); these are documented rather than normalised away.
  • A local, untracked a4d-python/ directory at the repo root is a stale copy
    predating the current src/ layout — not in git, pending a decision to delete.

Full history, per-ticket evidence and every decision:
docs/wayfinder/map.md.

pmayd and others added 30 commits November 28, 2025 23:54
Implemented FILE_COLUMN_EXCEPTIONS configuration for systematic
R extraction errors affecting entire files/columns.

Investigation findings for Jayavarman VII tracker:
- Excel file uses Unicode '≥15' (U+2265) not ASCII '>15'
- R's regex grepl(">|<") only matches ASCII characters
- R fails to detect exceed marker, can't parse ≥15 as number
- Results in error value 999999 and exceeds=false
- Python correctly handles both ASCII and Unicode operators

Root cause: R needs update to support Unicode comparison operators
(≥, ≤) in addition to ASCII (>, <).

Added patient-level exceptions for single-patient R errors:
- KH_CD018 insulin_type extraction issue

Test results:
- 4/15 2025 trackers passing data value comparison
- Documented systematic HbA1c Unicode handling issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Updated documentation to explain why Python works but R fails with
Unicode comparison operators:

- Excel cells contain Unicode '≥' (U+2265)
- R's readxl library reads raw Unicode characters as-is
- Python's openpyxl (data_only=True) normalizes Unicode to ASCII '>'
- R's regex grepl('>|<') only matches ASCII characters
- R fails to detect marker, can't parse '≥15', gets error value 999999

This explains why Python extraction succeeds while R extraction fails
on the same Excel file - it's a difference in how the Excel libraries
handle Unicode character normalization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Changes:
- Add patient-level exception for KH_JV078 lost_date in Jayavarman VII tracker
  (R sets error date '9999-09-09' when Excel cell is empty, Python correctly extracts null)
- Update string column comparison to treat null and empty string as equivalent
  (normalized to null before comparison)
- This fixes the observations column mismatch for KH_JV086 (null vs "")

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Patient-level exceptions:
- KH_KB073 and KH_KB139: R missing 'Analog Insulin' in insulin_regimen column

File-level exception:
- Province column: R sets 'Undefined' for Takéo, Tboung Khmum, and Preah Sihanouk
  despite all being properly listed in allowed_provinces.yaml
- YAML has correct UTF-8 encoding (Takéo with é as U+00E9)
- R's sanitize_str() should remove accents and match, but validation fails
- Needs investigation in R's check_allowed_values() or YAML loading

Note: Python does not validate provinces during cleaning, it preserves
whatever is in Excel. R validates and sets invalid ones to 'Undefined'.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Changes:
1. Add load_canonical_provinces() to provinces.py
   - Returns province names with original casing (e.g., "Takéo", "Bangkok")
   - Unlike load_allowed_provinces() which lowercases for matching

2. Add validate_province() to validators.py
   - Uses sanitize_str() to match R's normalization (lowercase + remove special chars)
   - Validates against allowed_provinces.yaml
   - Sets invalid provinces to "Undefined" (matching R)
   - Normalizes valid provinces to canonical form (e.g., "tboung khmum" → "Tboung Khmum")

3. Integrate into validate_all_columns()
   - Province validation now runs automatically in clean_patient_data() pipeline

Testing shows correct behavior:
- "Takéo" stays as "Takéo" (valid, canonical form)
- "tboung khmum" normalized to "Tboung Khmum" (valid, case-insensitive match)
- "Invalid Province" becomes "Undefined" (invalid)

This matches R's validation behavior using sanitize_str() and check_allowed_values().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Python now validates provinces the same way as R, so both should produce
the same results for Takéo, Tboung Khmum, and Preah Sihanouk.

Note: R still has a bug where it sets these provinces to "Undefined"
despite them being in allowed_provinces.yaml. This needs investigation
in the R pipeline, but Python now correctly validates them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Python now correctly validates provinces (Takéo, Tboung Khmum, Preah Sihanouk),
but R still incorrectly sets them to "Undefined". The test exception must remain
until R is fixed.

Updated exception reason to clarify:
- Python implementation is now CORRECT (uses sanitize_str() properly)
- R implementation has a BUG (needs investigation)
- Exception allows tests to pass despite R's bug

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Changes:
1. Add fix_sex() function to transformers.py
   - Maps female synonyms: female, girl, woman, fem, feminine, f → "F"
   - Maps male synonyms: male, boy, man, masculine, m → "M"
   - Sets invalid values to "Undefined"
   - Matches R's fix_sex() function exactly

2. Integrate into _apply_transformations() in patient.py
   - Runs during cleaning pipeline before type conversions
   - Applied to all patient data automatically

3. Add comprehensive tests to test_transformers.py
   - test_fix_sex_female_synonyms: All female synonyms → "F"
   - test_fix_sex_male_synonyms: All male synonyms → "M"
   - test_fix_sex_invalid_values: Invalid → "Undefined"
   - test_fix_sex_preserves_nulls: null/empty → null
   - test_fix_sex_case_insensitive: Case-insensitive matching
   - test_fix_sex_missing_column: Graceful handling
   - test_fix_sex_matches_r_behavior: Comprehensive R behavior match
   - All 7 tests pass ✓

4. Add exception for KH_KB023 in Kantha Bopha II tracker
   - R extraction error: sex should be 'F' but R sets 'Undefined'
   - Python now correctly maps and extracts 'F'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add fix_bmi() function that calculates BMI from weight and height,
matching R's fix_bmi() behavior exactly. This replaces any existing
BMI values with the calculated value: BMI = weight / height^2.

Changes:
- Add fix_bmi() function in transformers.py
- Integrate into patient cleaning pipeline via _calculate_bmi()
- Add comprehensive test coverage (8 tests)
- Use pytest.approx() for float comparisons in tests

The calculation handles:
- Null weight or height → BMI becomes null
- Error value in weight or height → BMI becomes error value
- Normal values → Calculate BMI = weight / height^2

This fixes BMI discrepancies between R and Python outputs where
R recalculates BMI from weight/height while Python previously
only validated the BMI range.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add 4 missing transformation functions from R pipeline step 2:
- replace_range_with_mean(): Helper to calculate range means
- fix_testing_frequency(): Replace ranges with mean values
- split_bp_in_sys_and_dias(): Split blood pressure into sys/dias
- fix_patient_id(): Validate and fix patient ID format (XX_YY###)

All functions match R behavior exactly with comprehensive test coverage
(35 new tests, 78 total tests passing, 98% transformers coverage).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The find_data_start_row() function expects numeric values (patient
row numbers) in column A, but two tests were incorrectly using
string values:
- test_randomized_data_position: Used string instead of number
- test_ignores_none_values: Used "First data" instead of numeric 1

Both tests now correctly use numeric values and pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added REQUIRED_COLUMN_EXCEPTIONS to handle cases where trackers have
known missing values in required columns. The 2017_Mandalay Children's
Hospital tracker has missing status values in the source Excel file.

Changes:
- Added REQUIRED_COLUMN_EXCEPTIONS dict for file/column-specific exceptions
- Updated test_required_columns_not_null to check exceptions before failing
- Added exception for 2017_Mandalay tracker status column

This allows the test to pass for known data quality issues in source
files while still validating required columns for all other trackers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added exception for patient KH_CD008 who has a missing status value
in April 2019. Both R and Python outputs show null for this record,
confirming it's a data quality issue in the source Excel file.

Note: "Lost Follow Up" status IS being recognized correctly - 12 other
records in this tracker have this status. The validation and mapping
are working as expected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Modified test_required_columns_not_null to fail when a tracker is
listed in REQUIRED_COLUMN_EXCEPTIONS but no longer has null values
in that column. This alerts developers to remove outdated exceptions.

Changes:
- Added validation check before main test logic
- If exception exists but column has no nulls, test fails with message
- Added exception for 2019_Mahosot Hospital (LA_MH005 missing status)

This ensures REQUIRED_COLUMN_EXCEPTIONS stays up-to-date and developers
are notified when source data issues are fixed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added exception for patient KH_PK022 who has a missing status value
in August 2019 in the 2019_Preah Kossamak Hospital A4D Tracker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added exception for patients VN_VC053 and VN_VC054 who have missing
status values in the 2019_Vietnam National Children_s Hospital A4D Tracker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…tracker

The merge_headers() function incorrectly applied forward-fill logic when
adjacent columns had headers in different rows, causing the Status column
to be merged as "Level of Support Status" instead of "Status". This merged
header didn't match any synonym, so the column was dropped during extraction.

Changes:
- Track both prev_h2 and prev_h1 to distinguish true horizontal merges from
  adjacent standalone columns with headers in different rows
- Only apply forward-fill when previous column also had h1 (indicating a
  true horizontal merge across multiple columns)
- Preserve standalone columns that have header in row 1 only

Impact: Fixes 1115 missing status values in 2021 Kantha Bopha Hospital tracker
(May-December months where data started at row 87 instead of row 15).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fixed Pydantic validation error that occurred when processing rows with
null patient_id values. The bug was introduced in commit 740cb52 when
adding str.strip_chars() filtering.

Issue:
- Some trackers (e.g., 2025_06 Lao Friends Hospital) had rows with None patient_id
- These rows passed through extraction filters because pl.col("patient_id").str.strip_chars()
  on None returns None (not False), so the filter condition ~(...) also returned None
- Rows reached cleaning phase where row.get("patient_id", "unknown") returned None
  (dict.get() only returns default if key is missing, not if value is None)
- Pydantic DataError validation failed: "Input should be a valid string [type=string_type, input_value=None]"

Solution:
1. Extraction phase (extract/patient.py):
   - Filter out ALL rows with missing patient_id FIRST before any string operations
   - Log missing patient_id rows to both logger.error() and ErrorCollector
   - Includes metadata (sheet_name, name) for debugging
   - Then safely apply other filters (numeric zeros, Excel errors)

2. Error collection safety net (clean/converters.py, clean/patient.py):
   - Changed row.get(col, "unknown") to row.get(col) or "unknown"
   - Handles None values correctly (returns "unknown" instead of None)

3. Pipeline integration (pipeline/tracker.py):
   - Create ErrorCollector before extraction (not just cleaning)
   - Pass to read_all_patient_sheets() for extraction error tracking
   - Missing patient_id errors now appear in final error summary

Impact:
- Rows with missing patient_id are excluded with ERROR-level logging
- Clear visibility into data quality issues in both log files and error summaries
- Pipeline continues processing valid rows instead of crashing
- All tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Changed worker log files from memory-address based names like
'main_worker_4396950800.log' to human-readable timestamp-based names
like 'main_worker_20251115_230652_pid12345.log'.

Changes:
- Added timestamp (YYYYMMDD_HHMMSS) for readability
- Added process ID (pid) to ensure uniqueness when workers start simultaneously
- Format: main_worker_{timestamp}_pid{pid}.log

Benefits:
- Easy to identify when worker started
- Clear which process owns which log
- Sortable by time
- Guaranteed uniqueness even with parallel worker initialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…er suffix

Fixed bug where patient_ids with hyphens and transfer suffixes (e.g.,
"LA-MH093_LF") were being filtered out instead of normalized to "LA_MH093".

Issue:
- Transfer patient normalization extracted pattern ^([A-Z]+_[^_]+)
- This expects underscore after country code, but "LA-MH093_LF" has hyphen
- Pattern didn't match, str.extract returned None
- Row was filtered by missing patient_id filter

Solution:
- Normalize hyphens to underscores FIRST: "LA-MH093_LF" → "LA_MH093_LF"
- Then extract transfer suffix: "LA_MH093_LF" → "LA_MH093"
- Later validation step also normalizes hyphens (idempotent, no effect)

Flow:
1. _apply_preprocessing: "LA-MH093_LF" → "LA_MH093" (remove suffix)
2. validate_all_columns: "LA_MH093" → "LA_MH093" (already normalized)

Impact:
- Transfer patients with hyphenated IDs now properly normalized
- Matches R pipeline behavior
- All tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Patient MM_MD072 has missing status value in February 2021 (Feb21 sheet)
in the source Excel file for 2021_Mandalay Children's Hospital A4D Tracker.

This is a known data quality issue in the source file that affects both
R and Python pipelines. Added to REQUIRED_COLUMN_EXCEPTIONS to prevent
false positive test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Patient KH_KB017_PK has missing status value in the source Excel file
for 2021_Preah Kossamak Hospital A4D Tracker.

This is a known data quality issue in the source file that affects both
R and Python pipelines. Added to REQUIRED_COLUMN_EXCEPTIONS to prevent
false positive test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added TH_CP027, TH_CP028, TH_CP029, TH_CP030 to status column exceptions
for 2022_Chiang Mai Maharaj Nakorn A4D Tracker due to missing status
values in source Excel file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Michael Aydinbas added 10 commits August 19, 2026 22:31
…d floats

R's fix_t1d_diagnosis_age is unit-tested against the very strings the trackers
carry, but its call site is commented out, so R only ever passes the source
column through as.numeric: a blank cell stays NA and a word-written age becomes
its 999999 sentinel. Python derives from dob and the diagnosis date and lands
on a figure the source's own words confirm. Two classifiers name that, and the
16 cells where a date was typed into the age column instead.

Separately, the cleaned stage kept flagging 4.8600000000000003 against 4.86 on
the two screening columns the schema types as strings, which cleaning therefore
never casts. Numeric normalization now covers the cleaned stage's string-typed
columns, derived from each frame's own dtypes.
In-scope residual 1,200 to 770, raw held at 0, with no pipeline change: both
findings were an R limitation and a comparison-harness gap. The round's own
hypothesis, that these ages were downstream of the bare-year date fix, was
killed by measurement rather than argued away.

Round 8 opens with two suspected Python defects at the head of the queue for
the first time on this stage: height, where Python emits 0.069 metres from
source cells R rejects outright, and fbg_updated_mg, where R manufactures a
reading of 140 from the text "Lost follow up". Three source-defect findings
went to ticket 40, and ticket 12's blocker swaps 54 for 55.
Round 8 of the patient cleaned-stage triage: 770 in-scope mismatches to
448, raw stage still 0, three Python defects fixed.

Height converted to metres above 2.3 where R converts above 50, so the
120 source cells sitting between the two units (2.43, 6.9, 13.0) were
divided by 100 and published as 0.069 metres instead of failing the
[0, 2.3] bound. BMI was derived before range validation, so it came from
that impossible height and passed its own bound; R cuts height first and
propagates the sentinel, which the pipeline now does too.

parse_date_flexible accepted a year with a digit missing (1/16/224,
13-Mar-0202), which dateutil reads literally, so dates in antiquity
reached production. Floored at 1900, mirroring the existing
beyond-tracker-year guard at the other end of the calendar.

insulin_subtype recovers 56 rows where one clinic ticks the template's
insulin boxes by writing the drug name. Making an unticked row null
instead of Undefined was measured and reverted: it created 17,418 new
divergences because R publishes Undefined there too.

fbg_updated_mg needed no pipeline change: R's fix_fbg matches its
category words as substrings, so "Lost follow up" becomes a glucose of
140 and every out-of-range HI marker becomes 200. Three classifiers
added for that and for the insulin recovery.
…reads

R's parse_dates deletes the fourth letter of any word of four or more
letters, then walks a fixed order list ending in my and y. Executed
against R with lubridate installed, this reproduces its frozen output
exactly: a spelled-out month collapses to 1 January of its year, an
unreadable month makes R read the day as the month, and a day past 12
leaves R with no reading at all. Three classifiers carry it.

The same investigation found three Python gaps, all real data loss:
month spellings Python did not know (Bahasa Malaysia Mac/Mei/Okt, the
Thai abbreviations, Dce, ug), separator runs damaged by a stray
keystroke, and zero-width characters. Checked old parser against new
over all 5,187 distinct raw date strings: 32 changed, every one from
the sentinel to a real date, none of the already-parsing values moved.

The repair refuses to run when more than three numbers remain, after
the first version turned the range 11-15 /01/2019 into 2001-11-15.

Patient cleaned in-scope residual 448 -> 94; raw byte-identical at 0.
R's extract_date_from_measurement makes the closing parenthesis optional,
and its own test suite covers a cell that never closes one. Python's
regex required it, so a 2017/2018 measurement cell written 180(May-2017
lost its date entirely - 25 of the 30 source cells in that group are
written that way.

Python now uses R's shape, minus R's own data loss: R's greedy prefix
leaves 196( as the value for 196((Dec-2017) and then fails its own
numeric cast, throwing the reading away. Python strips the stray paren
and keeps the 196.

Patient cleaned-stage in-scope residual 96 -> 19 on the real 254-tracker
set; raw byte-identical, product untouched. Four classifiers added for
what remains: dates R invents from a damaged token, a glucose reading
typed into the HbA1c column, a date in an integer column, and a reading
the unit swap recovers where R sentinelled.
Ticket 12's blocked_by has been maintained as a fixed list swapped one
entry at a time. It is really a derived list: whichever open tickets
currently need the R source in r-archive/. Writing the rule down means a
ticket spawned later that needs R is added without re-arguing it, and
that the ticket cannot be unblocked again by treating spawned residuals
as out of scope - which happened once and cost a session.

The frozen output baseline on the data drive is explicitly not gated by
this: it lives outside the repo and ticket 12 never touches it.
Cleaning merged four distinct patients onto one identifier. fix_patient_id
inherited R's rule of truncating any malformed ID longer than 8 characters to
its first 8, so 2023_NPH's KH_NPH026 through KH_NPH029 all became KH_NPH02 -
an identifier present in no source workbook - carrying four children's
September records detached from their own October-December history.

The workbook is defective: only its Sep23 sheet types the stray H, while its
Patient List and three other month sheets spell the same four patients
correctly. But truncation turned a recoverable typo into a false merge, and
the function was self-inconsistent - a 7-character bad ID was honestly
sentinelled while a 9-character one was silently reshaped into something
plausible.

Truncation is dropped, a deliberate divergence from R. A malformed ID is now
recovered against the well-formed IDs the same tracker carries, at edit
distance 1 and only when exactly one candidate fits, and sentinelled to
Undefined otherwise. Every malformed ID is reported whether recovered or not,
since either way the source workbook needs correcting.

Measured by re-cleaning all 254 raw parquets: one file changes, the four
patients each regain their September row, and zero non-conforming IDs remain
corpus-wide. Re-running the comparison against the frozen R output shows the
divergence lands as row-key non-overlap (7 files/125/19, up from 6/121/15,
all of it 2023_NPH's 4+4), not as cell mismatches, which fell 114,373 to
114,371.

Ticket 45's count of four files losing nine identities turned out to be three
mechanisms: two are the pipeline correctly merging hyphen- and
underscore-spellings of one patient, and 2026_NOGH's MM_NO97/98/99 are an
unrepairable source defect - written that way in its own Patient List, with no
MM_NO097 to recover to, so three patients still share Undefined.

Closes ticket 47. Spawns ticket 58: extraction joins the Patient List on the
unfixed ID, so recovered rows have their identity back but not their
demographics. Five findings added to ticket 40.
compare_row_key_overlap reported three integers, so a row that found no
partner on the other side was knowable only as a count. That row never
reaches compare_cells either, so it appears in no cell_mismatches sheet,
contributes to no per-column or per-cause count, and cannot be classified -
it was effectively invisible to every triage ticket this migration has run.

Ticket 47's four recovered patient rows made this concrete: naming them
required querying the parquets by hand, outside the tool.

RowKeyOverlap now carries the unmatched keys alongside the counts, each with
the number of surplus rows on that side, sorted on the rendered key so the
report diffs cleanly run over run and a key holding nulls or mixed types
cannot raise on comparison. The report gained a row_key_unmatched sheet
listing file, side, key and rows.

Its first run against the real 254-tracker output named a population nobody
had looked at: 144 unmatched rows across 7 patient cleaned files and 130 at
the raw stage, of which only 8 are ticket 47's. The largest is 98 R-only rows
in 2026_Preah Kossamak. Product is 0 at both stages, resolved by ticket 17's
ordinal key.

Spawns ticket 59 to triage that population.
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

Michael Aydinbas added 14 commits August 21, 2026 23:32
hospitalisation_date's header is free text, so clinicians write a case
note and put the date inside it. Recovery previously depended on where
the date sat: the prefix walk reaches one at the front of the string, so
"16-Nov-2019 due to DKA" was published and "DKA 16-Nov-2019" was not.

recover_date_from_text scans for anchored day/month/year shapes and
refuses everything else. Built after scanning the whole column rather
than from examples: 48 cells carry digits and no date, which is what
rules out dateutil's fuzzy mode, since it declines on none of them.

A note naming several dates publishes the first and logs the discard; an
absent day becomes the 1st and an absent year comes from the tracker
year. Three warning codes make each decision auditable.

Also fixes a misreading that predates this change: a bare range with no
prose around it was dated from its first number as a year, so
"6-12 Nov 2020" was published as 2006-11-12.

673 sentinels down to 156 across 254 trackers; the column's R/Python
mismatches go from 478 unclassified to 0, under three new causes.
…eading

Neither pipeline reads the mmol fasting-glucose column from the workbook.
Both derive it from the mg cell beside it, so a difference there is a
difference already named next door, restated by arithmetic.

All 2,935 unclassified fbg_updated_mmol cells sit beside an mg cell that is
itself a mismatch with a named cause. mmol_derived_from_mg_sibling says so,
bounded by the division identity: an mmol cell holding a reading its mg
sibling cannot account for is left alone.

Two of the three shapes had never been described. R divides a number fix_fbg
manufactured out of text, or one past the analytical ceiling, and publishes
the quotient as a measurement - 2013 mg/dL becomes 111.8 mmol/L, which the
medical advisor called impossible. The other way round, R sentinels a reading
that carries its unit and Python reads it.

Also give the range test the fact it was missing. Which unit a column really
holds is a property of the whole column, read back from the run's own
glucose_unit_swapped records rather than guessed from the column's name, so
2020 Kantha Bopha's outliers are judged in the unit they were written in.

Patient cleaned-stage unclassified: 2955 -> 16, and the remaining 16 are
withheld decisions rather than unexplained differences. No mismatch was
created or removed - per_column counts are identical and the other three
stage snapshots are untouched.

Closes wayfinder ticket 44; ticket 12 now waits on ticket 32 alone.
The cause registry had four labels that matched a shape rather than a
mechanism, all written before the "triage means deciding" bar.

off_by_one_day claimed a one-day gap. Its ten cleaned-stage rows are
consecutive daily entries in one VNCH sheet, knocked a position out of
step because R holds null entry dates at ordinals 42, 47 and 51 and so
falls back to input order. That is row_order_divergence, which lost only
because the entry-date registry merges ahead of the row-order one.
Deleted.

ce_typo tested r_value.year > 2100, which is what normalize_date_column
stamps when R's raw string will not parse - so it described the tool, not
the data. Replaced by python_absurd_excel_serial, which judges the Python
side. Deleted.

sentinel_null is renamed python_sentinel_r_extraction_gap: Sarawak's 2023
tracker really does carry December 2024 dates in its Dec23 sheet, so
Python's guard fires while R's own extraction gap leaves null.

r_value_missing is right about R for 11,436 of 11,468 rows and was
speaking for 32 more where Python publishes 0202-06-20 or a Buddhist-era
year. Bounded by python_out_of_window_date_preserved, which needs the
tracker's own year, so CellMismatch now carries it.

Two pipeline defects sat underneath these.

_validate_entry_dates exempted every year past 2400 so Thai Buddhist-era
dates could flow through, which also let Excel serials 1339576 and 411384
reach the product table as 5567-08-19 and 3026-04-30. The exemption is now
the tracker's own BE band, keeping all 22 genuine BE dates and sentinelling
the 3 corrupt cells under a new implausible_era_date code.

Emitting that code showed the second: a4d run published an errors table
holding the patient arm only, because the patient arm writes it from inside
run_patient_pipeline and nothing wrote the product arm's. BigQuery has
therefore never held a product finding. run now writes it once after both
arms, 63295 to 97326 records.

Measured on the real 254-tracker set: both patient stages byte-identical,
product cleaned unclassified 20 to 21, the one new row left as a stated
question rather than a label.
… touches

The question was framed around product's 22 visible rows. Scanning every
raw patient date column found the patient arm is the larger half and is
losing data rather than publishing it oddly: 381 cells across 95 distinct
values, every one clobbered to the 9999-09-09 sentinel by _validate_dates
because a Buddhist year reads as a future date. Nothing surfaced them
because R sentinels them too, so no comparison sheet has a mismatch to show.

The band test the product fix already uses looks right for patient as well.
2022 Hat Yai holds 2560-01-01 in t1d_diagnosis_date; the band converts it
to 2017-01-01, which is the date ticket 40 had separately established from
that patient's own D.O.B., recruitment and age at diagnosis.

Decision recorded, not implemented.
The next session orients from Where this map stands, not from the ticket,
so the decision and the 381-cell patient measurement belong there too.
A tracker kept in a Thai-locale Excel writes its dates with a Buddhist-era
year (BE = CE + 543). Product published those as written, dating a stock
movement 543 years ahead; patient destroyed them, because a future date meets
the 9999-09-09 sentinel. Neither is what the clinic recorded.

The cleaned stage now shifts them to Gregorian on both arms and logs each
shift under a new buddhist_era_converted code, so a conversion is auditable
the way a recovered date is. Raw keeps what the workbook says.

375 patient cells recovered across 11 columns and 7 Thai trackers; 22 product
rows converted, leaving no entry date past year 2400. 2022 Hat Yai's
2560-01-01 lands on 2017-01-01, the date that patient's own D.O.B. and
recruitment independently give. Six cells decoding to no plausible year, and
two whose note names a date range, stay sentinelled as source defects.

The shift goes via a string because pl.date and dt.replace raise on invalid
components: 543 is not a multiple of 4, so a Buddhist leap day can land on a
non-leap Gregorian year and would abort the tracker.

Two comparison causes name what the change creates -- the conversion itself,
and the three product rows the resulting re-sort displaces, which plain value
membership cannot see because R's value is by construction absent from
Python's group. Unclassified did not move on either arm.
find_data_start_row scanned column A for the first numeric cell, so a
patient row whose number had been cleared - Excel keeps a whitespace-only
string there - pushed the start one row past the data. The header rows
were then read from the data itself, no patient_id survived
harmonization, and the sheet was skipped whole: 2022 Children's Hospital
2 lost its entire Oct22 sheet, 15 patients' records.

The numeric block now extends back over a whitespace-only cell directly
abutting it, one row only. Matching R's looser rule - any non-empty cell
- was measured across all 254 trackers and rejected: 14 sheets hold a
stray letter in A1 and would start there.

Rows whose patient ID cell holds a broken formula stay dropped, since
Undefined is a bucket rather than an identity and keeping them would pool
many patients under one group key. They no longer vanish silently: each
discard emits the new excel_error_patient_id code, 120 rows across 9
trackers, the largest being 98 rows of real measurements in 2026 Preah
Kossamak's May26 sheet.

Closes ticket 59.
buddhist_era_typo tested only year >= 2400. Since the cleaned stage began
converting genuine Buddhist-era dates, a year still above that threshold is
by construction not one - so the test claimed twelve cells that are ordinary
year typos in the source workbook (3035-03-01 in 2025 CDA, 5025-05-19 in
2025 Surat Thani, both verified in the cells themselves). It is now bounded
by the sheet's own Buddhist-era band.

Bounding it dropped five rows onto r_parse_order_cannot_read_cell, which is
also false about them: R never failed to parse, it carried the Excel serial
1141523 through as a string and the comparison's own date normalization
produced the sentinel. python_absurd_excel_serial already names that shape
but was wired only to the product entry-date registry, and its Buddhist-era
band could never apply to patient columns because no patient stage sets
tracker_year_col. It is now wired to every patient date column and falls back
to reading the year off the sheet name.

Measured across all four stages before and after: exactly four per-cause
entries move, and unclassified holds at 37.

Also corrects two docstrings without moving any count. r_extraction_gap spans
thirteen columns against the six mechanisms it documented; three more are
traced to source here, and one of them corrects an explanation already in the
registry, which blamed R for reading nothing from a sheet it reads 102 of 120
rows from. r_validator_rejects_multivalue covers two R defects, not one - the
documented CSV rejection plus NA propagation through ifelse/paste.
Finishes the twelve-cause audit scoped by ticket 32. Python is the correct
side in all three remaining populations and no pipeline code changes; what
was wrong was the explanation under each label.

r_category_lookup_miss (866 rows) is two mechanisms, not one. 652 rows are
CRLF vs LF: the reference workbook stores embedded line breaks as a bare LF
while the trackers store CRLF, readxl is faithful to both and openpyxl folds
them, so the two sides never compare the same string. Only 214 rows over
three names are the case difference the docstring described.

openpyxl_date_typed_stray_cell (100 rows) states one verdict for three
shapes. For 66 of them R's serial is the quantity the clinician typed and
Python's 1900 date is the misleading rendering, harmless only because both
sides publish identical cleaned output. The 20 patient rows the docstring
never mentioned run the other way: R publishes diagnosis ages of 20,668 and
42,859 years where Python nulls them.

r_extraction_gap's recruitment_date bulk (28,009 rows) does not fail for the
large majority of patients; R populates 64 percent. It fails for 91 whole
trackers and no partial file, because a trailing space in the header forces
xml:space="preserve" and openxlsx folds the attribute into the column name.
That junk column appears in 91 of 91 affected files and 0 of the other 148.

Ticket 12 stays blocked: re-deriving the open tickets that still need the R
source leaves ticket 58, whose own question 3 is to check R.

Verified by a second full comparison over the 254-tracker set showing zero
per-cause movement in all four stages. Suite 938 passed, ruff and ty clean.
Extraction attached the Patient List and Annual sheets on the raw
patient_id, while the fix that makes a respelled ID usable runs later,
in cleaning. Where a month sheet spelled a patient differently from the
Patient List in the same workbook, the join could not match: the row
kept its measurements and lost every static column.

Both joins now key on a derived normalized ID, sharing the rule cleaning
already applied. The raw patient_id column is unchanged, so the
comparison's row-alignment key is unaffected.

Measured across all 254 real trackers: 695 rows in 6 files missed the
join, 680 recovered carrying 6,863 Patient List cells, 672 of them the
2023 and 2024 Mahosot trackers writing LA-MH056 against a Patient List
spelled LA_MH056. The Annual join adds 8 rows / 40 cells. R has the
identical gap, so the comparison could never show any of it.

New cause r_static_join_misses_respelled_id names the raw-stage half
(5,959 cells, raw unclassified to zero). The cleaned stage cannot be
discriminated, since both pipelines normalize patient_id there; that
residue is ticket 63.
Ticket 58 made extraction key its Patient List and Annual joins on a
normalized patient ID, and named the resulting divergence by the month
row's own spelling. Cleaning normalizes exactly that, so the cause could
never fire on the cleaned stage and left 4,949 cells unexplained there.

Derive the affected identities per file from the run's own raw output,
which is the last place both spellings coexist, and carry them to the
cleaned stage as a CellMismatch flag. Comparison-side only; production
output is untouched.

Two guards were needed before the cause was true. A column R lost across
a whole file is r_extraction_gap's xml:space header defect, not this one
(2024 Preah Kossamak reads zero recruitment_date in 863 rows against 2024
Mahosot's 672 of 1,068), so r_column_empty_in_file keeps 2,336 cells off
the wrong label. And age is derived, so it gets its own cause,
r_age_not_derived_without_dob, keyed on the row's recovered date of birth.

Four existing causes were silently holding join-miss cells and hand back
671 recruitment_date, 677 fbg_baseline_mg, 224 each of edu_occ and
edu_occ_updated, and 9 per blood-pressure column. Each figure now matches
the raw stage's own count for that column, computed a different way.

Cleaned unclassified 4,949 -> 16; raw stays 0; both product stages
unmoved; four-stage totals unchanged. Ticket 12's blocked_by is now empty.
Delete r-archive/ (156 files) now that no open ticket needs the R source.
Replace CLAUDE.md's R Archive section with a git recovery recipe, and clear
the stale pointers in docs/CLAUDE.md, SETUP.md, pyproject.toml's ruff
exclusion, .dockerignore and .gitignore.

The 35 docstring citations naming R files stay for now; removing them is
ticket 64, which also blocks the promotion to dev.
Rewrite every docstring and comment that explained the pipeline by what the
retired R implementation did, replacing each with the behaviour and the source
evidence behind it. src/ 174 references to 0, tests/ 46 to 0, markdown 65 to 0.

src/a4d/migration/ keeps its 179: R divergence is that package's subject. Its
module docstring now marks it historical rather than current guidance.

Move docs/migration to docs/archive with an index, correcting each document to
the true current state before freezing it. Delete VALIDATION_SUMMARY.md, a
174-tracker verdict superseded by the 254-tracker comparison.

Add tests/test_docs_have_no_r_framing.py so this cannot regress.
Refresh the PR body from docs/archive/MR_DESCRIPTION.md and correct its two
now-false readiness claims. Record the verified pre-merge state on ticket 6.

The merge itself is left to a human.
@pmayd
pmayd merged commit 9977228 into dev Aug 24, 2026
2 checks passed
pmayd pushed a commit that referenced this pull request Aug 24, 2026
PR #2 merged as a fast-forward; dev and migration are both 9977228 with no
merge node. Both recovery tags remain ancestors of dev and the r-archive
recovery recipe was executed against merged dev.

MR_DESCRIPTION.md was live until this merge and is now frozen with the rest of
docs/archive/.
pmayd pushed a commit that referenced this pull request Sep 1, 2026
PR #2 merged as a fast-forward; dev and migration are both 5165a59 with no
merge node. Both recovery tags remain ancestors of dev and the r-archive
recovery recipe was executed against merged dev.

MR_DESCRIPTION.md was live until this merge and is now frozen with the rest of
docs/archive/.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants