Spaces:
Sleeping
Sleeping
| """ | |
| 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()) | |