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 +223 -210
- tensegrity/pipeline/canonical.py +103 -153
tensegrity/engine/unified_field.py
CHANGED
|
@@ -1,28 +1,26 @@
|
|
| 1 |
"""
|
| 2 |
-
Unified Energy Landscape
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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
|
| 48 |
surprise: float # -log P(observation | beliefs)
|
| 49 |
|
| 50 |
|
| 51 |
class HopfieldMemoryBank:
|
| 52 |
"""
|
| 53 |
-
Modern Hopfield network operating in
|
| 54 |
-
|
| 55 |
-
Stores
|
| 56 |
E(ξ) = -lse(β, Xᵀξ) + ½||ξ||²
|
| 57 |
ξ_new = X · softmax(β · Xᵀ · ξ)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
- β = 1/√d_k (inverse temperature)
|
| 63 |
"""
|
| 64 |
-
|
| 65 |
-
def __init__(self, dim: int, beta: float = 0.
|
| 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
|
| 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 |
-
|
| 151 |
-
1.
|
| 152 |
-
2. NGC circuit
|
| 153 |
-
3. Hopfield memory
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 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.
|
| 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:
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
hopfield_beta: Inverse temperature for Hopfield retrieval
|
| 182 |
ngc_settle_steps: Settling iterations for NGC
|
| 183 |
ngc_learning_rate: Hebbian learning rate
|
| 184 |
-
|
|
|
|
| 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 |
-
#
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
else:
|
| 209 |
-
self.
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
| 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
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
# Energy tracking
|
| 226 |
self._step_count = 0
|
| 227 |
-
self.energy_history: Deque[EnergyDecomposition] = deque(
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 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
|
| 265 |
-
if input_type == "
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
| 269 |
elif input_type == "tokens":
|
|
|
|
|
|
|
|
|
|
| 270 |
fhrr_vec = self.encoder.encode_sequence(raw_input)
|
| 271 |
-
elif input_type == "
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
else:
|
| 275 |
raise ValueError(f"Unknown input_type: {input_type}")
|
| 276 |
-
|
| 277 |
-
|
| 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
|
| 283 |
settle_result = self.ngc.settle(obs_vec)
|
| 284 |
perception_energy = settle_result["final_energy"]
|
| 285 |
-
|
| 286 |
-
# === 4.
|
| 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 |
-
|
| 294 |
-
|
| 295 |
-
#
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
else:
|
| 302 |
memory_similarity = 0.0
|
| 303 |
-
|
| 304 |
-
# Memory-guided re-settle
|
| 305 |
-
|
| 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 |
-
|
| 318 |
-
|
| 319 |
-
#
|
| 320 |
-
#
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 327 |
-
|
| 328 |
prediction_error_post_settle = self.ngc.prediction_error(obs_vec)
|
| 329 |
-
|
| 330 |
-
# === 5. LEARN
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
self.ngc.learn(modulation=learning_modulation)
|
| 348 |
-
self.memory.store(
|
| 349 |
-
|
| 350 |
-
# === 6. ENERGY
|
| 351 |
decomp = EnergyDecomposition(
|
| 352 |
perception=perception_energy,
|
| 353 |
memory=memory_energy,
|
| 354 |
-
causal=0.0,
|
| 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
|
| 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
|
| 363 |
|
| 364 |
-
The structure is always:
|
| 365 |
prompt_feature ──▶ choice_match ──▶ observation
|
| 366 |
▲
|
| 367 |
│ (lateral) coherence
|
| 368 |
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 452 |
-
|
|
|
|
|
|
|
| 453 |
try:
|
| 454 |
field.ngc.settle(choice_obs, steps=self.falsify_settle_steps)
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
| 474 |
derived_obs.append({
|
| 475 |
-
"prompt_feature": pf,
|
| 476 |
-
"
|
| 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
|
| 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 |
-
|
| 857 |
-
|
| 858 |
-
|
| 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 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
|
|
|
| 873 |
return scores
|
| 874 |
|
| 875 |
try:
|
| 876 |
-
|
| 877 |
-
f"{sample.prompt} {c}"
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 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 |
-
"""
|
| 896 |
|
| 897 |
-
|
| 898 |
-
|
|
|
|
|
|
|
|
|
|
| 899 |
"""
|
| 900 |
n = len(sample.choices)
|
| 901 |
scores = np.zeros(n, dtype=np.float64)
|
| 902 |
if n == 0:
|
| 903 |
return scores
|
| 904 |
|
| 905 |
-
|
| 906 |
-
if
|
| 907 |
return scores
|
| 908 |
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
query_belief = np.full(n, 1.0 / n, dtype=np.float64)
|
| 913 |
|
|
|
|
| 914 |
try:
|
| 915 |
-
|
| 916 |
-
retrieved = episodic.retrieve_by_context(query_context=query_ctx, k=8)
|
| 917 |
except Exception as e:
|
| 918 |
-
logger.debug("
|
| 919 |
return scores
|
| 920 |
|
| 921 |
-
|
|
|
|
| 922 |
return scores
|
| 923 |
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 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 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|