Spaces:
Running on Zero
Running on Zero
| """ | |
| Echo / Perspective-Collapse Detector | |
| Detects two failure modes in multi-perspective reasoning: | |
| 1. Echo: A perspective output is nearly identical to the raw input prompt | |
| — the "lens" is just relabeling the question rather than analyzing it. | |
| 2. Collapse: All perspective outputs are near-identical to each other | |
| — the multi-perspective call collapsed into a single viewpoint. | |
| Uses token-based cosine similarity (no ML dependencies). | |
| Thresholds: | |
| echo_threshold: similarity(output, prompt) > 0.70 → echo risk | |
| collapse_threshold: mean pairwise sim across perspectives > 0.80 → collapse | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections import Counter | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Optional, Tuple | |
| # ── Text utilities ──────────────────────────────────────────────────────────── | |
| _STOP = frozenset( | |
| "a an the is are was were be been being have has had do does did " | |
| "will would could should may might shall this that these those with " | |
| "from for of in on at to by as it its i you he she we they and or " | |
| "but not what when where which how who".split() | |
| ) | |
| def _tokenize(text: str) -> List[str]: | |
| return [ | |
| w for w in re.findall(r'\b[a-zA-Z]{3,}\b', text.lower()) | |
| if w not in _STOP | |
| ] | |
| def _term_vec(text: str) -> Counter: | |
| return Counter(_tokenize(text)) | |
| def _cosine(a: Counter, b: Counter) -> float: | |
| if not a or not b: | |
| return 0.0 | |
| dot = sum(a[k] * b[k] for k in a if k in b) | |
| mag_a = sum(v * v for v in a.values()) ** 0.5 | |
| mag_b = sum(v * v for v in b.values()) ** 0.5 | |
| if mag_a == 0 or mag_b == 0: | |
| return 0.0 | |
| return round(dot / (mag_a * mag_b), 4) | |
| def _novelty_ratio(output: str, prompt: str) -> float: | |
| """Fraction of output tokens NOT present in prompt (0=pure echo, 1=all novel).""" | |
| out_tokens = set(_tokenize(output)) | |
| prompt_tokens = set(_tokenize(prompt)) | |
| if not out_tokens: | |
| return 0.0 | |
| novel = out_tokens - prompt_tokens | |
| return round(len(novel) / len(out_tokens), 4) | |
| # ── Result types ────────────────────────────────────────────────────────────── | |
| class PerspectiveEchoResult: | |
| """Per-perspective echo analysis.""" | |
| name: str | |
| similarity_to_prompt: float # cosine similarity to raw prompt | |
| novelty_ratio: float # fraction of unique tokens vs prompt | |
| token_count: int | |
| is_echo: bool # True if similarity_to_prompt > threshold | |
| is_too_short: bool # True if suspiciously short output | |
| class EchoCollapseResult: | |
| """Full echo + collapse report for a multi-perspective generation.""" | |
| echo_risk: str # 'low' | 'medium' | 'high' | 'unknown' | |
| perspective_collapse_detected: bool | |
| per_perspective: List[PerspectiveEchoResult] = field(default_factory=list) | |
| mean_prompt_similarity: float = 0.0 | |
| mean_pairwise_similarity: float = 0.0 | |
| collapse_pairs: List[Tuple[str, str, float]] = field(default_factory=list) | |
| summary: str = "" | |
| def to_dict(self) -> dict: | |
| return { | |
| "echo_risk": self.echo_risk, | |
| "perspective_collapse_detected": self.perspective_collapse_detected, | |
| "mean_prompt_similarity": self.mean_prompt_similarity, | |
| "mean_pairwise_similarity": self.mean_pairwise_similarity, | |
| "collapse_pairs": [ | |
| {"a": a, "b": b, "similarity": s} | |
| for a, b, s in self.collapse_pairs | |
| ], | |
| "per_perspective": [ | |
| { | |
| "name": p.name, | |
| "similarity_to_prompt": p.similarity_to_prompt, | |
| "novelty_ratio": p.novelty_ratio, | |
| "token_count": p.token_count, | |
| "is_echo": p.is_echo, | |
| "is_too_short": p.is_too_short, | |
| } | |
| for p in self.per_perspective | |
| ], | |
| "summary": self.summary, | |
| } | |
| # ── Detector ────────────────────────────────────────────────────────────────── | |
| class EchoCollapseDetector: | |
| """Detect echo and collapse in multi-perspective outputs. | |
| Args: | |
| echo_threshold: similarity(output, prompt) above which output is flagged as echo. | |
| collapse_threshold: mean pairwise similarity above which collapse is flagged. | |
| min_token_count: outputs shorter than this are flagged as suspiciously short. | |
| """ | |
| def __init__( | |
| self, | |
| echo_threshold: float = 0.70, | |
| collapse_threshold: float = 0.80, | |
| min_token_count: int = 15, | |
| ): | |
| self.echo_threshold = echo_threshold | |
| self.collapse_threshold = collapse_threshold | |
| self.min_token_count = min_token_count | |
| def check( | |
| self, | |
| prompt: str, | |
| perspective_outputs: Dict[str, str], | |
| ) -> EchoCollapseResult: | |
| """Analyze perspective_outputs for echo and collapse. | |
| Args: | |
| prompt: The raw user query / input prompt. | |
| perspective_outputs: {perspective_name: output_text} | |
| Returns: | |
| EchoCollapseResult with echo_risk, collapse flag, and per-perspective detail. | |
| """ | |
| if not perspective_outputs: | |
| return EchoCollapseResult( | |
| echo_risk="unknown", | |
| perspective_collapse_detected=False, | |
| summary="No perspective outputs to analyze.", | |
| ) | |
| prompt_vec = _term_vec(prompt) | |
| per_perspective = [] | |
| output_vecs: Dict[str, Counter] = {} | |
| for name, output in perspective_outputs.items(): | |
| tokens = _tokenize(output) | |
| out_vec = _term_vec(output) | |
| output_vecs[name] = out_vec | |
| sim = _cosine(out_vec, prompt_vec) | |
| novelty = _novelty_ratio(output, prompt) | |
| n_tokens = len(tokens) | |
| per_perspective.append(PerspectiveEchoResult( | |
| name=name, | |
| similarity_to_prompt=sim, | |
| novelty_ratio=novelty, | |
| token_count=n_tokens, | |
| is_echo=(sim > self.echo_threshold), | |
| is_too_short=(n_tokens < self.min_token_count), | |
| )) | |
| # ── Echo risk ───────────────────────────────────────────────────────── | |
| mean_prompt_sim = ( | |
| sum(p.similarity_to_prompt for p in per_perspective) / len(per_perspective) | |
| ) | |
| n_echo = sum(1 for p in per_perspective if p.is_echo) | |
| echo_fraction = n_echo / len(per_perspective) | |
| if echo_fraction >= 0.6 or mean_prompt_sim > 0.80: | |
| echo_risk = "high" | |
| elif echo_fraction >= 0.3 or mean_prompt_sim > 0.65: | |
| echo_risk = "medium" | |
| else: | |
| echo_risk = "low" | |
| # ── Collapse detection ──────────────────────────────────────────────── | |
| names = list(output_vecs.keys()) | |
| pairwise_sims = [] | |
| collapse_pairs = [] | |
| for i in range(len(names)): | |
| for j in range(i + 1, len(names)): | |
| a, b = names[i], names[j] | |
| sim = _cosine(output_vecs[a], output_vecs[b]) | |
| pairwise_sims.append(sim) | |
| if sim > self.collapse_threshold: | |
| collapse_pairs.append((a, b, sim)) | |
| mean_pairwise = ( | |
| sum(pairwise_sims) / len(pairwise_sims) if pairwise_sims else 0.0 | |
| ) | |
| collapse_detected = ( | |
| mean_pairwise > self.collapse_threshold | |
| or len(collapse_pairs) / max(len(pairwise_sims), 1) > 0.5 | |
| ) | |
| # ── Summary ─────────────────────────────────────────────────────────── | |
| parts = [] | |
| if echo_risk == "high": | |
| parts.append( | |
| f"{n_echo}/{len(per_perspective)} perspectives are echoing the prompt " | |
| f"(mean similarity={mean_prompt_sim:.2f})" | |
| ) | |
| if collapse_detected: | |
| parts.append( | |
| f"Perspective collapse detected: mean pairwise similarity={mean_pairwise:.2f}, " | |
| f"{len(collapse_pairs)} collapsing pairs" | |
| ) | |
| if not parts: | |
| parts.append( | |
| f"No echo/collapse. Mean prompt-sim={mean_prompt_sim:.2f}, " | |
| f"mean pairwise-sim={mean_pairwise:.2f}" | |
| ) | |
| summary = "; ".join(parts) | |
| return EchoCollapseResult( | |
| echo_risk=echo_risk, | |
| perspective_collapse_detected=collapse_detected, | |
| per_perspective=per_perspective, | |
| mean_prompt_similarity=round(mean_prompt_sim, 4), | |
| mean_pairwise_similarity=round(mean_pairwise, 4), | |
| collapse_pairs=collapse_pairs, | |
| summary=summary, | |
| ) | |
| def check_single(self, prompt: str, output: str, name: str = "output") -> PerspectiveEchoResult: | |
| """Quick echo check for a single output (no collapse analysis).""" | |
| prompt_vec = _term_vec(prompt) | |
| out_vec = _term_vec(output) | |
| tokens = _tokenize(output) | |
| sim = _cosine(out_vec, prompt_vec) | |
| return PerspectiveEchoResult( | |
| name=name, | |
| similarity_to_prompt=sim, | |
| novelty_ratio=_novelty_ratio(output, prompt), | |
| token_count=len(tokens), | |
| is_echo=(sim > self.echo_threshold), | |
| is_too_short=(len(tokens) < self.min_token_count), | |
| ) | |