theapemachine commited on
Commit
45a87af
·
verified ·
1 Parent(s): 93c7862

feat: V3 SBERT-native canonical.py

Browse files
Files changed (1) hide show
  1. 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, 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: