checking which cohort users have no mentor on record



wikipedia has a feature where new users can get assigned a mentor (an experienced editor) at sign-up. the assignment happens server-side and is stored in a database table called growthexperiments_mentor_mentee. importantly, the backend assigns a mentor regardless of whether the user has the mentor ui module turned onmentorshipState controls only whether the ui shows, not whether the backend writes the row. so every user in the cohort should have a row in the table.

for some users there's no row at all. i want to figure out who they are, why the records are missing, and whether the rate of missing records changes over time.

three sources of truth used below:

1. inputs/z_mentorship_state.tsv — for every user, what value their mentorshipState user property carries. this is the updated version (2026-06-08+) of wikipedia's official released file (dataset.tsv from https://analytics.wikimedia.org/published/datasets/one-off/growth/growthexperiments-mentorship-enabled-T420387/), renamed locally. columns: userId, mentorshipState. the column now carries three raw values instead of the original two-way enabled/disabled split:
- unset — the property was never written for this user (ui defaults to on if everything else is in place)
- '0' — the user explicitly opted out
- '50' — proactive-assignment flag (a 2024-era addition; backend kicks an async job to assign a mentor)

2. inputs/cov_mentor_mentee_assignment_20260530.sql.gz — a snapshot of the actual mentor↔mentee database table, taken on 2026-05-30. downloaded from https://dumps.wikimedia.org/other/growthmentorship/. each row is a (mentee_id, mentor_role, mentor_id, mentee_is_active) tuple. the mentor_role column can take two values:
- primary — the mentor formally assigned to this mentee. by default, questions get routed here.
- backup — a fill-in mentor written by the system when the primary mentor sets themselves "away" temporarily. a mentee can have both rows simultaneously, only one, or neither.

3. inputs/excl_mentor_claim_log_raw.jsonl — every public log entry on Special:Log/growthexperiments, fetched directly from the mediawiki api. these record every time a mentor was reassigned (action='setmentor') or proactively claimed by a new mentor (action='claimmentee'). using this log, the snapshot can be "played backward" in time to figure out who the mentor was at any earlier point.

the mentorship module was rolled out gradually: 10% of new users initially, then 25%, 50%, 75%, then 100%. the boundary dates of each step come from the wikipedia mediawiki-config commit history and are listed in the next cell. the relevant identifying window is 2019-09-20 to 2025-02-16.
 

Setup



This notebook performs a data-quality audit of the mentorship-assignment data used in the 2SLS analysis. The audit answers a single question: for every user in Martin's mentorship-state table who does not have a corresponding row in the server-side mentor-assignment snapshot, what is the reason that row is absent?

The next cell imports libraries, defines absolute paths to the three input files, and declares the rollout phase table. The inputs are:

- DATASET (inputs/z_mentorship_state.tsv) — Martin's per-user file with two columns: userId and mentorshipState.
- SNAPSHOT (inputs/cov_mentor_mentee_assignment_*.sql.gz) — a MariaDB dump of the GrowthExperiments cov_mentor_mentee table. Each row records that a particular mentee was assigned a particular mentor server-side.
- REG_FILE (build/registrations.jsonl) — local en.wiki registration metadata parsed from the public dump: registration timestamp (reg_ts) and whether the account was self-created (is_self).

The ROLLOUT table records the production rollout phases of the mentorship feature. Phases are defined by gerrit commit dates and are used in later cells to assign each user a phase based on their registration date.
In [42]:
import os, re, gzip, json, pickle
from pathlib import Path
import pandas as pd
import numpy as np

# ---- paths ----
ROOT      = Path("/home/yubozhou/2026_summer/wikipedia_2sls/2sls_pipeline")
DATASET   = ROOT / "inputs/z_mentorship_state.tsv"                       # Martin's 4.98M list (userId, mentorshipState)
SNAPSHOT  = ROOT / "inputs/cov_mentor_mentee_assignment_20260530.sql.gz" # mentor-mentee snapshot dump
REG_FILE  = ROOT / "build/registrations.jsonl"                           # 11.87M registration timestamps
CLAIMLOG  = ROOT / "inputs/excl_mentor_claim_log_raw.jsonl"              # 1.59M claim/set mentor events

# ---- cache dir ----
CACHE = ROOT / "analysis/diagnose_missing_mentors/cache"
CACHE.mkdir(parents=True, exist_ok=True)

# ---- rollout phases (a user's reg date decides their phase) ----
# Dates = gerrit committer dates = production effective dates.
# Early phases are NESTED inside the Newcomer Homepage rollout (homepage < 100%),
# so they are NOT clean treatment periods. The per-user mentorship rollout only
# becomes identifying once Homepage = 100% on 2022-03-07.
# Columns: (label, start, end, mentorship_pct, homepage_pct, identifying)
ROLLOUT = [
    ("pre_anything",                  "2021-01-01", "2021-06-07", None, 0.00, False),
    ("homepage_2pct",                 "2021-06-08", "2021-09-19", None, 0.02, False),
    ("homepage_25pct_mentor_20pct",   "2021-09-20", "2022-03-06", 0.20, 0.25, False),
    ("p10",                           "2022-03-07", "2023-07-10", 0.10, 1.00, True),
    ("p25",                           "2023-07-11", "2023-10-04", 0.25, 1.00, True),
    ("p50",                           "2023-10-05", "2025-02-02", 0.50, 1.00, True),
    ("p75",                           "2025-02-03", "2025-02-16", 0.75, 1.00, True),
    ("p100",                          "2025-02-17", "2026-6-8", 1.00, 1.00, False),  # no control arm
]

def helper_cache(name):
    return CACHE / name
 

Step 1 — Load the mentorship-state table and compute the no_row count per state



This cell reads z_mentorship_state.tsv into a DataFrame called ds, loads the mentor-assignment snapshot from a cached pickle (or rebuilds it from the SQL dump on first run), and computes — for each value of mentorshipState — how many users do and do not have at least one row in the snapshot.

Three values of mentorshipState appear in this file:

- 0 — the user was placed in the disabled arm of the mentorship A/B test (UI not shown).
- unset — no value was written to user_properties for this preference. Per Martin's release script (notebooks/generate_dataset_for_release.ipynb, function convert_up_property), unset is mapped to the enabled bucket. The reason unset dominates the early period is that the A/B-assignment code only runs when the user is also shown the Newcomer Homepage; users not shown the homepage have no value written and remain unset.
- 50 — present for only a handful of users. The precise meaning of 50 is not documented in this repository. Martin's release script maps it to the same enabled bucket as 1 and NaN. This notebook reports counts for 50 separately throughout, in case its semantics turn out to differ.

The printed totals are:

- Dataset size: 4,981,433 users.
- Per-state counts: 0 2,457,315; unset 2,524,086; 50 32.
- The snapshot covers 5,758,864 mentees with at least one assignment row.
- Crosstab mentorshipState × has_snapshot_row gives the no_row count per state: state 0 → 11,600 (0.47%); state 50 → 4 (12.50%); state unset → 540,773 (21.42%). The total no_row count across all states is 552,377. All subsequent cells refer to these 552,377 users as the no_row population.
In [43]:
# load dataset
ds = pd.read_csv(DATASET, sep="\t", dtype={"userId": "int64", "mentorshipState": "string"})
print(len(ds), "users in dataset")
print(ds["mentorshipState"].value_counts(dropna=False), "\n")

# parse snapshot (cached): mentee_id -> set of roles
snap_pkl = helper_cache("snap.pkl")
if snap_pkl.exists():
    snap = pickle.load(open(snap_pkl, "rb"))
else:
    pat = re.compile(rb"\((\d+),'([^']+)',(\d+),(\d+)\)")
    snap = {}
    with gzip.open(SNAPSHOT, "rb") as f:
        for line in f:
            if not line.startswith(b"INSERT"):
                continue
            for m in pat.finditer(line):
                snap.setdefault(int(m.group(1)), set()).add(m.group(2).decode())
    pickle.dump(snap, open(snap_pkl, "wb"))
print(len(snap), "mentees have at least one snapshot row\n")

# mismatch: per mentorshipState, how many users have NO row in snapshot
ds["has_snapshot_row"] = ds["userId"].map(lambda u: u in snap)
g = ds.groupby("mentorshipState")["has_snapshot_row"].agg(n="size", in_snap="sum")
g["no_row"]     = g["n"] - g["in_snap"]
g["no_row_pct"] = (g["no_row"] / g["n"] * 100).round(4)
print(g[["n", "in_snap", "no_row", "no_row_pct"]])
4981433 users in dataset mentorshipState unset 2524086 0 2457315 50 32 Name: count, dtype: Int64 5758864 mentees have at least one snapshot row n in_snap no_row no_row_pct mentorshipState 0 2457315 2445715 11600 0.4721 50 32 28 4 12.5000 unset 2524086 1983313 540773 21.4245
 

Step 2 — When does each mentorshipState value appear in time, and where is no_row concentrated?



Before assigning causal reasons to the 552,377 no_row users, this cell describes the temporal distribution of mentorshipState values and of no_row users. It loads build/registrations.jsonl (cached), attaches each user's local en.wiki registration timestamp (reg_ts) and registration month (reg_month) to ds, and prints three monthly tables:

1. Counts per month, broken down by mentorshipState. This shows when each state value first appears.
2. no_row count per month, broken down by mentorshipState. This shows the absolute volume of missing-row users month by month.
3. no_row count per month as a percentage of that month's total registrants. This shows the rate at which no_row occurs over time.

Two facts read from the output are used later:

- State 0 does not appear until 2021-09 (first non-zero count 7,521). State 50 first appears in 2021-04 with a single row. Before 2021-09 every registrant in ds is unset. This is consistent with state 0 only being written after the mentorship A/B was activated.
- The monthly no_row rate is highest in the pre-rollout months and decays after 2021-06. Months from 2025-03 onward have very small total registrant counts because the dataset's right edge is the date Martin produced the file.

The remainder of the notebook decomposes the 552,377 no_row users into mutually exclusive reasons.
In [44]:
# snapshot (cached)
snap_pkl = helper_cache("snap.pkl")
if snap_pkl.exists():
    snap = pickle.load(open(snap_pkl, "rb"))
else:
    pat = re.compile(rb"\((\d+),'([^']+)',(\d+),(\d+)\)")
    snap = {}
    with gzip.open(SNAPSHOT, "rb") as f:
        for line in f:
            if not line.startswith(b"INSERT"):
                continue
            for m in pat.finditer(line):
                snap.setdefault(int(m.group(1)), set()).add(m.group(2).decode())
    pickle.dump(snap, open(snap_pkl, "wb"))

# registration timestamps (cached)
reg_pkl = helper_cache("reg_ts.pkl")
if reg_pkl.exists():
    reg = pickle.load(open(reg_pkl, "rb"))
else:
    reg = {}
    with open(REG_FILE) as f:
        for line in f:
            o = json.loads(line)
            reg[o["uid"]] = o["reg_ts"]
    pickle.dump(reg, open(reg_pkl, "wb"))

# attach month + no_row flag
ds["reg_ts"]    = pd.to_datetime(ds["userId"].map(reg), format="mixed", errors="coerce")
ds["reg_month"] = ds["reg_ts"].dt.to_period("M").astype("string")
ds["no_row"]    = ~ds["userId"].map(lambda u: u in snap)

# table 1: counts of each state per month
counts = ds.pivot_table(index="reg_month", columns="mentorshipState",
                        aggfunc="size", fill_value=0)
counts["total"] = counts.sum(axis=1)
print("=== counts per month ===")
print(counts.to_string())

# table 2: per month, no_row count of each state
norow = (ds[ds["no_row"]]
        .pivot_table(index="reg_month", columns="mentorshipState",
                    aggfunc="size", fill_value=0)
        .reindex(counts.index, fill_value=0))
print("\n=== no_row count per month ===")
print(norow.to_string())

# table 3: per month, no_row count of each state / that month's total registered users (%)
norow = (ds[ds["no_row"]]
        .pivot_table(index="reg_month", columns="mentorshipState",
                    aggfunc="size", fill_value=0)
        .reindex(counts.index, fill_value=0))
pct = norow.div(counts["total"], axis=0).mul(100).round(4)
print("\n=== no_row count as pct of monthly total ===")
print(pct.to_string())
=== counts per month === mentorshipState 0 50 unset total reg_month 2021-01 0 0 144367 144367 2021-02 0 0 123955 123955 2021-03 0 0 128316 128316 2021-04 0 1 113255 113256 2021-05 0 0 114201 114201 2021-06 0 0 105091 105091 2021-07 0 0 96899 96899 2021-08 0 0 98437 98437 2021-09 7521 0 98131 105652 2021-10 20744 0 85426 106170 2021-11 19849 0 80337 100186 2021-12 20404 0 81906 102310 2022-01 21752 0 90340 112092 2022-02 20343 0 82828 103171 2022-03 77733 0 27184 104917 2022-04 82665 0 11043 93708 2022-05 83378 0 11146 94524 2022-06 99563 0 12471 112034 2022-07 75959 1 9345 85305 2022-08 75999 0 11077 87076 2022-09 83747 0 12978 96725 2022-10 89413 0 11971 101384 2022-11 87836 0 11506 99342 2022-12 85440 0 10957 96397 2023-01 108096 3 17324 125423 2023-02 90925 0 13150 104075 2023-03 99392 1 12059 111452 2023-04 93025 0 10949 103974 2023-05 87331 0 10320 97651 2023-06 79192 0 9053 88245 2023-07 70964 1 18019 88984 2023-08 68605 0 24410 93015 2023-09 71452 0 26195 97647 2023-10 51557 1 45472 97030 2023-11 50337 1 50845 101183 2023-12 43299 2 43362 86663 2024-01 48400 2 51687 100089 2024-02 43336 2 44829 88167 2024-03 44125 1 45206 89332 2024-04 43522 0 44507 88029 2024-05 43515 0 44782 88297 2024-06 39784 2 39745 79531 2024-07 42842 2 42976 85820 2024-08 46381 2 48200 94583 2024-09 44081 2 46140 90223 2024-10 44586 2 45672 90260 2024-11 43692 1 44057 87750 2024-12 44204 1 44113 88318 2025-01 47178 1 49680 96859 2025-02 14226 1 71066 85293 2025-03 54 0 57 111 2025-04 15 0 9 24 2025-05 28 0 25 53 2025-06 22 0 20 42 2025-07 20 0 20 40 2025-08 21 0 19 40 2025-09 20 0 26 46 2025-10 23 0 16 39 2025-11 17 0 20 37 2025-12 43 0 43 86 2026-01 14 0 18 32 2026-02 12 2 19 33 2026-03 13 0 25 38 2026-04 13 0 13 26 === no_row count per month === mentorshipState 0 50 unset reg_month 2021-01 0 0 144279 2021-02 0 0 123853 2021-03 0 0 128193 2021-04 0 0 113150 2021-05 0 0 14848 2021-06 0 0 110 2021-07 0 0 102 2021-08 0 0 103 2021-09 18 0 95 2021-10 64 0 154 2021-11 58 0 201 2021-12 58 0 197 2022-01 61 0 221 2022-02 60 0 165 2022-03 221 0 72 2022-04 208 0 32 2022-05 232 0 34 2022-06 333 0 61 2022-07 311 1 37 2022-08 325 0 46 2022-09 312 0 47 2022-10 262 0 42 2022-11 280 0 45 2022-12 332 0 42 2023-01 442 0 65 2023-02 304 0 47 2023-03 321 0 45 2023-04 339 0 48 2023-05 360 0 50 2023-06 305 0 46 2023-07 299 0 85 2023-08 279 0 93 2023-09 286 0 101 2023-10 264 0 226 2023-11 231 0 228 2023-12 227 1 254 2024-01 274 0 277 2024-02 248 0 294 2024-03 244 0 256 2024-04 250 0 230 2024-05 310 0 300 2024-06 282 0 282 2024-07 408 0 407 2024-08 324 0 360 2024-09 341 1 341 2024-10 380 0 375 2024-11 390 0 419 2024-12 420 0 409 2025-01 649 1 630 2025-02 247 0 2317 2025-03 5 0 6 2025-04 3 0 4 2025-05 5 0 3 2025-06 0 0 1 2025-07 0 0 5 2025-08 0 0 2 2025-09 0 0 2 2025-10 0 0 2 2025-11 1 0 3 2025-12 0 0 1 2026-01 1 0 1 2026-02 0 0 2 2026-03 0 0 2 2026-04 0 0 0 === no_row count as pct of monthly total === mentorshipState 0 50 unset reg_month 2021-01 0.0000 0.0000 99.9390 2021-02 0.0000 0.0000 99.9177 2021-03 0.0000 0.0000 99.9041 2021-04 0.0000 0.0000 99.9064 2021-05 0.0000 0.0000 13.0016 2021-06 0.0000 0.0000 0.1047 2021-07 0.0000 0.0000 0.1053 2021-08 0.0000 0.0000 0.1046 2021-09 0.0170 0.0000 0.0899 2021-10 0.0603 0.0000 0.1451 2021-11 0.0579 0.0000 0.2006 2021-12 0.0567 0.0000 0.1926 2022-01 0.0544 0.0000 0.1972 2022-02 0.0582 0.0000 0.1599 2022-03 0.2106 0.0000 0.0686 2022-04 0.2220 0.0000 0.0341 2022-05 0.2454 0.0000 0.0360 2022-06 0.2972 0.0000 0.0544 2022-07 0.3646 0.0012 0.0434 2022-08 0.3732 0.0000 0.0528 2022-09 0.3226 0.0000 0.0486 2022-10 0.2584 0.0000 0.0414 2022-11 0.2819 0.0000 0.0453 2022-12 0.3444 0.0000 0.0436 2023-01 0.3524 0.0000 0.0518 2023-02 0.2921 0.0000 0.0452 2023-03 0.2880 0.0000 0.0404 2023-04 0.3260 0.0000 0.0462 2023-05 0.3687 0.0000 0.0512 2023-06 0.3456 0.0000 0.0521 2023-07 0.3360 0.0000 0.0955 2023-08 0.3000 0.0000 0.1000 2023-09 0.2929 0.0000 0.1034 2023-10 0.2721 0.0000 0.2329 2023-11 0.2283 0.0000 0.2253 2023-12 0.2619 0.0012 0.2931 2024-01 0.2738 0.0000 0.2768 2024-02 0.2813 0.0000 0.3335 2024-03 0.2731 0.0000 0.2866 2024-04 0.2840 0.0000 0.2613 2024-05 0.3511 0.0000 0.3398 2024-06 0.3546 0.0000 0.3546 2024-07 0.4754 0.0000 0.4742 2024-08 0.3426 0.0000 0.3806 2024-09 0.3780 0.0011 0.3780 2024-10 0.4210 0.0000 0.4155 2024-11 0.4444 0.0000 0.4775 2024-12 0.4756 0.0000 0.4631 2025-01 0.6700 0.0010 0.6504 2025-02 0.2896 0.0000 2.7165 2025-03 4.5045 0.0000 5.4054 2025-04 12.5000 0.0000 16.6667 2025-05 9.4340 0.0000 5.6604 2025-06 0.0000 0.0000 2.3810 2025-07 0.0000 0.0000 12.5000 2025-08 0.0000 0.0000 5.0000 2025-09 0.0000 0.0000 4.3478 2025-10 0.0000 0.0000 5.1282 2025-11 2.7027 0.0000 8.1081 2025-12 0.0000 0.0000 1.1628 2026-01 3.1250 0.0000 3.1250 2026-02 0.0000 0.0000 6.0606 2026-03 0.0000 0.0000 5.2632 2026-04 0.0000 0.0000 0.0000
 

Step 3 — Enumerate the candidate reasons a user can be no_row, and resolve those answerable from existing fields



The mentor-assignment snapshot writes a row only when the server-side assignment hook fires for a user. There are five candidate reasons for a user to be in ds but absent from the snapshot:

1. Pre-rollout — the user registered before mentorship was active on en.wiki (effective date 2021-06-01). The assignment hook never ran for them.
2. Auto-created — the local en.wiki account was created by CentralAuth or by another user (not self-registration). The onLocalUserCreated code path is not the same as onAccountCreated, and auto-created accounts can miss the assignment.
3. Indefinitely blocked at registration — when a registration is blocked, the GrowthExperiments code drops the assignment row entirely.
4. No mentor available — the auto-assign mentor pool was empty at the moment of registration.
5. Deleted account — the local user row was deleted after registration, so neither the snapshot nor build/registrations.jsonl records the user.

Reasons 1 and 2 can be answered directly from fields already present in ds (reg_ts, is_self). Reasons 3 and 4 require an external API call to determine block status (cell 7). Reason 5 is detectable as reg_ts being missing.

This cell tags every no_row user with three boolean flags — cause_pre_rollout (reg_ts < 2021-06-01), is_self == False (auto-created), and reg_ts_missing (no local registration row) — and prints the counts for each.

Output among the 552,377 no_row users:

- Reason 1, pre-rollout: 524,323 (94.92%).
- Reason 2, auto-created (is_self == False): 0 (0.00%).
- Aside, reg_ts missing (preliminarily labelled "deleted"): 6,451 (1.17%).
- Remainder after excluding reasons 1 and 2: 28,054 (5.08%).

The label "deleted" attached to the 6,451 is preliminary; cell 13 re-checks it against CentralAuth and finds it misleading. The 28,054 remainder is the group that needs block-status information to be classified between reasons 3 and 4.
In [45]:
# =============================================================================
# WHY A USER CAN BE "no_row" (in Martin's list but has NO mentor row in snapshot)
#
# Scope: this runs on ALL no_row users across every mentorshipState
#        (0 / 50 / unset), not just state=0. `nr = ds[ds["no_row"]]` has no
#        state filter, so the totals below are the 0 + 50 + unset combined.
#
# Assignment mechanism differs by state:
#   - unset / 0 : a mentor is assigned synchronously at registration
#                 (MentorHooks::onLocalUserCreated). state=0 (DISABLED) only
#                 hides the UI; it does NOT block the backend assignment.
#   - 50        : proactive-assignment flag (2024-era). Assignment is done by an
#                 async backend job, NOT onLocalUserCreated. So a no_row state=50
#                 user can simply mean that async job never ran / failed — a
#                 different mechanism from the reasons below.
#
# A user ends up with no mentor row for one of FIVE reasons. These are NOT
# mutually exclusive (e.g. a user can be both pre-rollout and autocreated); the
# final per-user classification in the later cell resolves overlaps by priority.
#
#   1. pre-rollout  : registered before en.wiki turned mentorship on (~2021-06).
#                     The feature wasn't live yet, so nobody was assigned.
#                     -> detectable from reg_ts.                        [we HAVE this]
#   2. autocreated  : the en.wiki local account was auto-created (CentralAuth) or
#                     created by someone else, not self-registered. onLocalUserCreated
#                     returns early for these.
#                     -> detectable from is_self == False.              [we HAVE this]
#   3. blocked      : user was indefinitely blocked, which DROPS the mentor row.
#                     -> from block_status.jsonl (fetched in a later cell).
#                                                                       [we NOW HAVE this]
#   4. no mentor available at registration : the auto-assign mentor pool was empty
#                     / everyone excluded at that moment. (There may be other
#                     causes I cannot enumerate.)
#                     -> no direct evidence; only by elimination.       [residual]
#   5. deleted      : the account was deleted, so its registration record is gone
#                     and reg_ts is missing.
#                     -> detectable from reg_ts being NaN.              [we HAVE this]
#
# This cell quantifies reasons 1, 2 and 5 among the no_row users (the ones we can
# cleanly identify here), and reports how many remain for reasons 3 and 4 (which
# the block-fetch cell resolves later).
# =============================================================================

# ---- load dataset ----
if "ds" not in globals():
    ds = pd.read_csv(DATASET, sep="\t", dtype={"userId": "int64", "mentorshipState": "string"})

# ---- snapshot (cached): used to flag no_row ----
snap_pkl = helper_cache("snap.pkl")
if snap_pkl.exists():
    snap = pickle.load(open(snap_pkl, "rb"))
else:
    pat = re.compile(rb"\((\d+),'([^']+)',(\d+),(\d+)\)")
    snap = {}
    with gzip.open(SNAPSHOT, "rb") as f:
        for line in f:
            if not line.startswith(b"INSERT"):
                continue
            for m in pat.finditer(line):
                snap.setdefault(int(m.group(1)), set()).add(m.group(2).decode())
    pickle.dump(snap, open(snap_pkl, "wb"))

# ---- registration info (cached): reg_ts + is_self in one pass ----
reginfo_pkl = helper_cache("reg_info.pkl")
if reginfo_pkl.exists():
    reg_ts_map, is_self_map = pickle.load(open(reginfo_pkl, "rb"))
else:
    reg_ts_map, is_self_map = {}, {}
    with open(REG_FILE) as f:
        for line in f:
            o = json.loads(line)
            reg_ts_map[o["uid"]]  = o["reg_ts"]
            is_self_map[o["uid"]] = bool(o.get("is_self"))
    pickle.dump((reg_ts_map, is_self_map), open(reginfo_pkl, "wb"))

# ---- tag every user with no_row / reg_ts / is_self ----
ds["no_row"]  = ~ds["userId"].map(lambda u: u in snap)
ds["reg_ts"]  = pd.to_datetime(ds["userId"].map(reg_ts_map), format="mixed", errors="coerce")
ds["is_self"] = ds["userId"].map(is_self_map)   # True=self-registered, False=autocreated/created-by-other, NaN=unknown (deleted account)

# ---- restrict to no_row users ----
ROLLOUT_LIVE = pd.Timestamp("2021-06-01")   # en.wiki mentorship becomes effective ~here
nr = ds[ds["no_row"]].copy()

nr["cause_pre_rollout"] = nr["reg_ts"] < ROLLOUT_LIVE   # reason 1
nr["cause_autocreated"] = nr["is_self"] == False        # reason 2
nr["reg_ts_missing"]    = nr["reg_ts"].isna()           # deleted accounts (no reg_ts)

total = len(nr)
print(f"total no_row users: {total:,}\n")

n1 = nr['cause_pre_rollout'].sum()
n2 = nr['cause_autocreated'].sum()
n3 = nr['reg_ts_missing'].sum()
print(f"reason 1  pre-rollout (reg_ts < {ROLLOUT_LIVE.date()}): {n1:,}  ({n1/total*100:.2f}%)")
print(f"reason 2  autocreated (is_self == False)             : {n2:,}  ({n2/total*100:.2f}%)")
print(f"(aside)   reg_ts unknown (deleted account)           : {n3:,}  ({n3/total*100:.2f}%)")

print("\noverlap (a user may match both):")
print(pd.crosstab(nr["cause_pre_rollout"], nr["cause_autocreated"],
                rownames=["pre_rollout"], colnames=["autocreated"]))

rest = nr[~nr["cause_pre_rollout"] & ~nr["cause_autocreated"]]
print(f"\nremaining no_row after excluding reasons 1 & 2: {len(rest):,}({len(rest)/total*100:.2f}%)")
print("this remainder needs block data (reason 3) and elimination (reason 4).")
total no_row users: 552,377 reason 1 pre-rollout (reg_ts < 2021-06-01): 524,323 (94.92%) reason 2 autocreated (is_self == False) : 0 (0.00%) (aside) reg_ts unknown (deleted account) : 6,451 (1.17%) overlap (a user may match both): autocreated False pre_rollout False 28054 True 524323 remaining no_row after excluding reasons 1 & 2: 28,054(5.08%) this remainder needs block data (reason 3) and elimination (reason 4).
 

Step 3b — Export the post-rollout self-registered remainder for the block-status API query



The previous cell left 28,054 no_row users unexplained after removing pre-rollout and auto-created. Of those, 6,451 have no reg_ts (preliminary "deleted"). The remaining 21,603 users are post-rollout, self-registered, and have a known reg_ts. These are the users for whom the block-status check is meaningful.

This cell collects their userIds, resolves each to a username from build/registrations.jsonl (the MediaWiki blocks API takes usernames, not userIds), and writes the (uid, name) pairs to analysis/diagnose_missing_mentors/remainder_to_block.tsv. The next cell consumes that file.

Printed output confirms: remainder 21,603; names resolved 21,603; output file written.
In [46]:
# export the remainder (post-rollout, self-registered, no_row) with usernames
need = set(ds.loc[ds["no_row"] & (ds["reg_ts"] >= ROLLOUT_LIVE) & (ds["is_self"] != False),
                "userId"].astype(int))
print("remainder:", len(need))

uid2name = {}
with open(REG_FILE) as f:
    for line in f:
        o = json.loads(line)
        if o["uid"] in need:
            uid2name[o["uid"]] = o["name"]
print("names resolved:", len(uid2name))

out = ROOT / "analysis/diagnose_missing_mentors/remainder_to_block.tsv"
with open(out, "w") as fo:
    for u, n in uid2name.items():
        fo.write(f"{u}\t{n}\n")
print("written:", out)
remainder: 21603 names resolved: 21603 written: /home/yubozhou/2026_summer/wikipedia_2sls/2sls_pipeline/analysis/diagnose_missing_mentors/remainder_to_block.tsv
 
Of the 552,377 no_row users (all states: 0 + 50 + unset), this cell has so far identified:
- pre-rollout: 524,323
- deleted account (reg_ts missing): 6,451

The remaining 21,603 (post-rollout, self-registered) still need block lookup — exported next and resolved into reason 3 (indefinitely blocked) vs reason 4 (residual).
 

Step 4 — Fetch block status from the en.wiki API and split the remainder into reason 3 vs reason 4



This cell queries the public en.wiki MediaWiki API (action=query&list=blocks) for each of the 21,603 users exported above, asking whether the username is currently indefinitely blocked. The results are written one record per line to analysis/diagnose_missing_mentors/block_status.jsonl. The fetch is resume-safe: usernames already present in the output file are skipped.

A user is classified as reason 3 (indefinitely blocked) if the API returns a block record whose expiry == "infinite". The other users in the 21,603 are classified as reason 4 (no mentor available, or unknown residual) by elimination — they are post-rollout, self-registered, not deleted, and not currently indefinitely blocked, so the assignment hook should have fired. The most likely explanation is that the mentor pool was empty at their registration moment; other unknown failure modes remain possible.

Printed counts of the 21,603 remainder:

- Reason 3, indefinitely blocked: 20,974 (97.09%).
- Reason 4, no mentor available or unknown residual: 629 (2.91%).

Combined with reasons 1, 2 and the preliminary 6,451 "deleted" count, the five reasons sum to 524,323 + 0 + 20,974 + 629 + 6,451 = 552,377, which matches the no_row total exactly.
In [47]:
import time
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError

API = "https://en.wikipedia.org/w/api.php"
UA  = "WikiMentorResearch/1.0 (academic; contact: yubozhou@umich.edu)"
INP = ROOT / "analysis/diagnose_missing_mentors/remainder_to_block.tsv"
OUT = ROOT / "analysis/diagnose_missing_mentors/block_status.jsonl"

def api_get(params):
    url = API + "?" + urlencode(dict(params, format="json"))
    for attempt in range(6):
        try:
            req = Request(url, headers={"User-Agent": UA})
            with urlopen(req, timeout=60) as r:
                return json.loads(r.read().decode())
        except (HTTPError, URLError, TimeoutError) as e:
            time.sleep(0.4 * (2 ** attempt))
    raise RuntimeError("API failed: " + url)

# load the list of (uid, name)
todo = []
with open(INP) as f:
    for line in f:
        uid, name = line.rstrip("\n").split("\t", 1)
        todo.append((int(uid), name))

# resume: skip uids already fetched
done = set()
if OUT.exists():
    with open(OUT) as f:
        for line in f:
            try: done.add(json.loads(line)["uid"])
            except Exception: pass
todo = [(u, n) for u, n in todo if u not in done]
print(f"to fetch: {len(todo):,} (already done {len(done):,})")

# fetch in batches of 50
name2uid = {n: u for u, n in todo}
with open(OUT, "a") as fout:
    for i in range(0, len(todo), 50):
        names = [n for _, n in todo[i:i+50]]
        data = api_get({"action": "query", "list": "users",
                        "ususers": "|".join(names), "usprop": "blockinfo"})
        seen = set()
        for u in data.get("query", {}).get("users", []):
            name = u.get("name"); uid = name2uid.get(name)
            if uid is None: continue
            blocked = "blockid" in u
            expiry  = u.get("blockexpiry")
            indef   = blocked and expiry in ("infinity","infinite","indefinite","never")
            fout.write(json.dumps({"uid": uid, "name": name, "blocked": blocked,
                                    "indefinite": bool(indef), "expiry": expiry,
                                    "reason": u.get("blockreason")}) + "\n")
            seen.add(name)
        for name in names:
            if name not in seen:
                fout.write(json.dumps({"uid": name2uid[name], "name": name, "blocked": False,
                                        "indefinite": False, "expiry": None,
                                        "reason": "api_no_return"}) + "\n")
        fout.flush()
        if (i // 50) % 20 == 0: print(f"  {i+len(names):,}/{len(todo):,}")
        time.sleep(0.4)
print("fetch done.")

# ---- results: reason 3 vs reason 4 ----
blk = pd.read_json(OUT, lines=True)
indef_uids = set(blk.loc[blk["indefinite"], "uid"])

rest = ds[ds["no_row"] & (ds["reg_ts"] >= ROLLOUT_LIVE) & (ds["is_self"] != False)].copy()
rest["cause_blocked"] = rest["userId"].isin(indef_uids)
n_rest = len(rest)
n_blk  = int(rest["cause_blocked"].sum())
print(f"remainder (reasons 3 & 4): {n_rest:,}")
print(f"reason 3  indefinitely blocked (row dropped): {n_blk:,}  ({n_blk/n_rest*100:.2f}%)")
print(f"reason 4  no mentor avail / unknown residual: {n_rest-n_blk:,}({(n_rest-n_blk)/n_rest*100:.2f}%)")
to fetch: 0 (already done 21,603) fetch done. remainder (reasons 3 & 4): 21,603 reason 3 indefinitely blocked (row dropped): 20,974 (97.09%) reason 4 no mentor avail / unknown residual: 629(2.91%)
 

Why no_row — final breakdown (ALL states: 0 + 50 + unset)



This covers every no_row user, not just state=0. Of the 552,377 total:

- reason 1 pre-rollout (registered before ~2021-06): 524,323
- reason 2 autocreated (is_self == False): 0
- reason 3 indefinitely blocked (mentor row dropped): 20,974
- reason 4 no mentor available / unknown residual: 629
- reason 5 account deleted (reg_ts missing): 6,451

Sum = 552,377 ✓. The per-state split (how each reason distributes over 0 / 50 / unset) is computed in the reason×state cell below.
 

Step 5 — Did any no_row users actually post a mentor question?



The cells above classify every no_row user by reason without distinguishing between users who would have used the mentorship feature and users who would not. This cell narrows the focus: among the 552,377 no_row users, how many actually posted a question through the GrowthExperiments mentor-questions interface? Question-askers who did not have a mentor row are the cohort whose treatment status is most likely to be misclassified in the 2SLS analysis.

This cell:

- Loads the growthexperiments-mentor-questions event table (one row per question event).
- Resolves the asker's username to a userId via build/registrations.jsonl.
- Intersects the askers with the no_row users in ds.
- Saves the intersection to analysis/diagnose_missing_mentors/asked_but_no_row.tsv.

Printed counts:

- Unique mentees who asked at least one question: 36,430.
- Resolved to userId: 35,102 of 36,430.
- Askers who appear in ds: 18,593.
- Askers in ds who are no_row: 385. Of these, 380 are unset, 4 are 50, and 1 is 0.

The 385 question-asking no_row users are the population analyzed in the next two cells.
In [48]:
# See if any of those who didn't have a mentor row (no_row) had actually ASKED A QUESTION (and thus were more likely to be impacted by missing mentorship, if they were in the treatment group).

# ---- 1. all mentee usernames who asked a question ----
QFILE = ROOT / "inputs/d_mentor_questions.jsonl"
askers = set()
with open(QFILE) as f:
    for line in f:
        o = json.loads(line)
        m = o.get("mentee")
        if m:
            askers.add(m)
print(f"unique mentees who asked a question: {len(askers):,}")

# ---- 2. map those usernames -> userId (stream registrations.jsonl) ----
name2uid = {}
with open(REG_FILE) as f:
    for line in f:
        o = json.loads(line)
        if o["name"] in askers:
            name2uid[o["name"]] = o["uid"]
print(f"resolved to userId: {len(name2uid):,} / {len(askers):,}")

# ---- 3. look up their state + no_row status in the dataset ----
uid2state = dict(zip(ds["userId"].astype(int), ds["mentorshipState"]))

rows = []
for name, uid in name2uid.items():
    if uid not in uid2state:
        continue                      # not in Martin's list
    state  = uid2state[uid]
    no_row = uid not in snap          # no mentor row in the current snapshot
    rows.append((uid, name, state, no_row))

q = pd.DataFrame(rows, columns=["uid", "name", "mentorshipState", "no_row"])
print(f"\naskers present in dataset: {len(q):,}")

# ---- 4. key result: asked a question BUT currently no_row, split by state ----
hit = q[q["no_row"]]
print(f"\n>>> asked a question BUT currently no mentor row (no_row): {len(hit):,}")
print(hit.groupby("mentorshipState").size().to_string())

# save for inspection
hit.to_csv(ROOT / "analysis/diagnose_missing_mentors/asked_but_no_row.tsv",
            sep="\t", index=False)
print("\nsaved -> analysis/diagnose_missing_mentors/asked_but_no_row.tsv")
unique mentees who asked a question: 36,430 resolved to userId: 35,102 / 36,430 askers present in dataset: 18,593 >>> asked a question BUT currently no mentor row (no_row): 385 mentorshipState 0 1 50 4 unset 380 saved -> analysis/diagnose_missing_mentors/asked_but_no_row.tsv
 

Step 6 — Classify the 385 question-asking no_row users by reason



This cell takes the 385 question-asking no_row users and assigns each one a reason from the five-reason classification. It joins the askers with reg_ts, is_self, and the block-status output produced in cell 7, then applies the same rules used in cell 4 and cell 7. The output is written to asked_but_no_row_classified.tsv.

Printed counts among the 385:

- Reason 1, pre-rollout: 6.
- Reason 3, indefinitely blocked: 373.
- Reason 4, residual: 6.

Cross-tabulated by mentorshipState:

- Reason 1 × state: 6 in unset, 0 in 0 or 50.
- Reason 3 × state: 368 in unset, 4 in 50, 1 in 0.
- Reason 4 × state: 6 in unset, 0 in 0 or 50.

The dominant reason among question-asking no_row users is indefinite block (373 of 385, 96.9%). The next cell drills into the textual block reasons for these 373 users.
In [49]:
# See why are those people missing mentor rows, and how many of them are indefinitely blocked (reason 3) vs potentially having no mentor available (reason 4, by elimination).


# indefinitely-blocked uids from the block fetch
OUT = ROOT / "analysis/diagnose_missing_mentors/block_status.jsonl"
blk = pd.read_json(OUT, lines=True)
indef_uids = set(blk.loc[blk["indefinite"], "uid"])

# bring reg_ts / is_self onto the 385 askers-without-row
h = hit.merge(ds[["userId", "reg_ts", "is_self"]],
            left_on="uid", right_on="userId", how="left")

def classify(r):
    if pd.isna(r["reg_ts"]):                       return "5_deleted"
    if r["is_self"] == False:                      return "2_autocreated"
    if r["reg_ts"] < pd.Timestamp(ROLLOUT_LIVE):   return "1_pre_rollout"
    if r["uid"] in indef_uids:                      return "3_indef_blocked"
    return "4_residual"

h["reason"] = h.apply(classify, axis=1)

print(f"asked a question but currently no_row: {len(h):,}\n")
print("by reason:")
print(h.groupby("reason").size().to_string())
print("\nby reason x state:")
print(pd.crosstab(h["reason"], h["mentorshipState"]))

h.to_csv(ROOT / "analysis/diagnose_missing_mentors/asked_but_no_row_classified.tsv",
        sep="\t", index=False)
print("\nsaved -> asked_but_no_row_classified.tsv")
asked a question but currently no_row: 385 by reason: reason 1_pre_rollout 6 3_indef_blocked 373 4_residual 6 by reason x state: mentorshipState 0 50 unset reason 1_pre_rollout 0 0 6 3_indef_blocked 1 4 368 4_residual 0 0 6 saved -> asked_but_no_row_classified.tsv
 

Step 7 — First look at indefinite-block reasons (restricted to the 373 question-asker subset)



This cell plots the indefinite-block reasons for the 373 question-asking indefinitely-blocked users identified above, split by mentorshipState. The block reason text is read from block_status.jsonl and bucketed into categories (such as spam/promo, checkuser, sockpuppet, vandalism, not-here, username, disruption, harassment, block-evasion, other) using string matching on the reason field.

The output is saved to blocked_reason_by_state.png and printed as a count table. The largest categories within the 373 are spam/promo (111 in unset), checkuser (63 in unset, plus 3 in 50 and 1 in 0), and sockpuppet (59 in unset).

This view covers only the 373 question-asker subset of indefinitely-blocked users, which is small. Cell 14 produces the same breakdown for the full 20,974 indefinitely-blocked no_row population and is the authoritative version. This cell is retained to show the block-reason composition specifically among users who actually engaged with the mentor-question interface.
In [50]:
import matplotlib.pyplot as plt

# block reasons for the indefinitely-blocked subset
blk = pd.read_json(ROOT / "analysis/diagnose_missing_mentors/block_status.jsonl", lines=True)
ib = h[h["reason"] == "3_indef_blocked"].merge(
        blk[["uid", "reason"]].rename(columns={"reason": "block_reason"}),
        on="uid", how="left")
print(f"indefinitely blocked: {len(ib)}")

# ---- bucket the messy free-text / template block reasons ----
def bucket(text):
    t = (text or "").lower()
    if "checkuser" in t:                                            return "checkuser"
    if "sock" in t:                                                 return "sockpuppet"
    if "spam" in t or "advertis" in t or "promot" in t or "paid" in t: return "spam/promo"
    if "username" in t or "ublock" in t:                            return "username"
    if "vandal" in t or "voa" in t:                                 return "vandalism"
    if "nothere" in t or "not here" in t:                           return "not-here"
    if "disrupt" in t:                                              return "disruption"
    if "harass" in t or "personal attack" in t or "npa" in t:       return "harassment"
    if "lta" in t or "long-term abuse" in t:                        return "LTA"
    if "block evasion" in t or "evasion" in t:                      return "block-evasion"
    if t.strip() == "":                                             return "(empty)"
    return "other"

ib["cat"] = ib["block_reason"].map(bucket)

# ---- count: category x state ----
tab = ib.pivot_table(index="cat", columns="mentorshipState",
                    aggfunc="size", fill_value=0)
tab = tab.loc[tab.sum(axis=1).sort_values(ascending=False).index]   # sort by total
print("\ncategory x state:")
print(tab.to_string())

# ---- stacked bar plot ----
ax = tab.plot(kind="bar", stacked=True, figsize=(11, 6))
ax.set_xlabel("block reason category")
ax.set_ylabel("number of users")
ax.set_title("Indefinitely-blocked no_row users (asked a question) — block reason by mentorshipState")
ax.legend(title="mentorshipState")
plt.xticks(rotation=35, ha="right")
plt.tight_layout()
plt.savefig(ROOT / "analysis/diagnose_missing_mentors/blocked_reason_by_state.png", dpi=130)
plt.show()
print("\nsaved -> blocked_reason_by_state.png")
indefinitely blocked: 373 category x state: mentorshipState 0 50 unset cat spam/promo 0 0 111 checkuser 1 3 63 sockpuppet 0 0 59 not-here 0 0 42 other 0 0 41 disruption 0 1 23 vandalism 0 0 13 username 0 0 9 harassment 0 0 5 block-evasion 0 0 2
Output
saved -> blocked_reason_by_state.png
 

Step 8 — Authoritative five-reason × mentorshipState classification of all 552,377 no_row users



This cell produces the authoritative reason-by-state table for the entire no_row population. Each user is assigned exactly one of the five reasons, in this precedence order: 5_deleted if reg_ts is missing; otherwise 1_pre_rollout if reg_ts < 2021-06-01; otherwise 2_autocreated if is_self == False; otherwise 3_indef_blocked if the user is in the indef-blocked set from cell 7; otherwise 4_residual. The result is cross-tabulated against mentorshipState.

Printed reason × mentorshipState totals:

| reason | 0 | 50 | unset | total |
|---|---|---|---|---|
| 1_pre_rollout | 0 | 0 | 524,323 | 524,323 |
| 2_autocreated | 0 | 0 | 0 | 0 |
| 3_indef_blocked | 11,409 | 4 | 9,561 | 20,974 |
| 4_residual | 165 | 0 | 464 | 629 |
| 5_deleted | 26 | 0 | 6,425 | 6,451 |

A sum check inside the cell confirms the column sums match no_row per state (0: 11,600; 50: 4; unset: 540,773; total 552,377). The figure is saved to no_row_reason_by_state.png and the table to no_row_reason_by_state.tsv.

Two observations from this table are used downstream: reason 1 (pre-rollout) is entirely within unset, consistent with state 0 not appearing in the data before 2021-09; indefinitely-blocked users are concentrated in state 0 (11,409) and state unset (9,561), with only 4 in state 50.
In [51]:
# =============================================================================
# Classify ALL no_row users (state 0 + 50 + unset) into the 5 reasons, by state.
# Reuses what earlier cells already built:
#   - ds["no_row"], ds["reg_ts"], ds["is_self"]   (from cell-6)
#   - block_status.jsonl -> indef_uids            (from cell-9; covers all states,
#                                                  since the remainder was NOT
#                                                  filtered by state)
# Priority (high -> low):  5_deleted > 2_autocreated > 1_pre_rollout
#                          > 3_indef_blocked > 4_residual
# =============================================================================
import matplotlib.pyplot as plt

ROLLOUT_LIVE = pd.Timestamp("2021-06-01")

# ---- indefinitely-blocked uids (covers the 50 & unset remainders too) ----
blk = pd.read_json(ROOT / "analysis/diagnose_missing_mentors/block_status.jsonl",
                    lines=True)
indef_uids = set(blk.loc[blk["indefinite"], "uid"])
print(f"indefinitely-blocked uids in block_status: {len(indef_uids):,}")

# ---- all no_row users (should be 11,600 + 4 + 540,773 = 552,377) ----
nr = ds[ds["no_row"]].copy()
print(f"total no_row users: {len(nr):,}")
print(nr.groupby("mentorshipState").size().to_string(), "\n")

# ---- vectorized classification (assign low priority first, overwrite upward) ----
reason = pd.Series("4_residual", index=nr.index, dtype="object")
reason[nr["userId"].astype(int).isin(indef_uids)] = "3_indef_blocked"
reason[nr["reg_ts"] < ROLLOUT_LIVE]               = "1_pre_rollout"
reason[nr["is_self"] == False]                    = "2_autocreated"
reason[nr["reg_ts"].isna()]                       = "5_deleted"
nr["reason"] = reason

REASON_ORDER = ["1_pre_rollout", "2_autocreated", "3_indef_blocked",
                "4_residual", "5_deleted"]

# ---- reason x state table ----
tab = (nr.pivot_table(index="reason", columns="mentorshipState",
                    aggfunc="size", fill_value=0)
        .reindex(REASON_ORDER, fill_value=0))
tab["total"] = tab.sum(axis=1)
print("=== reason x mentorshipState ===")
print(tab.to_string())
print(f"\nsum check: {tab['total'].sum():,} (should equal total no_row above)")

tab.to_csv(ROOT / "analysis/diagnose_missing_mentors/no_row_reason_by_state.tsv",
            sep="\t")

# ---- plot (state 0 included; mostly so we see 50 & unset clearly) ----
plot_tab = tab.drop(columns="total")
ax = plot_tab.plot(kind="bar", stacked=True, figsize=(10, 6), logy=True)
ax.set_xlabel("reason")
ax.set_ylabel("number of no_row users (log scale)")
ax.set_title("Why no_row — reason x mentorshipState (all states)")
ax.legend(title="mentorshipState")
plt.xticks(rotation=25, ha="right")
plt.tight_layout()
plt.savefig(ROOT / "analysis/diagnose_missing_mentors/no_row_reason_by_state.png",
            dpi=130)
plt.show()
print("saved -> no_row_reason_by_state.png  +  no_row_reason_by_state.tsv")
indefinitely-blocked uids in block_status: 20,974 total no_row users: 552,377 mentorshipState 0 11600 50 4 unset 540773 === reason x mentorshipState === mentorshipState 0 50 unset total reason 1_pre_rollout 0 0 524323 524323 2_autocreated 0 0 0 0 3_indef_blocked 11409 4 9561 20974 4_residual 165 0 464 629 5_deleted 26 0 6425 6451 sum check: 552,377 (should equal total no_row above)
Output
saved -> no_row_reason_by_state.png + no_row_reason_by_state.tsv
 

Step 9 — Re-check the "5_deleted" bucket against the CentralAuth API



The label 5_deleted was assigned in cell 4 to the 6,451 no_row users whose reg_ts is missing. "Missing local registration timestamp" is not the same as "account deleted": a user can have no local en.wiki user row but still have a live global (CentralAuth) account whose home wiki is another project. This cell tests that hypothesis by querying Meta's CentralAuth API (action=query&meta=globaluserinfo) for each of the 6,451 userIds, one at a time (guiid is not batchable), with a 0.4 s pacing between requests. The fetch is resume-safe and writes one JSON record per line to analysis/diagnose_missing_mentors/deleted_global_info.jsonl.

Printed results:

- Total records: 6,451.
- Accounts that do not exist globally (missing == True): 0.
- Accounts that exist but are globally locked: 55.
- Hidden accounts: 0.
- API errors or records without a registration timestamp: 11.

Registration date distribution among the 6,440 records with a known global registration timestamp:

- Pre-rollout (reg < 2021-06-01): 6,440 (100%).
- In the identifying window (2022-03-07 to 2025-02-16): 0.

Top home wikis (out of all 6,451): enwiki 3,154 (48.9%), eswiki 464 (7.2%), frwiki 252 (3.9%), ruwiki 213 (3.3%), ptwiki 204 (3.2%), dewiki 178 (2.8%), with the remainder spread across many other projects.

Conclusion printed at the bottom of the cell: none of the 6,451 users are actually deleted. They all have a queryable global account; all 6,440 with a known registration date registered before 2021-06-01; zero fall in the identifying window. The 5_deleted label is therefore misleading and should be read as "pre-rollout global accounts with no local en.wiki user row" — many because their home wiki is not en.wiki. This misclassification has no effect on the cohort used for 2SLS estimation, because none of these users fall in the identifying window.
In [52]:
# =============================================================================
# Re-checking the "5_deleted" bucket — are those 6,451 users actually deleted?
#
# Context: in the reason-by-state cell above we labelled 6,451 no_row users as
# "5_deleted" because their reg_ts is missing from build/registrations.jsonl
# (which is built from en.wiki's LOCAL user table). "Missing local reg" is not
# the same as "account deleted" — the account may simply be a global
# (CentralAuth) account whose home wiki is not en.wiki, in which case it has
# no local enwiki user row but the global account is alive and queryable on
# Meta.
#
# This cell:
#   1. Queries Meta's CentralAuth API (one uid at a time — guiid is not
#      batchable) and writes deleted_global_info.jsonl. Resume-safe.
#      API reference: https://www.mediawiki.org/wiki/API:Globaluserinfo
#   2. Summarises: where they registered, when, and whether any fall inside
#      the 2SLS identifying window (derived from the ROLLOUT table).
#
# NOTE: this cell reads the deleted_uids set from a cache pkl produced in an
# earlier ad-hoc step. If the pkl is missing, recompute it inline:
#     deleted = sorted(set(ds.loc[ds["no_row"] & ds["reg_ts"].isna(),
#                                 "userId"].astype(int)))
# =============================================================================
import pickle, time
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from collections import Counter

DEL  = ROOT / "analysis/diagnose_missing_mentors/cache/deleted_uids.pkl"
OUT  = ROOT / "analysis/diagnose_missing_mentors/deleted_global_info.jsonl"
META = "https://meta.wikimedia.org/w/api.php"
UA   = "WikiMentorResearch/1.0 (academic; contact: yubozhou@umich.edu)"
SLEEP = 0.4   # match cell-7's pacing

# cohort window derived from the ROLLOUT table (identifying phases only)
_ident = [(s, e) for label, s, e, mp, hp, ident in ROLLOUT if ident]
COHORT_START = min(s for s, _ in _ident)
COHORT_END   = max(e for _, e in _ident)
ROLLOUT_LIVE = "2021-06-01"   # en.wiki mentorship effective date (same as cell-4)
print(f"cohort window (from ROLLOUT identifying phases): {COHORT_START} .. {COHORT_END}")

# ---- 1) fetch (resume-safe; skips uids already in OUT) -----------------------
def fetch_one(uid):
    p = {"action":"query", "meta":"globaluserinfo", "guiid":uid, "format":"json"}
    url = META + "?" + urlencode(p)
    for k in range(5):
        try:
            req = Request(url, headers={"User-Agent": UA})
            with urlopen(req, timeout=30) as r:
                return json.loads(r.read().decode())
        except (HTTPError, URLError, TimeoutError):
            time.sleep(0.5 * (2**k))
    return {"error": "fail"}

deleted = sorted(pickle.load(open(DEL, "rb")))
done = set()
if OUT.exists():
    with open(OUT) as f:
        for line in f:
            try: done.add(json.loads(line)["uid"])
            except: pass
todo = [u for u in deleted if u not in done]
print(f"total={len(deleted):,}  already_fetched={len(done):,}  to_fetch={len(todo):,}")

if todo:
    with open(OUT, "a") as fout:
        for i, uid in enumerate(todo):
            d  = fetch_one(uid)
            gu = d.get("query", {}).get("globaluserinfo", d)
            rec = {"uid": uid, "name": gu.get("name"), "home": gu.get("home"),
                    "registration": gu.get("registration"),
                    "missing": "missing" in gu, "locked": "locked" in gu,
                    "hidden":  "hidden"  in gu,
                    "err": d.get("error") or gu.get("error")}
            fout.write(json.dumps(rec) + "\n")
            if i % 200 == 0:
                fout.flush()
                print(f"  {i:,}/{len(todo):,}")
            time.sleep(SLEEP)
    print("fetch done.")

# ---- 2) summarise ------------------------------------------------------------
rows = [json.loads(l) for l in open(OUT)]
print(f"\ntotal records: {len(rows):,}")

n_missing = sum(1 for r in rows if r.get("missing"))
n_locked  = sum(1 for r in rows if r.get("locked"))
n_hidden  = sum(1 for r in rows if r.get("hidden"))
n_err     = sum(1 for r in rows if r.get("err"))
n_no_reg  = sum(1 for r in rows if not r.get("registration"))
print(f"  globaluserinfo MISSING (account never existed globally): {n_missing:,}")
print(f"  locked (account exists but globally locked)            : {n_locked:,}")
print(f"  hidden                                                 : {n_hidden:,}")
print(f"  API errors / no registration timestamp                 : {n_err:,} / {n_no_reg:,}")

# robust date parsing (MediaWiki returns ISO 8601; coerce defensively)
reg_dt = pd.to_datetime(
    pd.Series([r.get("registration") for r in rows]),
    errors="coerce", utc=True,
).dt.tz_localize(None)
reg_date = reg_dt.dt.date.astype("string")

cs, ce, rl = pd.Timestamp(COHORT_START).date(), pd.Timestamp(COHORT_END).date(),pd.Timestamp(ROLLOUT_LIVE).date()
valid = reg_dt.notna()
n_pre   = int(((reg_dt.dt.date <  rl) & valid).sum())
n_btw   = int(((reg_dt.dt.date >= rl) & (reg_dt.dt.date <  cs) & valid).sum())
n_in    = int(((reg_dt.dt.date >= cs) & (reg_dt.dt.date <= ce) & valid).sum())
n_post  = int(((reg_dt.dt.date >  ce) & valid).sum())

print("\n--- registration vs cohort window ---")
print(f"  pre-rollout       (reg < {ROLLOUT_LIVE}) : {n_pre:,}")
print(f"  rollout..cohort   ({ROLLOUT_LIVE}..{COHORT_START}) : {n_btw:,}")
print(f"  IN cohort window  ({COHORT_START}..{COHORT_END}) : {n_in:,}")
print(f"  after cohort end  (> {COHORT_END})       : {n_post:,}")

yrs = Counter(d.year for d in reg_dt.dropna().dt.date)
print("\n--- registration year distribution ---")
for y in sorted(yrs):
    print(f"  {y}: {yrs[y]:,}")

homes = Counter(r.get("home") for r in rows if r.get("home"))
print("\n--- top 10 home wikis ---")
for w, c in homes.most_common(10):
    print(f"  {w}: {c:,}  ({c/len(rows)*100:.1f}%)")

print("\n=== conclusion ===")
print(f"None of the {len(rows):,} 'deleted' uids are actually deleted; all are queryable")
print(f"on CentralAuth. All {n_pre:,} with a known registration date registered BEFORE the")
print(f"mentorship rollout ({ROLLOUT_LIVE}), and 0 fall inside the identifying window")
print(f"({COHORT_START}..{COHORT_END}). They were tagged '5_deleted' only because the local")
print(f"en.wiki user table does not have their registration row — many are global accounts")
print(f"whose home wiki is not en.wiki ({homes.get('enwiki',0)/len(rows)*100:.1f}% are enwiki-home,")
print(f"the rest are eswiki/frwiki/etc). The '5_deleted' label is misleading — these are")
print(f"pre-rollout global accounts without a local enwiki user row.")
cohort window (from ROLLOUT identifying phases): 2022-03-07 .. 2025-02-16 total=6,451 already_fetched=6,451 to_fetch=0 total records: 6,451 globaluserinfo MISSING (account never existed globally): 0 locked (account exists but globally locked) : 55 hidden : 0 API errors / no registration timestamp : 11 / 11 --- registration vs cohort window --- pre-rollout (reg < 2021-06-01) : 6,440 rollout..cohort (2021-06-01..2022-03-07) : 0 IN cohort window (2022-03-07..2025-02-16) : 0 after cohort end (> 2025-02-16) : 0 --- registration year distribution --- 2015: 1,440 2016: 4,971 2017: 29 --- top 10 home wikis --- enwiki: 3,154 (48.9%) eswiki: 464 (7.2%) frwiki: 252 (3.9%) ruwiki: 213 (3.3%) ptwiki: 204 (3.2%) dewiki: 178 (2.8%) arwiki: 167 (2.6%) commonswiki: 159 (2.5%) zhwiki: 148 (2.3%) jawiki: 127 (2.0%) === conclusion === None of the 6,451 'deleted' uids are actually deleted; all are queryable on CentralAuth. All 6,440 with a known registration date registered BEFORE the mentorship rollout (2021-06-01), and 0 fall inside the identifying window (2022-03-07..2025-02-16). They were tagged '5_deleted' only because the local en.wiki user table does not have their registration row — many are global accounts whose home wiki is not en.wiki (48.9% are enwiki-home, the rest are eswiki/frwiki/etc). The '5_deleted' label is misleading — these are pre-rollout global accounts without a local enwiki user row.
 

Step 10 — Block-reason breakdown for all 20,974 indefinitely-blocked no_row users, split by state



This cell repeats the block-reason analysis from cell 11 on the full population of 20,974 indefinitely-blocked no_row users (rather than the 373 question-askers). Block-reason text is read from block_status.jsonl and bucketed using the same string-matching rules as cell 11. The outputs are saved to block_reason_by_state_all.png, block_reason_by_state_all.tsv, and block_reason_share_by_state.tsv.

Printed totals (top categories, absolute counts by mentorshipState):

| category | 0 | 50 | unset | total |
|---|---|---|---|---|
| spam/promo | 4,614 | 0 | 3,643 | 8,257 |
| checkuser | 1,672 | 3 | 1,626 | 3,301 |
| sockpuppet | 1,249 | 0 | 1,108 | 2,357 |
| vandalism | 1,251 | 0 | 1,104 | 2,355 |
| not-here | 891 | 0 | 666 | 1,557 |
| other | 603 | 0 | 474 | 1,077 |
| username | 519 | 0 | 357 | 876 |
| disruption | 452 | 1 | 388 | 841 |

Within-state shares are also printed. The composition is similar in state 0 and state unset: in both, the top three categories (spam/promo, checkuser, sockpuppet) account for roughly two-thirds of indefinite blocks. State 50 has only 4 indefinitely-blocked users and is too small to interpret.

This cell concludes the analysis of the 552,377 no_row users. The remaining cells are a separate completeness audit of registration timestamps, prompted by a finding made while building the for-Martin output file.
In [53]:
# =============================================================================
# Block reasons for ALL 20,974 indef-blocked no_row users, split by
# mentorshipState (0 / 50 / unset).
#
# cell-11 only bucketed the 373 indef-blocked who had also asked a question —
# too small to generalise from. This cell runs the same bucket function over
# the full block_status.jsonl set, so we can see whether state=0 vs unset have
# different abuse-reason mixes.
# =============================================================================
import matplotlib.pyplot as plt

blk = pd.read_json(ROOT / "analysis/diagnose_missing_mentors/block_status.jsonl",
                    lines=True)
blk = blk[blk["indefinite"]].copy()
print(f"indefinitely-blocked uids: {len(blk):,}")

# attach mentorshipState
uid2state = dict(zip(ds["userId"].astype(int), ds["mentorshipState"]))
blk["mentorshipState"] = blk["uid"].map(uid2state)
print("by state:")
print(blk.groupby("mentorshipState").size().to_string())

# same bucket function as cell-11
def bucket(text):
    t = (text or "").lower()
    if "checkuser" in t:                                                  return "checkuser"
    if "sock" in t:                                                       return "sockpuppet"
    if "spam" in t or "advertis" in t or "promot" in t or "paid" in t:    return "spam/promo"
    if "username" in t or "ublock" in t:                                  return "username"
    if "vandal" in t or "voa" in t:                                       return "vandalism"
    if "nothere" in t or "not here" in t:                                 return "not-here"
    if "disrupt" in t:                                                    return "disruption"
    if "harass" in t or "personal attack" in t or "npa" in t:             return "harassment"
    if "lta" in t or "long-term abuse" in t:                              return "LTA"
    if "block evasion" in t or "evasion" in t:                            return "block-evasion"
    if t.strip() == "":                                                   return "(empty)"
    return "other"

blk["cat"] = blk["reason"].map(bucket)

# absolute counts: category x state
tab = blk.pivot_table(index="cat", columns="mentorshipState",
                    aggfunc="size", fill_value=0)
tab["total"] = tab.sum(axis=1)
tab = tab.sort_values("total", ascending=False)
print("\n=== block reason category x mentorshipState (absolute counts) ===")
print(tab.to_string())

# within-state shares (column-wise %): lets you compare state-0 vs unset mixes
share = (tab.drop(columns="total")
            .div(tab.drop(columns="total").sum(axis=0), axis=1)
            .mul(100).round(2))
print("\n=== same table, as within-state share (%) ===")
print(share.to_string())

# save
tab.to_csv(ROOT / "analysis/diagnose_missing_mentors/block_reason_by_state_all.tsv", sep="\t")
share.to_csv(ROOT / "analysis/diagnose_missing_mentors/block_reason_share_by_state.tsv",sep="\t")

# stacked bar (absolute, log scale so state=50's tiny count doesn't disappear)
plot_tab = tab.drop(columns="total")
ax = plot_tab.plot(kind="bar", stacked=True, figsize=(11, 6), logy=True)
ax.set_xlabel("block reason category")
ax.set_ylabel("number of indef-blocked no_row users (log scale)")
ax.set_title("Block reasons for ALL indef-blocked no_row users, by mentorshipState")
ax.legend(title="mentorshipState")
plt.xticks(rotation=35, ha="right")
plt.tight_layout()
plt.savefig(ROOT / "analysis/diagnose_missing_mentors/block_reason_by_state_all.png",dpi=130)
plt.show()
print("\nsaved -> block_reason_by_state_all.{png,tsv}  +  block_reason_share_by_state.tsv")
indefinitely-blocked uids: 20,974 by state: mentorshipState 0 11409 50 4 unset 9561 === block reason category x mentorshipState (absolute counts) === mentorshipState 0 50 unset total cat spam/promo 4614 0 3643 8257 checkuser 1672 3 1626 3301 sockpuppet 1249 0 1108 2357 vandalism 1251 0 1104 2355 not-here 891 0 666 1557 other 603 0 474 1077 username 519 0 357 876 disruption 452 1 388 841 LTA 54 0 76 130 block-evasion 59 0 61 120 harassment 37 0 45 82 (empty) 8 0 13 21 === same table, as within-state share (%) === mentorshipState 0 50 unset cat spam/promo 40.44 0.0 38.10 checkuser 14.66 75.0 17.01 sockpuppet 10.95 0.0 11.59 vandalism 10.97 0.0 11.55 not-here 7.81 0.0 6.97 other 5.29 0.0 4.96 username 4.55 0.0 3.73 disruption 3.96 25.0 4.06 LTA 0.47 0.0 0.79 block-evasion 0.52 0.0 0.64 harassment 0.32 0.0 0.47 (empty) 0.07 0.0 0.14
Output
saved -> block_reason_by_state_all.{png,tsv} + block_reason_share_by_state.tsv
 

Where the reg_ts MISSING number comes from



The next cell will print users with reg_ts MISSING : 7,378. That count is every uid in z_mentorship_state.tsv whose lookup in build/registrations.jsonl returned NaN, regardless of whether the uid is in the snapshot. It splits into:

- no_row & reg_ts NaN — the 6,451 "deleted"-tagged group from the 5-reason no_row breakdown above.
- has_row & reg_ts NaN — 927 users with a real mentor row but no local enwiki reg_ts (audited in the new cell below).

The pre-check cell below prints both sub-counts so the 7,378 doesn't appear out of nowhere.
In [54]:
# Pre-check: decompose `reg_ts.isna()` BEFORE the realized-rollout cell prints 7,378.
# 7,378 = (no_row & reg_ts NaN) + (has_row & reg_ts NaN)
in_snap_mask = ds["userId"].map(lambda u: u in snap)
n_total = int(ds["reg_ts"].isna().sum())
n_A     = int((~in_snap_mask & ds["reg_ts"].isna()).sum())
n_B     = int(( in_snap_mask & ds["reg_ts"].isna()).sum())
print(f"reg_ts NaN total           : {n_total:,}")
print(f"  group A: no_row  & NaN   : {n_A:,}   # the 5-reason 'deleted' bucket")
print(f"  group B: has_row & NaN   : {n_B:,}   # has mentor row but no local enwiki reg_ts")
reg_ts NaN total : 7,378 group A: no_row & NaN : 6,451 # the 5-reason 'deleted' bucket group B: has_row & NaN : 927 # has mentor row but no local enwiki reg_ts
 

Step 11 — Realized rollout share per phase, with bounds for users whose reg_ts is missing



This cell measures how the realized Z=1 share in ds compares to the share implied by each rollout phase's mentorship_pct and homepage_pct. The Z mapping used here is Z=1 if mentorshipState ∈ {unset, 50}, else Z=0.

For each phase, the cell computes:

- n_known: number of users with a known reg_ts whose registration falls in that phase.
- realized_baseline_%: realized Z=1 share among n_known.
- nominal_Z1_all_users: expected Z=1 share under the rollout configuration. For phases with homepage_pct == 1.0, this equals mentorship_pct. For the early phase homepage_25pct_mentor_20pct, the expected share is (1 − homepage_pct) × 1 + homepage_pct × mentorship_pct = 0.75 × 1 + 0.25 × 0.20 = 0.80, because users not shown the Newcomer Homepage never have the A/B-assignment code run and remain unset (Z=1 under this mapping).
- Bounds (lower_all_miss_as_Z0, upper_all_miss_as_Z1): realized share recomputed under the two extreme assumptions about users whose reg_ts is missing (all assumed Z=0 vs all assumed Z=1).

The cell first prints the count of users with reg_ts missing — 7,378 (Z=1: 6,771; Z=0: 607). The two previous cells decomposed this 7,378 into 6,451 (no_row & reg_ts NaN) plus 927 (has_row & reg_ts NaN).

Per-phase realized vs nominal (identifying phases only):

| phase | window | nominal Z1 | realized baseline | diff (pp) |
|---|---|---|---|---|
| p10 | 2022-03-07 to 2023-07-10 | 10.0% | 11.753% | +1.75 |
| p25 | 2023-07-11 to 2023-10-04 | 25.0% | 26.145% | +1.14 |
| p50 | 2023-10-05 to 2025-02-02 | 50.0% | 50.574% | +0.57 |
| p75 | 2025-02-03 to 2025-02-16 | 75.0% | 73.899% | -1.10 |

Realized shares match the nominal targets to within 1.75 percentage points across all identifying phases. The bounds for missing-reg_ts users are narrow (less than 0.5 pp for p10, p25, p50), which shows that the missing-reg_ts cohort is too small to move the realized share appreciably.

The output table is saved to rollout_realized_with_bounds.tsv.
In [55]:
# =============================================================================
# Realized rollout % per phase, with bounds for missing-reg users.
#
# What "Z=1" means in OUR mapping (per Martin's release doc):
#   Z=1 (UI enabled by rollout) = mentorshipState ∈ {unset, 50}
#   Z=0 (A/B disabled)          = mentorshipState == 0
#
# Why "nominal" needs two columns for the nested early phase:
# -----------------------------------------------------------
# In the late phases (p10/p25/p50/p75/p100), Newcomer Homepage rollout is
# already 100%, so EVERY new user is evaluated by the mentorship A/B code.
# In that case, nominal = mentorship_pct directly.
#
# But in the early phase `homepage_25pct_mentor_20pct`, only 25% of new users
# saw the Newcomer Homepage, and only those 25% went through the mentorship
# A/B evaluation. The other 75% never had the A/B code touch their property,
# so their mentorshipState stays `unset` -> they get tagged Z=1 in OUR mapping.
#
# So among ALL new registrants in this phase, the EXPECTED Z=1 share is:
#
#     expected_Z1_share
#         = (1 - homepage_pct) * 1.0                # never saw homepage -> unset -> Z=1
#         + homepage_pct * mentorship_pct           # saw homepage, A/B picked them -> Z=1
#
# For the early phase that's 0.75*1 + 0.25*0.20 = 0.80 = 80%. That 80% does
# NOT mean "80% were really shown the mentor UI" — it means "80% would carry
# Z=1 under our `unset∪50` mapping", because 3/4 of them never had the A/B
# code run at all. This phase is therefore identifying=False; we only use it
# as a sanity check.
#
# For the late phases (homepage_pct=1.0), the formula collapses to
# `mentorship_pct`, which is the actual rollout target.
# =============================================================================
ds["Z"] = ds["mentorshipState"].isin(["unset", "50"]).astype(int)

known = ds[ds["reg_ts"].notna()].copy()
miss  = ds[ds["reg_ts"].isna()].copy()
M_total = len(miss); M_z1 = int(miss["Z"].sum()); M_z0 = M_total - M_z1
print(f"users with reg_ts known   : {len(known):,}")
print(f"users with reg_ts MISSING : {M_total:,}  (Z=1: {M_z1:,}, Z=0: {M_z0:,})")

def phase_of(ts):
    if pd.isna(ts): return None
    d = ts.date().isoformat()
    for label, start, end, *_ in ROLLOUT:
        if start <= d <= end: return label
    return None
known["phase"] = known["reg_ts"].apply(phase_of)

def pct(z1, n): return round(100*z1/n, 3) if n else float("nan")

rows = []
for label, start, end, mentor_pct, hp_pct, ident in ROLLOUT:
    if mentor_pct is None: continue
    expected_z1 = (1 - hp_pct) * 1.0 + hp_pct * mentor_pct      # effective nominal under our Z mapping
    sub = known[known["phase"] == label]
    n  = len(sub); z1 = int(sub["Z"].sum())
    rows.append({
        "phase":                label,
        "start":                start,
        "end":                  end,
        "homepage_pct":         round(hp_pct*100, 1),
        "mentor_pct_of_hp":     round(mentor_pct*100, 1),   # 'rollout target' AMONG homepage viewers
        "nominal_Z1_all_users": round(expected_z1*100, 2),  # what we should see in OUR mapping
        "n_known":              n,
        "realized_baseline_%":  pct(z1, n),
        "if_all_miss_here_actual_Z": pct(z1 + M_z1,    n + M_total),
        "lower_all_miss_as_Z0":      pct(z1,           n + M_total),
        "upper_all_miss_as_Z1":      pct(z1 + M_total, n + M_total),
        "diff_baseline_vs_nominal_pp": round(pct(z1, n) - expected_z1*100, 2),
        "identifying":          ident,
    })

res = pd.DataFrame(rows)
print("\n=== realized vs nominal-under-our-Z-mapping, per phase ===")
print(res.to_string(index=False))
res.to_csv(ROOT/"analysis/diagnose_missing_mentors/rollout_realized_with_bounds.tsv",
            sep="\t", index=False)
print("\nsaved -> rollout_realized_with_bounds.tsv")
users with reg_ts known : 4,974,055 users with reg_ts MISSING : 7,378 (Z=1: 6,771, Z=0: 607) === realized vs nominal-under-our-Z-mapping, per phase === phase start end homepage_pct mentor_pct_of_hp nominal_Z1_all_users n_known realized_baseline_% if_all_miss_here_actual_Z lower_all_miss_as_Z0 upper_all_miss_as_Z1 diff_baseline_vs_nominal_pp identifying homepage_25pct_mentor_20pct 2021-09-20 2022-03-06 25.0 20.0 80.0 583837 80.386 80.528 79.383 80.631 0.39 False p10 2022-03-07 2023-07-10 100.0 10.0 10.0 1610204 11.753 12.118 11.700 12.156 1.75 True p25 2023-07-11 2023-10-04 100.0 25.0 25.0 264563 26.145 27.926 25.436 28.149 1.14 True p50 2023-10-05 2025-02-02 100.0 50.0 50.0 1444626 50.574 50.784 50.317 50.825 0.57 True p75 2025-02-03 2025-02-16 100.0 75.0 75.0 42810 73.899 76.526 63.035 77.736 -1.10 True p100 2025-02-17 2026-6-8 100.0 100.0 100.0 37638 98.257 97.194 82.153 98.543 -1.74 False saved -> rollout_realized_with_bounds.tsv
 

Audit: reg_ts MISSING is two different groups



The 7,378 reg_ts.isna() users above are not all "deleted accounts." They split into:

| group | in snapshot? | meaning | count |
|---|---|---|---|
| A | no_row & reg_ts NaN | originally tagged 5_deleted in the no_row breakdown; later found to be pre-rollout global CentralAuth accounts whose enwiki local registration log is missing | 6,451 |
| B | has_row & reg_ts NaN | has a real mentor row, but build/registrations.jsonl has no local enwiki reg_ts for them | 927 |

Group A is the one analyzed throughout this notebook (5 no_row reasons).
Group B was not broken out anywhere; it was surfaced only when building the for-Martin file (analysis/for_martin/users_name_state_regts.tsv).

The next cell audits group B: by state, by snapshot's mentor_assigned_ts month (a proxy for when they entered), and exports the list.
In [56]:
# =============================================================================
# Audit group B: users WITH a mentor row in snapshot but NO local enwiki reg_ts.
# Expected: 927 (= 7,378 reg_ts MISSING - 6,451 no_row deleted).
# =============================================================================
in_snap = ds["userId"].map(lambda u: u in snap)
groupB  = ds[in_snap & ds["reg_ts"].isna()].copy()
print(f"group B size (has_row & reg_ts NaN): {len(groupB):,}")

print("\n--- by mentorshipState ---")
print(groupB["mentorshipState"].value_counts(dropna=False).to_string())

print("\n--- by Z (unset|50 vs 0) ---")
print(groupB["mentorshipState"].isin(["unset","50"]).value_counts().rename({True:"Z=1",False:"Z=0"}).to_string())

def assigned_ts(uid):
    v = snap.get(uid)
    if v is None: return None
    for x in (v if isinstance(v,(list,tuple)) else [v]):
        if isinstance(x,str) and len(x)>=8 and x[:4].isdigit():
            return x
    return None
groupB["assigned_ts"]    = groupB["userId"].map(assigned_ts)
groupB["assigned_month"] = pd.to_datetime(groupB["assigned_ts"], errors="coerce").dt.to_period("M").astype("string")

print("\n--- by assigned_month ---")
print(groupB["assigned_month"].value_counts(dropna=False).sort_index().to_string())

OUT = ROOT / "analysis/diagnose_missing_mentors/group_B_has_row_no_reg.tsv"
groupB[["userId","mentorshipState","assigned_ts"]].to_csv(OUT, sep="\t", index=False)
print(f"\nwritten -> {OUT}")

n_missing = int(ds["reg_ts"].isna().sum())
n_groupA  = int((~in_snap & ds["reg_ts"].isna()).sum())
print(f"\nsanity: reg_ts NaN total={n_missing:,} = A(no_row & NaN)={n_groupA:,} + B(in_snap & NaN)={len(groupB):,}")
group B size (has_row & reg_ts NaN): 927 --- by mentorshipState --- mentorshipState 0 581 unset 346 --- by Z (unset|50 vs 0) --- mentorshipState Z=0 581 Z=1 346 --- by assigned_month --- assigned_month <NA> 927 written -> /home/yubozhou/2026_summer/wikipedia_2sls/2sls_pipeline/analysis/diagnose_missing_mentors/group_B_has_row_no_reg.tsv sanity: reg_ts NaN total=7,378 = A(no_row & NaN)=6,451 + B(in_snap & NaN)=927