Skip to content

Commit a6c0fe5

Browse files
Jamie Nuchoclaude
andcommitted
Add path-invariance measurement using 3 independent embedding spaces
Embeds 206 probe responses into OpenAI (1536d), Mistral (1024d), and Google (3072d) vector spaces. Strips model identity. Measures whether responses cluster by question/phase or by model origin. Results: KNN purity by question 0.68-0.72, by model 0.10-0.13 across all 3 spaces. Model silhouette scores are negative (worse than random). Responses cluster 5-7x more by what was asked than by who answered. 3/3 embedding spaces agree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9e99acc commit a6c0fe5

4 files changed

Lines changed: 6184 additions & 0 deletions

File tree

scripts/path_invariance.py

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Path-invariance measurement for BST probe responses.
4+
5+
Embeds all 206 model responses into 3 independent embedding spaces
6+
(OpenAI, Mistral, Google), strips model identity, and measures whether
7+
responses cluster by TERMINAL STATE (question/phase) rather than by
8+
MODEL ORIGIN.
9+
10+
If responses cluster by question → path-invariant (different models,
11+
same endpoint). If responses cluster by model → shared training artifact.
12+
13+
Outputs: web/public/data/invariance.json
14+
"""
15+
16+
import json
17+
import os
18+
import sys
19+
import time
20+
import numpy as np
21+
from pathlib import Path
22+
23+
# Load env
24+
from dotenv import load_dotenv
25+
load_dotenv(Path(__file__).resolve().parent.parent.parent / "demerzel" / ".env")
26+
os.environ.setdefault("GEMINI_API_KEY", os.environ.get("GOOGLE_API_KEY", ""))
27+
28+
import openai
29+
30+
# --- Embedding clients ---
31+
32+
def embed_openai(texts, batch_size=50):
33+
"""OpenAI text-embedding-3-small (1536d)"""
34+
client = openai.OpenAI(api_key=os.environ["OPENAI_API_KEY"])
35+
all_embeddings = []
36+
for i in range(0, len(texts), batch_size):
37+
batch = texts[i:i+batch_size]
38+
r = client.embeddings.create(model="text-embedding-3-small", input=batch)
39+
all_embeddings.extend([d.embedding for d in r.data])
40+
if i + batch_size < len(texts):
41+
time.sleep(0.5)
42+
return np.array(all_embeddings)
43+
44+
def embed_mistral(texts, batch_size=50):
45+
"""Mistral mistral-embed (1024d)"""
46+
client = openai.OpenAI(
47+
api_key=os.environ["MISTRAL_API_KEY"],
48+
base_url="https://api.mistral.ai/v1"
49+
)
50+
all_embeddings = []
51+
for i in range(0, len(texts), batch_size):
52+
batch = texts[i:i+batch_size]
53+
r = client.embeddings.create(model="mistral-embed", input=batch)
54+
all_embeddings.extend([d.embedding for d in r.data])
55+
if i + batch_size < len(texts):
56+
time.sleep(0.5)
57+
return np.array(all_embeddings)
58+
59+
def embed_google(texts):
60+
"""Google gemini-embedding-001 (3072d)"""
61+
from google import genai
62+
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
63+
all_embeddings = []
64+
# Google API: embed one at a time to avoid rate limits
65+
for i, text in enumerate(texts):
66+
# Truncate very long texts (Google has input limits)
67+
truncated = text[:8000] if len(text) > 8000 else text
68+
r = client.models.embed_content(model="gemini-embedding-001", contents=truncated)
69+
all_embeddings.append(r.embeddings[0].values)
70+
if i % 20 == 19:
71+
time.sleep(1)
72+
return np.array(all_embeddings)
73+
74+
75+
# --- Clustering metrics ---
76+
77+
def cosine_similarity_matrix(embeddings):
78+
"""Compute pairwise cosine similarity."""
79+
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
80+
norms[norms == 0] = 1
81+
normalized = embeddings / norms
82+
return normalized @ normalized.T
83+
84+
def cluster_purity(sim_matrix, labels):
85+
"""
86+
For each response, find its k nearest neighbors.
87+
Measure what fraction share the same label.
88+
"""
89+
n = len(labels)
90+
k = min(5, n - 1)
91+
purities = []
92+
for i in range(n):
93+
sims = sim_matrix[i].copy()
94+
sims[i] = -1 # exclude self
95+
neighbors = np.argsort(sims)[-k:]
96+
same_label = sum(1 for j in neighbors if labels[j] == labels[i])
97+
purities.append(same_label / k)
98+
return np.mean(purities)
99+
100+
def silhouette_score_manual(sim_matrix, labels):
101+
"""Silhouette score using precomputed similarity (converted to distance)."""
102+
dist_matrix = 1 - sim_matrix
103+
unique_labels = list(set(labels))
104+
if len(unique_labels) < 2:
105+
return 0.0
106+
107+
n = len(labels)
108+
silhouettes = []
109+
for i in range(n):
110+
label_i = labels[i]
111+
# a(i) = mean distance to same-label points
112+
same = [dist_matrix[i][j] for j in range(n) if j != i and labels[j] == label_i]
113+
if not same:
114+
continue
115+
a_i = np.mean(same)
116+
# b(i) = min mean distance to any other cluster
117+
b_i = float('inf')
118+
for other_label in unique_labels:
119+
if other_label == label_i:
120+
continue
121+
other = [dist_matrix[i][j] for j in range(n) if labels[j] == other_label]
122+
if other:
123+
b_i = min(b_i, np.mean(other))
124+
if b_i == float('inf'):
125+
continue
126+
s_i = (b_i - a_i) / max(a_i, b_i)
127+
silhouettes.append(s_i)
128+
return np.mean(silhouettes) if silhouettes else 0.0
129+
130+
def inter_vs_intra_similarity(sim_matrix, labels):
131+
"""
132+
Compare within-group similarity to between-group similarity.
133+
Ratio > 1 means responses cluster by label.
134+
"""
135+
n = len(labels)
136+
intra_sims = []
137+
inter_sims = []
138+
for i in range(n):
139+
for j in range(i + 1, n):
140+
if labels[i] == labels[j]:
141+
intra_sims.append(sim_matrix[i][j])
142+
else:
143+
inter_sims.append(sim_matrix[i][j])
144+
intra_mean = np.mean(intra_sims) if intra_sims else 0
145+
inter_mean = np.mean(inter_sims) if inter_sims else 0
146+
ratio = intra_mean / inter_mean if inter_mean > 0 else float('inf')
147+
return {
148+
"intra_mean": float(intra_mean),
149+
"inter_mean": float(inter_mean),
150+
"ratio": float(ratio),
151+
}
152+
153+
154+
# --- UMAP-style dimensionality reduction (simple PCA for no-dependency version) ---
155+
156+
def pca_2d(embeddings):
157+
"""Simple PCA to 2D for visualization."""
158+
centered = embeddings - embeddings.mean(axis=0)
159+
cov = np.cov(centered.T)
160+
eigenvalues, eigenvectors = np.linalg.eigh(cov)
161+
# Take top 2 eigenvectors
162+
idx = np.argsort(eigenvalues)[::-1][:2]
163+
components = eigenvectors[:, idx]
164+
projected = centered @ components
165+
return projected
166+
167+
168+
# --- Main ---
169+
170+
def main():
171+
data_path = Path(__file__).resolve().parent.parent / "web" / "public" / "data" / "experiment.json"
172+
output_path = Path(__file__).resolve().parent.parent / "web" / "public" / "data" / "invariance.json"
173+
174+
print(f"Loading {data_path}...")
175+
with open(data_path) as f:
176+
data = json.load(f)
177+
178+
# Extract all response texts with metadata
179+
responses = []
180+
for q in data["questions"]:
181+
if not q.get("hasData"):
182+
continue
183+
for model, text in q.get("responses", {}).items():
184+
if text and isinstance(text, str) and not text.startswith("[ERROR"):
185+
responses.append({
186+
"question_num": q["num"],
187+
"question_title": q["title"],
188+
"phase": q["phase"],
189+
"model": model,
190+
"text": text,
191+
})
192+
193+
print(f"Found {len(responses)} responses from {len(set(r['model'] for r in responses))} models across {len(set(r['question_num'] for r in responses))} questions")
194+
195+
texts = [r["text"] for r in responses]
196+
question_labels = [r["question_num"] for r in responses]
197+
model_labels = [r["model"] for r in responses]
198+
phase_labels = [r["phase"] for r in responses]
199+
200+
# Embed with all 3 APIs
201+
embedders = {
202+
"openai": embed_openai,
203+
"mistral": embed_mistral,
204+
"google": embed_google,
205+
}
206+
207+
results = {
208+
"meta": {
209+
"n_responses": len(responses),
210+
"n_models": len(set(model_labels)),
211+
"n_questions": len(set(question_labels)),
212+
"models": sorted(set(model_labels)),
213+
"embedding_spaces": list(embedders.keys()),
214+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
215+
},
216+
"responses": [], # metadata per response (no text, just labels)
217+
"per_embedding_space": {},
218+
}
219+
220+
# Store response metadata (for visualization)
221+
for r in responses:
222+
results["responses"].append({
223+
"question_num": r["question_num"],
224+
"question_title": r["question_title"],
225+
"phase": r["phase"],
226+
"model": r["model"],
227+
})
228+
229+
for name, embed_fn in embedders.items():
230+
print(f"\n{'='*50}")
231+
print(f"Embedding with {name}...")
232+
print(f"{'='*50}")
233+
234+
try:
235+
embeddings = embed_fn(texts)
236+
print(f" Shape: {embeddings.shape}")
237+
238+
sim_matrix = cosine_similarity_matrix(embeddings)
239+
240+
# Key question: do responses cluster by QUESTION or by MODEL?
241+
question_purity = cluster_purity(sim_matrix, question_labels)
242+
model_purity = cluster_purity(sim_matrix, model_labels)
243+
phase_purity = cluster_purity(sim_matrix, phase_labels)
244+
245+
question_silhouette = silhouette_score_manual(sim_matrix, question_labels)
246+
model_silhouette = silhouette_score_manual(sim_matrix, model_labels)
247+
phase_silhouette = silhouette_score_manual(sim_matrix, phase_labels)
248+
249+
question_sim = inter_vs_intra_similarity(sim_matrix, question_labels)
250+
model_sim = inter_vs_intra_similarity(sim_matrix, model_labels)
251+
phase_sim = inter_vs_intra_similarity(sim_matrix, phase_labels)
252+
253+
# PCA projection for visualization
254+
coords = pca_2d(embeddings)
255+
256+
# Per-question: avg cosine similarity between models answering the same question
257+
per_question = {}
258+
questions_by_num = {}
259+
for idx, r in enumerate(responses):
260+
qn = r["question_num"]
261+
if qn not in questions_by_num:
262+
questions_by_num[qn] = []
263+
questions_by_num[qn].append(idx)
264+
265+
for qn, indices in questions_by_num.items():
266+
if len(indices) < 2:
267+
continue
268+
sims = []
269+
for i in range(len(indices)):
270+
for j in range(i + 1, len(indices)):
271+
sims.append(float(sim_matrix[indices[i]][indices[j]]))
272+
per_question[int(qn)] = {
273+
"n_models": len(indices),
274+
"mean_similarity": float(np.mean(sims)),
275+
"min_similarity": float(np.min(sims)),
276+
"max_similarity": float(np.max(sims)),
277+
"models": [responses[i]["model"] for i in indices],
278+
}
279+
280+
space_result = {
281+
"dimensions": int(embeddings.shape[1]),
282+
"clustering": {
283+
"by_question": {
284+
"knn_purity": float(question_purity),
285+
"silhouette": float(question_silhouette),
286+
"intra_vs_inter": question_sim,
287+
},
288+
"by_model": {
289+
"knn_purity": float(model_purity),
290+
"silhouette": float(model_silhouette),
291+
"intra_vs_inter": model_sim,
292+
},
293+
"by_phase": {
294+
"knn_purity": float(phase_purity),
295+
"silhouette": float(phase_silhouette),
296+
"intra_vs_inter": phase_sim,
297+
},
298+
},
299+
"verdict": {
300+
"clusters_by_question_more": bool(question_purity > model_purity),
301+
"clusters_by_phase_more": bool(phase_purity > model_purity),
302+
"question_vs_model_ratio": float(question_purity / model_purity) if model_purity > 0 else float('inf'),
303+
"phase_vs_model_ratio": float(phase_purity / model_purity) if model_purity > 0 else float('inf'),
304+
},
305+
"per_question_similarity": per_question,
306+
"pca_2d": [[float(c[0]), float(c[1])] for c in coords],
307+
}
308+
309+
print(f"\n RESULTS for {name}:")
310+
print(f" KNN Purity — by question: {question_purity:.3f}, by model: {model_purity:.3f}, by phase: {phase_purity:.3f}")
311+
print(f" Silhouette — by question: {question_silhouette:.3f}, by model: {model_silhouette:.3f}, by phase: {phase_silhouette:.3f}")
312+
print(f" Intra/Inter ratio — by question: {question_sim['ratio']:.3f}, by model: {model_sim['ratio']:.3f}")
313+
print(f" >>> {'CLUSTERS BY QUESTION' if question_purity > model_purity else 'CLUSTERS BY MODEL'} <<<")
314+
315+
results["per_embedding_space"][name] = space_result
316+
317+
except Exception as e:
318+
print(f" ERROR: {e}")
319+
import traceback
320+
traceback.print_exc()
321+
results["per_embedding_space"][name] = {"error": str(e)}
322+
323+
# Cross-embedding-space invariance: do the three spaces agree?
324+
spaces_with_data = [s for s in results["per_embedding_space"].values() if "error" not in s]
325+
if len(spaces_with_data) >= 2:
326+
verdicts = [s["verdict"]["clusters_by_question_more"] for s in spaces_with_data]
327+
results["cross_space_invariance"] = {
328+
"n_spaces": len(spaces_with_data),
329+
"all_agree_question_clustering": bool(all(verdicts)),
330+
"spaces_favoring_question": int(sum(verdicts)),
331+
"spaces_favoring_model": int(sum(1 for v in verdicts if not v)),
332+
}
333+
print(f"\n{'='*50}")
334+
print(f"CROSS-SPACE INVARIANCE:")
335+
print(f" {sum(verdicts)}/{len(verdicts)} embedding spaces show question-clustering > model-clustering")
336+
if all(verdicts):
337+
print(f" >>> PATH INVARIANCE HOLDS ACROSS ALL EMBEDDING SPACES <<<")
338+
else:
339+
print(f" >>> MIXED RESULTS — NOT INVARIANT ACROSS SPACES <<<")
340+
341+
# Save
342+
output_path.parent.mkdir(parents=True, exist_ok=True)
343+
with open(output_path, "w") as f:
344+
json.dump(results, f, indent=2)
345+
print(f"\nSaved to {output_path}")
346+
347+
348+
if __name__ == "__main__":
349+
main()

0 commit comments

Comments
 (0)