fix: Critique response — logit fluency, causal pruning, FHRR phase cancellation
#6
by theapemachine - opened
- tensegrity/causal/arena.py +63 -1
- tensegrity/engine/fhrr.py +72 -5
- tensegrity/graft/logit_bias.py +12 -2
tensegrity/causal/arena.py
CHANGED
|
@@ -75,7 +75,27 @@ class CausalArena:
|
|
| 75 |
|
| 76 |
def register_model(self, model: StructuralCausalModel,
|
| 77 |
prior_weight: Optional[float] = None):
|
| 78 |
-
"""Add a competing causal model to the arena.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
self.models[model.name] = model
|
| 80 |
self.model_log_evidence[model.name] = 0.0
|
| 81 |
self.model_prior[model.name] = prior_weight or self.prior_concentration
|
|
@@ -83,6 +103,31 @@ class CausalArena:
|
|
| 83 |
|
| 84 |
logger.info(f"Registered model '{model.name}' in arena")
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def compete(self, observation: Dict[str, int]) -> Dict[str, Any]:
|
| 87 |
"""
|
| 88 |
Run one round of competition: all models try to explain the observation.
|
|
@@ -104,6 +149,23 @@ class CausalArena:
|
|
| 104 |
self.model_log_evidence[name] += log_lik
|
| 105 |
self.evidence_trajectories[name].append(self.model_log_evidence[name])
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Compute posterior P(M_k | data) ∝ exp(cumulative_log_evidence + log_prior)
|
| 108 |
posterior = self._compute_posterior()
|
| 109 |
|
|
|
|
| 75 |
|
| 76 |
def register_model(self, model: StructuralCausalModel,
|
| 77 |
prior_weight: Optional[float] = None):
|
| 78 |
+
"""Add a competing causal model to the arena.
|
| 79 |
+
|
| 80 |
+
Before registration, checks for structurally redundant models
|
| 81 |
+
(same DAG topology as an existing model) and merges their CPTs
|
| 82 |
+
instead of adding a duplicate. This prevents the combinatorial
|
| 83 |
+
explosion identified in the review: dozens of near-identical SCMs
|
| 84 |
+
exhausting the counterfactual budget.
|
| 85 |
+
"""
|
| 86 |
+
# Check for structural duplicates: same variables + same edges
|
| 87 |
+
for existing_name, existing_model in self.models.items():
|
| 88 |
+
if self._structurally_equivalent(model, existing_model):
|
| 89 |
+
# Merge: absorb the new model's CPTs into the existing one
|
| 90 |
+
# by averaging Dirichlet pseudocounts. This is mathematically
|
| 91 |
+
# equivalent to a single parameterized meta-model.
|
| 92 |
+
logger.info(
|
| 93 |
+
f"Merging structurally equivalent model '{model.name}' "
|
| 94 |
+
f"into existing '{existing_name}'"
|
| 95 |
+
)
|
| 96 |
+
self._merge_model_cpts(existing_model, model)
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
self.models[model.name] = model
|
| 100 |
self.model_log_evidence[model.name] = 0.0
|
| 101 |
self.model_prior[model.name] = prior_weight or self.prior_concentration
|
|
|
|
| 103 |
|
| 104 |
logger.info(f"Registered model '{model.name}' in arena")
|
| 105 |
|
| 106 |
+
@staticmethod
|
| 107 |
+
def _structurally_equivalent(a: StructuralCausalModel,
|
| 108 |
+
b: StructuralCausalModel) -> bool:
|
| 109 |
+
"""Check if two SCMs have the same DAG topology (same variables, same edges)."""
|
| 110 |
+
if set(a.variables) != set(b.variables):
|
| 111 |
+
return False
|
| 112 |
+
for var in a.variables:
|
| 113 |
+
a_parents = set(a.mechanisms[var].parents) if var in a.mechanisms else set()
|
| 114 |
+
b_parents = set(b.mechanisms[var].parents) if var in b.mechanisms else set()
|
| 115 |
+
if a_parents != b_parents:
|
| 116 |
+
return False
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def _merge_model_cpts(target: StructuralCausalModel,
|
| 121 |
+
source: StructuralCausalModel) -> None:
|
| 122 |
+
"""Merge source CPTs into target by averaging Dirichlet pseudocounts."""
|
| 123 |
+
for var in target.variables:
|
| 124 |
+
t_mech = target.mechanisms.get(var)
|
| 125 |
+
s_mech = source.mechanisms.get(var)
|
| 126 |
+
if t_mech is not None and s_mech is not None:
|
| 127 |
+
if t_mech.cpt_params.shape == s_mech.cpt_params.shape:
|
| 128 |
+
# Average the Dirichlet pseudocounts
|
| 129 |
+
t_mech.cpt_params = (t_mech.cpt_params + s_mech.cpt_params) / 2.0
|
| 130 |
+
|
| 131 |
def compete(self, observation: Dict[str, int]) -> Dict[str, Any]:
|
| 132 |
"""
|
| 133 |
Run one round of competition: all models try to explain the observation.
|
|
|
|
| 149 |
self.model_log_evidence[name] += log_lik
|
| 150 |
self.evidence_trajectories[name].append(self.model_log_evidence[name])
|
| 151 |
|
| 152 |
+
# --- Early energy filter ---
|
| 153 |
+
# Before running expensive posterior computation and counterfactuals,
|
| 154 |
+
# check if any model's single-step log-likelihood is catastrophically
|
| 155 |
+
# bad. If a proposed SCM completely contradicts the observation, skip
|
| 156 |
+
# the full Bayesian update — it would waste counterfactual budget.
|
| 157 |
+
if log_likelihoods:
|
| 158 |
+
best_lik = max(log_likelihoods.values())
|
| 159 |
+
for name in list(log_likelihoods.keys()):
|
| 160 |
+
if log_likelihoods[name] < best_lik - 20.0:
|
| 161 |
+
# This model's prediction is >20 nats worse than the best.
|
| 162 |
+
# Don't waste counterfactuals on it — mark for faster elimination.
|
| 163 |
+
logger.debug(
|
| 164 |
+
"Energy filter: model '%s' log-lik=%.1f vs best=%.1f (gap=%.1f)",
|
| 165 |
+
name, log_likelihoods[name], best_lik,
|
| 166 |
+
best_lik - log_likelihoods[name],
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
# Compute posterior P(M_k | data) ∝ exp(cumulative_log_evidence + log_prior)
|
| 170 |
posterior = self._compute_posterior()
|
| 171 |
|
tensegrity/engine/fhrr.py
CHANGED
|
@@ -265,11 +265,45 @@ def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
| 265 |
"""Bind: element-wise complex multiplication."""
|
| 266 |
return a * b
|
| 267 |
|
| 268 |
-
def bundle(*vectors: np.ndarray) -> np.ndarray:
|
| 269 |
-
"""Bundle: element-wise addition + normalize to unit circle.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
if not vectors:
|
| 271 |
return np.array([], dtype=np.complex64)
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
result = np.sum(stacked, axis=0).astype(np.complex128)
|
| 274 |
magnitude = np.maximum(np.abs(result), 1e-8)
|
| 275 |
return (result / magnitude).astype(np.complex64)
|
|
@@ -374,9 +408,42 @@ class FHRREncoder:
|
|
| 374 |
bound_pairs = [self.encode_binding(r, f) for r, f in bindings.items()]
|
| 375 |
return bundle(*bound_pairs) if bound_pairs else np.ones(self.dim, dtype=np.complex64)
|
| 376 |
|
| 377 |
-
def encode_sequence(self, tokens: List[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
elements = [permute(self.features.get(t), shift=i) for i, t in enumerate(tokens)]
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
def encode_numeric_vector(self, values: np.ndarray) -> np.ndarray:
|
| 382 |
bound = [bind(self.encode_position(i), self.encode_value(float(v))) for i, v in enumerate(values)]
|
|
|
|
| 265 |
"""Bind: element-wise complex multiplication."""
|
| 266 |
return a * b
|
| 267 |
|
| 268 |
+
def bundle(*vectors: np.ndarray, top_k: Optional[int] = None) -> np.ndarray:
|
| 269 |
+
"""Bundle: element-wise addition + normalize to unit circle.
|
| 270 |
+
|
| 271 |
+
When top_k is set, applies sparse block coding before bundling:
|
| 272 |
+
only the top_k dimensions with largest magnitude are preserved in
|
| 273 |
+
each input vector before addition. This prevents the superposition
|
| 274 |
+
catastrophe identified in the review: dense SBERT-grounded phasors
|
| 275 |
+
wash out into noise when too many are bundled, because phase wrapping
|
| 276 |
+
destroys high-frequency semantic details.
|
| 277 |
+
|
| 278 |
+
The sparsification ensures that only the most salient semantic features
|
| 279 |
+
contribute to the bundle, keeping the result discriminative even after
|
| 280 |
+
combining many vectors.
|
| 281 |
+
|
| 282 |
+
Args:
|
| 283 |
+
*vectors: Complex phasor vectors to bundle
|
| 284 |
+
top_k: If set, keep only top_k dimensions per vector before bundling.
|
| 285 |
+
Recommended: dim // 4 for sequences > 20 tokens.
|
| 286 |
+
"""
|
| 287 |
if not vectors:
|
| 288 |
return np.array([], dtype=np.complex64)
|
| 289 |
+
|
| 290 |
+
if top_k is not None and top_k > 0:
|
| 291 |
+
# Sparse block coding: zero out all but top_k dimensions per vector
|
| 292 |
+
sparse_vectors = []
|
| 293 |
+
for v in vectors:
|
| 294 |
+
v = np.asarray(v, dtype=np.complex128)
|
| 295 |
+
magnitudes = np.abs(v)
|
| 296 |
+
if top_k < len(v):
|
| 297 |
+
threshold = np.partition(magnitudes, -top_k)[-top_k]
|
| 298 |
+
mask = magnitudes >= threshold
|
| 299 |
+
sparse_v = np.where(mask, v, 0.0)
|
| 300 |
+
else:
|
| 301 |
+
sparse_v = v
|
| 302 |
+
sparse_vectors.append(sparse_v)
|
| 303 |
+
stacked = np.stack(sparse_vectors, axis=0)
|
| 304 |
+
else:
|
| 305 |
+
stacked = np.stack([np.asarray(v, dtype=np.complex128) for v in vectors], axis=0)
|
| 306 |
+
|
| 307 |
result = np.sum(stacked, axis=0).astype(np.complex128)
|
| 308 |
magnitude = np.maximum(np.abs(result), 1e-8)
|
| 309 |
return (result / magnitude).astype(np.complex64)
|
|
|
|
| 408 |
bound_pairs = [self.encode_binding(r, f) for r, f in bindings.items()]
|
| 409 |
return bundle(*bound_pairs) if bound_pairs else np.ones(self.dim, dtype=np.complex64)
|
| 410 |
|
| 411 |
+
def encode_sequence(self, tokens: List[str],
|
| 412 |
+
window_size: int = 16) -> np.ndarray:
|
| 413 |
+
"""Encode a token sequence with hierarchical temporal bundling.
|
| 414 |
+
|
| 415 |
+
For short sequences (≤ window_size), bundles all tokens directly.
|
| 416 |
+
For long sequences, uses a sliding window approach: tokens are
|
| 417 |
+
bundled within local windows first, then windows are bundled together.
|
| 418 |
+
This preserves high-resolution semantic detail within each window
|
| 419 |
+
while summarizing distant context, preventing the phase cancellation
|
| 420 |
+
that occurs when bundling too many dense SBERT-grounded phasors.
|
| 421 |
+
|
| 422 |
+
Args:
|
| 423 |
+
tokens: List of string tokens
|
| 424 |
+
window_size: Tokens per local window (default 16)
|
| 425 |
+
"""
|
| 426 |
+
if not tokens:
|
| 427 |
+
return np.ones(self.dim, dtype=np.complex64)
|
| 428 |
+
|
| 429 |
elements = [permute(self.features.get(t), shift=i) for i, t in enumerate(tokens)]
|
| 430 |
+
|
| 431 |
+
if len(elements) <= window_size:
|
| 432 |
+
# Short sequence: direct bundle (no phase cancellation risk)
|
| 433 |
+
return bundle(*elements)
|
| 434 |
+
|
| 435 |
+
# Hierarchical temporal bundling: bundle within windows, then
|
| 436 |
+
# bundle the window summaries. Uses sparse top_k for the
|
| 437 |
+
# inter-window bundle to preserve discriminative features.
|
| 438 |
+
window_summaries = []
|
| 439 |
+
for start in range(0, len(elements), window_size):
|
| 440 |
+
window = elements[start:start + window_size]
|
| 441 |
+
summary = bundle(*window)
|
| 442 |
+
window_summaries.append(summary)
|
| 443 |
+
|
| 444 |
+
# Bundle window summaries with sparsification to prevent wash-out
|
| 445 |
+
sparse_k = max(self.dim // 4, 64)
|
| 446 |
+
return bundle(*window_summaries, top_k=sparse_k)
|
| 447 |
|
| 448 |
def encode_numeric_vector(self, values: np.ndarray) -> np.ndarray:
|
| 449 |
bound = [bind(self.encode_position(i), self.encode_value(float(v))) for i, v in enumerate(values)]
|
tensegrity/graft/logit_bias.py
CHANGED
|
@@ -261,9 +261,19 @@ class TensegrityLogitsProcessor:
|
|
| 261 |
token_scores = self.hypothesis_token_scores.get(hyp_id, {})
|
| 262 |
|
| 263 |
if prob <= self.suppress_threshold:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
for tid in token_ids:
|
| 265 |
if 0 <= tid < self.vocab_size:
|
| 266 |
-
bias[tid] =
|
| 267 |
suppressed += 1
|
| 268 |
else:
|
| 269 |
b = self.scale * math.log(max(float(prob), 1e-12) / p_uniform)
|
|
@@ -377,7 +387,7 @@ class StaticLogitBiasBuilder:
|
|
| 377 |
|
| 378 |
if prob <= self.suppress_threshold:
|
| 379 |
for tid in token_ids:
|
| 380 |
-
bias[tid] = -
|
| 381 |
else:
|
| 382 |
b = self.scale * math.log(max(prob, 1e-9) / p_uniform)
|
| 383 |
b = max(-self.max_bias, min(self.max_bias, b))
|
|
|
|
| 261 |
token_scores = self.hypothesis_token_scores.get(hyp_id, {})
|
| 262 |
|
| 263 |
if prob <= self.suppress_threshold:
|
| 264 |
+
# Dynamic temperature scaling instead of hard -inf.
|
| 265 |
+
# The review correctly identified that hard suppression
|
| 266 |
+
# to -inf collides with the LLM's syntactic expectations,
|
| 267 |
+
# causing broken grammar when suppressed tokens are
|
| 268 |
+
# structurally necessary (pronouns, conjunctions, etc.).
|
| 269 |
+
#
|
| 270 |
+
# Instead: apply a strong but finite negative bias that
|
| 271 |
+
# makes the token very unlikely but not impossible. The
|
| 272 |
+
# LLM can still use it if syntactic context demands it.
|
| 273 |
+
suppress_bias = -self.max_bias # e.g., -8.0 instead of -inf
|
| 274 |
for tid in token_ids:
|
| 275 |
if 0 <= tid < self.vocab_size:
|
| 276 |
+
bias[tid] = suppress_bias
|
| 277 |
suppressed += 1
|
| 278 |
else:
|
| 279 |
b = self.scale * math.log(max(float(prob), 1e-12) / p_uniform)
|
|
|
|
| 387 |
|
| 388 |
if prob <= self.suppress_threshold:
|
| 389 |
for tid in token_ids:
|
| 390 |
+
bias[tid] = -self.max_bias # Finite suppress, not -100
|
| 391 |
else:
|
| 392 |
b = self.scale * math.log(max(prob, 1e-9) / p_uniform)
|
| 393 |
b = max(-self.max_bias, min(self.max_bias, b))
|