# Cleaning and definition rules

Generated by `python -m pso rules` from `data/raw/pso_funnel_events.csv` (sha256 `0d7f8ca86978e06923e8e659d4841a7aca909f92227e094144505964a12ba5e7`, 37,899,350 bytes) with polars 2.0.0 on CPython 3.12.3. Do not edit by hand: every number here is regenerated by the command, and the output is deterministic, so `git diff` after regenerating shows exactly what changed.

R0–R2 tag rows (`rules.py`); R3–R7 are definitions the funnel is parameterised by (`funnel.py`). R1–R7 each have exactly one alternative (R3's alternative is two windows); R0 is a guard with none. The sensitivity run swaps one alternative in at a time — eight runs — and reports whether the headline answer moves (`07` prints the verdicts).

| rule | what | effect on this export | primary | alternative |
|---|---|---|---|---|
| R0 `tag_out_of_window` | Events before the invitation window opens (2026-06-01) or on/after the extraction date (2026-09-01). | 0 rows | tag and exclude | none — a guard; a different export would show a count here |
| R1 `tag_exact_duplicates` | A row identical to an earlier row; keep the first. A repeated `event_id` with *different* content is excluded too, under its own reason `conflicting_event_id`, so it is counted separately. | 3,121 duplicate rows excluded; 0 conflicting ids | exclude both, each under its reason | keep duplicates — pair-level reach is unchanged by construction (first occurrence per event); event-level counts inflate |
| R2 `normalize_device` | Case-fold `device`, then map to a class (A15). Pair device = first non-empty class in time order (system events carry no device). | 40,935 rows change under case-folding and 27,900 under the class map (24,267 under both; 44,568 change at all); 0 unmapped. Classes: none: 189,801, desktop: 45,705, mobile: 74,873, tablet: 4,890 | R2b classes: mobile = {mobile, ios, android}; desktop = {desktop, web}; tablet = {tablet, ipad} | R2a case-fold only (seven values) |
| R3 maturity window | Rates are computed on pairs allocated on or before `2026-08-31 23:59:59 − W days`; counts for every pair are still shown with a censored marker. The export stops at the window's end (A3). | of 53,935 pairs, excluded from rates at W=7: 5,001, W=14: 10,289, W=21: 15,657 | W = 14 days | W = 7 and W = 21 days (two sensitivity runs) |
| R4 invitation anchor | Which event is the denominator of the Invitation stage (A11). | 853 pairs have no `email_sent` (853 have only the allocation) | `pso_allocated` (present for every pair) | `email_sent` — pairs allocated but never emailed are a system failure, not a tasker loss |
| R5 pilot pairs in the aggregate funnel | Whether the three likeness-pilot projects are part of the overall funnel (A16). | 12,347 pilot pairs | included; arm is a filter; likeness losses are labelled | excluded — nine non-pilot projects only |
| R6 assessment latency anchor | Which `assessment_started` the assessment latencies are measured from when a pair has several sessions (A17). Reach always uses the first. | 1,479 pairs with more than one `assessment_started`; first-to-last session gap p10 / p50 / p90 = 3.8 / 7.4 / 11.1 days | first occurrence | last occurrence, for latency only |
| R7 email tracking signals | `email_opened` and `email_clicked` are tracking signals, not gates (A14): opened can be missing when the pixel is blocked; clicked is bypassed by in-app acceptance. Both are reported as 'has the event'; no loss is attributed to them. | 1,272 pairs clicked without an opened event (1,113 of them accepted by email); 1,701 accepted without a click, of which 0 were not in-app acceptances; 0 in-app acceptors had a click | report as-is, never a gate | impute `opened` for pairs that clicked or accepted by email |

Facts computed from this export that need no rule (`05` §15): 0 events follow a likeness decline, so a decline is terminal; 0 inversions and 0 impossible skips among the non-optional events, so no re-ordering rule exists; `pso_completed` and `contract_finished` disagree on 0 pairs, so either marks finished PSO. Projects that record no device on any event: Quartz (5,600 pairs) — their pairs fall in R2's "unknown" class, which is therefore a mix of "never acted" and "project does not record devices" (`05` §7 has per-project coverage).
