theapemachine commited on
Commit
f476620
·
1 Parent(s): 641ae8e

feat: V3 — SBERT-native cognitive stack (NGC, Hopfield, falsification all in embedding space) (#4)

Browse files

- feat: V3 SBERT-native unified_field.py (93c78629933b148fae5c0534af1175978c7da812)
- feat: V3 SBERT-native canonical.py (45a87afec2a282e8340bae1f8128a8711aa3f5f9)

tensegrity/engine/unified_field.py CHANGED
@@ -1,28 +1,26 @@
1
  """
2
- Unified Energy Landscape: One functional to rule them all.
3
 
4
- Earlier designs used separate components for separate kinds of energy
5
- minimization. This module unifies them into a single energy
6
- functional that decomposes into local terms:
7
 
8
- E_total = E_perception + E_memory + E_causal
 
 
9
 
10
- Where:
11
- E_perception = Σ_ℓ (1/2Σℓ) ||zℓ - Wℓφ(z^{ℓ+1})||² (NGC/predictive coding)
12
- E_memory = -lse(β, Xᵀξ) + ½||ξ||² (Hopfield energy)
13
- E_causal = Σ_v (1/2) ||z_v - f_v(z_pa(v))||² (SCM prediction error)
14
 
15
- All three are: "sum of squared prediction errors on a graph."
16
- The NGC circuit predicts its input. The Hopfield network predicts its query.
17
- The causal model predicts effects from causes. Same operation, different scale.
18
 
19
- The system settles by passing messages on this combined graph until the
20
- total energy reaches a minimum. That minimum IS the system's best explanation
21
- of the observation, given its memory and causal beliefs.
22
-
23
- This is what Friston's Free Energy Principle actually says: every component
24
- of the system minimizes its own local VFE, and the global behavior emerges
25
- from the composition of these local optimizations.
26
  """
27
 
28
  import logging
@@ -44,41 +42,38 @@ class EnergyDecomposition:
44
  memory: float # Hopfield retrieval energy
45
  causal: float # Causal SCM prediction error
46
  total: float # Sum
47
- prediction_error_norm: float # ||obs − predicted||² after settling (sensor space)
48
  surprise: float # -log P(observation | beliefs)
49
 
50
 
51
  class HopfieldMemoryBank:
52
  """
53
- Modern Hopfield network operating in FHRR space.
54
-
55
- Stores FHRR hypervectors as patterns. Retrieval is energy minimization:
56
  E(ξ) = -lse(β, Xᵀξ) + ½||ξ||²
57
  ξ_new = X · softmax(β · Xᵀ · ξ)
58
-
59
- This is mathematically identical to a single attention head where:
60
- - stored patterns X = keys = values
61
- - query ξ = the probe
62
- - β = 1/√d_k (inverse temperature)
63
  """
64
-
65
- def __init__(self, dim: int, beta: float = 0.01, capacity: int = 10000):
66
  self.dim = dim
67
  self.beta = beta
68
  self.capacity = capacity
69
-
70
  self.patterns: deque = deque(maxlen=capacity)
71
  self._matrix: Optional[np.ndarray] = None
72
  self._dirty = True
73
 
74
  def clear(self) -> None:
75
- """Remove all stored patterns; invalidate the pattern matrix cache."""
76
  self.patterns.clear()
77
  self._matrix = None
78
  self._dirty = True
79
 
80
  def store(self, pattern: np.ndarray, normalize: bool = True):
81
- """Store a pattern (FHRR vector — use real part for Hopfield)."""
82
  p = np.real(pattern).astype(np.float64) if np.iscomplexobj(pattern) else pattern.astype(np.float64)
83
  if normalize:
84
  norm = np.linalg.norm(p)
@@ -86,25 +81,21 @@ class HopfieldMemoryBank:
86
  p = p / norm
87
  self.patterns.append(p)
88
  self._dirty = True
89
-
90
  def retrieve(self, query: np.ndarray, steps: int = 3) -> Tuple[np.ndarray, float]:
91
- """
92
- Retrieve via energy minimization.
93
- Returns (retrieved_pattern, energy).
94
- """
95
  if not self.patterns:
96
  return np.zeros(self.dim), 0.0
97
-
98
  self._ensure_matrix()
99
-
100
  q = np.real(query).astype(np.float64) if np.iscomplexobj(query) else query.astype(np.float64)
101
  norm = np.linalg.norm(q)
102
  if norm > 0:
103
  q = q / norm
104
-
105
  xi = q.copy()
106
  for _ in range(steps):
107
- sims = self._matrix.T @ xi # (n_patterns,)
108
  scaled = self.beta * sims
109
  scaled -= scaled.max()
110
  weights = np.exp(scaled)
@@ -116,28 +107,21 @@ class HopfieldMemoryBank:
116
  if np.allclose(xi, xi_new, atol=1e-8):
117
  break
118
  xi = xi_new
119
-
120
- # Energy
121
  sims = self._matrix.T @ xi
122
  if self.beta <= 1e-12:
123
- _logger.warning(
124
- "HopfieldMemoryBank.retrieve: self.beta=%g is near zero; "
125
- "energy uses approximate uniform-attention form "
126
- "(0.5||xi||² - mean(sims)) instead of -lse/beta)",
127
- float(self.beta),
128
- )
129
  energy = float(0.5 * np.dot(xi, xi) - np.mean(sims))
130
  else:
131
  log_sum_exp = np.log(np.sum(np.exp(self.beta * sims - self.beta * sims.max()))) + self.beta * sims.max()
132
  energy = float(-log_sum_exp / self.beta + 0.5 * np.dot(xi, xi))
133
-
134
  return xi, energy
135
-
136
  def _ensure_matrix(self):
137
  if self._dirty and self.patterns:
138
  self._matrix = np.column_stack(list(self.patterns))
139
  self._dirty = False
140
-
141
  @property
142
  def n_patterns(self):
143
  return len(self.patterns)
@@ -145,95 +129,150 @@ class HopfieldMemoryBank:
145
 
146
  class UnifiedField:
147
  """
148
- The unified cognitive field.
149
-
150
- Composes:
151
- 1. FHRR encoder (observation compositional hypervector)
152
- 2. NGC circuit (hierarchical predictive coding)
153
- 3. Hopfield memory (content-addressed retrieval)
154
-
155
- All connected through a single energy functional.
156
- One step of cognition:
157
- a. Encode observation as FHRR vector
158
- b. Settle NGC circuit (minimize perception energy)
159
- c. Query Hopfield memory with settled top-layer state (minimize memory energy)
160
- d. Use memory retrieval to refine predictions (close the loop)
161
- e. Learn: Hebbian update on NGC weights + store in Hopfield
162
-
163
- The total energy E_total = E_ngc + E_hopfield monotonically decreases.
164
  """
165
-
 
 
 
166
  def __init__(self,
167
  obs_dim: int = 256,
168
  hidden_dims: List[int] = None,
169
  fhrr_dim: int = 2048,
170
- hopfield_beta: float = 0.01,
171
  ngc_settle_steps: int = 20,
172
  ngc_learning_rate: float = 0.005,
173
  ngc_precisions: Optional[List[float]] = None,
174
- energy_history_maxlen: int = 500):
 
175
  """
176
  Args:
177
- obs_dim: Dimension of the observation layer (FHRR real projection)
178
- hidden_dims: NGC hidden layer dimensions [h1, h2, ...].
179
- Full hierarchy = [obs_dim] + hidden_dims
180
- fhrr_dim: FHRR hypervector dimensionality
 
 
181
  hopfield_beta: Inverse temperature for Hopfield retrieval
182
  ngc_settle_steps: Settling iterations for NGC
183
  ngc_learning_rate: Hebbian learning rate
184
- energy_history_maxlen: Max UnifiedField energy decomposition records retained
 
185
  """
186
  if hidden_dims is None:
187
  hidden_dims = [128, 32]
188
-
189
- self.obs_dim = obs_dim
190
  self.fhrr_dim = fhrr_dim
191
-
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
 
 
214
  self.ngc = PredictiveCodingCircuit(
215
  layer_sizes=layer_sizes,
216
  precisions=ngc_precisions,
217
  settle_steps=ngc_settle_steps,
218
  learning_rate=ngc_learning_rate,
219
  )
220
-
221
- # Hopfield memory: stores abstract states from NGC top layer
222
- top_dim = hidden_dims[-1]
223
- self.memory = HopfieldMemoryBank(dim=top_dim, beta=hopfield_beta)
224
-
 
 
 
 
 
 
 
225
  # Energy tracking
226
  self._step_count = 0
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)
@@ -243,123 +282,104 @@ class UnifiedField:
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
  """
249
- Full cognitive cycle: observe predict → error → settle → learn → remember.
250
-
251
- Args:
252
- raw_input: The observation. Type depends on input_type:
253
- "numeric": np.ndarray of floats
254
- "bindings": dict of {role: filler} string pairs
255
- "tokens": list of string tokens
256
- "text": a single string (split into tokens)
257
- input_type: How to interpret raw_input
258
-
259
- Returns:
260
- Full cycle diagnostics
261
  """
262
  self._step_count += 1
263
-
264
- # === 1. ENCODE: raw input → FHRR → observation vector ===
265
- if input_type == "numeric":
266
- fhrr_vec = self.encoder.encode_numeric_vector(np.asarray(raw_input))
267
- elif input_type == "bindings":
268
- fhrr_vec = self.encoder.encode_observation(raw_input)
 
269
  elif input_type == "tokens":
 
 
 
270
  fhrr_vec = self.encoder.encode_sequence(raw_input)
271
- elif input_type == "text":
272
- tokens = str(raw_input).lower().split()
273
- fhrr_vec = self.encoder.encode_sequence(tokens)
 
 
 
 
 
274
  else:
275
  raise ValueError(f"Unknown input_type: {input_type}")
276
-
277
- obs_vec = self._fhrr_to_obs(fhrr_vec)
278
-
279
- # === 2. PREDICT: what did the NGC expect before this observation's settle cycle? ===
280
  prediction_error_pre_settle = self.ngc.prediction_error(obs_vec)
281
-
282
- # === 3. SETTLE: minimize perception energy ===
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:
299
- memory_similarity = float(np.dot(abstract_state, retrieved) /
300
- (abstract_norm * retrieved_norm))
 
 
 
 
 
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.
333
- # This prevents the NGC from learning equally from truth and lies.
334
- #
335
- # modulation = sigmoid(memory_similarity * temperature)
336
- # When mem_sim is high (consistent): modulation → 1.0 (learn fully)
337
- # When mem_sim is low/negative (contradictory): modulation → 0.0 (don't learn)
338
- # When no memory yet (step 1-2): modulation = 1.0 (learn from everything initially)
339
  if self.memory.n_patterns <= 2:
340
- # Not enough memory to judge consistency — learn from everything
341
  learning_modulation = 1.0
342
  else:
343
- # Sigmoid: maps [-1, 1] similarity to [0, 1] modulation
344
- # temperature=3.0 makes the transition fairly sharp
345
- learning_modulation = float(1.0 / (1.0 + np.exp(-3.0 * memory_similarity)))
346
-
347
  self.ngc.learn(modulation=learning_modulation)
348
- self.memory.store(abstract_state)
349
-
350
- # === 6. ENERGY: compute decomposition ===
351
  decomp = EnergyDecomposition(
352
  perception=perception_energy,
353
  memory=memory_energy,
354
- causal=0.0, # Will be added when causal module is connected
355
  total=perception_energy + memory_energy,
356
  prediction_error_norm=float(prediction_error_post_settle),
357
- # Monotone prediction-error proxy. ``log1p`` keeps surprise
358
- # non-negative even when the squared prediction error is below 1.0.
359
  surprise=float(np.log1p(max(prediction_error_post_settle, 0.0))),
360
  )
361
  self.energy_history.append(decomp)
362
-
363
  return {
364
  "step": self._step_count,
365
  "fhrr_vector": fhrr_vec,
@@ -374,21 +394,16 @@ class UnifiedField:
374
  "prediction_error_pre_settle": prediction_error_pre_settle,
375
  "prediction_error_post_settle": prediction_error_post_settle,
376
  }
377
-
378
  def predict(self) -> np.ndarray:
379
- """
380
- What does the system expect to observe next?
381
-
382
- This is the forward prediction from the settled internal state.
383
- """
384
  return self.ngc.predict_observation()
385
-
386
  @property
387
  def total_energy(self) -> float:
388
  if self.energy_history:
389
  return self.energy_history[-1].total
390
  return 0.0
391
-
392
  @property
393
  def statistics(self) -> Dict[str, Any]:
394
  return {
@@ -398,7 +413,5 @@ class UnifiedField:
398
  "memory_patterns": self.memory.n_patterns,
399
  "fhrr_dim": self.fhrr_dim,
400
  "obs_dim": self.obs_dim,
 
401
  }
402
-
403
-
404
-
 
1
  """
2
+ Unified Energy Landscape operating in SBERT embedding space.
3
 
4
+ V3: The cognitive stack operates DIRECTLY in the sentence-transformer
5
+ embedding space. No random projection, no FHRR→obs conversion for the
6
+ cognitive path.
7
 
8
+ Layer 0 of NGC = SBERT embedding (384-dim for MiniLM-L6-v2)
9
+ Hopfield memory stores SBERT embeddings
10
+ Falsification compares SBERT embeddings through learned W matrices
11
 
12
+ Why: The NGC needs to learn the structure of how questions map to answers.
13
+ Random projections destroy the semantic structure that SBERT provides.
14
+ With 100+ benchmark items, the NGC sees enough data to learn real
15
+ generative models of question→answer mappings in SBERT space.
16
 
17
+ The FHRR encoder is preserved for compositional binding operations
18
+ (role-filler pairs, sequence encoding) but is NOT in the NGC's
19
+ observation path. FHRR lives alongside SBERT, not in front of it.
20
 
21
+ Energy decomposition remains:
22
+ E_total = E_perception (NGC) + E_memory (Hopfield)
23
+ Both now operate on semantically meaningful vectors.
 
 
 
 
24
  """
25
 
26
  import logging
 
42
  memory: float # Hopfield retrieval energy
43
  causal: float # Causal SCM prediction error
44
  total: float # Sum
45
+ prediction_error_norm: float # ||obs − predicted||² after settling
46
  surprise: float # -log P(observation | beliefs)
47
 
48
 
49
  class HopfieldMemoryBank:
50
  """
51
+ Modern Hopfield network operating in SBERT embedding space.
52
+
53
+ Stores sentence embeddings as patterns. Retrieval is energy minimization:
54
  E(ξ) = -lse(β, Xᵀξ) + ½||ξ||²
55
  ξ_new = X · softmax(β · Xᵀ · ξ)
56
+
57
+ Now stores 384-dim SBERT embeddings (not 8-dim NGC top states).
58
+ This gives the memory enough information to distinguish semantically
59
+ different inputs and retrieve genuinely relevant past experiences.
 
60
  """
61
+
62
+ def __init__(self, dim: int, beta: float = 0.05, capacity: int = 10000):
63
  self.dim = dim
64
  self.beta = beta
65
  self.capacity = capacity
66
+
67
  self.patterns: deque = deque(maxlen=capacity)
68
  self._matrix: Optional[np.ndarray] = None
69
  self._dirty = True
70
 
71
  def clear(self) -> None:
 
72
  self.patterns.clear()
73
  self._matrix = None
74
  self._dirty = True
75
 
76
  def store(self, pattern: np.ndarray, normalize: bool = True):
 
77
  p = np.real(pattern).astype(np.float64) if np.iscomplexobj(pattern) else pattern.astype(np.float64)
78
  if normalize:
79
  norm = np.linalg.norm(p)
 
81
  p = p / norm
82
  self.patterns.append(p)
83
  self._dirty = True
84
+
85
  def retrieve(self, query: np.ndarray, steps: int = 3) -> Tuple[np.ndarray, float]:
 
 
 
 
86
  if not self.patterns:
87
  return np.zeros(self.dim), 0.0
88
+
89
  self._ensure_matrix()
90
+
91
  q = np.real(query).astype(np.float64) if np.iscomplexobj(query) else query.astype(np.float64)
92
  norm = np.linalg.norm(q)
93
  if norm > 0:
94
  q = q / norm
95
+
96
  xi = q.copy()
97
  for _ in range(steps):
98
+ sims = self._matrix.T @ xi
99
  scaled = self.beta * sims
100
  scaled -= scaled.max()
101
  weights = np.exp(scaled)
 
107
  if np.allclose(xi, xi_new, atol=1e-8):
108
  break
109
  xi = xi_new
110
+
 
111
  sims = self._matrix.T @ xi
112
  if self.beta <= 1e-12:
 
 
 
 
 
 
113
  energy = float(0.5 * np.dot(xi, xi) - np.mean(sims))
114
  else:
115
  log_sum_exp = np.log(np.sum(np.exp(self.beta * sims - self.beta * sims.max()))) + self.beta * sims.max()
116
  energy = float(-log_sum_exp / self.beta + 0.5 * np.dot(xi, xi))
117
+
118
  return xi, energy
119
+
120
  def _ensure_matrix(self):
121
  if self._dirty and self.patterns:
122
  self._matrix = np.column_stack(list(self.patterns))
123
  self._dirty = False
124
+
125
  @property
126
  def n_patterns(self):
127
  return len(self.patterns)
 
129
 
130
  class UnifiedField:
131
  """
132
+ The unified cognitive field — operating in SBERT embedding space.
133
+
134
+ V3 architecture:
135
+ 1. SBERT encoder provides the observation vector (no projection needed)
136
+ 2. NGC circuit operates on SBERT embeddings: layer 0 = sbert_dim
137
+ 3. Hopfield memory stores SBERT embeddings directly
138
+ 4. FHRR encoder preserved for compositional binding (parallel path)
139
+
140
+ The NGC learns a generative model of how text maps to embeddings.
141
+ After 100+ items, the W matrices encode real structure: "prompts in
142
+ this domain tend to predict answers with these embedding patterns."
143
+ This makes falsification genuine: "does settling on this answer's
144
+ embedding produce a good prediction of the prompt's embedding?"
 
 
 
145
  """
146
+
147
+ # Default SBERT dim for all-MiniLM-L6-v2
148
+ DEFAULT_SBERT_DIM = 384
149
+
150
  def __init__(self,
151
  obs_dim: int = 256,
152
  hidden_dims: List[int] = None,
153
  fhrr_dim: int = 2048,
154
+ hopfield_beta: float = 0.05,
155
  ngc_settle_steps: int = 20,
156
  ngc_learning_rate: float = 0.005,
157
  ngc_precisions: Optional[List[float]] = None,
158
+ energy_history_maxlen: int = 500,
159
+ sbert_dim: Optional[int] = None):
160
  """
161
  Args:
162
+ obs_dim: Legacy parameter. If sbert_dim is set, NGC uses sbert_dim
163
+ for layer 0 instead. Kept for backward compatibility with
164
+ code that constructs UnifiedField with obs_dim.
165
+ hidden_dims: NGC hidden layer dimensions. Full hierarchy =
166
+ [sbert_dim or obs_dim] + hidden_dims
167
+ fhrr_dim: FHRR dimensionality (for compositional binding path)
168
  hopfield_beta: Inverse temperature for Hopfield retrieval
169
  ngc_settle_steps: Settling iterations for NGC
170
  ngc_learning_rate: Hebbian learning rate
171
+ sbert_dim: If set, NGC layer 0 uses this dimension (SBERT space).
172
+ Detected automatically when SBERT is available.
173
  """
174
  if hidden_dims is None:
175
  hidden_dims = [128, 32]
176
+
 
177
  self.fhrr_dim = fhrr_dim
178
+
179
+ # FHRR encoder (for compositional binding — parallel path)
180
  self.encoder = FHRREncoder(dim=fhrr_dim)
181
+
182
+ # Detect SBERT dimension from the encoder
183
+ self._sbert_dim = sbert_dim
184
+ if self._sbert_dim is None:
185
+ # Try to detect from the semantic codebook
186
+ features = self.encoder.features
187
+ if hasattr(features, '_sbert_dim') and features._sbert_dim is not None:
188
+ self._sbert_dim = features._sbert_dim
189
+ elif hasattr(features, '_ensure_sbert'):
190
+ features._ensure_sbert()
191
+ if hasattr(features, '_sbert_dim') and features._sbert_dim is not None:
192
+ self._sbert_dim = features._sbert_dim
193
+
194
+ # NGC operates in SBERT space when available, else falls back to obs_dim
195
+ if self._sbert_dim is not None and self._sbert_dim > 0:
196
+ self.obs_dim = self._sbert_dim
197
+ _logger.info(
198
+ "UnifiedField: NGC operating in SBERT space (dim=%d)", self._sbert_dim
199
+ )
200
  else:
201
+ self.obs_dim = obs_dim
202
+ _logger.info(
203
+ "UnifiedField: SBERT unavailable, NGC using obs_dim=%d", obs_dim
204
+ )
205
+
206
+ # NGC circuit: layer 0 = SBERT dim (or obs_dim fallback)
207
+ layer_sizes = [self.obs_dim] + hidden_dims
208
  self.ngc = PredictiveCodingCircuit(
209
  layer_sizes=layer_sizes,
210
  precisions=ngc_precisions,
211
  settle_steps=ngc_settle_steps,
212
  learning_rate=ngc_learning_rate,
213
  )
214
+
215
+ # Hopfield memory: stores SBERT embeddings directly
216
+ # NOT the tiny NGC top-layer states — full SBERT embeddings so
217
+ # retrieval can distinguish semantically different inputs.
218
+ self.memory = HopfieldMemoryBank(
219
+ dim=self.obs_dim, beta=hopfield_beta
220
+ )
221
+
222
+ # Legacy: keep _fhrr_to_obs working for callers that still use it
223
+ self._proj_mode = "identity_or_sbert"
224
+ self._proj_block_size = max(1, fhrr_dim // self.obs_dim)
225
+
226
  # Energy tracking
227
  self._step_count = 0
228
+ self.energy_history: Deque[EnergyDecomposition] = deque(
229
+ maxlen=max(1, int(energy_history_maxlen))
230
+ )
231
+
232
+ def get_sbert_embedding(self, text: str) -> Optional[np.ndarray]:
233
+ """Encode text directly to SBERT embedding, bypassing FHRR.
234
+
235
+ Returns None if SBERT is not available.
236
+ """
237
+ features = self.encoder.features
238
+ getter = getattr(features, "get_sbert_model", None)
239
+ sbert = getter() if callable(getter) else None
240
+ if sbert is None:
241
+ return None
242
+ try:
243
+ emb = sbert.encode([text], show_progress_bar=False)[0]
244
+ return np.asarray(emb, dtype=np.float64)
245
+ except Exception as e:
246
+ _logger.debug("SBERT encoding failed: %s", e)
247
+ return None
248
+
249
+ def text_to_obs(self, text: str) -> np.ndarray:
250
+ """Convert text to observation vector for NGC.
251
+
252
+ Prefers SBERT embedding (semantically rich, right dimensionality).
253
+ Falls back to FHRR→block-average if SBERT is unavailable.
254
  """
255
+ emb = self.get_sbert_embedding(text)
256
+ if emb is not None:
257
+ # Ensure correct dimension
258
+ if len(emb) == self.obs_dim:
259
+ return emb
260
+ elif len(emb) > self.obs_dim:
261
+ return emb[:self.obs_dim]
262
+ else:
263
+ return np.pad(emb, (0, self.obs_dim - len(emb)))
264
+
265
+ # Fallback: FHRR path
266
+ import re
267
+ tokens = re.findall(
268
+ r"[a-zA-Z]+(?:'[a-z]+)?|[0-9]+(?:\.[0-9]+)?", text.lower()
269
+ )[-64:]
270
+ fhrr_vec = self.encoder.encode_sequence(tokens) if tokens else \
271
+ np.ones(self.fhrr_dim, dtype=np.complex64)
272
+ return self._fhrr_to_obs(fhrr_vec)
273
+
274
+ def _fhrr_to_obs(self, fhrr_vec: np.ndarray) -> np.ndarray:
275
+ """Legacy: project FHRR to obs space via block averaging."""
276
  real_part = np.real(fhrr_vec).astype(np.float64)
277
  bs = self._proj_block_size
278
  obs = np.zeros(self.obs_dim, dtype=np.float64)
 
282
  if start < len(real_part):
283
  obs[i] = np.mean(real_part[start:end])
284
  return obs
285
+
286
  def observe(self, raw_input: Any, input_type: str = "numeric") -> Dict[str, Any]:
287
  """
288
+ Full cognitive cycle in SBERT space.
289
+
290
+ For text inputs: encodes via SBERT directly (no FHRR intermediary).
291
+ For legacy inputs (numeric, bindings, tokens): uses FHRR→obs fallback.
 
 
 
 
 
 
 
 
292
  """
293
  self._step_count += 1
294
+
295
+ # === 1. ENCODE ===
296
+ if input_type == "text":
297
+ obs_vec = self.text_to_obs(str(raw_input))
298
+ fhrr_vec = self.encoder.encode_sequence(
299
+ str(raw_input).lower().split()
300
+ )
301
  elif input_type == "tokens":
302
+ # Try SBERT on joined text, fall back to FHRR
303
+ text = " ".join(raw_input) if isinstance(raw_input, list) else str(raw_input)
304
+ obs_vec = self.text_to_obs(text)
305
  fhrr_vec = self.encoder.encode_sequence(raw_input)
306
+ elif input_type == "bindings":
307
+ fhrr_vec = self.encoder.encode_observation(raw_input)
308
+ # Try to get SBERT embedding from the binding values
309
+ text = " ".join(f"{k} {v}" for k, v in raw_input.items())
310
+ obs_vec = self.text_to_obs(text)
311
+ elif input_type == "numeric":
312
+ fhrr_vec = self.encoder.encode_numeric_vector(np.asarray(raw_input))
313
+ obs_vec = self._fhrr_to_obs(fhrr_vec)
314
  else:
315
  raise ValueError(f"Unknown input_type: {input_type}")
316
+
317
+ # === 2. PREDICT ===
 
 
318
  prediction_error_pre_settle = self.ngc.prediction_error(obs_vec)
319
+
320
+ # === 3. SETTLE ===
321
  settle_result = self.ngc.settle(obs_vec)
322
  perception_energy = settle_result["final_energy"]
323
+
324
+ # === 4. MEMORY: query and re-settle ===
 
 
 
 
 
325
  abstract_state = self.ngc.get_abstract_state(level=-1)
326
+
327
+ # Store the SBERT embedding in Hopfield (not the tiny abstract state)
328
+ # but query with the abstract state projected back to obs_dim
329
+ # Actually: store obs_vec (SBERT embedding) and query with obs_vec
330
+ # The memory operates in the same space as NGC layer 0.
331
+ retrieved, memory_energy = self.memory.retrieve(obs_vec)
332
+
333
+ obs_norm = np.linalg.norm(obs_vec)
334
+ ret_norm = np.linalg.norm(retrieved)
335
+ if obs_norm > 1e-8 and ret_norm > 1e-8:
336
+ memory_similarity = float(
337
+ np.dot(obs_vec / obs_norm, retrieved / ret_norm)
338
+ )
339
  else:
340
  memory_similarity = 0.0
341
+
342
+ # Memory-guided re-settle
343
+ if self.memory.n_patterns > 2 and ret_norm > 1e-8:
 
 
 
 
 
 
344
  blend = float(1.0 / (1.0 + np.exp(-3.0 * memory_similarity)))
345
  blend = min(blend, 0.5)
 
 
346
  top_layer = self.ngc.layers[-1]
347
+ # Project retrieved memory (obs_dim) to top-layer dim
348
+ # Use the NGC's own prediction to do this: retrieve → settle layer 0
349
+ # the top layer state IS the "memory's view" of the abstract state
350
+ # Simpler: just blend at layer 0 and re-settle
351
+ self.ngc.layers[0].z = (1.0 - blend) * obs_vec + blend * retrieved
352
+ re_settle = self.ngc.settle(
353
+ self.ngc.layers[0].z,
354
+ steps=max(3, self.ngc.settle_steps // 3)
355
+ )
356
  perception_energy = re_settle["final_energy"]
 
 
357
  abstract_state = self.ngc.get_abstract_state(level=-1)
358
+
 
359
  prediction_error_post_settle = self.ngc.prediction_error(obs_vec)
360
+
361
+ # === 5. LEARN ===
 
 
 
 
 
 
 
 
362
  if self.memory.n_patterns <= 2:
 
363
  learning_modulation = 1.0
364
  else:
365
+ learning_modulation = float(
366
+ 1.0 / (1.0 + np.exp(-3.0 * memory_similarity))
367
+ )
368
+
369
  self.ngc.learn(modulation=learning_modulation)
370
+ self.memory.store(obs_vec) # Store SBERT embedding, not abstract state
371
+
372
+ # === 6. ENERGY ===
373
  decomp = EnergyDecomposition(
374
  perception=perception_energy,
375
  memory=memory_energy,
376
+ causal=0.0,
377
  total=perception_energy + memory_energy,
378
  prediction_error_norm=float(prediction_error_post_settle),
 
 
379
  surprise=float(np.log1p(max(prediction_error_post_settle, 0.0))),
380
  )
381
  self.energy_history.append(decomp)
382
+
383
  return {
384
  "step": self._step_count,
385
  "fhrr_vector": fhrr_vec,
 
394
  "prediction_error_pre_settle": prediction_error_pre_settle,
395
  "prediction_error_post_settle": prediction_error_post_settle,
396
  }
397
+
398
  def predict(self) -> np.ndarray:
 
 
 
 
 
399
  return self.ngc.predict_observation()
400
+
401
  @property
402
  def total_energy(self) -> float:
403
  if self.energy_history:
404
  return self.energy_history[-1].total
405
  return 0.0
406
+
407
  @property
408
  def statistics(self) -> Dict[str, Any]:
409
  return {
 
413
  "memory_patterns": self.memory.n_patterns,
414
  "fhrr_dim": self.fhrr_dim,
415
  "obs_dim": self.obs_dim,
416
+ "sbert_dim": self._sbert_dim,
417
  }
 
 
 
tensegrity/pipeline/canonical.py CHANGED
@@ -226,16 +226,6 @@ class CanonicalPipeline:
226
  self._choice_model_names: List[str] = []
227
  self._last_derived_obs: List[Dict[str, int]] = []
228
 
229
- # --- Persistent causal knowledge ---
230
- # Domain-level SCMs persist across items within a task. Instead of
231
- # rebuilding every SCM from scratch per item (which gives uniform CPTs
232
- # that contribute noise), we maintain a library of domain SCMs keyed
233
- # by task domain. When a new item arrives, we look up existing SCMs
234
- # for that domain and re-register them with accumulated experience.
235
- # Per-choice ephemeral SCMs are still created, but the domain SCM
236
- # provides a prior that shapes the per-choice energy competition.
237
- self._domain_scm_library: Dict[str, StructuralCausalModel] = {}
238
-
239
  if self.persistent_state_path:
240
  self.load_state(self.persistent_state_path)
241
 
@@ -296,15 +286,14 @@ class CanonicalPipeline:
296
  self._scm_topologies = {}
297
  self._choice_model_names = []
298
  self._last_derived_obs = []
299
-
300
- # Determine domain for persistent SCM lookup
301
- domain = sample.metadata.get("domain", "general")
302
-
303
  for i, label in enumerate(labels[:len(sample.choices)]):
304
- scm = self._build_choice_scm(i, label, domain=domain)
305
  try:
306
  self.energy_arena.register(scm)
307
  self._choice_model_names.append(scm.name)
 
 
 
308
  n_ngc_layers = len(self.controller.agent.field.ngc.layer_sizes)
309
  topology = self._topology_mapper.from_scm(scm, n_layers=n_ngc_layers)
310
  self._scm_topologies[scm.name] = topology
@@ -356,46 +345,23 @@ class CanonicalPipeline:
356
 
357
  # ---------- per-choice SCM (used by EnergyCausalArena) ----------
358
 
359
- def _build_choice_scm(self, choice_idx: int, label: str,
360
- domain: str = "general") -> StructuralCausalModel:
361
  """
362
- Build a per-choice SCM, seeded with persistent domain knowledge.
363
 
364
- The structure is always:
365
  prompt_feature ──▶ choice_match ──▶ observation
366
 
367
  │ (lateral) coherence
368
 
369
- But CPTs are initialized from the domain SCM library if a matching
370
- domain model exists. This means the per-choice SCMs start with
371
- accumulated experience from prior items in the same domain, not
372
- uniform Dirichlet priors. The domain model is the persistent
373
- causal knowledge that survives across items.
374
  """
375
  scm = StructuralCausalModel(name=f"choice_{choice_idx}_{label}")
376
  scm.add_variable("prompt_feature", n_values=4, parents=[])
377
  scm.add_variable("coherence", n_values=4, parents=[])
378
  scm.add_variable("choice_match", n_values=4, parents=["prompt_feature"])
379
  scm.add_variable("observation", n_values=4, parents=["choice_match", "coherence"])
380
-
381
- # Seed from domain library if available
382
- domain_key = f"domain_{domain}"
383
- if domain_key in self._domain_scm_library:
384
- domain_scm = self._domain_scm_library[domain_key]
385
- # Copy accumulated CPTs from the domain model
386
- for var_name, mech in scm.mechanisms.items():
387
- domain_mech = domain_scm.mechanisms.get(var_name)
388
- if domain_mech is not None and mech.cpt.shape == domain_mech.cpt.shape:
389
- mech.cpt[:] = domain_mech.cpt
390
- else:
391
- # Create a new domain SCM for future seeding
392
- domain_scm = StructuralCausalModel(name=domain_key)
393
- domain_scm.add_variable("prompt_feature", n_values=4, parents=[])
394
- domain_scm.add_variable("coherence", n_values=4, parents=[])
395
- domain_scm.add_variable("choice_match", n_values=4, parents=["prompt_feature"])
396
- domain_scm.add_variable("observation", n_values=4, parents=["choice_match", "coherence"])
397
- self._domain_scm_library[domain_key] = domain_scm
398
-
399
  return scm
400
 
401
  # ---------- one-shot ingest (delegates to controller) ----------
@@ -421,61 +387,73 @@ class CanonicalPipeline:
421
  self, prompt: str, choices: List[str]
422
  ) -> Tuple[np.ndarray, List[Dict[str, int]]]:
423
  """
424
- For each choice c_i:
425
- 1. Save NGC base state (prompt-grounded after perceive).
426
- 2. Encode c_i alone, settle NGC under it.
427
- 3. Ask the field to top-down predict the prompt observation.
428
- 4. score_i = -prediction_error.
429
- 5. Discretize the obs/pred for use as energy-arena observations.
430
 
431
- Returns (scores, energy_arena_observations).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  """
433
  field = self.controller.agent.field
434
- prompt_tokens = _alphanum_tokens(prompt, max_tokens=64)
435
- prompt_obs = field._fhrr_to_obs(field.encoder.encode_sequence(prompt_tokens))
436
 
437
- # Snapshot the prompt-grounded state to restore between choices.
 
 
 
438
  try:
 
439
  base_state = field.ngc.save_state()
440
  except Exception:
441
  base_state = None
442
 
443
  scores = np.zeros(len(choices), dtype=np.float64)
444
  derived_obs: List[Dict[str, int]] = []
 
445
  for i, c in enumerate(choices):
446
  if base_state is not None:
447
  try:
448
  field.ngc.restore_state(base_state)
449
  except Exception:
450
  pass
451
- ctoks = _alphanum_tokens(c, max_tokens=32)
452
- choice_obs = field._fhrr_to_obs(field.encoder.encode_sequence(ctoks))
 
 
453
  try:
454
  field.ngc.settle(choice_obs, steps=self.falsify_settle_steps)
455
- pe = float(field.ngc.prediction_error(prompt_obs))
 
 
 
 
456
  except Exception as e:
457
- logger.error(
458
- "NGC falsification failed for choice %d: %s",
459
- i, e, exc_info=True,
460
- )
461
  pe = float(1e9)
462
  scores[i] = -pe
463
 
464
- # Derive a compact discrete observation for the energy arena.
465
- # Each variable is bucketed into 4 levels to match the per-choice
466
- # SCM cardinality. The buckets are deterministic from the field
467
- # state, not random.
468
  try:
469
- pred_obs = field.ngc.predict_observation()
470
- pf = self._bucket_4(float(np.dot(prompt_obs, prompt_obs) ** 0.5))
471
- cm = self._bucket_4(-pe)
472
- co = self._bucket_4(float(np.dot(pred_obs, prompt_obs)))
473
- ob = self._bucket_4(float(np.linalg.norm(pred_obs)))
 
 
474
  derived_obs.append({
475
- "prompt_feature": pf,
476
- "choice_match": cm,
477
- "coherence": co,
478
- "observation": ob,
479
  })
480
  except Exception:
481
  derived_obs.append({
@@ -483,8 +461,7 @@ class CanonicalPipeline:
483
  "coherence": 0, "observation": 0,
484
  })
485
 
486
- # Restore the prompt-grounded state so subsequent perceive calls aren't
487
- # contaminated by the last falsification settle.
488
  if base_state is not None:
489
  try:
490
  field.ngc.restore_state(base_state)
@@ -853,11 +830,9 @@ class CanonicalPipeline:
853
  def _sbert_choice_scores(self, sample: TaskSample) -> np.ndarray:
854
  """Score choices by SBERT sentence-level cosine similarity.
855
 
856
- This is the strongest semantic signal: it compares the prompt against
857
- each choice using frozen sentence embeddings from a pretrained SBERT
858
- model. Unlike the NGC falsification path, this signal is NOT destroyed
859
- by the random FHRR→obs projection and directly measures semantic
860
- relatedness in the original embedding space.
861
  """
862
  n = len(sample.choices)
863
  scores = np.zeros(n, dtype=np.float64)
@@ -865,83 +840,66 @@ class CanonicalPipeline:
865
  return scores
866
 
867
  field = self.controller.agent.field
868
- features = field.encoder.features
869
- # Try to get the SBERT model from the semantic codebook
870
- getter = getattr(features, "get_sbert_model", None)
871
- sbert = getter() if callable(getter) else None
872
- if sbert is None:
 
873
  return scores
874
 
875
  try:
876
- texts = [sample.prompt] + [
877
- f"{sample.prompt} {c}" for c in sample.choices
878
- ]
879
- embs = sbert.encode(texts, show_progress_bar=False)
880
- pe = embs[0]
881
- pn = float(np.linalg.norm(pe))
882
- if pn < 1e-8:
883
- return scores
884
- for i in range(n):
885
- ce = embs[i + 1]
886
- cn = float(np.linalg.norm(ce))
887
- if cn > 1e-8:
888
- scores[i] = float(np.dot(pe, ce) / (pn * cn))
889
  except Exception as e:
890
  logger.debug("SBERT choice scoring failed: %s", e)
891
 
892
  return scores
893
 
894
  def _memory_choice_scores(self, sample: TaskSample) -> np.ndarray:
895
- """Retrieve prior successful episodes and score choices by similarity.
896
 
897
- This is the persistent memory channel inside the same posterior update
898
- as predictive-coding falsification, causal energy, and LLM evidence.
 
 
 
899
  """
900
  n = len(sample.choices)
901
  scores = np.zeros(n, dtype=np.float64)
902
  if n == 0:
903
  return scores
904
 
905
- episodic = getattr(self.controller.agent, "episodic", None)
906
- if episodic is None or not getattr(episodic, "episodes", None):
907
  return scores
908
 
909
- field = self.controller.agent.field
910
- prompt_fhrr = self._encode_text_fhrr(sample.prompt, max_tokens=96)
911
- prompt_obs = field._fhrr_to_obs(prompt_fhrr)
912
- query_belief = np.full(n, 1.0 / n, dtype=np.float64)
913
 
 
914
  try:
915
- query_ctx = episodic.compute_item_representation(prompt_obs, query_belief)
916
- retrieved = episodic.retrieve_by_context(query_context=query_ctx, k=8)
917
  except Exception as e:
918
- logger.debug("persistent episodic retrieval skipped: %s", e)
919
  return scores
920
 
921
- if not retrieved:
 
922
  return scores
923
 
924
- choice_vecs = [
925
- self._unit_real(self._encode_text_fhrr(choice, max_tokens=48))
926
- for choice in sample.choices
927
- ]
928
- for ep in retrieved:
929
- meta = getattr(ep, "metadata", {}) or {}
930
- correct_vec = meta.get("correct_fhrr_real")
931
- if correct_vec is None:
932
- continue
933
- correct_vec = np.asarray(correct_vec, dtype=np.float64)
934
- cn = np.linalg.norm(correct_vec)
935
- if cn <= 1e-10:
936
- continue
937
- correct_vec = correct_vec / cn
938
- ctx_sim = float(np.dot(query_ctx, ep.context_vector))
939
- if ctx_sim <= 0.0:
940
- continue
941
- confidence = 1.0 - float(ep.surprise)
942
- weight = ctx_sim * max(0.05, confidence)
943
- for i, choice_vec in enumerate(choice_vecs):
944
- scores[i] += weight * float(np.dot(choice_vec, correct_vec))
945
 
946
  return scores
947
 
@@ -977,18 +935,23 @@ class CanonicalPipeline:
977
  gold_rank_score = 1.0 / n # no discrimination
978
  self._channel_alpha[name] += gold_rank_score * 0.5
979
  field = self.controller.agent.field
980
- prompt_fhrr = self._encode_text_fhrr(sample.prompt, max_tokens=96)
981
- correct_fhrr = self._encode_text_fhrr(
982
- f"{sample.prompt} {sample.choices[sample.gold]}",
983
- max_tokens=128,
984
- )
985
- prompt_obs = field._fhrr_to_obs(prompt_fhrr)
986
- correct_obs = field._fhrr_to_obs(correct_fhrr)
 
 
 
 
 
 
987
 
988
  try:
989
  field.ngc.settle(correct_obs, steps=max(1, self.falsify_settle_steps))
990
  field.ngc.learn(modulation=max(0.0, self.feedback_learning_rate))
991
- field.memory.store(field.ngc.get_abstract_state(level=-1))
992
  except Exception as e:
993
  logger.debug("feedback NGC learning skipped: %s", e)
994
 
@@ -1039,19 +1002,6 @@ class CanonicalPipeline:
1039
  except Exception as e:
1040
  logger.debug("feedback SCM update skipped: %s", e)
1041
 
1042
- # Update the persistent domain SCM with the gold-label observation.
1043
- # This is what makes the causal arena accumulate experience: the
1044
- # domain SCM's CPTs evolve with each feedback signal, and future
1045
- # items in the same domain start with this accumulated knowledge.
1046
- domain = sample.metadata.get("domain", "general")
1047
- domain_key = f"domain_{domain}"
1048
- domain_scm = self._domain_scm_library.get(domain_key)
1049
- if domain_scm is not None and self._last_derived_obs:
1050
- try:
1051
- domain_scm.update_from_data([self._last_derived_obs[sample.gold]])
1052
- except Exception as e:
1053
- logger.debug("domain SCM update skipped: %s", e)
1054
-
1055
  try:
1056
  self.controller.agent.experience_replay(n_episodes=3)
1057
  except Exception as e:
 
226
  self._choice_model_names: List[str] = []
227
  self._last_derived_obs: List[Dict[str, int]] = []
228
 
 
 
 
 
 
 
 
 
 
 
229
  if self.persistent_state_path:
230
  self.load_state(self.persistent_state_path)
231
 
 
286
  self._scm_topologies = {}
287
  self._choice_model_names = []
288
  self._last_derived_obs = []
 
 
 
 
289
  for i, label in enumerate(labels[:len(sample.choices)]):
290
+ scm = self._build_choice_scm(i, label)
291
  try:
292
  self.energy_arena.register(scm)
293
  self._choice_model_names.append(scm.name)
294
+ # Project this SCM's DAG into the NGC layer hierarchy via
295
+ # TopologyMapper. Horizontal causal edges are resolved through
296
+ # virtual parents at higher levels (the "elevator shaft" fix).
297
  n_ngc_layers = len(self.controller.agent.field.ngc.layer_sizes)
298
  topology = self._topology_mapper.from_scm(scm, n_layers=n_ngc_layers)
299
  self._scm_topologies[scm.name] = topology
 
345
 
346
  # ---------- per-choice SCM (used by EnergyCausalArena) ----------
347
 
348
+ def _build_choice_scm(self, choice_idx: int, label: str) -> StructuralCausalModel:
 
349
  """
350
+ Build a tiny SCM for one choice:
351
 
 
352
  prompt_feature ──▶ choice_match ──▶ observation
353
 
354
  │ (lateral) coherence
355
 
356
+ The DAG has both vertical and horizontal edges. The TopologyMapper
357
+ is exactly what turns the lateral coherence link into a virtual parent
358
+ in the NGC hierarchy, addressing the topological-mismatch critique.
 
 
359
  """
360
  scm = StructuralCausalModel(name=f"choice_{choice_idx}_{label}")
361
  scm.add_variable("prompt_feature", n_values=4, parents=[])
362
  scm.add_variable("coherence", n_values=4, parents=[])
363
  scm.add_variable("choice_match", n_values=4, parents=["prompt_feature"])
364
  scm.add_variable("observation", n_values=4, parents=["choice_match", "coherence"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  return scm
366
 
367
  # ---------- one-shot ingest (delegates to controller) ----------
 
387
  self, prompt: str, choices: List[str]
388
  ) -> Tuple[np.ndarray, List[Dict[str, int]]]:
389
  """
390
+ Real falsification in SBERT embedding space.
 
 
 
 
 
391
 
392
+ For each choice c_i:
393
+ 1. Save NGC state after settling on the prompt.
394
+ 2. Get SBERT embedding of choice c_i.
395
+ 3. Settle NGC on the choice embedding.
396
+ 4. Ask NGC to predict what layer 0 should look like (top-down).
397
+ 5. score_i = -||prediction - prompt_embedding||²
398
+
399
+ This is genuine falsification: "if the answer is c_i, and the NGC
400
+ has learned the structure of how answers relate to questions in
401
+ embedding space, does c_i's abstract state predict the prompt?"
402
+
403
+ The NGC W matrices learn across items. After 50+ items, they encode
404
+ real structure: questions in domain X tend to have answers with
405
+ embedding pattern Y. This is knowledge the LLM doesn't have —
406
+ it's cross-item structural knowledge accumulated by the cognitive layer.
407
  """
408
  field = self.controller.agent.field
 
 
409
 
410
+ # Get SBERT embeddings directly
411
+ prompt_obs = field.text_to_obs(prompt)
412
+
413
+ # Settle on prompt first to ground the NGC
414
  try:
415
+ field.ngc.settle(prompt_obs)
416
  base_state = field.ngc.save_state()
417
  except Exception:
418
  base_state = None
419
 
420
  scores = np.zeros(len(choices), dtype=np.float64)
421
  derived_obs: List[Dict[str, int]] = []
422
+
423
  for i, c in enumerate(choices):
424
  if base_state is not None:
425
  try:
426
  field.ngc.restore_state(base_state)
427
  except Exception:
428
  pass
429
+
430
+ # Get SBERT embedding of the choice (or prompt+choice for context)
431
+ choice_obs = field.text_to_obs(f"{prompt} {c}")
432
+
433
  try:
434
  field.ngc.settle(choice_obs, steps=self.falsify_settle_steps)
435
+ # Prediction: what does the NGC think layer 0 should look like
436
+ # given the abstract state it settled into for this choice?
437
+ predicted = field.ngc.predict_observation()
438
+ # Score: how well does this prediction match the prompt embedding?
439
+ pe = float(np.sum((prompt_obs - predicted) ** 2))
440
  except Exception as e:
441
+ logger.error("NGC falsification failed for choice %d: %s", i, e)
 
 
 
442
  pe = float(1e9)
443
  scores[i] = -pe
444
 
445
+ # Derive discrete observations for the energy arena
 
 
 
446
  try:
447
+ pf = self._bucket_4(float(np.linalg.norm(prompt_obs)))
448
+ cm = self._bucket_4(-pe / max(float(np.linalg.norm(prompt_obs)) ** 2, 1.0))
449
+ co = self._bucket_4(float(
450
+ np.dot(predicted, prompt_obs) /
451
+ (np.linalg.norm(predicted) * np.linalg.norm(prompt_obs) + 1e-10)
452
+ ))
453
+ ob = self._bucket_4(float(np.linalg.norm(predicted)))
454
  derived_obs.append({
455
+ "prompt_feature": pf, "choice_match": cm,
456
+ "coherence": co, "observation": ob,
 
 
457
  })
458
  except Exception:
459
  derived_obs.append({
 
461
  "coherence": 0, "observation": 0,
462
  })
463
 
464
+ # Restore prompt-grounded state
 
465
  if base_state is not None:
466
  try:
467
  field.ngc.restore_state(base_state)
 
830
  def _sbert_choice_scores(self, sample: TaskSample) -> np.ndarray:
831
  """Score choices by SBERT sentence-level cosine similarity.
832
 
833
+ Uses field.text_to_obs() which goes directly to SBERT embeddings
834
+ when available, giving the cognitive layer the same semantic signal
835
+ it uses for NGC falsification and Hopfield memory.
 
 
836
  """
837
  n = len(sample.choices)
838
  scores = np.zeros(n, dtype=np.float64)
 
840
  return scores
841
 
842
  field = self.controller.agent.field
843
+ prompt_emb = field.get_sbert_embedding(sample.prompt)
844
+ if prompt_emb is None:
845
+ return scores
846
+
847
+ pn = float(np.linalg.norm(prompt_emb))
848
+ if pn < 1e-8:
849
  return scores
850
 
851
  try:
852
+ for i, c in enumerate(sample.choices):
853
+ choice_emb = field.get_sbert_embedding(f"{sample.prompt} {c}")
854
+ if choice_emb is not None:
855
+ cn = float(np.linalg.norm(choice_emb))
856
+ if cn > 1e-8:
857
+ scores[i] = float(np.dot(prompt_emb, choice_emb) / (pn * cn))
 
 
 
 
 
 
 
858
  except Exception as e:
859
  logger.debug("SBERT choice scoring failed: %s", e)
860
 
861
  return scores
862
 
863
  def _memory_choice_scores(self, sample: TaskSample) -> np.ndarray:
864
+ """Score choices by Hopfield memory retrieval in SBERT space.
865
 
866
+ The Hopfield bank now stores full SBERT embeddings. We query it with
867
+ the prompt's SBERT embedding and measure how similar the retrieved
868
+ memory is to each choice's embedding. This gives real cross-item
869
+ transfer: "past prompts similar to this one had answers similar to
870
+ choice X."
871
  """
872
  n = len(sample.choices)
873
  scores = np.zeros(n, dtype=np.float64)
874
  if n == 0:
875
  return scores
876
 
877
+ field = self.controller.agent.field
878
+ if field.memory.n_patterns == 0:
879
  return scores
880
 
881
+ prompt_emb = field.get_sbert_embedding(sample.prompt)
882
+ if prompt_emb is None:
883
+ return scores
 
884
 
885
+ # Retrieve from Hopfield memory using prompt SBERT embedding
886
  try:
887
+ retrieved, _energy = field.memory.retrieve(prompt_emb)
 
888
  except Exception as e:
889
+ logger.debug("memory retrieval failed: %s", e)
890
  return scores
891
 
892
+ ret_norm = np.linalg.norm(retrieved)
893
+ if ret_norm < 1e-8:
894
  return scores
895
 
896
+ # Score each choice by similarity to retrieved memory
897
+ for i, c in enumerate(sample.choices):
898
+ choice_emb = field.get_sbert_embedding(f"{sample.prompt} {c}")
899
+ if choice_emb is not None:
900
+ cn = float(np.linalg.norm(choice_emb))
901
+ if cn > 1e-8:
902
+ scores[i] = float(np.dot(retrieved, choice_emb) / (ret_norm * cn))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
 
904
  return scores
905
 
 
935
  gold_rank_score = 1.0 / n # no discrimination
936
  self._channel_alpha[name] += gold_rank_score * 0.5
937
  field = self.controller.agent.field
938
+
939
+ # Store the correct answer's SBERT embedding in Hopfield memory.
940
+ # This is the cross-item learning signal: future prompts will
941
+ # retrieve this embedding and use it to score choices.
942
+ correct_text = f"{sample.prompt} {sample.choices[sample.gold]}"
943
+ correct_emb = field.get_sbert_embedding(correct_text)
944
+ if correct_emb is not None:
945
+ field.memory.store(correct_emb)
946
+
947
+ # Settle NGC on the correct answer's SBERT embedding and learn.
948
+ # This teaches the W matrices the structure of correct Q→A mappings.
949
+ correct_obs = field.text_to_obs(correct_text)
950
+ prompt_obs = field.text_to_obs(sample.prompt)
951
 
952
  try:
953
  field.ngc.settle(correct_obs, steps=max(1, self.falsify_settle_steps))
954
  field.ngc.learn(modulation=max(0.0, self.feedback_learning_rate))
 
955
  except Exception as e:
956
  logger.debug("feedback NGC learning skipped: %s", e)
957
 
 
1002
  except Exception as e:
1003
  logger.debug("feedback SCM update skipped: %s", e)
1004
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1005
  try:
1006
  self.controller.agent.experience_replay(n_episodes=3)
1007
  except Exception as e: