"""Coach API: recipes / bench / compile / cost / health."""
from __future__ import annotations
from fastapi.testclient import TestClient
from mindxtrain.operator.app import app
client = TestClient(app)
def test_root_redirects_to_coach():
r = client.get("/", follow_redirects=False)
assert r.status_code in (302, 307)
assert r.headers["location"] == "/coach/"
def test_coach_index_serves_html():
r = client.get("/coach/")
assert r.status_code == 200
assert "
mindXtrain" in r.text
assert "/coach/static/coach.js" in r.text
def test_coach_static_files_served():
r_css = client.get("/coach/static/style.css")
r_js = client.get("/coach/static/coach.js")
assert r_css.status_code == 200
assert r_js.status_code == 200
assert "AMD orange" in r_css.text or "--accent" in r_css.text
assert "loadRecipes" in r_js.text
def test_coach_index_includes_receipt_card():
r = client.get("/coach/")
assert r.status_code == 200
assert 'id="step-receipt"' in r.text
assert 'id="receipt-badge"' in r.text
# Chat re-probe affordance.
assert 'id="chat-recheck"' in r.text
def test_coach_js_wires_receipt_loader():
r = client.get("/coach/static/coach.js")
assert r.status_code == 200
assert "loadReceiptForRun" in r.text
assert "/coach/api/receipt/" in r.text
def test_coach_train_diagnostics_accordions():
r = client.get("/coach/")
assert r.status_code == 200
# Per-step metrics table is now a collapsible accordion with a live count.
assert 'id="metrics-table-wrap"' in r.text
assert 'id="metrics-table-count"' in r.text
assert 'id="train-log-count"' in r.text
assert 'id="chart-window-note"' in r.text
def test_coach_js_caps_and_counts():
r = client.get("/coach/static/coach.js")
assert r.status_code == 200
# Honest compression: rolling chart window + per-step/line counters.
assert "MAX_CHART_POINTS" in r.text
assert "_updateMetricsTableCount" in r.text
assert "_updateLogCount" in r.text
def test_coach_index_has_create_script_card():
r = client.get("/coach/")
assert r.status_code == 200
assert 'id="step-create-dataset"' in r.text
assert 'id="ds-save"' in r.text
assert 'id="ds-exchanges"' in r.text
def test_coach_js_wires_create_dataset():
r = client.get("/coach/static/coach.js")
assert r.status_code == 200
assert "wireCreateDataset" in r.text
assert "/coach/api/datasets" in r.text
def test_recipes_list_includes_known_recipes():
r = client.get("/coach/api/recipes")
assert r.status_code == 200
items = r.json()
assert len(items) >= 12
names = {item["name"] for item in items}
assert "qwen3_8b_sft_lora" in names
assert "instella_3b_lora" in names
assert "mindx_fallback_qwen3_1_5b_sft_lora" in names
assert "mindx_fallback_qwen3_1_5b_cpu_smoke" in names
for item in items:
assert "base_model" in item
assert "method" in item
assert "gpus" in item
def test_recipe_detail_returns_yaml_and_summary():
r = client.get("/coach/api/recipes/qwen3_8b_sft_lora")
assert r.status_code == 200
data = r.json()
assert "yaml" in data
assert "Qwen/Qwen3-8B" in data["yaml"]
assert data["summary"]["base_model"] == "Qwen/Qwen3-8B"
assert data["summary"]["method"] == "lora"
def test_recipe_detail_404_for_unknown():
r = client.get("/coach/api/recipes/does_not_exist")
assert r.status_code == 404
def test_bench_returns_autotune_plan():
r = client.post("/coach/api/bench")
assert r.status_code == 200
plan = r.json()
assert plan["schema_version"] == "1"
assert plan["gpu_arch"] == "gfx942"
assert plan["attention_backend"] in ("ck", "triton")
def test_compile_returns_axolotl_yaml_and_overrides():
r = client.post(
"/coach/api/compile",
json={"recipe": "qwen3_8b_sft_lora"},
)
assert r.status_code == 200
data = r.json()
assert data["recipe"] == "qwen3_8b_sft_lora"
assert data["axolotl_yaml"]["base_model"] == "Qwen/Qwen3-8B"
assert data["axolotl_yaml"]["adapter"] == "lora"
assert any("attention_backend" in o for o in data["overrides"])
def test_compile_404_for_unknown_recipe():
r = client.post("/coach/api/compile", json={"recipe": "ghost"})
assert r.status_code == 404
def test_cost_returns_three_breakdowns():
r = client.post("/coach/api/cost", json={"gpus": 1, "hours": 1.5})
assert r.status_code == 200
data = r.json()
for key in ("mi300x", "h100", "h200"):
assert key in data
assert data[key]["cost_usdc"] > 0
# MI300X must come out cheapest in this configuration.
assert data["mi300x"]["cost_usdc"] < data["h100"]["cost_usdc"]
assert data["speedup_vs_h100_x"] > 1.0
assert data["mi300x"]["fits_qwen3_8b_bf16_bs8_seq4096"] is True
assert data["h100"]["fits_qwen3_8b_bf16_bs8_seq4096"] is False
def test_cost_validates_input():
r = client.post("/coach/api/cost", json={"gpus": 0, "hours": 1.5})
assert r.status_code == 422
def test_cost_calculator_generalizes_and_includes_a100():
# Default 8B full FT: needs ~137 GB → MI300X/H200 fit, H100/A100 don't.
r = client.post("/coach/api/cost", json={"gpus": 1, "hours": 1.0})
assert r.status_code == 200, r.text
d = r.json()
assert "a100" in d
assert d["needed_vram_gb"] > 100
assert d["mi300x"]["fits_qwen3_8b_bf16_bs8_seq4096"] is True
assert d["a100"]["fits_qwen3_8b_bf16_bs8_seq4096"] is False
assert len(d["comparisons"]) == 4
assert d["cheapest_that_fits"]
# A tiny LoRA workload fits everywhere.
r2 = client.post("/coach/api/cost", json={
"gpus": 1, "hours": 1.0, "params_b": 0.135, "method": "lora", "seq_len": 256, "batch": 1,
})
d2 = r2.json()
assert d2["a100"]["fits_qwen3_8b_bf16_bs8_seq4096"] is True
assert d2["needed_vram_gb"] < d["needed_vram_gb"]
def test_cost_card_hidden_recipe_default_present():
html = client.get("/coach/").text
# Cost card is kept in the background but not displayed.
assert 'id="step-cost" class="card" data-step-id="step-cost" hidden' in html
# Recipe picker shows a default + an accordion of the rest.
assert 'id="recipe-default"' in html
assert 'id="recipe-more"' in html
js = client.get("/coach/static/coach.js").text
assert "renderDefaultRecipe" in js
assert "DEFAULT_RECIPE" in js
def test_health_endpoint_reports_recipes_count(monkeypatch):
# Force the auto-detect probe off so the legacy "no live backend" shape
# holds regardless of whether ollama happens to be running on the host
# executing the suite. Backend-ready specifics are covered by
# tests/test_ollama_backend.py.
from mindxtrain.operator import app as operator_app
monkeypatch.delenv("MINDXTRAIN_BACKEND", raising=False)
monkeypatch.delenv("AUTOMINDX_BACKEND", raising=False)
monkeypatch.setattr(operator_app, "_ollama_reachable", lambda: False)
r = client.get("/coach/api/health")
assert r.status_code == 200
data = r.json()
assert data["recipes_available"] >= 12
assert data["chat_backend_ready"] is False
def test_app_health_mentions_coach_url():
r = client.get("/health")
assert r.status_code == 200
assert r.json()["coach_url"] == "/coach/"