Conversation
This commit fixes two critical extraction bugs found during validation: 1. Handle worksheets with None max_row value - Some Excel files don't have dimension metadata, causing ws.max_row to be None - Added fallback to use 1000 as max_row when None is encountered - Fixes: 2024 Sultanah Bahiyah tracker processing error 2. Filter out Excel error values in patient_id - Excel error values like #REF!, #DIV/0!, etc. should not be extracted as valid patient IDs - Added filtering to remove any patient_id starting with "#" - Applied to all three extraction paths: monthly sheets, Patient List, and Annual - Fixes: 2024 Sultanah Bahiyah had 3 extra records with patient_id="#REF!" Impact: - 2024 Sultanah Bahiyah: Now matches R output (142 records, was 145) - Aligns Python extraction with R pipeline behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Updated VALIDATION_TRACKING.md to reflect successful fix of 2024 Sultanah Bahiyah tracker: Status Changes: - 2024 Sultanah Bahiyah:⚠️ INVESTIGATE → ✅ FULLY FIXED - Record count: 145 → 142 (matches R output) - Removed 3 Excel error records (#REF! patient IDs) Statistics Updates: - Record count mismatches: 7 → 6 files remaining - Fixed issues: 3 → 4 trackers resolved - Acceptable differences: 160 → 161 files validated Added comprehensive fix documentation: - Detailed root cause analysis (Excel #REF! errors + ws.max_row=None) - Code changes and line numbers - Impact assessment - Known minor difference: string normalization (MY_SM003_SB vs MY_SM003) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Normalize patient_id by removing transfer clinic suffix to ensure consistent patient linking across years. Pattern: "COUNTRY_ID_TRANSFERCLINIC" → "COUNTRY_ID" Example: "MY_SM003_SB" → "MY_SM003" Why: - Patient IDs follow pattern: COUNTRY_ID (e.g., MY_SM003) - When patients transfer clinics, new clinic is appended: COUNTRY_ID_NEWCLINIC - For longitudinal tracking, we need consistent IDs across years - R pipeline normalizes by keeping only first two underscore parts Implementation: - Added normalization in _apply_preprocessing() in cleaning pipeline - Uses regex to extract: ^([A-Z]+_[^_]+) (first two parts) - Raw extraction preserves original value (e.g., MY_SM003_SB) - Cleaned data has normalized value (e.g., MY_SM003) Impact: - 2024 Sultanah Bahiyah: MY_SM003_SB → MY_SM003 (now matches R) - Ensures proper patient linking when analyzing multi-year data - Maintains data lineage: raw has original, cleaned has normalized 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Added comprehensive unit tests for _apply_preprocessing() function: Patient ID Normalization Tests (6 tests): - test_normalize_transfer_patient_id: MY_SM003_SB → MY_SM003 - test_preserve_normal_patient_id: Keeps normal IDs unchanged - test_mixed_patient_ids: Handles mix of normal and transfer IDs - test_multiple_underscores_keeps_only_first_two_parts - test_patient_id_without_underscores: Preserves non-matching patterns - test_null_patient_id_preserved: Handles null values HbA1c Preprocessing Tests (2 tests): - test_hba1c_baseline_exceeds_marker: Extracts > or < markers - test_hba1c_updated_exceeds_marker: Handles updated HbA1c FBG Preprocessing Tests (2 tests): - test_fbg_qualitative_to_numeric: high→200, medium→170, low→140 - test_fbg_removes_dka_marker: Documents current behavior Insulin Y/N Hyphen Replacement Tests (2 tests): - test_replace_hyphen_in_insulin_columns: - → N for insulin columns - test_preserve_hyphen_in_other_columns: Other columns unchanged All tests pass ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Extended invalid patient_id filtering to catch numeric-looking zeros that weren't caught by the previous string comparison. Issue: - 2025_06 Taunggyi had 4 extra records with patient_id='0.0' and name='0.0' - Previous filter only checked for exact string "0", missing "0.0" - Python had 170 records, R had 166 records (4 extra invalid records) Solution: - Filter rows where BOTH patient_id AND name are in ["0", "0.0"] - Applied to all three extraction paths: monthly sheets, Patient List, Annual - Uses string matching with strip_chars() for robust comparison Impact: - 2025_06 Taunggyi: Now matches R output (166 records, was 170) - Filtered out 4 invalid records with numeric zero IDs - More robust filtering handles variations like "0", "0.0", " 0 ", etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Investigated all 10 trackers with record count mismatches: ✅ Resolved (8 trackers): - 2021 Phattalung Hospital: Fixed extraction bugs - 2021 Vietnam National Children's Hospital: Validated - 2022 Surat Thani Hospital: Fixed missing row number handling - 2022 Mandalay Children's Hospital: Fixed by numeric zero filtering - 2024 Likas Women & Children's Hospital: Fixed by earlier improvements - 2024 Sultanah Bahiyah: Fixed #REF! and ws.max_row issues - 2025_06 Kantha Bopha II Hospital: Fixed by earlier improvements - 2025_06 Taunggyi Women & Children Hospital: Fixed numeric zero filtering for "0.0"⚠️ Known Difference (1 tracker): - 2024 Mandalay Children's Hospital: R implicitly filters MM_MD001 from 12→1 records Decision: Keep Python's behavior (all monthly records valid for longitudinal tracking)⚠️ Skipped (1 tracker): - 2024 Vietnam National Children Hospital: Excel has 27 duplicate patient rows in Jul24 with conflicting data Final Statistics: - 169/174 trackers (97.1%) match exactly - 1 tracker with known acceptable difference - 1 tracker skipped due to Excel data quality issues
- Created parametrized tests for all 174 trackers comparing R and Python outputs - Test record counts, schemas, patient IDs, and data quality (duplicates) - Added KNOWN_DIFFERENCES for acceptable variations (Mahosot, Mandalay) - Added SKIP_VALIDATION for trackers with Excel data quality issues - File coverage test shows 99.4% (173/174) Python outputs available - Tests marked as slow and integration for selective execution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Separated test configurations: - ACCEPTABLE_DIFFERENCES: Python improvements over R (tests pass) - KNOWN_ISSUES: Python bugs to fix (auto-detect when resolved) - SKIP_COLUMNS_IN_COMPARISON: Columns with known differences - VALUE_MAPPINGS: Known equivalent values between R/Python - REQUIRED_COLUMNS: Columns that must never be null Added new tests: - test_required_columns_not_null: Validates critical columns - test_data_values_match: Comprehensive data comparison Fixed patient.py: - HbA1c exceeds columns now default to False instead of null - Added .fill_null(False) after .str.contains() for both baseline and updated Test results: - 833/870 tests passed (37 failures expected) - 25 trackers have null status values (data quality issue) - 12/15 2025 trackers have data mismatches to investigate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Added PATIENT_LEVEL_EXCEPTIONS configuration to handle cases where R has extraction errors for specific patients but Python is correct. Example: KH_CD018 in 2025_06_CDA tracker - R misses "Analog Insulin" value in insulin_type column that Python correctly extracts. This allows excluding specific patient-column combinations from comparison without skipping the entire column or file. Test results: - 2025_06_CDA tracker now passes data value comparison - 4/15 2025 trackers passing (was 3/15) - 11/15 still have data mismatches to investigate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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>
added 23 commits
August 17, 2026 21:29
…position The patient comparison joined R and Python on (patient_id, sheet_name). Where a monthly sheet lists the same patient twice, that key fans out: two rows a side produce four pairings, half of which compare unrelated records and report every column as a mismatch. Group add_row_ordinal by the identity key itself rather than adopting product's positional key, so identity is still checked in the 249 of 254 files whose key is unique and the ordinal only breaks ties. Break the tie by minimum differing cells instead of by position: occurrence order is already optimal at the raw stage but pairs the wrong copies in 9 cleaned-stage groups, where cleaning reorders. Name the two strategies on a Stage dataclass in place of a nine-field positional tuple, and derive the row-order-divergence diagnostic from the alignment so it stays product-only. Patient raw unclassified mismatches 1,409 -> 601, cleaned 9,392 -> 8,383, row-key overlap unchanged, product output identical.
…ed rich-text space Patient raw-stage triage, round 4. Four causes settled; unclassified 601 -> 278. - read_patient_rows converts a pre-1903 datetime back to its Excel serial. Excel's epoch is 1899-12-30, so such a date is a small number that inherited a date format from a neighbouring cell. openpyxl honours the format and hands back a datetime, which the numeric conversion sentinelled: 24 real systolic readings were reaching BigQuery as 999999. - r_drops_richtext_space: readxl drops a whitespace-only rich-text run, found in the sharedStrings.xml of 2017 Yangon and 2022 Mahosot. Explains the 8.8(20.9.16) shape ticket 43 left unexplained. - python_trims_merged_subvalue: the opposite direction, ticket 31's merge trimming each fragment where tidyr::unite does not. - insulin_regimen in 2021 Kantha Bopha wired to r_extraction_gap after re-verifying both header rows are empty at source. Spawns ticket 48 (screening columns lost to a merged header, a second Python data loss) and ticket 49 (round-5 residual).
…g inside it A merged upper header now names every column its span covers, but only where that column has a sub-header of its own to qualify. Two independent mechanisms were dropping complication-screening data: - merge_headers' forward-fill resets at a column blank in both header rows, so a title merged across a block never reached the sub-headers past the gap. Results and Date (mmm-yy) were emitted bare and mapped nowhere, though the synonym map already knew their qualified names. - recover_blank_headers then named those leftover columns from a sibling sheet laid out differently, filing a screening selection under observations. merged_header_spans reads the sheet's mergeCells straight from the archive openpyxl already has open, so the read_only load and its 6.6x stay intact. A sweep of all 254 trackers shaped the rule. Giving the bare block title to a column blank in both header rows would have put 290 sheets into a state where two columns compete for complication_screening, and would have comma-joined 3,659 near-duplicate Insulin Regimen cells that R and Python already agree on. Qualifying only real sub-headers drops that to zero at no measured cost. Measured against the real 248-tracker drive data: complication_screening_results 11 -> 0, complication_screening_date 31 -> 0, observations 8 unclassified -> 1, patient raw unclassified 278 -> 229, cleaned 8,148 -> 8,141, product unchanged. complication_screening_date is appended to PATIENT_RAW_DATE_NORMALIZE_COLS for the same reason meter_received_date was: the cleaned schema splits screening dates per test, so get_date_columns cannot derive the generic raw column, and R's Excel serial vs Python's parsed date would otherwise read as 382 mismatches. Closes ticket 48; ticket 12 now has one blocker left.
Six mechanisms behind the last 84 raw-stage mismatches, none a Python defect. Three are header defects R cannot survive and Python does (a header opening with thirteen spaces, two sheets whose header merges were deleted, a column unheaded in both header rows), all r_extraction_gap. A date typed into a numeric column reuses the existing openpyxl_date_typed_stray_cell. R keeping only the first selection of a merged multi-select block gets the new r_duplicate_header_selection_dropped. Two were the comparison harness measuring wrong, and both are fixed in parse_date_flexible itself: the month-name truncation required a word boundary a following digit cannot provide, so a rich-text-damaged July2014 fell through to dateutil, which filled the day from today and made the comparison depend on the day it ran; and the Excel-serial ceiling excluded the Buddhist-Era serials a BE year typed into a Gregorian cell produces, so 241062 was read positionally as 24/10/62. Patient raw unclassified 84 -> 0, total mismatches 26,557 -> 26,171. Patient cleaned unclassified 8,008 -> 7,967. Cleaned residual spawned as ticket 51.
… year-first Three named causes, each with an explicit verdict that Python is right: r_ymd_first_misparse (1,714 cells) - R's parse_date_string asks lubridate for "ymd" before "dmy", so a source date written 30.1.18 is read as 2030-01-18. Verified in 2018 Yangon's own cells; R's readings put 1,236 of the 1,714 in the future relative to the tracker's own year, Python's put none there. python_rejects_beyond_tracker_year (722) - Python's _validate_dates sentinels a date past its tracker year; R has no future-date guard at all. Surfaced a corrupt source column (2022 VNCH dates every diagnosis 2023, for patients recruited 2017) and a false docstring claiming the guard matched R. r_unicode_sanitizer_rejects_accent (57) - R's [^[:alnum:]] keeps accents, Python's [^a-z0-9] folds them, so Python recovers Thai Nguyen where R stamps Undefined. The folding cannot merge two provinces: validate_allowed_values raises on colliding allowed values, and the 209-entry list has no such pair. Cleaned-stage in-scope residual 5,031 -> 2,538; remainder split into ticket 52.
Patient cleaned-stage triage, round 5. In-scope residual 2,538 -> 2,278, and the largest shape was two real Python bugs rather than a divergence to label. - A bare four-digit year typed into a date cell was read as an Excel serial and became a 1905 date: 590 dob cells across four Yangon Children's trackers, 425 t1d_diagnosis_date cells across five more clinics. That drove age to the 999999 sentinel and t1d_diagnosis_age to -95. Fixed in both places the misreading happens - the bare-year window in parse_date_flexible, and _IMPOSSIBLE_DATE_BEFORE raised 1903 to 1906 in read_patient_rows for the one file whose column is date-formatted. Sarawak General writes the same patients' diagnoses as real 1-January dates in its 2024 workbook and as bare years in 2025/2026, so 1 January is the clinic's own convention. - _fix_t1d_diagnosis_age emitted negative ages where the source records a diagnosis before the birth date and the workbook's own formula says #NUM!. Now nulled; 25 cells across 2023 Likas, 2023 Yangon General, 2024 Putrajaya. - python_reads_bare_year (1,166) and python_age_from_bare_year (992), the second on a new row-level row_has_bare_year_date flag set by compare_cells rather than on the derived values' shape. Cleaned output now holds zero pre-1930 birth dates and zero negative diagnosis ages, down from 590 and 264. Raw stage stays at 0 unclassified. Residual split into round 6, which replaces 52 as ticket 12's blocker.
Two Python defects found while triaging the patient cleaned stage. parse_date_flexible fell through to dateutil for every month-year spelling the alphabetic branch does not match - "10/2019", "Mar, 2017", "Jun'09" - and dateutil completes an absent day from datetime.now(), so the cleaned output changed with the run date and no input change. Parsing twice against two disjoint defaults now tells a supplied component from an invented one: an invented day resolves to the first of the month, an invented month or year makes the cell unparseable. 42 production cells were on the run date's own day of month. split_bp_in_sys_and_dias left the padding on each fragment. R's as.numeric ignores surrounding whitespace where Polars' cast fails on it, so "70 / 40" reached the cleaned output as the 999999 error sentinel and the recorded blood pressure was lost. 465 cells across 7 trackers. Patient cleaned-stage in-scope unclassified 2278 -> 1689; patient raw stays at 0.
Records the two Python defects round 6 fixed, hands hospitalisation_date's whole 489-cell population to ticket 39 with the multi-date sub-question it surfaced, adds two source-workbook defects to ticket 40, swaps ticket 12's last blocker to round 7, and redraws both generated views.
…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 6 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.
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.
240 commits, 335 files, +37,949 / -701. CI green on
migrationHEAD(run
31841680157). 664 tests.This MR is not ready to merge yet — see "Still open" at the end. It is
posted so the state is reviewable while the remaining verification work runs.
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"] end subgraph TRIAGE["R/Python triage - 28 of 39 tickets closed"] J["Product cleaned: COMPLETE<br/>20 unclassified, kept as signals"] K["Product raw: COMPLETE<br/>0 unclassified"] L["Patient cleaned: 6,652 unclassified<br/>down from 55,670"] M["Patient raw: 14,844 unclassified<br/>ticket 30 + 31"] end subgraph OPEN["Still open"] N["30 - patient raw column divergence"] O["31 - patient raw mismatches r2"] P["32 - re-audit all classifiers"] Q["34 - local checks match CI"] R["35 - Polars 2.0 deprecations"] S["39 - dates inside free text"] T["16 - per-file log drill-down"] end subgraph BLOCKED["Blocked on the above"] U["12 - retire R from the workspace"] V["6 - promote migration to dev"] W["9 - golden-master snapshot tests"] end A --> C B --> C C --> D --> F C --> I --> TRIAGE M --> N M --> O N --> U O --> U U --> V --> W classDef done fill:#1a7f37,stroke:#116329,color:#fff classDef partial fill:#9a6700,stroke:#7d4e00,color:#fff classDef open fill:#1f6feb,stroke:#0b3d91,color:#fff classDef blocked fill:#6e7781,stroke:#424a53,color:#fff class A,B,C,D,E,F,G,H,I,J,K done class L,M partial class N,O,P,Q,R,S,T open class U,V,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"— matching R's constants.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_namepatient_data_cleaned/patient_id+sheet_nameproduct_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).
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.normalize_whitespace_column— readxl'strim_ws=TRUEstrips what openpyxlkeeps, and represents an embedded line break as
\r\nvs\n.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_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_canonical_label,python_future_date_sentinel,stray_date_zeroed,wide_format_fragment.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/when the mechanism lives there.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 cellsexfind_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 failureAlso added: a
balance_reconciliationerror code that fires when the computedclosing stock contradicts the tracker's own recorded total (113 groups across
21 files).
Where verification stands
Current baseline:
output/comparison/2026-08-14T204031Z, 254 trackers.Earlier counts on the wayfinder map were measured against smaller tracker sets
and should be read as historical.
The product arm is fully triaged on both stages. Patient's cleaned stage is
down 88%; patient's raw stage is the remaining body of work.
Still open
Nothing here blocks review of the code — it blocks the merge.
Frontier (takeable now)
245 files: hundreds of uniquely-numbered only-in-R junk columns (
na,na1, …na10064) and a large only-in-Python set of unmapped literalsource header text. Blocks retiring R.
dominated by
complication_screening(a probable multi-select extractionPython captures and R only partially does) plus ~50 smaller columns. Blocks
retiring R.
when written, but the decision bar was tightened partway through; this
re-checks that none merely labels a diff it never explained.
unnoticed because the locally-run check set was a strict subset.
change; they need decisions, not silencing.
of free text and Python does not. Open question: recover or discard.
LogViewerA4D's job. Not yetdecided whether it gates rollout or is a nice-to-have.
Blocked
r-archive/, stray R scripts). Blockedon 30 and 31: triage has repeatedly needed to read R's actual source to
root-cause a mismatch, not just diff its output.
migrationintodev(this PR). Blocked on 12.after promotion.
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.