I was staring at our migration tracker and feeling the thing you feel when a spreadsheet has 66 rows of “blocked” and no obvious place to start. Sixty-six packages. All waiting on something. The tracker says what each package is blocked by, but when everything is blocked by everything else, you’re not looking at a to-do list — you’re looking at a hairball.
So I asked a different question: what are the actual root causes?
Not “which packages are blocked” — that’s 66 and it’s overwhelming. But: which packages are causing the blocking, and aren’t themselves blocked by something else? The first-class blockers. The diseases, not the symptoms.
Turns out there are 8.
The setup
106-package Python monorepo. Here’s where we stand:
| Status | Count | % |
|---|---|---|
| Already on Bazel | 28 | 26% |
| Ready to migrate | 3 | 3% |
| Blocked by dependencies | 66 | 62% |
| Blocked by non-Python code | 4 | 4% |
| Manually excluded | 4 | 4% |
| Skipped (monorepo root) | 1 | 1% |
26% coverage. Not terrible — we started at 24% and the torch fix got us some headroom. But 66 packages sitting behind dependency walls means we can’t just “migrate more packages.” The low-hanging fruit is gone. Everything remaining is blocked by something.
The method
The data comes from MIGRATION_STATUS.md, which is machine-generated by find_bazel_ready_packages.py. For each blocked package, it lists exactly which dependencies are blocking it. No guessing, no “I think package X needs Y.” The script crawls real pyproject.toml files and resolves the graph.
Step one: count how often each package appears as a blocker. Simple frequency analysis.
Step two: for each frequent blocker, check — is it blocked by something else? If yes, it’s a symptom, not a root cause. Keep tracing backward until you hit something that isn’t itself blocked.
Here’s the top 15 by direct block count:
conversation → blocks 39 packages (also blocked)
mai_preprocessors → blocks 26 packages (also blocked)
mai_config → blocks 24 packages (ROOT — now solved)
mai_cluster → blocks 24 packages (also blocked)
common_utils → blocks 23 packages (also blocked)
lib/oai_api → blocks 21 packages (also blocked)
mai_distributed → blocks 14 packages (also blocked)
mai_logger → blocks 13 packages (ROOT — now solved)
judges → blocks 11 packages (also blocked)
mai_dataplatform → blocks 11 packages (also blocked)
mai_layers → blocks 11 packages (also blocked)
mai_trainer → blocks 10 packages (also blocked)
caas → blocks 9 packages (also blocked)
st_server → blocks 9 packages (also blocked)
mai_kernels → blocks 9 packages (ROOT)
Look at conversation. Blocks 39 packages — the single most-cited blocker in the whole monorepo. Your instinct says “fix conversation first.” But conversation is itself blocked. It can’t migrate until its own dependencies are resolved. Fixing it isn’t a thing you can just do. You have to fix what’s blocking it.
This is the trap. The most-cited blockers aren’t the most important ones — they’re the most downstream ones. They’re popular because lots of packages depend on them, but they’re symptoms of something deeper.
The 8 root cause blockers
A root cause blocker is a package that:
- Appears as a blocker for other packages
- Is not itself blocked by any other unresolved dependency
It’s either non-Python code that Bazel can’t compile, a package that’s manually excluded, or a package that just needs migration work but nobody’s done it yet. These are the places where effort actually has leverage.
There are exactly 8.
Tier 1: High ROI — just needs migration work
| Blocker | Direct blocks | Transitive blocks | Status |
|---|---|---|---|
mai_config |
24 | ~40+ | Solved — Ryan merged #23994 |
mai_logger |
13 | ~30+ | Solved — same PR |
mai_preprocessors |
26 | ~50+ | Not started |
mai_config and mai_logger are done. Ryan shipped those. That leaves mai_preprocessors as the single highest-ROI migration target in the entire monorepo.
Here’s why. conversation — the package that blocks 39 others — is blocked by two things: mai_config (solved) and mai_preprocessors (not solved). Fix mai_preprocessors, and conversation unblocks. Once conversation unblocks, those 39 downstream packages start cascading.
One package. One migration. The gateway to 39.
Tier 2: Hard walls — non-Python code
| Blocker | Type | Direct blocks | Transitive blocks | Difficulty |
|---|---|---|---|---|
lib/bus |
Cython (4 .pyx files) |
2 | ~28 | Medium-Hard |
mai_kernels |
CUDA/C++ (scikit-build-core) | 9 | ~15 | Hard |
simplertransformer |
CUDA/C++ (CMake) | 5 | ~10 | Very Hard |
lib/bus is the sneaky one. The migration scanner tagged it as “Contains: C++” because of language="c++" in its setup.py. I went to look at the actual C++ files.
There aren’t any. 420 Python files. 4 Cython files. Zero C++. Zero CUDA. Zero headers.
The language="c++" flag is a Cython codegen directive — it tells Cython to generate C++ instead of C as the intermediate compiled output. The scanner saw “c++” and categorized lib/bus alongside real CUDA libraries. The scariest-looking blocker in the entire migration was four .pyx files cosplaying as a C++ library.
lib/bus blocks only 2 packages directly (lib/oai_api), but lib/oai_api feeds into common_utils, and common_utils feeds into everything. The transitive chain: lib/bus → lib/oai_api → common_utils → ~28 packages. It’s the choke point for the entire bottom half of the dependency graph.
mai_kernels is real CUDA — scikit-build-core, CMake, the whole build-system-within-a-build-system situation. There’s a PR #23478 with a precompiled .so approach. simplertransformer is the same flavor but harder — more CUDA, more CMake, more pain.
Tier 3: Manually excluded — fixable but niche
| Blocker | Why excluded | Direct blocks | Difficulty |
|---|---|---|---|
dataprocessing/audio_ops |
Gazelle saw optional torch import | 3 | Easy — torch is solved |
dataprocessing/resumable_ray_pipeline |
Same | 3 | Easy |
lib/excel_evaluation |
Complex native deps | 1 | Hard |
These were excluded back when torch was unsolvable. Now that torch works in Bazel, the first two just need a re-scan. lib/excel_evaluation is genuinely gnarly (native Excel parsing libraries) but only blocks 1 package, so it’s low priority.
The priority stack
Sort by ROI — impact divided by difficulty:
Priority 1: mai_preprocessors → conversation → 39+ packages cascade
ROI: MASSIVE. Single biggest unlock remaining.
Priority 2: lib/bus → lib/oai_api → common_utils → ~28 packages
ROI: MASSIVE. Clears the ceiling.
Priority 3: mai_kernels (PR exists)
ROI: MEDIUM. 9 direct, overlaps with lib/bus chain.
Priority 4: audio_ops + resumable_ray_pipeline (re-run scanner)
ROI: LOW. 6 packages total.
Priority 5: simplertransformer
ROI: LOW. Overlaps with lib/bus chain.
The math
Here’s the punchline.
| If you fix… | Packages unblocked | Total on Bazel | Coverage |
|---|---|---|---|
| Nothing (today) | — | 28 / 106 | 26% |
| mai_preprocessors + conversation cascade | ~30-39 | ~58-67 / 106 | 55-63% |
| + lib/bus chain | ~28 more | ~86-95 / 106 | 81-90% |
| + mai_kernels | ~5-9 more | ~91-100 / 106 | 86-94% |
| + everything | all remaining | 106 / 106 | 100% |
Two fixes take you from 26% to 85-90%. mai_preprocessors (a standard Python migration — torch is already solved) and lib/bus (4 Cython files, not real C++). Everything after that is diminishing returns.
Two out of eight root blockers account for roughly 80% of the blocked packages. Pareto, right on schedule.
The meta-insight
Most large migrations have this structure. A small number of root causes propagate transitively through the dependency graph, creating the illusion of a massive, evenly-distributed problem. You look at 66 blocked packages and think “this will take months.” But when you trace backward to root causes, you find a handful of packages doing all the damage.
The mistake is treating the migration as a flat list. “66 packages to go, let’s chip away.” That framing makes it feel insurmountable. The right framing is: “8 root blockers, and 2 of them account for 80% of the problem.”
This is the same method from the cascade analysis we ran before committing to the torch fix. That analysis showed Option C would unblock 30 packages while Path A would unblock 1 — a 30x difference in ROI. It saved weeks of wasted engineering on the wrong approach.
This time the same method, applied one level up:
- Parse the dependency graph for blocker relationships
- Identify root causes — blockers that aren’t themselves blocked
- Count transitive impact of each root cause
- Rank by ROI (impact / difficulty)
- Fix in order
The dep graph isn’t just useful for bazel query. It’s the analytical substrate that makes this kind of reasoning possible. Without a machine-readable graph, you’re guessing. With it, you can do the math — and the math says focus everything on mai_preprocessors and lib/bus.
Two fixes. 26% to 85-90%. That’s the number that matters.