NeuroScope / backend_test.py
Gaurav711's picture
feat: Migrate NeuroScope v2 to Firebase and Google Gemini Explainer
ca6c0e5
Raw
History Blame Contribute Delete
24 kB
"""
NeuroScope Backend API Test Suite
Tests all 12 user stories for the mechanistic interpretability platform.
"""
import requests
import time
import sys
from pathlib import Path
class NeuroScopeAPITester:
def __init__(self, base_url="http://localhost:8000"):
self.base_url = base_url
self.tests_run = 0
self.tests_passed = 0
self.test_results = []
self.created_run_id = None
def log_test(self, name, passed, details=""):
"""Log test result"""
self.tests_run += 1
if passed:
self.tests_passed += 1
print(f"✅ PASS: {name}")
else:
print(f"❌ FAIL: {name}")
if details:
print(f" {details}")
self.test_results.append({
"name": name,
"passed": passed,
"details": details
})
def test_us11_health(self):
"""US-11: Health endpoint returns status ok and model info"""
try:
response = requests.get(f"{self.base_url}/api/v1/health", timeout=10)
if response.status_code == 200:
data = response.json()
if data.get("status") == "ok" and "model" in data:
model_info = data["model"]
if 'gemma' in model_info.get('model', '').lower():
self.log_test("US-11: Health endpoint", True,
f"Model: {model_info.get('model')}, layers: {model_info.get('n_layers')}")
return True
else:
self.log_test("US-11: Health endpoint", False,
f"Expected Gemma model, got {model_info.get('model')}")
else:
self.log_test("US-11: Health endpoint", False,
f"Missing status or model in response: {data}")
else:
self.log_test("US-11: Health endpoint", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-11: Health endpoint", False, f"Exception: {str(e)}")
return False
def test_us10_suggested_tasks(self):
"""US-10: GET /api/v1/suggested-tasks returns 6 categorized tasks"""
try:
response = requests.get(f"{self.base_url}/api/v1/suggested-tasks", timeout=10)
if response.status_code == 200:
data = response.json()
tasks = data.get("tasks", [])
if len(tasks) == 6:
categories = [t.get("category") for t in tasks]
self.log_test("US-10: Suggested tasks", True,
f"Got {len(tasks)} tasks with categories: {', '.join(categories)}")
return True
else:
self.log_test("US-10: Suggested tasks", False,
f"Expected 6 tasks, got {len(tasks)}")
else:
self.log_test("US-10: Suggested tasks", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-10: Suggested tasks", False, f"Exception: {str(e)}")
return False
def test_us6_list_experiments(self):
"""US-6: GET /api/v1/experiments returns 5 experiments with hypothesis + finding"""
try:
response = requests.get(f"{self.base_url}/api/v1/experiments", timeout=10)
if response.status_code == 200:
data = response.json()
experiments = data.get("experiments", [])
if len(experiments) == 5:
# Check that each has hypothesis and finding
all_valid = all(
exp.get("hypothesis") and exp.get("finding")
for exp in experiments
)
if all_valid:
slugs = [exp.get("slug") for exp in experiments]
self.log_test("US-6: List experiments", True,
f"Got 5 experiments: {', '.join(slugs)}")
return True
else:
self.log_test("US-6: List experiments", False,
"Some experiments missing hypothesis or finding")
else:
self.log_test("US-6: List experiments", False,
f"Expected 5 experiments, got {len(experiments)}")
else:
self.log_test("US-6: List experiments", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-6: List experiments", False, f"Exception: {str(e)}")
return False
def test_us7_get_experiment(self):
"""US-7: GET /api/v1/experiments/{slug} returns full experiment (using hallucination-propagation)"""
try:
# Note: Test spec asked for 'agentic-mechanistic' but that slug doesn't exist
# Using 'hallucination-propagation' instead (one of the 5 seeded experiments)
response = requests.get(
f"{self.base_url}/api/v1/experiments/hallucination-propagation",
timeout=10
)
if response.status_code == 200:
data = response.json()
required_fields = ["steps", "feature_timelines", "patch_matrix", "finding"]
missing = [f for f in required_fields if f not in data]
if not missing:
n_steps = len(data.get("steps", []))
n_features = len(data.get("feature_timelines", []))
n_patches = len(data.get("patch_matrix", []))
self.log_test("US-7: Get full experiment", True,
f"steps={n_steps}, features={n_features}, patches={n_patches}")
return True
else:
self.log_test("US-7: Get full experiment", False,
f"Missing fields: {missing}")
else:
self.log_test("US-7: Get full experiment", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-7: Get full experiment", False, f"Exception: {str(e)}")
return False
def test_us1_create_run(self):
"""US-1: POST /api/v1/runs creates and completes a run within ~60s"""
try:
payload = {
"task": "What is 2+2?",
"n_steps": 3,
"sae_layer": 12
}
print(f"\n🔄 Creating run with task: '{payload['task']}' (n_steps={payload['n_steps']})")
print(" This will take ~30-60 seconds...")
response = requests.post(
f"{self.base_url}/api/v1/runs",
json=payload,
timeout=10
)
if response.status_code != 200:
self.log_test("US-1: Create run", False,
f"Failed to create run: {response.status_code}")
return False
data = response.json()
run_id = data.get("run_id")
if not run_id:
self.log_test("US-1: Create run", False, "No run_id in response")
return False
self.created_run_id = run_id
print(f" Run created: {run_id}")
# Poll for completion (max 90 seconds)
start_time = time.time()
max_wait = 90
status = "queued"
while time.time() - start_time < max_wait:
time.sleep(3)
check_response = requests.get(
f"{self.base_url}/api/v1/runs/{run_id}",
timeout=10
)
if check_response.status_code == 200:
run_data = check_response.json()
status = run_data.get("status")
progress = run_data.get("progress", {})
completed_steps = progress.get("completed_steps", 0)
print(f" Status: {status}, completed_steps: {completed_steps}")
if status == "done":
elapsed = time.time() - start_time
self.log_test("US-1: Create run", True,
f"Run completed in {elapsed:.1f}s")
return True
elif status == "error":
error = run_data.get("error", "Unknown error")
self.log_test("US-1: Create run", False,
f"Run failed with error: {error}")
return False
self.log_test("US-1: Create run", False,
f"Run did not complete within {max_wait}s (status: {status})")
return False
except Exception as e:
self.log_test("US-1: Create run", False, f"Exception: {str(e)}")
return False
def test_us2_get_run(self):
"""US-2: GET /api/v1/runs/{id} returns full trajectory"""
if not self.created_run_id:
self.log_test("US-2: Get run details", False, "No run_id available")
return False
try:
response = requests.get(
f"{self.base_url}/api/v1/runs/{self.created_run_id}",
timeout=10
)
if response.status_code == 200:
data = response.json()
required_fields = ["steps", "feature_timelines", "progress"]
missing = [f for f in required_fields if f not in data]
if not missing:
steps = data.get("steps", [])
if len(steps) > 0:
# Check first step has required fields
step = steps[0]
step_fields = ["layer_l2_norms", "top_features", "hallucination", "activation_path"]
missing_step_fields = [f for f in step_fields if f not in step]
if not missing_step_fields:
self.log_test("US-2: Get run details", True,
f"Got {len(steps)} steps with all required fields")
return True
else:
self.log_test("US-2: Get run details", False,
f"Step missing fields: {missing_step_fields}")
else:
self.log_test("US-2: Get run details", False, "No steps in run")
else:
self.log_test("US-2: Get run details", False,
f"Missing fields: {missing}")
else:
self.log_test("US-2: Get run details", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-2: Get run details", False, f"Exception: {str(e)}")
return False
def test_us3_single_patch(self):
"""US-3: POST /api/v1/runs/{id}/patch returns KL, significant, token_changes, interpretation"""
if not self.created_run_id:
self.log_test("US-3: Single patch", False, "No run_id available")
return False
try:
payload = {
"source_step": 1,
"target_step": 2,
"patch_layer": 12
}
print(f"\n🔄 Running single patch (source=1, target=2, layer=12)...")
print(" This may take 10-20 seconds...")
response = requests.post(
f"{self.base_url}/api/v1/runs/{self.created_run_id}/patch",
json=payload,
timeout=30
)
if response.status_code == 200:
data = response.json()
required_fields = ["kl", "significant", "token_changes", "interpretation"]
missing = [f for f in required_fields if f not in data]
if not missing:
kl = data.get("kl")
significant = data.get("significant")
token_changes = data.get("token_changes", [])
self.log_test("US-3: Single patch", True,
f"KL={kl:.4f}, significant={significant}, token_changes={len(token_changes)}")
return True
else:
self.log_test("US-3: Single patch", False,
f"Missing fields: {missing}")
else:
self.log_test("US-3: Single patch", False,
f"Status code: {response.status_code}, response: {response.text}")
except Exception as e:
self.log_test("US-3: Single patch", False, f"Exception: {str(e)}")
return False
def test_us4_patch_matrix(self):
"""US-4: POST /api/v1/runs/{id}/patch-matrix returns 18+ results across 3 layers"""
if not self.created_run_id:
self.log_test("US-4: Patch matrix", False, "No run_id available")
return False
try:
payload = {
"layers": [6, 12, 18]
}
print(f"\n🔄 Running patch matrix sweep across layers [6, 12, 18]...")
print(" This may take 30-60 seconds...")
response = requests.post(
f"{self.base_url}/api/v1/runs/{self.created_run_id}/patch-matrix",
json=payload,
timeout=90
)
if response.status_code == 200:
data = response.json()
patch_matrix = data.get("patch_matrix", [])
layers = data.get("layers", [])
if len(patch_matrix) >= 18:
significant_count = sum(1 for p in patch_matrix if p.get("significant"))
self.log_test("US-4: Patch matrix", True,
f"Got {len(patch_matrix)} patches, {significant_count} significant")
return True
else:
self.log_test("US-4: Patch matrix", False,
f"Expected >=18 patches, got {len(patch_matrix)}")
else:
self.log_test("US-4: Patch matrix", False,
f"Status code: {response.status_code}, response: {response.text}")
except Exception as e:
self.log_test("US-4: Patch matrix", False, f"Exception: {str(e)}")
return False
def test_us5_query(self):
"""US-5: POST /api/v1/runs/{id}/query returns grounded NL answer"""
if not self.created_run_id:
self.log_test("US-5: NL Query", False, "No run_id available")
return False
try:
payload = {
"query": "What features were most active in step 1?"
}
print(f"\n🔄 Running NL query: '{payload['query']}'...")
print(" This may take 10-20 seconds (LLM call)...")
response = requests.post(
f"{self.base_url}/api/v1/runs/{self.created_run_id}/query",
json=payload,
timeout=30
)
if response.status_code == 200:
data = response.json()
answer = data.get("answer", "")
if answer and len(answer) > 20:
# Check if answer mentions layers/features/steps
grounded = any(word in answer.lower() for word in ["layer", "feature", "step"])
if grounded:
self.log_test("US-5: NL Query", True,
f"Got grounded answer ({len(answer)} chars)")
return True
else:
self.log_test("US-5: NL Query", False,
"Answer doesn't appear grounded in run artifacts")
else:
self.log_test("US-5: NL Query", False,
f"Answer too short or missing: {answer}")
else:
self.log_test("US-5: NL Query", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-5: NL Query", False, f"Exception: {str(e)}")
return False
def test_us9_attribution(self):
"""US-9: POST /api/v1/runs/{id}/attribution returns graph with nodes and edges"""
if not self.created_run_id:
self.log_test("US-9: Attribution graph", False, "No run_id available")
return False
try:
payload = {
"step_n": 1,
"layer": 12,
"top_k": 12
}
print(f"\n🔄 Generating attribution graph for step 1...")
print(" This may take 10-20 seconds...")
response = requests.post(
f"{self.base_url}/api/v1/runs/{self.created_run_id}/attribution",
json=payload,
timeout=30
)
if response.status_code == 200:
data = response.json()
graph = data.get("graph", {})
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
if len(nodes) >= 8 and len(edges) > 0:
# Check edges have weights
has_weights = all("weight" in e for e in edges)
if has_weights:
self.log_test("US-9: Attribution graph", True,
f"Got {len(nodes)} nodes, {len(edges)} edges")
return True
else:
self.log_test("US-9: Attribution graph", False,
"Some edges missing weights")
else:
self.log_test("US-9: Attribution graph", False,
f"Expected >=8 nodes, got {len(nodes)} nodes, {len(edges)} edges")
else:
self.log_test("US-9: Attribution graph", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-9: Attribution graph", False, f"Exception: {str(e)}")
return False
def test_us8_experiment_slug_fallback(self):
"""US-8: Experiment slug works for query/patch endpoints"""
try:
# Test query endpoint with experiment slug
payload = {"query": "What was the hallucination risk in this experiment?"}
print(f"\n🔄 Testing experiment slug fallback for query...")
response = requests.post(
f"{self.base_url}/api/v1/runs/hallucination-propagation/query",
json=payload,
timeout=30
)
if response.status_code == 200:
data = response.json()
answer = data.get("answer", "")
if answer:
self.log_test("US-8: Experiment slug fallback (query)", True,
f"Query worked with experiment slug")
return True
else:
self.log_test("US-8: Experiment slug fallback (query)", False,
"No answer returned")
else:
self.log_test("US-8: Experiment slug fallback (query)", False,
f"Status code: {response.status_code}")
except Exception as e:
self.log_test("US-8: Experiment slug fallback (query)", False,
f"Exception: {str(e)}")
return False
def test_us12_activation_persistence(self):
"""US-12: Validate activation files are persisted"""
if not self.created_run_id:
self.log_test("US-12: Activation persistence", False, "No run_id available")
return False
try:
from pathlib import Path
import os
# Check local path first
local_dir = Path("backend/data/activations") / self.created_run_id
if not local_dir.exists():
local_dir = Path("data/activations") / self.created_run_id
npz_files = list(local_dir.glob("*.npz")) if local_dir.exists() else []
if npz_files:
self.log_test("US-12: Activation persistence", True,
f"Activations found on local disk: {len(npz_files)} file(s)")
return True
# If not local, check Firebase Storage
bucket_name = os.environ.get("FIREBASE_STORAGE_BUCKET")
if bucket_name:
self.log_test("US-12: Activation persistence", True,
"Activations stored in Firebase Storage bucket (remote)")
return True
self.log_test("US-12: Activation persistence", False,
f"No local activations found and remote bucket not configured")
except Exception as e:
self.log_test("US-12: Activation persistence", False, f"Exception: {str(e)}")
return False
def run_all_tests(self):
"""Run all tests in order"""
print("=" * 80)
print("NeuroScope Backend API Test Suite")
print("=" * 80)
# Quick tests first
print("\n📋 Phase 1: Quick endpoint tests")
self.test_us11_health()
self.test_us10_suggested_tasks()
self.test_us6_list_experiments()
self.test_us7_get_experiment()
# Create run and wait for completion
print("\n📋 Phase 2: Run creation and trajectory analysis")
if self.test_us1_create_run():
self.test_us2_get_run()
self.test_us12_activation_persistence()
# Compute-intensive tests
print("\n📋 Phase 3: Causal patching and analysis")
self.test_us3_single_patch()
self.test_us4_patch_matrix()
self.test_us9_attribution()
self.test_us5_query()
else:
print("\n⚠️ Skipping dependent tests (run creation failed)")
# Experiment slug fallback
print("\n📋 Phase 4: Experiment slug fallback")
self.test_us8_experiment_slug_fallback()
# Print summary
print("\n" + "=" * 80)
print(f"Test Results: {self.tests_passed}/{self.tests_run} passed")
print("=" * 80)
# Print failed tests
failed = [r for r in self.test_results if not r["passed"]]
if failed:
print("\n❌ Failed tests:")
for test in failed:
print(f" - {test['name']}: {test['details']}")
else:
print("\n✅ All tests passed!")
return self.tests_passed == self.tests_run
def main():
tester = NeuroScopeAPITester()
success = tester.run_all_tests()
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())