feat: V3 SBERT-native canonical.py
Browse files- tensegrity/pipeline/canonical.py +103 -153
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:
|