Skip to content

Commit 26c35ec

Browse files
Jamie Nuchoclaude
andcommitted
Add 3 judgment-based measurement spaces (Claude, DeepSeek, Grok)
6/6 independent measurement instruments now agree: responses cluster by question, not by model. 3 embedding APIs (OpenAI, Mistral, Google) + 3 LLM judges (Claude, DeepSeek, Grok scoring pairwise similarity). Judgment models show even stronger signal: 19-24x question-vs-model clustering ratio. Model silhouette scores negative across all 6 spaces. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6c0fe5 commit 26c35ec

3 files changed

Lines changed: 4876 additions & 526 deletions

File tree

scripts/path_invariance.py

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
os.environ.setdefault("GEMINI_API_KEY", os.environ.get("GOOGLE_API_KEY", ""))
2727

2828
import openai
29+
import random
30+
31+
# Add probes directory to path for ai_clients
32+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "probes"))
33+
from ai_clients import query_model
2934

3035
# --- Embedding clients ---
3136

@@ -72,6 +77,110 @@ def embed_google(texts):
7277
return np.array(all_embeddings)
7378

7479

80+
# --- Judgment-based similarity (for models without embedding APIs) ---
81+
82+
SIMILARITY_PROMPT = """You are measuring semantic similarity between two AI responses to the same type of question.
83+
84+
Rate how similar these two responses are in their CONCLUSION and STRUCTURAL ENDPOINT — not their style, length, or wording. Do they arrive at the same place?
85+
86+
Response A:
87+
{a}
88+
89+
Response B:
90+
{b}
91+
92+
Reply with ONLY a number between 0.0 and 1.0 where:
93+
0.0 = completely different conclusions
94+
0.5 = partially overlapping conclusions
95+
1.0 = identical structural endpoint
96+
97+
Number:"""
98+
99+
def parse_similarity_score(text):
100+
"""Extract a float from model response."""
101+
text = text.strip()
102+
for token in text.split():
103+
token = token.strip('.,;:')
104+
try:
105+
val = float(token)
106+
if 0 <= val <= 1:
107+
return val
108+
except ValueError:
109+
continue
110+
return 0.5 # default if parsing fails
111+
112+
def judge_similarity_matrix(model_key, responses, texts, n_cross_sample=200):
113+
"""
114+
Build a similarity matrix by having a model judge pairwise similarity.
115+
Scores all within-question pairs + a random sample of cross-question pairs.
116+
"""
117+
n = len(responses)
118+
# Start with neutral similarity
119+
sim_matrix = np.full((n, n), 0.5)
120+
np.fill_diagonal(sim_matrix, 1.0)
121+
122+
# Group indices by question
123+
by_question = {}
124+
for i, r in enumerate(responses):
125+
qn = r["question_num"]
126+
if qn not in by_question:
127+
by_question[qn] = []
128+
by_question[qn].append(i)
129+
130+
# All within-question pairs
131+
within_pairs = []
132+
for qn, indices in by_question.items():
133+
for a in range(len(indices)):
134+
for b in range(a + 1, len(indices)):
135+
within_pairs.append((indices[a], indices[b]))
136+
137+
# Random sample of cross-question pairs
138+
cross_pairs = []
139+
questions = list(by_question.keys())
140+
random.seed(42)
141+
attempts = 0
142+
while len(cross_pairs) < n_cross_sample and attempts < n_cross_sample * 10:
143+
q1, q2 = random.sample(questions, 2)
144+
i = random.choice(by_question[q1])
145+
j = random.choice(by_question[q2])
146+
cross_pairs.append((i, j))
147+
attempts += 1
148+
149+
all_pairs = within_pairs + cross_pairs
150+
print(f" {len(within_pairs)} within-question + {len(cross_pairs)} cross-question = {len(all_pairs)} pairs")
151+
152+
for idx, (i, j) in enumerate(all_pairs):
153+
# Truncate long texts
154+
text_a = texts[i][:3000]
155+
text_b = texts[j][:3000]
156+
prompt = SIMILARITY_PROMPT.format(a=text_a, b=text_b)
157+
158+
try:
159+
result = query_model(model_key, prompt)
160+
score = parse_similarity_score(result)
161+
except Exception as e:
162+
print(f" Error on pair ({i},{j}): {e}")
163+
score = 0.5
164+
165+
sim_matrix[i][j] = score
166+
sim_matrix[j][i] = score
167+
168+
if (idx + 1) % 50 == 0:
169+
print(f" Scored {idx + 1}/{len(all_pairs)} pairs")
170+
time.sleep(0.3) # rate limiting
171+
172+
# Fill remaining cross-question pairs with mean cross-question score
173+
scored_cross = [sim_matrix[i][j] for i, j in cross_pairs]
174+
cross_mean = np.mean(scored_cross) if scored_cross else 0.5
175+
for i in range(n):
176+
for j in range(i + 1, n):
177+
if sim_matrix[i][j] == 0.5 and responses[i]["question_num"] != responses[j]["question_num"]:
178+
sim_matrix[i][j] = cross_mean
179+
sim_matrix[j][i] = cross_mean
180+
181+
return sim_matrix
182+
183+
75184
# --- Clustering metrics ---
76185

77186
def cosine_similarity_matrix(embeddings):
@@ -226,6 +335,15 @@ def main():
226335
"model": r["model"],
227336
})
228337

338+
# Judgment-based models (no embedding API)
339+
judges = {
340+
"claude_judge": "claude",
341+
"deepseek_judge": "deepseek",
342+
"grok_judge": "grok",
343+
}
344+
345+
results["meta"]["embedding_spaces"] = list(embedders.keys()) + list(judges.keys())
346+
229347
for name, embed_fn in embedders.items():
230348
print(f"\n{'='*50}")
231349
print(f"Embedding with {name}...")
@@ -320,7 +438,101 @@ def main():
320438
traceback.print_exc()
321439
results["per_embedding_space"][name] = {"error": str(e)}
322440

323-
# Cross-embedding-space invariance: do the three spaces agree?
441+
# Judgment-based similarity (Claude, DeepSeek, Grok)
442+
for name, model_key in judges.items():
443+
print(f"\n{'='*50}")
444+
print(f"Judgment similarity with {name} ({model_key})...")
445+
print(f"{'='*50}")
446+
447+
try:
448+
sim_matrix = judge_similarity_matrix(model_key, responses, texts)
449+
450+
question_purity = cluster_purity(sim_matrix, question_labels)
451+
model_purity = cluster_purity(sim_matrix, model_labels)
452+
phase_purity = cluster_purity(sim_matrix, phase_labels)
453+
454+
question_silhouette = silhouette_score_manual(sim_matrix, question_labels)
455+
model_silhouette = silhouette_score_manual(sim_matrix, model_labels)
456+
phase_silhouette = silhouette_score_manual(sim_matrix, phase_labels)
457+
458+
question_sim = inter_vs_intra_similarity(sim_matrix, question_labels)
459+
model_sim = inter_vs_intra_similarity(sim_matrix, model_labels)
460+
phase_sim = inter_vs_intra_similarity(sim_matrix, phase_labels)
461+
462+
# MDS-like projection from similarity matrix
463+
# Use PCA on the similarity matrix itself as a proxy
464+
coords = pca_2d(sim_matrix)
465+
466+
# Per-question similarity
467+
per_question = {}
468+
questions_by_num = {}
469+
for idx, r in enumerate(responses):
470+
qn = r["question_num"]
471+
if qn not in questions_by_num:
472+
questions_by_num[qn] = []
473+
questions_by_num[qn].append(idx)
474+
475+
for qn, indices in questions_by_num.items():
476+
if len(indices) < 2:
477+
continue
478+
sims = []
479+
for i in range(len(indices)):
480+
for j in range(i + 1, len(indices)):
481+
sims.append(float(sim_matrix[indices[i]][indices[j]]))
482+
per_question[int(qn)] = {
483+
"n_models": len(indices),
484+
"mean_similarity": float(np.mean(sims)),
485+
"min_similarity": float(np.min(sims)),
486+
"max_similarity": float(np.max(sims)),
487+
"models": [responses[i]["model"] for i in indices],
488+
}
489+
490+
space_result = {
491+
"dimensions": "judgment",
492+
"method": "pairwise_llm_scoring",
493+
"judge_model": model_key,
494+
"clustering": {
495+
"by_question": {
496+
"knn_purity": float(question_purity),
497+
"silhouette": float(question_silhouette),
498+
"intra_vs_inter": question_sim,
499+
},
500+
"by_model": {
501+
"knn_purity": float(model_purity),
502+
"silhouette": float(model_silhouette),
503+
"intra_vs_inter": model_sim,
504+
},
505+
"by_phase": {
506+
"knn_purity": float(phase_purity),
507+
"silhouette": float(phase_silhouette),
508+
"intra_vs_inter": phase_sim,
509+
},
510+
},
511+
"verdict": {
512+
"clusters_by_question_more": bool(question_purity > model_purity),
513+
"clusters_by_phase_more": bool(phase_purity > model_purity),
514+
"question_vs_model_ratio": float(question_purity / model_purity) if model_purity > 0 else float('inf'),
515+
"phase_vs_model_ratio": float(phase_purity / model_purity) if model_purity > 0 else float('inf'),
516+
},
517+
"per_question_similarity": per_question,
518+
"pca_2d": [[float(c[0]), float(c[1])] for c in coords],
519+
}
520+
521+
print(f"\n RESULTS for {name}:")
522+
print(f" KNN Purity — by question: {question_purity:.3f}, by model: {model_purity:.3f}, by phase: {phase_purity:.3f}")
523+
print(f" Silhouette — by question: {question_silhouette:.3f}, by model: {model_silhouette:.3f}, by phase: {phase_silhouette:.3f}")
524+
print(f" Intra/Inter ratio — by question: {question_sim['ratio']:.3f}, by model: {model_sim['ratio']:.3f}")
525+
print(f" >>> {'CLUSTERS BY QUESTION' if question_purity > model_purity else 'CLUSTERS BY MODEL'} <<<")
526+
527+
results["per_embedding_space"][name] = space_result
528+
529+
except Exception as e:
530+
print(f" ERROR: {e}")
531+
import traceback
532+
traceback.print_exc()
533+
results["per_embedding_space"][name] = {"error": str(e)}
534+
535+
# Cross-embedding-space invariance: do the spaces agree?
324536
spaces_with_data = [s for s in results["per_embedding_space"].values() if "error" not in s]
325537
if len(spaces_with_data) >= 2:
326538
verdicts = [s["verdict"]["clusters_by_question_more"] for s in spaces_with_data]

0 commit comments

Comments
 (0)