"""Tests for POST /project (UMAP dimensionality reduction).""" from __future__ import annotations from contextlib import asynccontextmanager import numpy as np import pytest from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.testclient import TestClient @pytest.fixture() def client(): """Lightweight app with core router only (no model load).""" from backend.routers.core import router as core_router @asynccontextmanager async def _noop_lifespan(_app): yield app = FastAPI(lifespan=_noop_lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) app.include_router(core_router) app.include_router(core_router, prefix="/api") with TestClient(app) as c: yield c def _random_vectors(n: int = 24, dim: int = 32, seed: int = 0) -> list[list[float]]: rng = np.random.default_rng(seed) mat = rng.standard_normal((n, dim)).astype(np.float64) norms = np.linalg.norm(mat, axis=1, keepdims=True) norms[norms == 0] = 1e-9 return (mat / norms).tolist() def test_project_rejects_empty_vectors(client: TestClient): res = client.post( "/project", json={"vectors": [], "method": "umap", "n_components": 3, "seed": 42}, ) assert res.status_code == 400 assert "empty" in res.json()["detail"].lower() or "1" in res.json()["detail"] def test_project_rejects_too_many_vectors(client: TestClient): vecs = _random_vectors(n=2, dim=4) bloated = vecs * 513 # 1026 res = client.post( "/project", json={"vectors": bloated, "method": "umap", "n_components": 3}, ) assert res.status_code == 400 assert "1024" in res.json()["detail"] def test_project_rejects_inconsistent_dims(client: TestClient): res = client.post( "/project", json={ "vectors": [[1.0, 0.0], [0.0, 1.0, 0.0]], "method": "umap", "n_components": 2, }, ) assert res.status_code == 400 assert "dim" in res.json()["detail"].lower() def test_project_rejects_invalid_n_components(client: TestClient): res = client.post( "/project", json={"vectors": _random_vectors(8, 8), "method": "umap", "n_components": 4}, ) assert res.status_code == 422 # pydantic Field constraint @pytest.mark.parametrize("method", ["pca", "tsne"]) def test_project_pca_tsne_not_implemented(client: TestClient, method: str): res = client.post( "/project", json={ "vectors": _random_vectors(12, 8), "method": method, "n_components": 3, "seed": 42, }, ) assert res.status_code == 501 detail = res.json()["detail"].lower() assert method in detail assert "not implemented" in detail or "umap" in detail def test_project_rejects_unknown_method(client: TestClient): res = client.post( "/project", json={ "vectors": _random_vectors(12, 8), "method": "mds", "n_components": 3, }, ) assert res.status_code == 400 assert "umap" in res.json()["detail"].lower() def test_project_umap_smoke_3d_seeded(client: TestClient): vectors = _random_vectors(n=20, dim=16, seed=7) payload = { "vectors": vectors, "method": "umap", "n_components": 3, "seed": 42, "params": {"n_neighbors": 5, "min_dist": 0.1, "metric": "cosine"}, } res = client.post("/project", json=payload) assert res.status_code == 200, res.text data = res.json() assert data["method"] == "umap" assert data["n_components"] == 3 assert len(data["positions"]) == 20 assert all(len(p) == 3 for p in data["positions"]) meta = data["meta"] assert meta["seed"] == 42 assert meta["n_neighbors"] == 5 assert meta["min_dist"] == pytest.approx(0.1) assert meta["metric"] == "cosine" # Server-side normalize: approximately zero-mean pos = np.asarray(data["positions"], dtype=np.float64) means = pos.mean(axis=0) assert np.allclose(means, 0.0, atol=1e-5) # Same inputs → same positions (seeded) res2 = client.post("/project", json=payload) assert res2.status_code == 200 pos2 = np.asarray(res2.json()["positions"], dtype=np.float64) assert np.allclose(pos, pos2, atol=1e-5) def test_project_umap_n_components_2(client: TestClient): vectors = _random_vectors(n=16, dim=12, seed=3) res = client.post( "/project", json={ "vectors": vectors, "method": "umap", "n_components": 2, "seed": 42, "params": {"n_neighbors": 5}, }, ) assert res.status_code == 200 data = res.json() assert data["n_components"] == 2 assert all(len(p) == 2 for p in data["positions"]) def test_project_umap_pre_pca_when_high_dim(client: TestClient): """dim > 50 triggers internal PCA→50 before UMAP; meta reports pre_pca_dims.""" vectors = _random_vectors(n=60, dim=128, seed=1) res = client.post( "/project", json={ "vectors": vectors, "method": "umap", "n_components": 3, "seed": 42, "params": {"n_neighbors": 8}, }, ) assert res.status_code == 200, res.text meta = res.json()["meta"] assert meta["pre_pca_dims"] == 50 def test_project_small_n_one_vector(client: TestClient): """Token Comparison with a single token must still project (Galaxy path).""" vectors = _random_vectors(n=1, dim=32, seed=2) res = client.post( "/project", json={"vectors": vectors, "method": "umap", "n_components": 3, "seed": 42}, ) assert res.status_code == 200, res.text data = res.json() assert len(data["positions"]) == 1 assert len(data["positions"][0]) == 3 assert data["meta"]["fallback"] == "origin" assert data["positions"][0] == pytest.approx([0.0, 0.0, 0.0]) def test_project_small_n_two_vectors_pair(client: TestClient): """Two tokens (e.g. White, Blanco) — UMAP cannot run; PCA micro-layout.""" vectors = _random_vectors(n=2, dim=64, seed=9) payload = { "vectors": vectors, "method": "umap", "n_components": 3, "seed": 42, } res = client.post("/project", json=payload) assert res.status_code == 200, res.text data = res.json() assert len(data["positions"]) == 2 assert all(len(p) == 3 for p in data["positions"]) assert data["meta"]["fallback"] == "pca_micro" assert data["meta"]["pca_components"] == 1 pos = np.asarray(data["positions"], dtype=np.float64) assert np.allclose(pos.mean(axis=0), 0.0, atol=1e-5) # Distinct points on an axis after normalize assert float(np.linalg.norm(pos[0] - pos[1])) > 0.5 res2 = client.post("/project", json=payload) assert res2.status_code == 200 assert np.allclose(pos, np.asarray(res2.json()["positions"]), atol=1e-5) def test_project_module_unit_rejects_method(): from backend.projection import ProjectError, project_embeddings with pytest.raises(ProjectError) as ei: project_embeddings( _random_vectors(10, 8), method="pca", n_components=3, seed=42, ) assert ei.value.status_code == 501