Repository navigation
Conversation
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>
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.
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 ☂️ |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 atgit show r-archive-removed^:r-archive/R/<file>).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.
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 blockedPart 1 — The
a4dpackageWhat 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 ListsheetINV/ stock section (insulin and supply movements)clinic_idis the tracker's parent folder name. The tracker year comes fromsheet names (
Jan24-> 2024) or the filename.Module map
extract/patient.pyextract/product.pyextract/wide_format.pyclean/patient.pyclean/product.pyclean/schema.py/schema_product.pyclean/converters.pyErrorCollectorclean/validators.pyclean/transformers.pyclean/date_parser.pytables/*.pypipeline/*.pygcp/*.pyreference/*.pyreference_data/)validate/*.pymigration/compare.pystate/*.pyconfig.py.env/A4D_*env varscli.pyRow-level data-quality problems never raise:
ErrorCollectoraccumulates themand they land in
table_errors/table_logswith anerror_code. Sentinelsfor 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
.envorA4D_*env vars:A4D_DATA_ROOTA4D_OUTPUT_DIRoutput(relative todata_root)A4D_PROJECT_ID/A4D_DATASETa4dphase2/trackerA4D_DOWNLOAD_BUCKET/A4D_UPLOAD_BUCKETa4dphase2_upload/a4dphase2_outputA4D_MAX_WORKERS4A4D_ERROR_VAL_NUMERIC/_CHARACTER/_DATE999999/Undefined/9999-09-09A4D_MIN_TRACKER_YEAR/_MAX_TRACKER_YEAR2017/2030CLI
Commands are grouped by process, not by the object they act on:
Key flags on
run:--file(single tracker),--workers/-w,--skip-download,--skip-upload,--skip-drive-download,--skip-patient,--skip-product,--skip-tables,--incremental,--force.--incrementalskips trackers whose MD5 and completion state match the previousrun's manifest (BigQuery -> local parquet -> empty fallback); both arms see the
same filtered queue.
--forcewipes prior local outputs first and overrides--incremental.Usage scenarios
Development commands
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, theCloud 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.
a4d-pipeline(Cloud Run Job,asia-southeast2)asia-southeast2-docker.pkg.dev/a4dphase2/a4d/pipeline:latest, also tagged per git SHAA4D_MAX_WORKERS=8a4d-pipeline@a4dphase2.iam.gserviceaccount.com—storage.objectViewerona4dphase2_upload,storage.objectCreatorona4dphase2_output,bigquery.jobUser+bigquery.dataEditorproject-levelA4D_DATA_ROOT=/tmp/data, ephemeral in-container — nothing persists between executionspython:3.14-slim,uv sync --frozen --no-dev, defaultCMDisa4d runStart, monitor, verify, roll back:
verify_production_run.py(+src/a4d/gcp/verify.py, unit-tested) compares rowcounts, distinct clinic counts and schema against the
backup-bqsnapshot. Thefirst 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 definedend of life at R's retirement (ticket 12). Engine in
src/a4d/migration/compare.py(pure, unit-tested), thin CLI inscripts/compare_outputs.py.How to run it
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:
patient_data_raw/patient_id+sheet_name, ordinal tie-breakpatient_data_cleaned/patient_id+sheet_name, ordinal tie-breakproduct_data_raw/(clinic_id, sheet)product_data_cleaned/(clinic_id, sheet)Product has no natural identity key —
product_entry_dateis null on many rowsand collapsed the join, so
add_row_ordinal()computes a positional key atcomparison 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.IDENTITYvsRowAlignment.POSITIONALnames which arm does which.Seven measures per file, coarse to fine:
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
STAGEStable:normalize_date_column— R stores unparsed Excel serials as strings, Pythonstores parsed dates. Both sides go through the pipeline's own
parse_date_flexible. Product rawproduct_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
floatso the tolerance applies. On the patient rawstage the target columns come from each frame itself, not the cleaned
schema, which cannot name the Patient List join's
.staticcopies or ameasurement column typed as a string. 434 mismatches -> 0.
normalize_whitespace_column— readxl'strim_ws=TRUEstrips what openpyxlkeeps, and represents an embedded line break as
\r\nvs\n. On thepatient 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 asFloat64columns the raw stagestill holds as text (
insulin_injections,hba1c_updated). 40 mismatches-> 0.
normalize_boolean_literal_column— readxl writes an Excel boolean asFALSE, openpyxl asFalse. Only the exact literals are folded. Raw stageonly; 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 themechanism 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.unclassifiedis the number that matters. A classifier records that adifference 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
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 meansloading 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:
just compare-outputs ...-> pick the largestunclassifiedpopulation inthe
cell_mismatchessheet.(R-null vs Python-has-value? sentinel? ordering?).
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.
r-archive/for the whole of the triage; the archive is now deleted, and thesame files are reachable at
git show r-archive-removed^:r-archive/R/<file>.explaining the mechanism, or record it as an open question.
a4d run patient --force) and the comparison; thedelta shows the effect.
sprawling.
Rules that made it work, learned the hard way:
mechanism, and say what you checked.
workbook, not R's output.
inspection" is a finding, not a failure.
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_t1d_diagnosis_agerecomputed from dates, discarding recorded agesmerge_headersleft the 2022 template'sUpdated 2022header unmapped_apply_type_conversionssplit on the first space; dateutil then completed from todayparse_date_flexibledeleted the 4th letter of month names (March->Marh)extract_regimenlowercased every unmatched value (NPH->nph)validate_allowed_valuespicked the last of two identically-sanitising spellingsremove_header_rowsmissed rows blank except one formula-emptied cellread_patient_rowsaccepted a row on its ID alone, turning a stray list of patient IDs below the data block into recordssexfind_data_start_rowwas O(n^2) on read-only worksheetsclean_product_datacrashed on pre-product-tracking trackersrun-pipelineaborted the whole run on one patient tracker failurerename_columnskept only the first of several source columns sharing a canonical nametracker_layout_changedflags it: 37 trackers, 284 positions, incl. one clinic's baseline FBG silently dropped for 5 monthsread_patient_rowsreturned openpyxl's datetime for a number typed into a date-formatted cell, which the numeric conversion then sentinelledobservationsAlso added: a
balance_reconciliationerror code that fires when the computedclosing stock contradicts the tracker's own recorded total (113 groups across
21 files), and a
blank_header_with_dataerror code for columns holding realvalues 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 labelthat column,
recover_blank_headerstakes the name from them and the datasurvives; 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_changedflags any position whose meaning is notstable 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'sApril sheet is missing the
Insulin Regimencolumn entirely, shifting sevencolumns 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.
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-
Undefinedquestion, 2 areR-null against Python's
999999where neither side published a reading, and 1is 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 packagekeeps 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 truecurrent state before being frozen;
docs/VALIDATION_SUMMARY.mddeleted as asuperseded 174-tracker verdict. New CI guard
tests/test_docs_have_no_r_framing.pystops regressions. Suite 1,080passed, 86% coverage.
12 — retire R from the workspace, now closed.
r-archive/is deleted: 156tracked files, 1.9M, gone from the working tree and recoverable through the
annotated tag
r-archive-removed(r-archive-removed^is the last commitcontaining it).
CLAUDE.md's "R Archive / do not modify" section is replacedby that recovery recipe;
docs/CLAUDE.md,SETUP.md,pyproject.toml's ruffexclusion,
.dockerignoreand.gitignoreall updated. Inlining the cited Rexcerpts 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_unitsin Python,fix_fbg'sfbg/18in R) — soan 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. Rdivides a number
fix_fbgmanufactured out of text (140 from "Lost followup") or one far past the analytical ceiling (2013 mg/dL) and publishes the
quotient as a measurement —
2023_CDAgets 111.8 mmol/L, above the levelA4D's medical advisor called impossible. A further 28, also unlooked-at, run
a third way: 2018 CDA writes
148 mg/dl (Mar-18), R'sas.numericfailson the whole string and Python reads it. Python is the correct side in all
three.
mmol_derived_from_mg_siblingnames the cascade rather thanre-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— readback from the run's own
glucose_unit_swappederror records rather thanrestated 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_mgunclassified 6 -> 0). Verified across two full runsagainst the real 254-tracker data:
per_columncounts byte-identical, theother 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
Undefinedquestion, 6 a named toolinglimitation), so there is no round 11. The last real Python defect was one
character: R's
extract_date_from_measurementmakes the closing parenthesisoptional and Python's did not, so
180(May-2017lost its date. 24 cellsrecovered. 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_datepopulation ticket 39 owns. Unlike every round beforeit 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 equivalentpath; 18 are one undecidable cell (
25-Ma4-2025— Mar or May, and R answersApril); ~19 are glued digit groups (
26/102022,10/1023) where the missingseparator'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
Undefinedcells that belong to a standing openquestion 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_datesdeletes the fourth letter of any word of fouror more letters —
April-17becomesAprl-17— and then walks a fixed orderlist ending in
myandythat cannot fail loudly. So a spelled-out monthcollapses to 1 January of its year (88 cells), an unreadable month makes R
read the day as the month —
9-Dce-20becomes 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,MeiandOktthat the Malaysian clinics write in a column every otherclinic writes in English, all twelve Thai abbreviations for Nakornping's
trackers, plus the transposition
Dceand the dropped letter inug.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 onany 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 of2001-11-15. The repair now runs only when at most three numbers remain, which
is also why
26/102022and10/1023are deliberately still rejected.insulin_subtype's 11 cells were traced and deliberately leftunclassified. R's derivation is a chain of
ifelse(x == "Y", ...): anall-
-row yields""and becomes null, an all-null row yieldsNAandbecomes
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 sourceworkbook — with four different patients' September records filed under it.
Cause:
fix_patient_idinherited R's rule of truncating any malformed IDover 8 characters to its first 8, and
2023_NPH'sSep23sheet types astray
Hinto four IDs its own Patient List and three other month sheetsspell 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
Undefinedotherwise, so the pipeline never invents an identity. Measuredby 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'sMM_NO97/98/99, written that way in its ownPatient List, three patients still sharing
Undefined).Frontier, continued
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.pythat ticket 12's inventory claimed. Not a find-and-replace: round4 found
_validate_datesclaiming its future-date guard "matches R pipelinebehavior" 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.pyitself, whose subject is a pipeline no longer in this repo.
unnoticed because the locally-run check set was a strict subset.
change; they need decisions, not silencing.
themselves can be corrected rather than guessed at in the pipeline.
LogViewerA4D's job. Not yetdecided whether it gates rollout or is a nice-to-have.
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.
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
migrationintodev(this PR). Fully unblocked: everyticket that gated it is closed, including 64. Nothing on this map now stands
between here and the merge.
Frontier, not blocking the merge
logstable still name Rscripts:
function_name="read_product_data_step1"(the only one of twentythat is not the emitting Python function) and
script="script1"/"script3"in a column whose default is the stage name
"clean". These are data, notcomments, so ticket 64 recorded them rather than changing published output.
Deliberately not wired as a merge blocker — inconsistency, not falsehood.
after promotion.
Two production defects fixed by the classifier audit (ticket 32)
_validate_entry_datesexempted every parsed year>= 2400so ThaiBuddhist-era dates could flow through, which also let corrupt Excel serials
(1,339,576 and 411,384) reach the product table as
5567-08-19and3026-04-30. The exemption is now the tracker's own BE band, so all 22genuine BE dates survive and the 3 corrupt cells are sentinelled and logged
under a new
implausible_era_dateerror code.a4d runpublished an errors table holding the patient arm only — thepatient arm writes it from inside
run_patient_pipelineand nothing wrotethe product arm's. BigQuery's
errorstable has therefore never carried aproduct finding (e.g. zero of the 116
balance_reconciliationrecords).runnow 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_convertedcode; the raw stage still keepswhat the workbook says.
_validate_datessaw a year centuries aheadand 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.
product_datano longer carries a stockmovement dated 543 years in the future.
3035-03-01,5025-05-19), as do twohospitalisation_datecells whose note names a daterange. All are reported as source defects instead.
A cleared row number no longer costs a whole month sheet (ticket 59)
find_data_start_rowscanned column A for the first numeric cell. Where aclinician 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_idafter harmonization, and skipped the sheet entirely.2022_Children's Hospital 2,whose
Oct22sheet was being dropped whole.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 arestill discarded —
Undefinedis a bucket rather than an identity, and keepingthem would pool many patients' measurements under one group key — but each
discard now emits the new
excel_error_patient_idcode so the workbook can berepaired.
2026_Preah Kossamak'sMay26sheet (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 Listdemographics andthe
Annualsheet — attached on the rawpatient_id, while the fix that makesa 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_idcolumn isuntouched.
rows / 40 cells from the Annual join.
dob,sex,province,t1d_diagnosis_date,hba1c_baseline,recruitment_date,patient_consent,lost_dateandstatus_outamong them.sheets write
LA-MH056and up with a hyphen against a Patient List spelledLA_MH056— 38 patients in 2024, 27 in 2023.Verified against R's own frozen cleaned output.
zero have two entries folding to one normalized key.
2023_NPH's strayH, and2024_Mandalay General'sMM_YG013_MG, which that Patient List does notcontain 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
unclassifiedis back to 16, with the four-stage totals unchanged.claimed 43 files instead of 6. 2024 Preah Kossamak reads zero
recruitment_datein 863 rows (R'sxml:spaceheader defect) where 2024Mahosot reads 672 of 1,068. A new
r_column_empty_in_fileguard keeps 2,336cells off the wrong label.
back 671
recruitment_date, 677fbg_baseline_mg, 224 each ofedu_occand
edu_occ_updated, and 9 per blood-pressure column — every figure nowmatching the raw stage's own count for that column, computed a different way.
ageis derived, so it got its own cause._fix_age_from_doboverridesthe 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
map; new clinics added after the last R run have no R counterpart and show as
Python-only.
compare_columnsdeliberately flags every dtype difference, includingharmless representation artifacts (R
Float64vs PythonInt32on integercolumns); these are documented rather than normalised away.
a4d-python/directory at the repo root is a stale copypredating the current
src/layout — not in git, pending a decision to delete.Full history, per-ticket evidence and every decision:
docs/wayfinder/map.md.