Project Status

Rendered from trainer-phase2-decision.md · generated 2026-07-09 18:53:18Z · Back to Dashboard

Queue Forecasting — Phase 2 Decision

Created: 2026-04-23

Last updated: 2026-06-30 (tail-accuracy program / Bet 1 queue-context features — see §0)

Companion to: trainer-spec.md, trainer-plan.md, bet1-queue-context-features-design.md

Authors: residual-model experiment, wait-time transform variants, run-duration residual experiment

0. Current status & direction (2026-06-30)

Where we are. The residual wait/duration models (Phase 2/3, below) are live and broadly calibrated, and a priority-aware wait p90 guardrail shipped 2026-06-04 — verified healthy on live data 2026-06-25 (overall wait & run bands ≈ 50% ≤ p50 / 40% / 10% > p90; the earlier priority-blind over-inflation, e.g. osx-1015 at ~30×, is gone). The one stubborn weakness is the long-wait tail: completed-only 30m+ waits still exceed their p90 ~38% of the time, and weak-fallback / capacity-sensitive pools underestimate.

The north star. The goal is a mach try group ETA — "when will my whole push finish." Group completion is the *max* over the push's tasks of (wait + run), and that max is dominated by the single slowest task — which lives in exactly the long tail above. So the tail has to be accurate *per task* before a group ETA can be trustworthy. We nail individual tasks first; group composition and dependency/priority-ordering are separate, later problems.

Current work — Bet 1: queue-context features. The tail is first an *information* problem: at pending time the model knows the task's own priority and the aggregate queue depth, but not *what is ahead of it*. A try task with 50 low-priority tasks ahead and one with 50 beta/autoland tasks ahead look identical today, yet wait very differently. Bet 1 reconstructs, at the task's pending moment: the priority backlog ahead of it (higher / same / lower priority, FIFO-correct), recent arrival & drain flow, worker capacity / utilization, and repo-family backlog (try / autoland / central / beta-release) on shared pools. Status: implemented, reviewed, and parity-verified locally; pending a walk-forward ablation on production data to decide go/no-go. Gate: 30m+ wait p90 miss materially down (<35% experimental, <30% broad), no regression on overall p90 / p50 / within-2x / MAE.

Next bets (after Bet 1). Bet 2 — model the full predictive *distribution* of time-to-completion (a survival/hazard model for wait), giving a calibrated tail at any quantile and letting a prediction update live as a task keeps waiting. Bet 3 — compose per-task distributions into a group-max completion ETA for the push.

Honest status. This remains a diagnostics/dashboard tool, not a broad Treeherder/UI ETA surface. The per-task tail and the group-composition story both have to stabilize first. Detailed phase history and the per-cohort experiment log follow below.

1. Decision (architecture current — 2026-04-29; live status updated 2026-05-20)

Wait_time: ship wait_time_residual_throughput_filtered_baseline.yaml (Policy B). Across 15 cohorts (Apr 15-29, E18) it never regressed baseline on MAE (worst Δ% −2.59%) or within-2x (worst +4.58pp), hits the 30m+ user-perceptibility goal on 14/15 cohorts, and improves p90 in-band ratio from 9/15 (unfiltered residual) to 11/15. The "regime fragility" diagnosed in E16 turned out to be baseline contamination: the unfiltered percentile baseline's 7-day history averaged over a shifted regime, dragging the residual reference wrong-on-current-data. Policy B excludes anomalous days (per queue_forecast_daily_health.is_anomalous) from that history; the residual model then corrects from a clean reference.

Run_duration: ship run_duration_residual.yaml. 15 cohorts in E18 (was 11 in E16): mean MAE Δ% −4.50%, p90 in-band 13/15, beats LGB-only on 8/10 MAE wins and 9/10 within-2x wins. No regime fragility on duration (already noted in E16).

Production deployment is live as of 2026-05-15. The Phase 3b core path (ONNX bundle loading, hierarchical baseline-stats refresh, throughput features, NOTIFY + catch-up, versioned audit writes) is deployed and producing predictions into queue_forecast_run_predictions. Several items from §5.3b are explicitly deferred or accepted as V1 limitations — see §5 "Phase 3b — core path closed 2026-05-15" for the gap list.

Live calibration status as of 2026-05-20: core serving is working, but p90/tail hardening is active. The updated aggregation dashboard shows completed-only calibration close to target overall (52.1% <= wait p50, 12.0% > wait p90; 49.1% <= run p50, 11.2% > run p90), but the misses are not evenly distributed. Long waits, weak baseline fallbacks, capacity-sensitive queues, and specific duration cohorts still produce user-visible underestimates. Recent baseline research also found that the residual p90 model can undercut a stronger historical p90 baseline. Payload research then split duration misses into no-signal cohorts, where the p90 guardrail is the right fix, and test-task cohorts, where richer tags.* identity fields already exist in the DB. Current status is "live and useful for investigation", not "ready for broad UI/ETA promotion".

Decision history

For per-cohort metrics and the regime-shift narrative supporting each step in the decision history, see §6 / E13, E16, E18.

2. Evidence (Phase 2 launch — historical)

The numbers below are from the original Phase 2 close (2026-04-23, 5-day holdout Apr 18-22). They show that the residual architecture validated against the percentile baseline on a single cohort. Cross-cohort robustness, regime-fragility diagnosis, and the Policy B remedy are all in §6 / E13, E16, E18. The current production decision is anchored to E18, not to this section.

Five-day holdout (Apr 18-22), cohort-matched, primary slice (reason_resolved = 'completed').

Run duration

MetricBaselineLGB-onlyResidualΔ vs BaselineSpec
MAE138.8s146.6s130.1s−6.3%≥5% ✅
within-2x88.7%89.1%89.7%+1.0pp(MAE primary)
p90 coverage88.0%87.9%[85, 95]% ✅

Phase 1 classified duration as a "clean miss" because LightGBM-only lost by +5.6% MAE to the baseline's metadata_name exact-match. Residual reverses that verdict — same memorization becomes an input to the model rather than a competitor to it.

Wait time

MetricBaselineLGB-onlyResidual (log_ratio)Δ vs BaselineSpec
MAE613.7s539.1s519.9s−15.3%≥15% ✅
within-2x51.7%42.7%54.6%+2.9pp+5pp ❌
p90 coverage94.2%85.8%[85, 95]% ✅ (edge)

Wait clears the MAE spec (−15.3%) and moves within-2x from regression to improvement, but the +2.9pp gain does not reach the original +5pp target. This is carried as a known gap, not a blocker.

Per-bucket wait breakdown

Bucketn%Base MAELGB MAERes MAEBase w/in-2xLGB w/in-2xRes w/in-2x
<1m357k50%32.0s35.9s29.4s43.3%23.5%44.8%
1-5m182k26%117.6s82.3s105.3s67.5%76.1%69.8%
5-30m127k18%423.2s455.5s478.3s62.3%53.7%65.1%
30m+43k6%8175s6956s6525s22.8%27.0%38.6%

Residual wins on 82% of the cohort (<1m, 1-5m, 30m+) on either MAE or within-2x or both. The one bucket where it loses to baseline on MAE (5-30m) is the smallest except for the tail.

30m+ ratio-accuracy is the production blocker. The 38.6% within-2x in the 30m+ bucket means ~61% of long-wait predictions are ratio-wrong by a user-visible margin — the kind of error where actual wait is 30 minutes but the predictor shows 1 minute. This is the specific gap that blocks production. The 30m+ bucket is 6% of volume but disproportionately the cases users care about most (long-running or delayed tasks where an ETA is most meaningful).

Transform variants (tested, rejected)

Both additive (y_t = y - bl) and log_diff (y_t = log1p(y) - log1p(bl)) were trained and evaluated against log_ratio:

3. Chosen design

Residual LightGBM with log_ratio transform + filtered-baseline history (Policy B for wait, no filter needed for duration). Both targets share the same residual shape:

Wait-only addition (Policy B): the percentile baseline's 7-day trailing history excludes days where queue_forecast_daily_health.is_anomalous = TRUE. This applies to both training (via --exclude-dates to predictor.js's NDJSON export) and serving (see Phase 3b serving contract in §5). Without this filter the residual architecture catastrophically degrades under regime drift (E16); with it, it is the strictly-best wait config (E18).

Production configs:

As of 2026-05-20, both active configs include Tier 1 tag identity fields already present in queue_forecast_tasks.tags: tags.test-suite, tags.test-platform, tags.test-variant, and tags.retrigger. These are config-only features; the Python and JS feature builders already support arbitrary tags.* keys.

4. Known gaps

The four gaps logged at Phase 2 close (2026-04-23) are largely resolved by Phase 3a's queue-velocity + throughput features (E10-E13) and Policy B baseline filtering (E18). Status as of 2026-04-29:

New gap discovered by E18:

Live gaps discovered after production deployment:

5. Next phase

Phase 3a — closed 2026-04-29

Phase 3a entered with two production blockers (30m+ ratio-accuracy, within-2x gap) and exited with both resolved. Closing summary:

Exit criteria met:

Phase 3b — production path (active)

Historical Phase 3b premise: the offline architecture decision was settled, and the remaining work was a serving system that respected the training/eval contract end-to-end. Live data later reopened p90 hardening work; see Phase 3c below.

3b.1 Serving contract (new — derived from E18 Policy B requirement)

Baseline computation in serving must mirror training/eval semantics exactly. Drift between offline and online breaks the residual reference and re-introduces the failure mode E18 fixed.

Cutoff convention. Live serving anchors the percentile-history window to the last completed UTC day, not now():

Excluded-date set. Read from queue_forecast_daily_health at baseline-cache-rebuild time:

SELECT sample_date FROM queue_forecast_daily_health
WHERE sample_date < {cutoff_date}
  AND is_anomalous = TRUE

The flag_subset consulted (default = all flags participating in is_anomalous_default, optionally narrowed) must match the trained model's anomaly_filter.flag_subset field. The model's bundle manifest records this, and the serving process refuses to load a bundle whose flag_subset disagrees with what the predictor would compute at runtime.

Refresh policy. Baseline percentile cache + excluded-date set rebuild on TTL = 1 hour. Rationale:

Failure modes (fail closed):

Fail-open (serving with stale data or bypassing the filter) re-introduces the regime-fragility risk E18 just closed.

3b.2 Model bundle format

A versioned directory per training run, atomically swapped via symlink:

trainer/data/models_bundles/v_2026-04-29_abc123/
  ├── wait_time_p50.onnx
  ├── wait_time_p90.onnx
  ├── run_duration_p50.onnx
  ├── run_duration_p90.onnx
  ├── category_mappings.json     # categorical encoder state per target
  ├── baseline_stats.json        # training-time baseline reference (for parity tests)
  └── bundle.json                # manifest below

bundle.json records:

Hot-reload watches trainer/data/models_bundles/current symlink; on change, re-loads ONNX models + category mappings + bundle.json atomically.

3b.3 Prediction audit fields

Schema additions to queue_forecast_run_predictions (additive migration):

ColumnTypePurpose
predictor_kindTEXTresidual_lightgbm_policy_b etc. — drives offline analysis grouping
model_versionTEXTbundle id at prediction time
baseline_cutoff_dateDATEUTC midnight used as percentile-history cutoff
baseline_excluded_datesDATE[]exact set of anomalous dates excluded at prediction time
health_max_computed_atTIMESTAMPTZmax computed_at across consulted health rows — for staleness debugging

These let any prediction be reproduced offline byte-for-byte: given the bundle, the cutoff, and the excluded-date set, the trainer can replay the exact baseline + model decision.

3b.4 Implementation tasks (in order)

Each task should produce its own design + test pass before the next starts; many have parity-validation requirements that don't compose well if rolled together.

Phase 3b — core path closed 2026-05-15

Live predictor service is deployed and producing predictions. Implementation lives under src/live-predictor/ (separate from src/predictor.js, which remains the backtest/baseline-export tool). Source: model-loader.js, feature-builder.js, baseline-stats.js, throughput.js, predict.js, index.js; 21 unit tests; integration via migrate.sql Stage 2 + collector NOTIFY hook + docker-compose live-predictor profile.

What was built (§5.3b.4 task numbering):

Deviations from §5.3b spec (V1 acceptances or follow-ups):

Open work items derived from the gaps above:

Phase 3c — live calibration hardening (active as of 2026-05-20)

The core predictor is live; the current work is making tail behavior explainable and preventing the model from worsening a stronger baseline.

Recommendation

Shipped (2026-05-15), with live calibration hardening active (2026-05-20). Policy B for wait and residual duration are both served via src/live-predictor/. The core serving path is closed, but the live p90 evidence changes the project status: do not promote this into broad Treeherder/UI ETA surfaces yet. First finish the p90 baseline-regression guardrails and wait tail diagnostics in Phase 3c.

The original risk framing — offline/online drift on baseline computation — is mostly mitigated: Policy B is correctly wired through the bundle (anomaly_filter in _feature_schema.jsonfilterFromSchema() in serving → <> ALL($2::date[]) clause in baseline SQL). The one residual risk is deviation 1 (cutoff anchored on now() not UTC-midnight), which leaves a 24h window for same-day incidents to slip into the baseline before health-monitor flags them. Worth fixing if any regime drift reproduces; not worth blocking shipping for.

The newer risk framing is p90 tail underestimation: a residual model can improve central tendency while still lowering a p90 that the historical baseline estimated better. Run-duration now has an exact-name p90 guardrail and tag features (test-suite, test-platform, test-variant, retrigger) for test-task identity. Wait-time has bl_wait_p90 as a training feature but tag features were reverted after walk-forward showed MAE regression — wait is queue/capacity-driven, not task-identity-driven. Next priorities: verify the run-duration guardrail in live data, evaluate a wait p90 serving guardrail, and add worker-capacity features for wait.


6. Experiment log

Append-only, reverse-chronological. Each entry records the exact config, cohort windows, aggregate and per-bucket metrics (wait-model only), the comparison reference, and the concrete next action it triggered.

Conventions:

2026-04-29 — Re-confirmation sweep on fresh GCP VM + Stage 2 infrastructure

E17: Walk-forward sweep across 14 cohorts (Apr 15 – Apr 28) on the new GCP host

First end-to-end sweep on the new dedicated GCP VM after migration off local development.

The sweep ran the three baseline configs only — wait_time, wait_time_residual,

wait_time_residual_throughput. Filtered-policy configs (_filtered,

_filtered_baseline, _filtered_both) and run_duration_residual were not part

of this run; they remain pending under E18 below.

Two cohort × config cells are missing: 2026-04-28 wait_time and

2026-04-28 wait_time_residual did not produce manifests (the Apr 28

wait_time_residual_throughput cohort completed). Likely a left-over from the

earlier walk-forward crash before the set -euo pipefail fix in

scripts/run_training.sh. Re-running walk_forward will fill them in.

Per-config summary (cohorts complete):

ConfignMean MAE Δ%Worst MAE Δ%Mean w/in2x ppWorst w/in2x ppp90 in-band30m+ ≥50%
wait_time (LGB-only)13−17.82%+39.37%−2.81pp−9.08pp12/138/13
wait_time_residual13−15.21%+6.67%−0.52pp−18.05pp9/138/13
wait_time_residual_throughput14−19.14%+3.08%+2.33pp−15.56pp8/149/14

Win counts (across the 13 cohorts where all three configs completed):

Metricwait_timewait_time_residualwait_time_residual_throughput
best_MAE616
best_within_2x409
best_30m+_within_2x904

Pattern matches E16. Throughput dominates within-2x, LGB-only dominates 30m+

tail, vanilla residual is dominated. The decision in §1 still holds:

residual_throughput is regime-fragile on p90 (8/14 in band), LGB-only is the

calibration-robust option (12/13 in band), hybrid remains the leading

sophisticated path.

The wait_time worst-MAE outlier of +39.37% reproduces from E16 — same value to

two decimals — confirming it is a single specific cohort in the Apr 15-27

overlap with E16. Worth identifying that cohort against

queue_forecast_daily_health to see whether its holdout overlaps the data-loss

days (Apr 25-26 with n_total = 0) or one of the regime-shifted days (Apr 23+).

E18: Stage 2 anomaly-filter sweep — Policy B reverses the unshippable verdict

Walk-forward across 14 cohorts (Apr 15-28) × 6 wait configs + 2 duration configs.

Anomaly filter sources from queue_forecast_daily_health.is_anomalous (12 days

flagged in the 35-day backfill: 2026-03-24, 03-27, 03-29, 04-04, 04-05, 04-09,

04-12, 04-18, 04-21, 04-23, 04-25, 04-26).

Wait time, full results (15 cohorts as of 2026-04-30; 14 at first sweep on 2026-04-29):

ConfignMean MAE Δ%Worst MAE Δ%Mean w/in2x ppWorst w/in2x ppp90 in-band30m+ ≥50%
wait_time (LGB-only)15−17.91%+39.37%−3.66pp−9.76pp14/158/15
wait_time_residual13−15.21%+6.67%−0.52pp−18.05pp9/138/13
wait_time_residual_throughput15−18.81%+3.08%+2.39pp−15.56pp9/159/15
..._filtered (Policy A: train+val filter)11−20.91%−2.12%+5.35pp+2.31pp6/115/11
..._filtered_baseline (Policy B: baseline only)15−21.50%−2.59%+6.56pp+4.58pp11/1514/15
..._filtered_both (Policy C: A+B)11−22.35%−5.52%+5.71pp+4.50pp7/117/11

The Apr 29 cohort (added 2026-04-30) extends the verdict: Policy B's worst-case MAE and within-2x are unchanged, p90 ticks up by one in-band cohort, 30m+ ≥50% stays at 93% (14/15). LGB-only drifts in the wrong direction on within-2x with each added cohort (mean now −3.66pp from −2.81pp at 13-cohort), reinforcing the choice not to ship it.

wait_time_residual remains stuck at 13/15 (Apr 28 + Apr 29 both produce no manifest) — same silent-crash mode noted as a low-priority pipeline-robustness issue in §4. Doesn't block Policy B.

Decisive observation: Policies A, B, C all eliminate the catastrophic regression

cohorts. The worst-case MAE Δ% goes from +3.08% (unfiltered) to −2.12% / −2.59% /

−5.52% — every filtered cohort beats baseline. The worst-case within-2x goes from

−15.56pp to +4.5pp — never below baseline.

Policy B specifically is the production candidate: it gets the same

worst-case improvements as A/C, but without skipping any cohorts (B does not

touch train/val, so empty-val never happens). 14/14 vs A/C's 10/14 means it has

3-4× more comparison data. It also achieves the best 30m+ ≥50% rate (13/14)

and the cleanest absolute numbers on the table.

The dominant effect is baseline contamination, not training-data contamination.

A and C touch train+val and gain very little over B. C marginally outperforms B

on worst-case MAE (−5.52% vs −2.59%) but at the cost of 4 lost cohorts and worse

on every other metric.

Why baseline filtering helps so much: the unfiltered baseline's 7-day

percentile history averaged across both regimes (pre-Apr-23 normal + Apr-23+

shifted). The log_ratio residual is anchored to baseline_p50; when baseline is

catastrophically wrong on the new regime's tail, residual inherits that

wrongness even with throughput features in the input set. With anomalous days

excluded from history, the baseline percentiles reflect actual current-regime

behavior, and the residual model corrects from a clean reference.

Run duration, full results (15 cohorts as of 2026-04-30):

ConfignMean MAE Δ%Worst MAE Δ%p90 in-bandBest MAE winsBest within-2x wins
run_duration (LGB-only)10+1.62%+13.03%10/102/101/10
run_duration_residual15−4.50%+12.03%13/158/109/10

Duration confirms residual ships. The 14-cohort version of E16's 11-cohort sweep:

same conclusion, more cohorts, no regime fragility.

Apr 28 wait_time / wait_time_residual missing manifests (`ls

trainer/data/models/2026-04-28/` shows only the 4 throughput-family configs +

duration_residual, no wait_time / wait_time_residual). Cause unknown — these

configs don't have anomaly_filter blocks so the explicit skip path doesn't

trigger. Likely a silent crash on a regime-shifted holdout that the trainer

didn't catch as a known skip case. Worth investigating directly with verbose

stderr; doesn't affect E18's conclusions because Policy B (the production

candidate) completed all 14 cohorts.

Implications for production:

Policy B baseline filtering is strictly better than every alternative on

the metrics that matter (worst-case MAE, worst-case within-2x, 30m+

accuracy). No hybrid needed.

--exclude-dates; the production serving path needs to consult

queue_forecast_daily_health.is_anomalous at baseline-computation time.

This wasn't expected pre-E18 — adds a small wrinkle to Phase 3b.

set up as a research tool; it's now a production dependency. The

health-monitor service running hourly must stay healthy in production.

without identifying what shifted on Apr 22-23, Policy B handles the

contamination automatically. Investigating remains useful for ops

awareness but no longer blocks shipping.

Next actions:

block production).

in serving path, versioned writes, hot-reload).

added them; not yet enabled in is_anomalous default subset).

2026-04-27 — Walk-forward extension reveals regime drift

E16: Walk-forward sweep across 14 cohorts (Apr 14 – Apr 27) + duration sweep across 11 cohorts (Apr 17 – Apr 27)

Extended E13 with 3 more recent cohorts (Apr 25, 26, 27). Added duration sweep at the same time. Reverses the E13 conclusion that residual_throughput is a viable single-model production candidate.

Wait time, 14 cohorts:

ConfigMean MAE Δ%Worst MAE Δ%Mean w/in2x ppWorst w/in2x ppp90 in-bandBest 30m+ wins
wait_time (LGB-only)−20.10%+39.37%−2.71pp−9.08pp13/1410/14
wait_time_residual−17.24%+6.67%−0.13pp−18.05pp10/140/14
wait_time_residual_throughput−20.80%+3.08%+2.28pp−15.56pp9/144/14

Win counts (14 cohorts):

The decisive new evidence: p90 coverage by cohort for residual variants. Stable 0.87-0.89 through Apr 22, then monotonic decline:

        LGB-only   Residual   Throughput
Apr 22  0.916      0.873      0.870
Apr 23  0.942      0.858      0.849
Apr 24  0.865      0.737      0.733
Apr 25  0.877      0.659      0.667
Apr 26  0.881      0.616      0.624
Apr 27  0.864      0.517      0.547

LGB-only stays in band; residual variants drop to ~52% p90 coverage by Apr 27. Within-2x regressions concentrate similarly: residual configs regress on Apr 25/26/27; LGB-only's regressions are scattered (Apr 15, 16, 19, 21, 22, 23, 26).

Conclusion: regime drift broke the residual architecture. The percentile baseline averages over a regime that has shifted; the log_ratio residual cannot push predictions far enough up from the baseline reference to catch the new long-wait reality. LGB-only is unanchored and adapts.

Run duration, 11 cohorts:

ConfigMean MAE Δ%Worst MAE Δ%p90 in-bandBest MAE winsBest within-2x wins
run_duration (LGB-only)+1.48% (regression)+13.03%11/112/111/11
run_duration_residual−3.93%+12.03%11/119/1110/11

Duration shows no regime fragility — both configs maintain p90 calibration across all cohorts. Residual is the cleaner choice. Phase 2 conclusion holds for duration.

Implications:

Next actions:

2026-04-24 — Phase 3a feature work

E13: Walk-forward sweep across 11 cohorts (Apr 14 – Apr 24) ⚠ SUPERSEDED by E16

The E13 conclusions ("ship throughput as default") have been superseded by E16's extended sweep. The "throughput is robust" property held only on cohorts where the holdout did not include post-Apr-22 dates. Once 4 more cohorts were added (Apr 24-27), residual variants showed catastrophic p90 collapse and within-2x regression. Keep E13 as historical context but do not treat it as current guidance.

The decisive experiment for the regime question. Three configs × 11 cohorts × each holdout a 5-day window ending on the cohort's as_of_date.

Per-config summary (11 complete cohorts each):

ConfigMAE Δ% meanMAE Δ% worstw/in2x pp meanw/in2x pp worstp90 cov meanp90 in-band30m+ w/in2x mean30m+ ≥50%
wait_time (LGB-only)−27.87%−0.12%−3.28−9.08pp92.01%10/1156.69%8/11
wait_time_residual (log_ratio)−21.25%+6.67% (regressed)+3.08−1.42pp87.08%10/1153.14%9/11
wait_time_residual_throughput−24.56%−0.22%+5.46+0.98pp86.63%9/1154.70%9/11

Per-cohort win counts (of 11 cohorts):

Metricwait_timewait_time_residualwait_time_residual_throughput
best_MAE614
best_within_2x1010
best_30m+_within_2x704

Conclusions:

Candidate production paths (in order of preference):

A. Ship residual_throughput as the single wait model. Rationale: best-in-class on within-2x (the user-perceptibility metric), never worse than baseline on MAE, meets 30m+ target on 9/11 cohorts. Concedes ~2pp of 30m+ win-rate to LGB-only but avoids LGB-only's 9pp within-2x downside risk. Simple serving path. Recommended.

B. Hybrid: residual_throughput + LGB-only for long-predicted waits. When the primary (residual_throughput) predicts above a threshold (e.g. ≥20m), run LGB-only too and use the higher prediction (or a blend). Closes the 30m+ gap at the cost of two models in the serving path. Re-visit if A's 30m+ miss rate (~2/11) is unacceptable.

C. More feature work. Tree-status, landing-queue, queue-level historical drift. Diminishing-returns territory after throughput already closed most of the gap.

Duration (1 cohort only — need more runs for stability): run_duration_residual wins MAE (−6.27%) vs run_duration (+5.63% regression). Consistent with the single-cohort E7 result from yesterday. Residual is still the answer for duration, but we haven't stress-tested it across cohorts the way we just did for wait.

Next action: if A is acceptable, proceed to production path work (Phase 3b). If you want the hybrid, Phase 3a gets a Phase 3a-8 item for the ensemble design.

Same-cohort comparison on Apr 19-23 (summary across E10, E11, E12)

All three runs on identical windows (train [2026-04-04, 2026-04-18), val 2026-04-18, holdout [2026-04-19, 2026-04-24), hold=892k rows, primary slice completed-only).

ConfigMAEw/in-2xp90cov<1m MAE<1m w/in2x30m+ MAE30m+ w/in2x
Baseline811.9s48.5%56.1s41.8%6925s17.8%
LGB-only (E12)748.5s54.3%86.5%116.7s41.4%5153s65.9%
Residual log_ratio (E11)737.9s47.0%73.7%28.2s41.8%6328s26.2%
Residual + throughput (E10)706.8s49.4%73.3%22.8s43.2%6141s26.0%

Plot twist: LGB-only dominates 30m+ within-2x on this cohort (65.9% vs 26.2% for residual) — the reverse of yesterday's Apr 18-22 result where residual won the tail.

Interpretation (working theory — regime hypothesis):

Implications:

Candidate next directions:

E12: wait_time.yaml (LGB-only, no residual) — Apr 19-23 cohort

bucketnbase MAElgb MAEbase w/in2xlgb w/in2x
<1m389,48856.1s116.7s41.8%41.4%
1-5m201,803175.9s277.2s64.7%69.9%
5-30m141,964517.5s794.6s60.0%60.3%
30m+75,9786925s5153s17.8%65.9%

E11: wait_time_residual.yaml on today's cohort (re-run of Phase 2 winner)

bucketnbase MAEres MAEbase w/in2xres w/in2x
<1m389,48856.1s28.2s41.8%41.8%
1-5m201,803175.9s114.8s64.7%60.2%
5-30m141,964517.5s578.6s60.0%52.8%
30m+75,9786925s6328s17.8%26.2%

E10: wait_time_residual_throughput.yaml (Apr 19-23 cohort)

First run with DB-derived throughput/drain features: queue_tasks_started_{15,60}m, queue_tasks_completed_{15,60}m, queue_avg_wait_{15,60}m, queue_avg_run_time_{15,60}m (leakage-gated to resolved_at < pending_at). Per-row loops vectorized via np.searchsorted over per-queue cumulative arrays (earlier impl took ~20 min, vectorized ~0.5s at 100k rows).

bucketnbase MAElgb MAEbase w/in2xlgb w/in2x
<1m389,48856.1s22.8s (−59%)41.8%43.2%
1-5m201,803175.9s100.2s (−43%)64.7%65.7%
5-30m141,964517.5s537.6s (+4%)60.0%54.7%
30m+75,9786925s6141s (−11%)17.8%26.0%

E9: backfill_claimed_tasks.py (infra, not a model run)

Computed historical claimed_tasks from queue_forecast_task_runs and wrote to queue_forecast_worker_counts with source='db_derived' for the full data range. Used generate_series over 5-min steps joined to runs where started_at ≤ T AND (resolved_at > T OR resolved_at IS NULL). Replaces Prometheus backfill path (no API access).

E8: worker-counter service launched

Live 5-min polling of worker-manager.listWorkerPoolsStats started. Initial sample: 558 rows, 531 dynamic pools + 122 static pools classified in queue_forecast_worker_pools. Source column value tc_api. Live collection ongoing.

2026-04-23 — Phase 2 residual experiments

E7: run_duration_residual.yaml

E6: wait_time_residual_logdiff.yaml (log_diff transform)

E5: wait_time_residual_additive.yaml (additive transform)

bucketbase MAEadd MAEbase w/in2xadd w/in2x
<1m32.0s39.9s43.3%43.1%
1-5m117.6s136.8s67.5%68.3%
5-30m423.2s528.0s62.3%63.1%
30m+8175s6652s22.8%45.3%

E4: wait_time_residual.yaml (log_ratio transform) — the Phase 2 incumbent winner

bucketnbase MAEres MAEbase w/in2xres w/in2x
<1m357k32.0s29.4s43.3%44.8%
1-5m182k117.6s105.3s67.5%69.8%
5-30m127k423.2s478.3s62.3%65.1%
30m+43k8175s6525s22.8%38.6%

E3: run_duration.yaml (LGB-only, Phase 1)

E2: wait_time.yaml (LGB-only, Phase 1)

E1: Baseline percentile predictor — predictor.js --pending-eval-date

Reference point, not a model run. Per-day JSONs under trainer/data/baseline/*.json. Aggregate over Apr 18-22, completed-only:

Shared observations (valid across experiments)