fix: Critique response — logit fluency, causal pruning, FHRR phase cancellation

#6
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
- stacked = np.stack([np.asarray(v, dtype=np.complex128) for v in vectors], axis=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  elements = [permute(self.features.get(t), shift=i) for i, t in enumerate(tokens)]
379
- return bundle(*elements) if elements else np.ones(self.dim, dtype=np.complex64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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] = -np.inf
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] = -100.0 # OpenAI convention for hard suppress
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))