theapemachine commited on
Commit
bf2a178
·
1 Parent(s): e200b0f

feat: Self-tuning engine — Friston precisions, Dirichlet channels, joint settling, structured projection (#2)

Browse files

- feat: self-tuning unified_field.py (d9cf00b3acd6ac798ffe552aca8276f284b9cd15)
- feat: self-tuning ngc.py (e6f258cb86a70347cf9b9a562ab1a9b793818432)
- feat: self-tuning canonical.py (bb45ec3d6d837f759d8f2cd94f04fb7e18f19588)

tensegrity/engine/ngc.py CHANGED
@@ -93,7 +93,12 @@ class PredictiveCodingCircuit:
93
  precision_momentum: float = 0.9,
94
  precision_min: float = 0.1,
95
  precision_max: float = 100.0,
96
- max_history_length: int = 2000):
 
 
 
 
 
97
  """
98
  Args:
99
  layer_sizes: [dim_sensory, dim_hidden1, ..., dim_top]
@@ -102,15 +107,28 @@ class PredictiveCodingCircuit:
102
  If None, defaults to 1.0 everywhere. Length must equal ``n_layers`` when given.
103
  tau: Membrane time constant (settling speed)
104
  gamma: State decay rate (leaky integration)
105
- settle_steps: How many steps to run before declaring convergence
106
  settle_steps_warm: Steps when the observation is nearly unchanged (warm-started z)
107
- obs_change_threshold: L2 change above this triggers full settle_steps
108
  learning_rate: Hebbian learning rate for synaptic updates
109
  activation: Nonlinearity: "tanh", "relu", "sigmoid", or "linear"
110
- adaptive_precision: If True, update precisions from prediction-error variance in learn()
 
 
 
 
111
  precision_momentum: EMA factor for precision updates (higher = slower change)
112
  precision_min / precision_max: Clamp learned precisions
113
  max_history_length: Max entries retained in energy / error history (ring buffer)
 
 
 
 
 
 
 
 
 
114
  """
115
  self.n_layers = len(layer_sizes)
116
  self.layer_sizes = layer_sizes
@@ -126,19 +144,29 @@ class PredictiveCodingCircuit:
126
  self.precision_max = precision_max
127
  self.max_history_length = max(1, int(max_history_length))
128
  self.activation = activation
 
 
 
 
129
 
130
  # Activation function
131
  self._phi, self._phi_deriv = self._get_activation(activation)
132
 
133
- # Precisions (per layer): ρ = 1/σ²
 
 
 
 
134
  if precisions is None:
135
  self.precisions = [1.0] * self.n_layers
 
136
  else:
137
  if len(precisions) != self.n_layers:
138
  raise ValueError(
139
  f"precisions must have length n_layers={self.n_layers}, got {len(precisions)}"
140
  )
141
  self.precisions = list(precisions)
 
142
 
143
  # Generative weights W[ℓ]: maps layer ℓ+1 → prediction of layer ℓ
144
  # W[ℓ] has shape (layer_sizes[ℓ], layer_sizes[ℓ+1])
@@ -281,6 +309,9 @@ class PredictiveCodingCircuit:
281
 
282
  if steps is not None:
283
  n_steps = steps
 
 
 
284
  elif not self._initialized:
285
  n_steps = self.settle_steps
286
  elif obs_changed:
@@ -336,6 +367,13 @@ class PredictiveCodingCircuit:
336
 
337
  energy_trace.append(total_energy)
338
  error_norms.append(step_error_norms)
 
 
 
 
 
 
 
339
 
340
  self.energy_history.append(energy_trace[-1])
341
  self.error_history.append(error_norms[-1])
@@ -351,18 +389,23 @@ class PredictiveCodingCircuit:
351
 
352
  def learn(self, modulation: float = 1.0):
353
  """
354
- Hebbian synaptic update after settling.
355
 
356
  ΔWℓ = modulation * lr * (e^{ℓ-1} · (φ(z^ℓ))ᵀ)
357
 
358
- The modulation parameter gates learning: when the current observation
359
- is inconsistent with established beliefs (high prediction error +
360
- low memory similarity), modulation should be low, preventing the
361
- system from learning from contradictory evidence.
 
 
 
 
362
 
363
- This is precision-weighted Hebbian learning: the effective learning
364
- rate is lr * modulation, where modulation encodes the system's
365
- confidence that this observation is trustworthy.
 
366
  """
367
  effective_lr = self.lr * modulation
368
 
@@ -370,25 +413,41 @@ class PredictiveCodingCircuit:
370
  for ell in range(self.n_layers):
371
  residual = self.layers[ell].z - self.layers[ell].z_bar
372
  sq_error = float(np.mean(residual ** 2))
373
- target_precision = 1.0 / max(sq_error, 1e-6)
374
- mom = self.precision_momentum
375
- self.precisions[ell] = mom * self.precisions[ell] + (1.0 - mom) * target_precision
376
- self.precisions[ell] = float(
377
- np.clip(self.precisions[ell], self.precision_min, self.precision_max)
 
 
 
 
 
 
 
 
 
 
 
378
  )
 
379
  self.layers[ell].precision = self.precisions[ell]
380
 
381
  for ell in range(self.n_layers - 1):
382
  error_below = self.layers[ell].error
383
  z_above = self._phi(self.layers[ell + 1].z)
384
 
 
 
 
 
385
  # Generative weight update: Hebbian + decay
386
  dW = np.outer(error_below, z_above)
387
- self.W[ell] += effective_lr * dW - effective_lr * self.gamma * self.W[ell]
388
 
389
  # Feedback weight update
390
  dE = np.outer(self.layers[ell + 1].z, error_below)
391
- self.E[ell] += effective_lr * dE - effective_lr * self.gamma * self.E[ell]
392
 
393
  # Spectral normalization (power iteration — cheaper than full SVD)
394
  w_norm = _spectral_norm_power_iteration(self.W[ell])
@@ -441,6 +500,7 @@ class PredictiveCodingCircuit:
441
  "W": [w.copy() for w in self.W],
442
  "E": [e.copy() for e in self.E],
443
  "precisions": list(self.precisions),
 
444
  "_initialized": self._initialized,
445
  "_last_obs": None if self._last_obs is None else self._last_obs.copy(),
446
  }
@@ -448,6 +508,8 @@ class PredictiveCodingCircuit:
448
  def restore_state(self, state: Dict[str, Any]) -> None:
449
  """Restore from ``save_state()``."""
450
  self.precisions = list(state["precisions"])
 
 
451
  self.W = [w.copy() for w in state["W"]]
452
  self.E = [e.copy() for e in state["E"]]
453
  self._initialized = bool(state["_initialized"])
 
93
  precision_momentum: float = 0.9,
94
  precision_min: float = 0.1,
95
  precision_max: float = 100.0,
96
+ max_history_length: int = 2000,
97
+ # --- Self-tuning settle parameters ---
98
+ adaptive_settle: bool = True,
99
+ settle_convergence_threshold: float = 0.01,
100
+ settle_min_steps: int = 5,
101
+ settle_max_steps: int = 100):
102
  """
103
  Args:
104
  layer_sizes: [dim_sensory, dim_hidden1, ..., dim_top]
 
107
  If None, defaults to 1.0 everywhere. Length must equal ``n_layers`` when given.
108
  tau: Membrane time constant (settling speed)
109
  gamma: State decay rate (leaky integration)
110
+ settle_steps: Default settling steps (used as fallback if adaptive_settle=False)
111
  settle_steps_warm: Steps when the observation is nearly unchanged (warm-started z)
112
+ obs_change_threshold: L2 change above this triggers full settle
113
  learning_rate: Hebbian learning rate for synaptic updates
114
  activation: Nonlinearity: "tanh", "relu", "sigmoid", or "linear"
115
+ adaptive_precision: If True, update precisions from prediction-error variance
116
+ via Friston's log-precision gradient (Millidge et al. 2021, Eq 20-22):
117
+ dΣ/dt = ε̃·ε̃ᵀ − Σ⁻¹
118
+ At fixed point, Σ_l = Var[ε̃] → precision = 1/Var[ε̃].
119
+ Implemented as EMA: Σ_l ← (1-α)·Σ_l + α·mean(ε²)
120
  precision_momentum: EMA factor for precision updates (higher = slower change)
121
  precision_min / precision_max: Clamp learned precisions
122
  max_history_length: Max entries retained in energy / error history (ring buffer)
123
+ adaptive_settle: If True, settle until energy convergence instead of fixed steps.
124
+ The system monitors ||E_t - E_{t-1}|| and stops when the energy
125
+ change drops below settle_convergence_threshold, bounded by
126
+ [settle_min_steps, settle_max_steps]. This replaces the fixed
127
+ settle_steps parameter with a self-tuning criterion derived from
128
+ the system's own dynamics.
129
+ settle_convergence_threshold: Energy change threshold for early stopping
130
+ settle_min_steps: Minimum settling steps (even if converged)
131
+ settle_max_steps: Maximum settling steps (hard ceiling)
132
  """
133
  self.n_layers = len(layer_sizes)
134
  self.layer_sizes = layer_sizes
 
144
  self.precision_max = precision_max
145
  self.max_history_length = max(1, int(max_history_length))
146
  self.activation = activation
147
+ self.adaptive_settle = adaptive_settle
148
+ self.settle_convergence_threshold = settle_convergence_threshold
149
+ self.settle_min_steps = max(1, int(settle_min_steps))
150
+ self.settle_max_steps = max(self.settle_min_steps, int(settle_max_steps))
151
 
152
  # Activation function
153
  self._phi, self._phi_deriv = self._get_activation(activation)
154
 
155
+ # --- Friston log-precision state (per-layer) ---
156
+ # γ_l = log(precision_l). Updated via gradient descent on VFE:
157
+ # F_γ = 0.5·(mean(ε̃²) − 1) (Millidge Eq 21)
158
+ # At fixed point: precision = 1/Var[ε] = exp(γ)
159
+ # Initialized to log(1.0) = 0.0 (unit precision = maximum uncertainty)
160
  if precisions is None:
161
  self.precisions = [1.0] * self.n_layers
162
+ self._log_precisions = [0.0] * self.n_layers
163
  else:
164
  if len(precisions) != self.n_layers:
165
  raise ValueError(
166
  f"precisions must have length n_layers={self.n_layers}, got {len(precisions)}"
167
  )
168
  self.precisions = list(precisions)
169
+ self._log_precisions = [float(np.log(max(p, 1e-8))) for p in precisions]
170
 
171
  # Generative weights W[ℓ]: maps layer ℓ+1 → prediction of layer ℓ
172
  # W[ℓ] has shape (layer_sizes[ℓ], layer_sizes[ℓ+1])
 
309
 
310
  if steps is not None:
311
  n_steps = steps
312
+ elif self.adaptive_settle:
313
+ # Adaptive: we'll settle until convergence, bounded by min/max
314
+ n_steps = self.settle_max_steps
315
  elif not self._initialized:
316
  n_steps = self.settle_steps
317
  elif obs_changed:
 
367
 
368
  energy_trace.append(total_energy)
369
  error_norms.append(step_error_norms)
370
+
371
+ # --- Adaptive settle: early exit when energy converges ---
372
+ if self.adaptive_settle and steps is None and step >= self.settle_min_steps - 1:
373
+ if len(energy_trace) >= 2:
374
+ delta_e = abs(energy_trace[-1] - energy_trace[-2])
375
+ if delta_e < self.settle_convergence_threshold:
376
+ break
377
 
378
  self.energy_history.append(energy_trace[-1])
379
  self.error_history.append(error_norms[-1])
 
389
 
390
  def learn(self, modulation: float = 1.0):
391
  """
392
+ Hebbian synaptic update after settling, with Friston precision update.
393
 
394
  ΔWℓ = modulation * lr * (e^{ℓ-1} · (φ(z^ℓ))ᵀ)
395
 
396
+ Precision update (Millidge et al. 2021, Eq 20-22):
397
+ dΣ/dt = ε̃·ε̃ᵀ Σ⁻¹
398
+ At fixed point: Σ_l = Var[ε̃] precision = 1/Var[ε̃]
399
+
400
+ Implemented in log-space for numerical stability:
401
+ γ_l = log(precision_l)
402
+ F_γ = 0.5 · (mean(ε̃²) − 1) (gradient of VFE w.r.t. log-precision)
403
+ γ_l ← γ_l − lr_precision · F_γ
404
 
405
+ The learning rate for Hebbian weights is precision-scaled:
406
+ η_eff = lr · modulation · precision_l
407
+ This is the natural gradient preconditioning from Friston's theory:
408
+ more precise layers learn faster because their errors are more trustworthy.
409
  """
410
  effective_lr = self.lr * modulation
411
 
 
413
  for ell in range(self.n_layers):
414
  residual = self.layers[ell].z - self.layers[ell].z_bar
415
  sq_error = float(np.mean(residual ** 2))
416
+
417
+ # Friston log-precision gradient: F_γ = 0.5·(precision·mean(ε²) − 1)
418
+ # At fixed point: precision·Var[ε] = 1 precision = 1/Var[ε]
419
+ current_prec = self.precisions[ell]
420
+ f_gamma = 0.5 * (current_prec * sq_error - 1.0)
421
+
422
+ # Update log-precision via gradient descent
423
+ # lr_precision = 0.1 · (1-momentum) to match EMA time constant
424
+ lr_precision = 0.1 * (1.0 - self.precision_momentum)
425
+ self._log_precisions[ell] -= lr_precision * f_gamma
426
+
427
+ # Clamp and exponentiate
428
+ log_min = np.log(max(self.precision_min, 1e-8))
429
+ log_max = np.log(self.precision_max)
430
+ self._log_precisions[ell] = float(
431
+ np.clip(self._log_precisions[ell], log_min, log_max)
432
  )
433
+ self.precisions[ell] = float(np.exp(self._log_precisions[ell]))
434
  self.layers[ell].precision = self.precisions[ell]
435
 
436
  for ell in range(self.n_layers - 1):
437
  error_below = self.layers[ell].error
438
  z_above = self._phi(self.layers[ell + 1].z)
439
 
440
+ # Precision-scaled learning rate: more precise layers learn faster.
441
+ # This IS the natural gradient from Friston's theory.
442
+ layer_lr = effective_lr * min(self.precisions[ell], 10.0)
443
+
444
  # Generative weight update: Hebbian + decay
445
  dW = np.outer(error_below, z_above)
446
+ self.W[ell] += layer_lr * dW - layer_lr * self.gamma * self.W[ell]
447
 
448
  # Feedback weight update
449
  dE = np.outer(self.layers[ell + 1].z, error_below)
450
+ self.E[ell] += layer_lr * dE - layer_lr * self.gamma * self.E[ell]
451
 
452
  # Spectral normalization (power iteration — cheaper than full SVD)
453
  w_norm = _spectral_norm_power_iteration(self.W[ell])
 
500
  "W": [w.copy() for w in self.W],
501
  "E": [e.copy() for e in self.E],
502
  "precisions": list(self.precisions),
503
+ "_log_precisions": list(self._log_precisions),
504
  "_initialized": self._initialized,
505
  "_last_obs": None if self._last_obs is None else self._last_obs.copy(),
506
  }
 
508
  def restore_state(self, state: Dict[str, Any]) -> None:
509
  """Restore from ``save_state()``."""
510
  self.precisions = list(state["precisions"])
511
+ self._log_precisions = list(state.get("_log_precisions",
512
+ [float(np.log(max(p, 1e-8))) for p in self.precisions]))
513
  self.W = [w.copy() for w in state["W"]]
514
  self.E = [e.copy() for e in state["E"]]
515
  self._initialized = bool(state["_initialized"])
tensegrity/engine/unified_field.py CHANGED
@@ -192,10 +192,22 @@ class UnifiedField:
192
  # FHRR encoder
193
  self.encoder = FHRREncoder(dim=fhrr_dim)
194
 
195
- # Random projection: FHRR (complex, fhrr_dim) → real (obs_dim)
196
- # Fixed, not learned this is the sensory transduction
197
- rng = np.random.RandomState(42)
198
- self._proj = rng.randn(obs_dim, fhrr_dim).astype(np.float64) / np.sqrt(fhrr_dim)
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  # NGC circuit: hierarchical predictive coding
201
  layer_sizes = [obs_dim] + hidden_dims
@@ -215,9 +227,22 @@ class UnifiedField:
215
  self.energy_history: Deque[EnergyDecomposition] = deque(maxlen=max(1, int(energy_history_maxlen)))
216
 
217
  def _fhrr_to_obs(self, fhrr_vec: np.ndarray) -> np.ndarray:
218
- """Project FHRR complex vector to real observation space."""
 
 
 
 
 
 
219
  real_part = np.real(fhrr_vec).astype(np.float64)
220
- return self._proj @ real_part
 
 
 
 
 
 
 
221
 
222
  def observe(self, raw_input: Any, input_type: str = "numeric") -> Dict[str, Any]:
223
  """
@@ -258,13 +283,16 @@ class UnifiedField:
258
  settle_result = self.ngc.settle(obs_vec)
259
  perception_energy = settle_result["final_energy"]
260
 
261
- prediction_error_post_settle = self.ngc.prediction_error(obs_vec)
262
-
263
- # === 4. REMEMBER: query Hopfield with abstract state ===
 
 
 
264
  abstract_state = self.ngc.get_abstract_state(level=-1)
265
  retrieved, memory_energy = self.memory.retrieve(abstract_state)
266
 
267
- # Compute memory consistency: how similar is this observation to stored patterns?
268
  abstract_norm = np.linalg.norm(abstract_state)
269
  retrieved_norm = np.linalg.norm(retrieved)
270
  if abstract_norm > 1e-8 and retrieved_norm > 1e-8:
@@ -273,6 +301,32 @@ class UnifiedField:
273
  else:
274
  memory_similarity = 0.0
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  # === 5. LEARN: Precision-modulated Hebbian update ===
277
  # Learning modulation: high when observation is consistent with memory,
278
  # low when it contradicts stored patterns.
 
192
  # FHRR encoder
193
  self.encoder = FHRREncoder(dim=fhrr_dim)
194
 
195
+ # Structure-preserving projection: FHRR (complex, fhrr_dim) → real (obs_dim)
196
+ # Instead of a random matrix that destroys semantic structure, we use
197
+ # a fixed projection derived from the FHRR basis itself. The real part
198
+ # of the FHRR vector is sliced/averaged into obs_dim buckets. This
199
+ # preserves the phasor structure: similar FHRR vectors → similar obs.
200
+ #
201
+ # For obs_dim < fhrr_dim: average adjacent blocks of size fhrr_dim/obs_dim.
202
+ # For obs_dim >= fhrr_dim: pad with zeros (rare in practice).
203
+ self._proj_mode = "structured"
204
+ if obs_dim <= fhrr_dim:
205
+ # Structured averaging: each obs dimension = mean of a block of FHRR dims
206
+ self._proj_block_size = fhrr_dim // obs_dim
207
+ self._proj_remainder = fhrr_dim % obs_dim
208
+ else:
209
+ self._proj_block_size = 1
210
+ self._proj_remainder = 0
211
 
212
  # NGC circuit: hierarchical predictive coding
213
  layer_sizes = [obs_dim] + hidden_dims
 
227
  self.energy_history: Deque[EnergyDecomposition] = deque(maxlen=max(1, int(energy_history_maxlen)))
228
 
229
  def _fhrr_to_obs(self, fhrr_vec: np.ndarray) -> np.ndarray:
230
+ """Project FHRR complex vector to real observation space.
231
+
232
+ Uses structure-preserving block averaging instead of random projection.
233
+ Each obs dimension = mean of a contiguous block of FHRR real components.
234
+ This preserves semantic similarity: if two FHRR vectors have similar
235
+ phasor angles, their block averages will also be similar.
236
+ """
237
  real_part = np.real(fhrr_vec).astype(np.float64)
238
+ bs = self._proj_block_size
239
+ obs = np.zeros(self.obs_dim, dtype=np.float64)
240
+ for i in range(self.obs_dim):
241
+ start = i * bs
242
+ end = min(start + bs, len(real_part))
243
+ if start < len(real_part):
244
+ obs[i] = np.mean(real_part[start:end])
245
+ return obs
246
 
247
  def observe(self, raw_input: Any, input_type: str = "numeric") -> Dict[str, Any]:
248
  """
 
283
  settle_result = self.ngc.settle(obs_vec)
284
  perception_energy = settle_result["final_energy"]
285
 
286
+ # === 4. JOINT SETTLING: Hopfield retrieval feeds back into NGC ===
287
+ # This closes the loop that was previously sequential:
288
+ # settle NGC query Hopfield DONE (old: pipeline)
289
+ # Now: settle NGC → query Hopfield → inject memory → re-settle NGC
290
+ # The second settle integrates memory evidence, making the energy
291
+ # decomposition genuinely joint rather than a sequential pipeline.
292
  abstract_state = self.ngc.get_abstract_state(level=-1)
293
  retrieved, memory_energy = self.memory.retrieve(abstract_state)
294
 
295
+ # Compute memory consistency
296
  abstract_norm = np.linalg.norm(abstract_state)
297
  retrieved_norm = np.linalg.norm(retrieved)
298
  if abstract_norm > 1e-8 and retrieved_norm > 1e-8:
 
301
  else:
302
  memory_similarity = 0.0
303
 
304
+ # Memory-guided re-settle: blend retrieved memory into top NGC layer
305
+ # and re-settle to integrate memory evidence into the full hierarchy.
306
+ # The blend weight is derived from memory_similarity itself:
307
+ # high similarity → strong blend (memory confirms), low → weak blend.
308
+ if self.memory.n_patterns > 2 and retrieved_norm > 1e-8:
309
+ # Blend weight = sigmoid(memory_similarity * 3) clamped to [0, 0.5]
310
+ # This means memory can provide up to 50% of the top-layer state,
311
+ # but only when it strongly matches the current abstract state.
312
+ blend = float(1.0 / (1.0 + np.exp(-3.0 * memory_similarity)))
313
+ blend = min(blend, 0.5)
314
+
315
+ # Inject retrieved memory into the top NGC layer
316
+ top_layer = self.ngc.layers[-1]
317
+ top_layer.z = (1.0 - blend) * top_layer.z + blend * retrieved
318
+
319
+ # Re-settle with memory evidence integrated
320
+ # Use fewer steps since we're refining, not starting from scratch
321
+ re_settle = self.ngc.settle(obs_vec, steps=max(3, self.ngc.settle_steps // 3))
322
+ perception_energy = re_settle["final_energy"]
323
+
324
+ # Re-query Hopfield with the refined abstract state
325
+ abstract_state = self.ngc.get_abstract_state(level=-1)
326
+ retrieved, memory_energy = self.memory.retrieve(abstract_state)
327
+
328
+ prediction_error_post_settle = self.ngc.prediction_error(obs_vec)
329
+
330
  # === 5. LEARN: Precision-modulated Hebbian update ===
331
  # Learning modulation: high when observation is consistent with memory,
332
  # low when it contradicts stored patterns.
tensegrity/pipeline/canonical.py CHANGED
@@ -130,14 +130,15 @@ class CanonicalPipeline:
130
  model_name: str = "meta-llama/Llama-3.2-1B-Instruct",
131
  # Loop budget
132
  max_iterations: int = 4,
133
- # Convergence: top1/top2 ratio above which we commit. Default 2.0
134
- # means the leader must be at least twice the runner-up in mass.
135
  commit_ratio: float = 2.0,
136
  # Falsification: how many NGC steps to settle each choice for the
137
  # top-down-predict-the-prompt operation.
138
  falsify_settle_steps: int = 20,
139
- # Bayesian update strength when integrating falsification likelihood
140
- # into the controller's hypothesis posteriors.
 
141
  falsify_update_strength: float = 1.0,
142
  # Energy-arena precision (passed through to CausalEnergyTerm).
143
  energy_arena_precision: float = 1.0,
@@ -151,8 +152,6 @@ class CanonicalPipeline:
151
  # Persistent episodic recall enters as a memory-evidence channel.
152
  memory_evidence_weight: float = 0.75,
153
  # SBERT sentence similarity enters as a semantic-evidence channel.
154
- # This is the strongest signal source: it compares the prompt against
155
- # each (prompt+choice) concatenation using frozen sentence embeddings.
156
  sbert_evidence_weight: float = 0.8,
157
  feedback_learning_rate: float = 1.0,
158
  persistent_state_path: Optional[str] = None,
@@ -163,11 +162,32 @@ class CanonicalPipeline:
163
  self.falsify_settle_steps = int(falsify_settle_steps)
164
  self.falsify_update_strength = float(falsify_update_strength)
165
  self.max_hypotheses = max(2, int(max_hypotheses))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  self.llm_evidence_weight = float(llm_evidence_weight)
167
  self.memory_evidence_weight = float(memory_evidence_weight)
168
  self.sbert_evidence_weight = float(sbert_evidence_weight)
169
- self.feedback_learning_rate = float(feedback_learning_rate)
170
- self.persistent_state_path = persistent_state_path
171
 
172
  initial_labels = list(hypothesis_labels or [])
173
  while len(initial_labels) < self.max_hypotheses:
@@ -539,6 +559,78 @@ class CanonicalPipeline:
539
  return top > 0
540
  return top >= ratio * second
541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  # ---------- main entry: score one item ----------
543
 
544
  def score_multichoice(
@@ -602,21 +694,32 @@ class CanonicalPipeline:
602
  )
603
 
604
  # 3. Bayesian update of controller's hypothesis posteriors:
605
- # new_p_i ∝ old_p_i * exp(strength * z(falsify_i)) * energy_post_i.
 
 
606
  old_belief = self._belief_from_controller(n)
607
  fz = self._znorm(falsify)
608
  lz = self._znorm(linguistic)
609
  mz = self._znorm(memory_scores)
610
  sz = self._znorm(sbert_scores)
611
- log_lik_falsify = self.falsify_update_strength * fz
 
612
  log_post = (
613
  np.log(np.maximum(old_belief, 1e-12))
614
- + log_lik_falsify
615
- + self.llm_evidence_weight * lz
616
- + self.memory_evidence_weight * mz
617
- + self.sbert_evidence_weight * sz
618
- + np.log(np.maximum(energy_post, 1e-12))
619
  )
 
 
 
 
 
 
 
 
620
  log_post -= log_post.max()
621
  new_belief = np.exp(log_post)
622
  sb = new_belief.sum()
@@ -657,7 +760,12 @@ class CanonicalPipeline:
657
  top_p=top_p,
658
  ))
659
 
660
- if self._converged(new_belief, self.commit_ratio):
 
 
 
 
 
661
  converged = True
662
  break
663
 
@@ -665,6 +773,9 @@ class CanonicalPipeline:
665
  final_belief = self._belief_from_controller(n)
666
  committed_idx = int(np.argmax(final_belief))
667
 
 
 
 
668
  # Calibrated score for the harness: belief shifted away from uniform,
669
  # bounded in [-1, 1]. Comparable in magnitude to the previous z-scored
670
  # outputs; the harness's confidence-gated blending stays sane.
@@ -813,6 +924,24 @@ class CanonicalPipeline:
813
  return {"learned": False, "reason": "invalid sample"}
814
 
815
  correct = int(committed_idx) == int(sample.gold)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  field = self.controller.agent.field
817
  prompt_fhrr = self._encode_text_fhrr(sample.prompt, max_tokens=96)
818
  correct_fhrr = self._encode_text_fhrr(
 
130
  model_name: str = "meta-llama/Llama-3.2-1B-Instruct",
131
  # Loop budget
132
  max_iterations: int = 4,
133
+ # Convergence is now self-tuning: derived from belief entropy dynamics.
134
+ # commit_ratio is kept as an initial value but will be overridden.
135
  commit_ratio: float = 2.0,
136
  # Falsification: how many NGC steps to settle each choice for the
137
  # top-down-predict-the-prompt operation.
138
  falsify_settle_steps: int = 20,
139
+ # These weights are now INITIAL values for the Dirichlet channel
140
+ # reliability tracker. They will be dynamically updated based on each
141
+ # channel's prediction accuracy. The system auto-tunes them.
142
  falsify_update_strength: float = 1.0,
143
  # Energy-arena precision (passed through to CausalEnergyTerm).
144
  energy_arena_precision: float = 1.0,
 
152
  # Persistent episodic recall enters as a memory-evidence channel.
153
  memory_evidence_weight: float = 0.75,
154
  # SBERT sentence similarity enters as a semantic-evidence channel.
 
 
155
  sbert_evidence_weight: float = 0.8,
156
  feedback_learning_rate: float = 1.0,
157
  persistent_state_path: Optional[str] = None,
 
162
  self.falsify_settle_steps = int(falsify_settle_steps)
163
  self.falsify_update_strength = float(falsify_update_strength)
164
  self.max_hypotheses = max(2, int(max_hypotheses))
165
+ self.feedback_learning_rate = float(feedback_learning_rate)
166
+ self.persistent_state_path = persistent_state_path
167
+
168
+ # --- Dirichlet channel reliability tracking ---
169
+ # Instead of fixed weights, each evidence channel has a Dirichlet
170
+ # pseudo-count that grows when the channel's top-ranked choice matches
171
+ # the committed belief (cross-channel agreement) or the gold label
172
+ # (post-feedback). Fusion weights = normalized counts.
173
+ #
174
+ # This is the VFE-minimizing closed form from pymdp:
175
+ # α* = α₀ + Σ_t obs_t ⊗ qs_t
176
+ # where α₀ is the initial prior strength.
177
+ #
178
+ # Channels: falsify, llm, memory, sbert, energy_arena
179
+ self._channel_names = ["falsify", "llm", "memory", "sbert", "energy"]
180
+ self._channel_alpha = {
181
+ "falsify": float(falsify_update_strength),
182
+ "llm": float(llm_evidence_weight),
183
+ "memory": float(memory_evidence_weight),
184
+ "sbert": float(sbert_evidence_weight),
185
+ "energy": float(energy_arena_beta),
186
+ }
187
+ # Expose derived weights (computed from alpha each call)
188
  self.llm_evidence_weight = float(llm_evidence_weight)
189
  self.memory_evidence_weight = float(memory_evidence_weight)
190
  self.sbert_evidence_weight = float(sbert_evidence_weight)
 
 
191
 
192
  initial_labels = list(hypothesis_labels or [])
193
  while len(initial_labels) < self.max_hypotheses:
 
559
  return top > 0
560
  return top >= ratio * second
561
 
562
+ def _channel_weights(self) -> Dict[str, float]:
563
+ """Compute normalized fusion weights from Dirichlet pseudo-counts.
564
+
565
+ weights_m = alpha_m / sum(alpha)
566
+
567
+ This is the expected value of the Dirichlet posterior over channel
568
+ reliabilities. As channels accumulate evidence of correctness,
569
+ their weight grows; unreliable channels fade toward zero.
570
+ """
571
+ total = sum(self._channel_alpha.values())
572
+ if total <= 0:
573
+ n = len(self._channel_names)
574
+ return {c: 1.0 / n for c in self._channel_names}
575
+ return {c: self._channel_alpha[c] / total for c in self._channel_names}
576
+
577
+ def _update_channel_reliability(
578
+ self, channel_scores: Dict[str, np.ndarray], committed_idx: int, n: int
579
+ ) -> None:
580
+ """Update Dirichlet pseudo-counts via cross-channel agreement.
581
+
582
+ Each channel earns pseudo-counts when its top-ranked choice agrees
583
+ with other channels. This is the consensus-based reliability update
584
+ from the IterativeCognitiveScorer, elevated to the canonical pipeline.
585
+
586
+ After feedback (gold label revealed), the channel that ranked the
587
+ gold answer highest gets a bonus pseudo-count — this is the
588
+ VFE-minimizing Dirichlet update from pymdp.
589
+ """
590
+ if n < 2:
591
+ return
592
+
593
+ # Get each channel's top pick
594
+ picks = {}
595
+ for name, scores in channel_scores.items():
596
+ if scores is not None and len(scores) >= n:
597
+ s = scores[:n]
598
+ if np.any(np.abs(s) > 1e-12):
599
+ picks[name] = int(np.argmax(s))
600
+
601
+ if len(picks) < 2:
602
+ return
603
+
604
+ # Cross-channel agreement: each channel gets credit for agreeing
605
+ # with others. This is NOT self-fulfilling — the anchor is the
606
+ # consensus structure, not any single channel.
607
+ for name_i, pick_i in picks.items():
608
+ agreements = sum(1 for name_j, pick_j in picks.items()
609
+ if name_j != name_i and pick_j == pick_i)
610
+ if agreements > 0:
611
+ credit = float(agreements) / max(len(picks) - 1, 1)
612
+ self._channel_alpha[name_i] += credit * 0.1 # slow accumulation
613
+
614
+ def _adaptive_commit_ratio(self, belief: np.ndarray) -> float:
615
+ """Derive the convergence commit ratio from belief entropy dynamics.
616
+
617
+ Instead of a fixed commit_ratio=2.0, the threshold adapts:
618
+ - When entropy is high (uniform beliefs), require higher separation (more cautious)
619
+ - When entropy is low (concentrated beliefs), require less separation (confident)
620
+
621
+ commit_ratio = 1.5 + entropy * 1.5
622
+ At max entropy (1.0): ratio = 3.0 (very cautious)
623
+ At min entropy (0.0): ratio = 1.5 (quick commit)
624
+ """
625
+ n = len(belief)
626
+ if n < 2:
627
+ return self.commit_ratio
628
+ nz = belief[belief > 0]
629
+ if len(nz) < 2:
630
+ return 1.5
631
+ entropy = float(-np.sum(nz * np.log(nz)) / np.log(n))
632
+ return 1.5 + entropy * 1.5
633
+
634
  # ---------- main entry: score one item ----------
635
 
636
  def score_multichoice(
 
694
  )
695
 
696
  # 3. Bayesian update of controller's hypothesis posteriors:
697
+ # new_p_i ∝ old_p_i * exp(w_c * z(channel_c_i)) for each channel c.
698
+ # Channel weights w_c are derived from Dirichlet pseudo-counts,
699
+ # not hardcoded — they auto-tune based on reliability.
700
  old_belief = self._belief_from_controller(n)
701
  fz = self._znorm(falsify)
702
  lz = self._znorm(linguistic)
703
  mz = self._znorm(memory_scores)
704
  sz = self._znorm(sbert_scores)
705
+
706
+ w = self._channel_weights()
707
  log_post = (
708
  np.log(np.maximum(old_belief, 1e-12))
709
+ + w["falsify"] * fz
710
+ + w["llm"] * lz
711
+ + w["memory"] * mz
712
+ + w["sbert"] * sz
713
+ + w["energy"] * np.log(np.maximum(energy_post, 1e-12))
714
  )
715
+
716
+ # Track per-channel scores for reliability update
717
+ _channel_scores = {
718
+ "falsify": falsify, "llm": linguistic,
719
+ "memory": memory_scores, "sbert": sbert_scores,
720
+ "energy": energy_post,
721
+ }
722
+ self._last_channel_scores_iter = _channel_scores
723
  log_post -= log_post.max()
724
  new_belief = np.exp(log_post)
725
  sb = new_belief.sum()
 
760
  top_p=top_p,
761
  ))
762
 
763
+ # Update channel reliability via cross-channel agreement
764
+ self._update_channel_reliability(_channel_scores, top_idx, n)
765
+
766
+ # Adaptive convergence: commit ratio derived from belief entropy
767
+ adaptive_ratio = self._adaptive_commit_ratio(new_belief)
768
+ if self._converged(new_belief, adaptive_ratio):
769
  converged = True
770
  break
771
 
 
773
  final_belief = self._belief_from_controller(n)
774
  committed_idx = int(np.argmax(final_belief))
775
 
776
+ # Save last channel scores for gold-label Dirichlet update in learn_from_feedback
777
+ self._last_channel_scores = getattr(self, '_last_channel_scores_iter', {})
778
+
779
  # Calibrated score for the harness: belief shifted away from uniform,
780
  # bounded in [-1, 1]. Comparable in magnitude to the previous z-scored
781
  # outputs; the harness's confidence-gated blending stays sane.
 
924
  return {"learned": False, "reason": "invalid sample"}
925
 
926
  correct = int(committed_idx) == int(sample.gold)
927
+ # --- Dirichlet channel reliability update from gold label ---
928
+ # This is the VFE-minimizing update: channels that ranked the gold
929
+ # answer higher get more pseudo-counts. This is the ONLY place where
930
+ # external supervision enters the channel weighting system.
931
+ # The update is: α_m += correctness_score_m (how well channel m
932
+ # ranked the gold answer relative to its ranking of other choices).
933
+ if hasattr(self, '_last_channel_scores') and self._last_channel_scores:
934
+ for name, scores in self._last_channel_scores.items():
935
+ if scores is not None and len(scores) >= n and sample.gold < n:
936
+ s = scores[:n]
937
+ s_range = float(np.max(s) - np.min(s))
938
+ if s_range > 1e-12:
939
+ # How well did this channel rank the gold answer?
940
+ # Normalized to [0, 1]: 1 = gold was ranked highest
941
+ gold_rank_score = float((s[sample.gold] - np.min(s)) / s_range)
942
+ else:
943
+ gold_rank_score = 1.0 / n # no discrimination
944
+ self._channel_alpha[name] += gold_rank_score * 0.5
945
  field = self.controller.agent.field
946
  prompt_fhrr = self._encode_text_fhrr(sample.prompt, max_tokens=96)
947
  correct_fhrr = self._encode_text_fhrr(