theapemachine commited on
Commit
be04d92
·
1 Parent(s): 45cc459

Enhance README and scripts for cognitive architecture testing

Browse files

- Updated README to clarify the functionality of `UnifiedField.predict()`, including the expected output format and common follow-up actions.
- Added a minimal example in the README demonstrating the use of `TopologyMapper` for graph projection.
- Improved error handling in `chat.py` by removing redundant traceback imports and ensuring consistent exception logging.
- Refactored `compare_iterative.py` to consolidate shared parameters for scoring bridges, enhancing maintainability.
- Enhanced `smoke_extract.py` with detailed logging and summary statistics for grounding checks, improving diagnostic capabilities.
- Updated `runner.py` and `tasks.py` for better error handling and logging during task evaluation and sample loading.
- Introduced new validation checks in `StructuralCausalModel` to ensure proper parent variable definitions and counterfactual limits.

README.md CHANGED
@@ -45,9 +45,14 @@ cycle = field.observe(
45
  )
46
 
47
  print(cycle["energy"].total)
48
- print(field.predict())
 
 
 
49
  ```
50
 
 
 
51
  The old Morton/POMDP frontend is still available for migration and baselines:
52
 
53
  ```python
@@ -97,8 +102,39 @@ explicit. It projects an arbitrary acyclic SCM graph into NGC-compatible layers:
97
  - same-layer or inverted edges receive virtual parent nodes one layer above the
98
  endpoints, turning lateral causal structure into shared vertical dependency.
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ## Semantic Grafting
101
 
 
 
 
 
 
 
102
  `VocabularyGrounding.from_keywords(...)` remains as a deterministic baseline.
103
  For less brittle grounding, `VocabularyGrounding.from_semantic_projection(...)`
104
  uses frozen phrase/token embeddings and cosine proximity to build weighted
 
45
  )
46
 
47
  print(cycle["energy"].total)
48
+ expected_obs = field.predict() # np.ndarray, shape (obs_dim,) — settled NGC readout
49
+ # This is the sensory prediction from the current internal state (not a class label).
50
+ print("expected observation vector (first 8 dims):", expected_obs[:8])
51
+ # Common follow-ups: flatten to a label by argmax over logits elsewhere, or pipe into a probe / monitor.
52
  ```
53
 
54
+ **`UnifiedField.predict()`** returns a **`numpy.ndarray`** of shape **`(obs_dim,)`**: the **predicted next observation** vector from the settled hierarchical circuit after the last `observe` (NGC’s `predict_observation()`). Assign it (as above), inspect slice or norm, or send it to downstream binding / decoding—there is no bundled string label.
55
+
56
  The old Morton/POMDP frontend is still available for migration and baselines:
57
 
58
  ```python
 
102
  - same-layer or inverted edges receive virtual parent nodes one layer above the
103
  endpoints, turning lateral causal structure into shared vertical dependency.
104
 
105
+ Minimal example (four variables so one graph can show **direct**, **bypass**, and **same-layer lateral** edges). The mapper API is **`TopologyMapper.project_graph(...)`** (or **`TopologyMapper.from_scm(scm, ...)`** with the same `variable_layers`):
106
+
107
+ ```python
108
+ import networkx as nx
109
+ from tensegrity.causal.scm import StructuralCausalModel
110
+ from tensegrity.engine.causal_energy import TopologyMapper
111
+
112
+ scm = StructuralCausalModel("topology_demo")
113
+ scm.add_variable("A", n_values=4, parents=[])
114
+ scm.add_variable("D", n_values=4, parents=["A"]) # bypass path A → D
115
+ scm.add_variable("B", n_values=4, parents=["A"]) # direct step A → B
116
+ scm.add_variable("C", n_values=4, parents=["B"]) # lateral topology: B,C share a layer below
117
+
118
+ variable_layers = {"A": 3, "D": 0, "B": 2, "C": 2}
119
+ # A→B / A→D: direct + bypass | B→C at same abstract layer → virtual parent in the embedding
120
+
121
+ mapping = TopologyMapper(expand_layers=True).from_scm(
122
+ scm,
123
+ n_layers=8,
124
+ variable_layers=variable_layers,
125
+ )
126
+ print(dict(mapping.embedded_layers)) # layer index per node after relays / vparents
127
+ print(mapping.ngc_layer_sizes()) # e.g. widths per layer → output "shape" at a glance
128
+ ```
129
+
130
  ## Semantic Grafting
131
 
132
+ ```python
133
+ from tensegrity.graft.vocabulary import VocabularyGrounding
134
+ # Keyword baseline: VocabularyGrounding.from_keywords(...)
135
+ # Semantic: VocabularyGrounding.from_semantic_projection(...)
136
+ ```
137
+
138
  `VocabularyGrounding.from_keywords(...)` remains as a deterministic baseline.
139
  For less brittle grounding, `VocabularyGrounding.from_semantic_projection(...)`
140
  uses frozen phrase/token embeddings and cosine proximity to build weighted
scripts/chat.py CHANGED
@@ -25,7 +25,7 @@ from __future__ import annotations
25
  import argparse
26
  import json
27
  import sys
28
- from typing import Dict, List
29
 
30
  from tensegrity.graft.pipeline import HybridPipeline
31
 
@@ -141,7 +141,6 @@ def main():
141
  try:
142
  pipe.process_observation(line)
143
  except Exception as e:
144
- import traceback
145
  print(f"[perception failed: {type(e).__name__}: {e}]")
146
  traceback.print_exc()
147
  continue
@@ -154,7 +153,6 @@ def main():
154
  max_tokens=100,
155
  )
156
  except Exception as e:
157
- import traceback
158
  print(f"[generation failed: {type(e).__name__}: {e}]")
159
  traceback.print_exc()
160
  continue
 
25
  import argparse
26
  import json
27
  import sys
28
+ import traceback
29
 
30
  from tensegrity.graft.pipeline import HybridPipeline
31
 
 
141
  try:
142
  pipe.process_observation(line)
143
  except Exception as e:
 
144
  print(f"[perception failed: {type(e).__name__}: {e}]")
145
  traceback.print_exc()
146
  continue
 
153
  max_tokens=100,
154
  )
155
  except Exception as e:
 
156
  print(f"[generation failed: {type(e).__name__}: {e}]")
157
  traceback.print_exc()
158
  continue
scripts/compare_iterative.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
  import time
9
  import argparse
10
  import logging
 
11
 
12
  import numpy as np
13
 
@@ -34,23 +35,32 @@ def run_task(task_name: str, n: int):
34
  print(f" [{task_name}] no samples")
35
  return None
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  single = ScoringBridge(
38
- obs_dim=256, hidden_dims=[128, 32], fhrr_dim=2048,
39
- ngc_settle_steps=30, ngc_learning_rate=0.01,
40
- hopfield_beta=0.05, confidence_threshold=0.15,
41
- context_settle_steps=40, choice_settle_steps=25,
42
- context_learning_epochs=3,
43
  )
44
  iterative = IterativeCognitiveScorer(
45
- obs_dim=256, hidden_dims=[128, 32], fhrr_dim=2048,
46
- ngc_settle_steps=30, ngc_learning_rate=0.01,
47
- hopfield_beta=0.05,
48
- max_iterations=6, convergence_top_p=0.75,
49
- context_settle_steps=40, choice_settle_steps=25,
50
- context_learning_epochs=3,
51
- w_sbert=1.0, w_fhrr=0.3, w_ngc=0.6,
52
- belief_step=0.6, shaping_lr_scale=0.5,
53
- use_hopfield=True, hopfield_steps=2,
 
54
  )
55
 
56
  n_total = len(samples)
@@ -75,7 +85,21 @@ def run_task(task_name: str, n: int):
75
  sa = np.array(scores_s)
76
  if np.allclose(sa, 0.0):
77
  # use raw sbert sim as tiebreaker (single's gate = uninformative)
78
- sims = single._sentence_similarities(s.prompt, s.choices)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  pred_s = int(np.argmax(sims))
80
  else:
81
  pred_s = int(np.argmax(sa))
 
8
  import time
9
  import argparse
10
  import logging
11
+ import warnings
12
 
13
  import numpy as np
14
 
 
35
  print(f" [{task_name}] no samples")
36
  return None
37
 
38
+ shared_params = {
39
+ "obs_dim": 256,
40
+ "hidden_dims": [128, 32],
41
+ "fhrr_dim": 2048,
42
+ "ngc_settle_steps": 30,
43
+ "ngc_learning_rate": 0.01,
44
+ "hopfield_beta": 0.05,
45
+ "context_settle_steps": 40,
46
+ "choice_settle_steps": 25,
47
+ "context_learning_epochs": 3,
48
+ }
49
  single = ScoringBridge(
50
+ **shared_params,
51
+ confidence_threshold=0.15,
 
 
 
52
  )
53
  iterative = IterativeCognitiveScorer(
54
+ **shared_params,
55
+ max_iterations=6,
56
+ convergence_top_p=0.75,
57
+ w_sbert=1.0,
58
+ w_fhrr=0.3,
59
+ w_ngc=0.6,
60
+ belief_step=0.6,
61
+ shaping_lr_scale=0.5,
62
+ use_hopfield=True,
63
+ hopfield_steps=2,
64
  )
65
 
66
  n_total = len(samples)
 
85
  sa = np.array(scores_s)
86
  if np.allclose(sa, 0.0):
87
  # use raw sbert sim as tiebreaker (single's gate = uninformative)
88
+ if hasattr(single, "sentence_similarities"):
89
+ sims = single.sentence_similarities(s.prompt, s.choices)
90
+ elif hasattr(single, "_sentence_similarities"):
91
+ warnings.warn(
92
+ "ScoringBridge has no public sentence_similarities(); using "
93
+ "_sentence_similarities (private). Prefer adding a stable public API.",
94
+ UserWarning,
95
+ stacklevel=2,
96
+ )
97
+ sims = single._sentence_similarities(s.prompt, s.choices)
98
+ else:
99
+ raise AttributeError(
100
+ "ScoringBridge exposes no sentence_similarities() or "
101
+ "_sentence_similarities(); add a public API on ScoringBridge for tie-breaks.",
102
+ )
103
  pred_s = int(np.argmax(sims))
104
  else:
105
  pred_s = int(np.argmax(sa))
scripts/smoke_extract.py CHANGED
@@ -12,16 +12,24 @@ roles bound to actual phrases.
12
  """
13
  from __future__ import annotations
14
 
 
15
  import time
16
- from typing import List, Literal
 
17
 
18
  from pydantic import BaseModel, Field
19
 
20
  from outlines import models, generate
21
 
22
-
23
  MODEL_NAME = "meta-llama/Llama-3.2-1B-Instruct"
24
 
 
 
 
 
 
 
 
25
 
26
  class Entity(BaseModel):
27
  text: str = Field(description="The exact phrase as it appears in the prompt")
@@ -45,8 +53,13 @@ class PromptStructure(BaseModel):
45
  roles: List[RoleBinding]
46
 
47
 
 
 
 
 
 
48
  # Hand-picked items spanning the benchmark behavior zones.
49
- ITEMS = [
50
  {
51
  "task": "truthfulqa",
52
  "prompt": "What happens if you crack your knuckles a lot?",
@@ -89,7 +102,7 @@ ITEMS = [
89
  ]
90
 
91
 
92
- def build_prompt(item) -> str:
93
  return (
94
  "You convert a question into structured fields. Do NOT answer the question. "
95
  "Do NOT guess. Only extract what is literally in the text.\n\n"
@@ -102,7 +115,7 @@ def build_prompt(item) -> str:
102
  )
103
 
104
 
105
- def main():
106
  print(f"Loading {MODEL_NAME}...")
107
  t0 = time.time()
108
  model = models.transformers(MODEL_NAME)
@@ -110,6 +123,13 @@ def main():
110
 
111
  gen = generate.json(model, PromptStructure)
112
 
 
 
 
 
 
 
 
113
  for i, item in enumerate(ITEMS):
114
  print("=" * 78)
115
  print(f"[{i}] {item['task']}")
@@ -119,23 +139,43 @@ def main():
119
  try:
120
  s = gen(build_prompt(item), max_tokens=400)
121
  except Exception as e:
 
 
 
 
 
122
  print(f" FAILED: {type(e).__name__}: {e}")
 
123
  continue
124
  dt = time.time() - t0
 
125
 
126
- # Grounding check: are entity/role fillers actually substrings of the prompt?
127
  text = item["prompt"].lower()
128
  ent_grounded = sum(1 for e in s.entities if e.text.lower() in text)
129
  role_grounded = sum(1 for r in s.roles if r.filler.lower() in text)
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  print(f"\n entities ({len(s.entities)}, {ent_grounded} grounded, {dt:.1f}s):")
132
  for e in s.entities:
133
  mark = "" if e.text.lower() in text else " [NOT IN PROMPT]"
134
  print(f" {e.kind:<10} {e.text!r}{mark}")
135
 
136
- print(f"\n relations ({len(s.relations)}):")
137
  for r in s.relations:
138
- print(f" ({r.subject!r}) -[{r.predicate}]-> ({r.object!r})")
 
 
139
 
140
  print(f"\n roles ({len(s.roles)}, {role_grounded} grounded):")
141
  for r in s.roles:
@@ -143,6 +183,22 @@ def main():
143
  print(f" {r.role:<18} := {r.filler!r}{mark}")
144
  print()
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  if __name__ == "__main__":
148
  main()
 
12
  """
13
  from __future__ import annotations
14
 
15
+ import logging
16
  import time
17
+ import traceback
18
+ from typing import List, Literal, TypedDict
19
 
20
  from pydantic import BaseModel, Field
21
 
22
  from outlines import models, generate
23
 
 
24
  MODEL_NAME = "meta-llama/Llama-3.2-1B-Instruct"
25
 
26
+ _LOGGER = logging.getLogger(__name__)
27
+
28
+ try:
29
+ from outlines.errors import OutlinesStructuredGenerationError as _OutlineGenError
30
+ except ImportError:
31
+ _OutlineGenError = None # type: ignore[misc, assignment]
32
+
33
 
34
  class Entity(BaseModel):
35
  text: str = Field(description="The exact phrase as it appears in the prompt")
 
53
  roles: List[RoleBinding]
54
 
55
 
56
+ class SmokeItem(TypedDict):
57
+ task: str
58
+ prompt: str
59
+
60
+
61
  # Hand-picked items spanning the benchmark behavior zones.
62
+ ITEMS: list[SmokeItem] = [
63
  {
64
  "task": "truthfulqa",
65
  "prompt": "What happens if you crack your knuckles a lot?",
 
102
  ]
103
 
104
 
105
+ def build_prompt(item: SmokeItem) -> str:
106
  return (
107
  "You convert a question into structured fields. Do NOT answer the question. "
108
  "Do NOT guess. Only extract what is literally in the text.\n\n"
 
115
  )
116
 
117
 
118
+ def main() -> None:
119
  print(f"Loading {MODEL_NAME}...")
120
  t0 = time.time()
121
  model = models.transformers(MODEL_NAME)
 
123
 
124
  gen = generate.json(model, PromptStructure)
125
 
126
+ total_items = len(ITEMS)
127
+ failures = 0
128
+ total_entities = grounded_entities = 0
129
+ total_relations = grounded_relations = 0
130
+ total_roles = grounded_roles = 0
131
+ time_sum = 0.0
132
+
133
  for i, item in enumerate(ITEMS):
134
  print("=" * 78)
135
  print(f"[{i}] {item['task']}")
 
139
  try:
140
  s = gen(build_prompt(item), max_tokens=400)
141
  except Exception as e:
142
+ failures += 1
143
+ if _OutlineGenError is not None and isinstance(e, _OutlineGenError):
144
+ _LOGGER.exception("Outlines structured generation failed [item %s]", i)
145
+ else:
146
+ _LOGGER.exception("Generation failed [item %s]", i)
147
  print(f" FAILED: {type(e).__name__}: {e}")
148
+ print(traceback.format_exc())
149
  continue
150
  dt = time.time() - t0
151
+ time_sum += dt
152
 
153
+ # Grounding check: are entity/role fillers — and relation ends — actually substrings of the prompt?
154
  text = item["prompt"].lower()
155
  ent_grounded = sum(1 for e in s.entities if e.text.lower() in text)
156
  role_grounded = sum(1 for r in s.roles if r.filler.lower() in text)
157
+ rel_grounded = sum(
158
+ 1 for r in s.relations
159
+ if r.subject.lower() in text and r.object.lower() in text
160
+ )
161
+
162
+ total_entities += len(s.entities)
163
+ grounded_entities += ent_grounded
164
+ total_relations += len(s.relations)
165
+ grounded_relations += rel_grounded
166
+ total_roles += len(s.roles)
167
+ grounded_roles += role_grounded
168
 
169
  print(f"\n entities ({len(s.entities)}, {ent_grounded} grounded, {dt:.1f}s):")
170
  for e in s.entities:
171
  mark = "" if e.text.lower() in text else " [NOT IN PROMPT]"
172
  print(f" {e.kind:<10} {e.text!r}{mark}")
173
 
174
+ print(f"\n relations ({len(s.relations)}, {rel_grounded} grounded in prompt text):")
175
  for r in s.relations:
176
+ ok_rel = r.subject.lower() in text and r.object.lower() in text
177
+ mark = "" if ok_rel else " [NOT IN PROMPT]"
178
+ print(f" ({r.subject!r}) -[{r.predicate}]-> ({r.object!r}){mark}")
179
 
180
  print(f"\n roles ({len(s.roles)}, {role_grounded} grounded):")
181
  for r in s.roles:
 
183
  print(f" {r.role:<18} := {r.filler!r}{mark}")
184
  print()
185
 
186
+ ok = total_items - failures
187
+ avg_dt = time_sum / ok if ok else 0.0
188
+ eg = grounded_entities / total_entities if total_entities else 0.0
189
+ rg = grounded_roles / total_roles if total_roles else 0.0
190
+ rlg = grounded_relations / total_relations if total_relations else 0.0
191
+
192
+ print("=" * 78)
193
+ print(
194
+ "SUMMARY:",
195
+ f"items={total_items}, failures={failures}, ok={ok},",
196
+ f"entity grounding={eg:.1%} ({grounded_entities}/{total_entities}),",
197
+ f"role grounding={rg:.1%} ({grounded_roles}/{total_roles}),",
198
+ f"relation grounding={rlg:.1%} ({grounded_relations}/{total_relations}),",
199
+ f"avg time (success)={avg_dt:.2f}s",
200
+ )
201
+
202
 
203
  if __name__ == "__main__":
204
  main()
tensegrity/__init__.py CHANGED
@@ -7,9 +7,13 @@ The primary engine is now the V2 ``UnifiedField`` stack:
7
  FHRR encoding -> hierarchical predictive coding -> Hopfield memory
8
  -> optional causal energy terms
9
 
10
- Legacy V1 components remain importable from ``tensegrity.legacy.v1``:
11
-
12
- from tensegrity.legacy.v1 import TensegrityAgent, MortonEncoder
 
 
 
 
13
 
14
  Top-level exports intentionally expose the unified field as the default
15
  architecture. Deprecated V1 names are resolved lazily for migration only.
 
7
  FHRR encoding -> hierarchical predictive coding -> Hopfield memory
8
  -> optional causal energy terms
9
 
10
+ Legacy V1 components (`TensegrityAgent`, `MortonEncoder`, `MarkovBlanket`) remain
11
+ importable from ``tensegrity.legacy.v1``. Several other names are re-exported lazily
12
+ via ``tensegrity`` for migration only: ``EpistemicMemory``, ``EpisodicMemory``, and
13
+ ``AssociativeMemory`` from ``tensegrity.memory.*``; ``CausalArena`` and
14
+ ``StructuralCausalModel`` from ``tensegrity.causal.*``; ``FreeEnergyEngine`` and
15
+ ``BeliefPropagator`` from ``tensegrity.inference.*``. Those are **not** defined under
16
+ ``tensegrity.legacy.v1``—use the module paths above when importing explicitly.
17
 
18
  Top-level exports intentionally expose the unified field as the default
19
  architecture. Deprecated V1 names are resolved lazily for migration only.
tensegrity/bench/runner.py CHANGED
@@ -257,7 +257,7 @@ class EvalRunner:
257
  prompt, return_tensors="pt",
258
  truncation=True, max_length=512,
259
  )["input_ids"]
260
-
261
  n_prompt = prompt_ids.shape[1]
262
  n_total = inputs["input_ids"].shape[1]
263
  log_probs = torch.nn.functional.log_softmax(logits[0], dim=-1)
@@ -278,7 +278,23 @@ class EvalRunner:
278
  (UnifiedField, FreeEnergyEngine, EpistemicMemory, EpisodicMemory,
279
  AssociativeMemory, log-lik CausalArena), Broca dynamic SCM injection,
280
  EnergyCausalArena + TopologyMapper for per-choice causal competition,
281
- NGC top-down falsification."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  import os
283
  import numpy as np
284
 
@@ -300,11 +316,13 @@ class EvalRunner:
300
  from tensegrity.pipeline.canonical import CanonicalPipeline
301
 
302
  if not hasattr(self, "_canonical"):
303
- # use_llm_broca defaults False to avoid LLM calls during MC scoring;
304
- # the LLM stays out of the reasoning path. Broca is still reachable
305
- # for the chat-mode narration step (HybridPipeline).
 
 
306
  self._canonical = CanonicalPipeline(
307
- hypothesis_labels=list(sample.choices),
308
  use_llm_broca=False,
309
  enable_hypothesis_generation=False,
310
  model_name=self.model_name,
@@ -450,8 +468,11 @@ class EvalRunner:
450
  # doesn't leak priors across tasks (different label spaces).
451
  if hasattr(self, "_canonical") and hasattr(self._canonical, "reset_session"):
452
  self._canonical.reset_session()
453
- if hasattr(self, "_iter_scorer") and hasattr(self._iter_scorer, "reset_session"):
454
- self._iter_scorer.reset_session()
 
 
 
455
 
456
  results = []
457
  for i, sample in enumerate(samples):
 
257
  prompt, return_tensors="pt",
258
  truncation=True, max_length=512,
259
  )["input_ids"]
260
+
261
  n_prompt = prompt_ids.shape[1]
262
  n_total = inputs["input_ids"].shape[1]
263
  log_probs = torch.nn.functional.log_softmax(logits[0], dim=-1)
 
278
  (UnifiedField, FreeEnergyEngine, EpistemicMemory, EpisodicMemory,
279
  AssociativeMemory, log-lik CausalArena), Broca dynamic SCM injection,
280
  EnergyCausalArena + TopologyMapper for per-choice causal competition,
281
+ NGC top-down falsification.
282
+
283
+ **Bench-specific behavior**: In ``single`` scorer mode (`TENSEGRITY_SCORER` env),
284
+ :meth:`ScoringBridge.reset` is called **once per benchmark sample**, so episodic /
285
+ Hopfield state does not accumulate across MC items — each example is isolated.
286
+
287
+ In the default canonical mode, reuse a single :class:`CanonicalPipeline` for all
288
+ samples — per-item hypotheses and SMCs come from ``reset_for_item`` /
289
+ ``_soft_reset_in_place``. Rebuilding the pipeline on each row would recreate
290
+ the agent stack and repeatedly load sentence-transformer weights into memory.
291
+ :meth:`CanonicalPipeline.reset_session` is invoked **once per task**
292
+ (``EvalRunner.evaluate_task``), wiping cross-task leakage while permitting
293
+ within-task learning where applicable.
294
+
295
+ Prefer ``canonical`` for behavior aligned with HybridPipeline/session semantics;
296
+ use ``single`` for a deterministic, isolated field snapshot per sample.
297
+ """
298
  import os
299
  import numpy as np
300
 
 
316
  from tensegrity.pipeline.canonical import CanonicalPipeline
317
 
318
  if not hasattr(self, "_canonical"):
319
+ # One CanonicalPipeline instance for all samples on this Runner. Hypothesis texts
320
+ # and per-choice SCMs are updated per sample inside ``reset_for_item`` /
321
+ # ``_soft_reset_in_place``; rebuilding ``CanonicalPipeline`` whenever multi-choice
322
+ # strings changed would recreate ``TensegrityAgent`` / FHRR SBERT loaders and spam
323
+ # "Loading weights" for each benchmark row (see CanonicalPipeline docs).
324
  self._canonical = CanonicalPipeline(
325
+ hypothesis_labels=None,
326
  use_llm_broca=False,
327
  enable_hypothesis_generation=False,
328
  model_name=self.model_name,
 
468
  # doesn't leak priors across tasks (different label spaces).
469
  if hasattr(self, "_canonical") and hasattr(self._canonical, "reset_session"):
470
  self._canonical.reset_session()
471
+ if hasattr(self, "_field_scorer"):
472
+ if hasattr(self._field_scorer, "reset_session"):
473
+ self._field_scorer.reset_session()
474
+ elif hasattr(self._field_scorer, "reset"):
475
+ self._field_scorer.reset()
476
 
477
  results = []
478
  for i, sample in enumerate(samples):
tensegrity/bench/tasks.py CHANGED
@@ -435,7 +435,7 @@ def load_task_samples(name: str, max_samples: Optional[int] = None) -> List[Task
435
 
436
  samples.append(sample)
437
  except Exception as e:
438
- logger.error(f"Error adapting task {name}: {e}")
439
  continue # Skip malformed rows
440
 
441
  return samples
 
435
 
436
  samples.append(sample)
437
  except Exception as e:
438
+ logger.exception("Error adapting task %s at row %s: %s", name, i, e)
439
  continue # Skip malformed rows
440
 
441
  return samples
tensegrity/broca/benchmark.py CHANGED
@@ -240,7 +240,6 @@ def run_tensegrity_agent(scenario: GameScenario, verbose: bool = True) -> Dict[s
240
 
241
  for turn in range(len(scenario.clues) + 3): # Extra turns for questions
242
  clue = game.get_next_clue()
243
-
244
  if clue is None:
245
  break
246
 
@@ -284,7 +283,6 @@ def run_tensegrity_agent(scenario: GameScenario, verbose: bool = True) -> Dict[s
284
  for h in gold:
285
  p = gold[h]
286
  q = agent_probs.get(h, 1e-16)
287
-
288
  if p > 0:
289
  kl_div += p * np.log(p / max(q, 1e-16))
290
 
 
240
 
241
  for turn in range(len(scenario.clues) + 3): # Extra turns for questions
242
  clue = game.get_next_clue()
 
243
  if clue is None:
244
  break
245
 
 
283
  for h in gold:
284
  p = gold[h]
285
  q = agent_probs.get(h, 1e-16)
 
286
  if p > 0:
287
  kl_div += p * np.log(p / max(q, 1e-16))
288
 
tensegrity/broca/controller.py CHANGED
@@ -569,8 +569,11 @@ class CognitiveController:
569
  if max_prob > 0.85:
570
  action_type = "state_conclusion"
571
  elif max_prob < 0.15 and any(h.probability > 0.3 for h in self.belief_state.hypotheses):
572
- logger.info(f"Some hypothesis just dropped — eliminating: {max_prob}")
573
- # Some hypothesis just dropped eliminate it
 
 
 
574
  pass # Let the EFE-selected action stand
575
 
576
  # Build the action content
 
569
  if max_prob > 0.85:
570
  action_type = "state_conclusion"
571
  elif max_prob < 0.15 and any(h.probability > 0.3 for h in self.belief_state.hypotheses):
572
+ logger.info(
573
+ "Competing hypotheses remain (max_prob=%.3f)keeping EFE-selected "
574
+ "action; no hypothesis elimination performed.",
575
+ max_prob,
576
+ )
577
  pass # Let the EFE-selected action stand
578
 
579
  # Build the action content
tensegrity/broca/schemas.py CHANGED
@@ -177,6 +177,7 @@ class Utterance(BaseModel):
177
  style_register: Literal["formal", "casual", "technical", "empathetic"] = Field(
178
  default="casual",
179
  alias="register",
 
180
  )
181
 
182
 
 
177
  style_register: Literal["formal", "casual", "technical", "empathetic"] = Field(
178
  default="casual",
179
  alias="register",
180
+ serialization_alias="register",
181
  )
182
 
183
 
tensegrity/causal/arena.py CHANGED
@@ -229,6 +229,15 @@ class CausalArena:
229
  In practice: find the variable where models disagree most about
230
  the effect of intervention, and suggest intervening on it.
231
  """
 
 
 
 
 
 
 
 
 
232
  if len(self.models) < 2:
233
  return {'intervention': None, 'expected_info_gain': 0.0}
234
 
@@ -254,9 +263,9 @@ class CausalArena:
254
  info_gain = self._estimate_info_gain(
255
  var, val,
256
  n_samples=n_samples,
257
- n_outcome_samples=n_outcome_samples,
258
  )
259
-
260
  if info_gain > best_info_gain:
261
  best_info_gain = info_gain
262
  best_experiment = {'variable': var, 'value': val}
@@ -294,33 +303,33 @@ class CausalArena:
294
  # Estimate expected posterior entropy after seeing outcomes
295
  # Use model-averaged predictions
296
  expected_tension = 0.0
297
- n_outcome_samples = min(n_samples, max(1, int(n_outcome_samples)))
298
 
299
  for name, outcomes in predicted_outcomes.items():
300
  model_weight = current_posterior.get(name, 1.0 / len(self.models))
301
 
302
- for outcome in outcomes[:n_outcome_samples]:
303
  # What would the posterior look like if we saw this outcome?
304
  hypothetical_log_liks = {}
305
-
306
  for m_name, model in self.models.items():
307
  hypothetical_log_liks[m_name] = model.log_evidence([outcome])
308
-
309
  # Hypothetical posterior
310
  hyp_evidence = {m: self.model_log_evidence[m] + hypothetical_log_liks[m]
311
  for m in self.models}
312
-
313
  max_e = max(hyp_evidence.values())
314
-
315
  log_Z = max_e + np.log(sum(
316
  np.exp(e - max_e) for e in hyp_evidence.values()))
317
-
318
  hyp_posterior = {m: np.exp(e - log_Z) for m, e in hyp_evidence.items()}
319
-
320
  expected_tension += model_weight * self._compute_tension(hyp_posterior)
321
-
322
- expected_tension /= max(n_outcome_samples, 1)
323
-
324
  # Information gain = current uncertainty - expected uncertainty after experiment
325
  return current_tension - expected_tension
326
 
 
229
  In practice: find the variable where models disagree most about
230
  the effect of intervention, and suggest intervening on it.
231
  """
232
+ if not isinstance(n_samples, int) or n_samples <= 0:
233
+ raise ValueError(f"n_samples must be a positive int, got {n_samples!r}")
234
+ if not isinstance(n_outcome_samples, int) or n_outcome_samples < 1:
235
+ raise ValueError(
236
+ f"n_outcome_samples must be an int >= 1, got {n_outcome_samples!r}"
237
+ )
238
+
239
+ outcome_cap = min(n_samples, n_outcome_samples)
240
+
241
  if len(self.models) < 2:
242
  return {'intervention': None, 'expected_info_gain': 0.0}
243
 
 
263
  info_gain = self._estimate_info_gain(
264
  var, val,
265
  n_samples=n_samples,
266
+ n_outcome_samples=outcome_cap,
267
  )
268
+
269
  if info_gain > best_info_gain:
270
  best_info_gain = info_gain
271
  best_experiment = {'variable': var, 'value': val}
 
303
  # Estimate expected posterior entropy after seeing outcomes
304
  # Use model-averaged predictions
305
  expected_tension = 0.0
306
+ effective_outcome_samples = min(n_samples, max(1, n_outcome_samples))
307
 
308
  for name, outcomes in predicted_outcomes.items():
309
  model_weight = current_posterior.get(name, 1.0 / len(self.models))
310
 
311
+ for outcome in outcomes[:effective_outcome_samples]:
312
  # What would the posterior look like if we saw this outcome?
313
  hypothetical_log_liks = {}
314
+
315
  for m_name, model in self.models.items():
316
  hypothetical_log_liks[m_name] = model.log_evidence([outcome])
317
+
318
  # Hypothetical posterior
319
  hyp_evidence = {m: self.model_log_evidence[m] + hypothetical_log_liks[m]
320
  for m in self.models}
321
+
322
  max_e = max(hyp_evidence.values())
323
+
324
  log_Z = max_e + np.log(sum(
325
  np.exp(e - max_e) for e in hyp_evidence.values()))
326
+
327
  hyp_posterior = {m: np.exp(e - log_Z) for m, e in hyp_evidence.items()}
328
+
329
  expected_tension += model_weight * self._compute_tension(hyp_posterior)
330
+
331
+ expected_tension /= max(effective_outcome_samples, 1)
332
+
333
  # Information gain = current uncertainty - expected uncertainty after experiment
334
  return current_tension - expected_tension
335
 
tensegrity/causal/from_proposal.py CHANGED
@@ -21,8 +21,8 @@ def build_scm_from_proposal(proposal: ProposedSCM, n_values: int = 4) -> Structu
21
  that edge is dropped (``G.remove_edge``) and a debug log is emitted — earlier edges
22
  are never removed. Variable order follows a topological sort when the retained graph is non-empty.
23
  """
24
- if n_values <= 0:
25
- raise ValueError(f"n_values must be a positive integer, got {n_values}")
26
 
27
  G = nx.DiGraph()
28
 
 
21
  that edge is dropped (``G.remove_edge``) and a debug log is emitted — earlier edges
22
  are never removed. Variable order follows a topological sort when the retained graph is non-empty.
23
  """
24
+ if not isinstance(n_values, int) or n_values <= 0:
25
+ raise ValueError(f"n_values must be a positive integer, got {n_values!r}")
26
 
27
  G = nx.DiGraph()
28
 
tensegrity/causal/scm.py CHANGED
@@ -25,6 +25,13 @@ from copy import deepcopy
25
  from itertools import product
26
 
27
 
 
 
 
 
 
 
 
28
  class CausalMechanism:
29
  """
30
  A single causal mechanism: V_i := f_i(parents, noise).
@@ -71,15 +78,22 @@ class CausalMechanism:
71
  return self.cpt_params / self.cpt_params.sum(axis=0, keepdims=True)
72
 
73
  def parent_config_index(self, parent_values: Dict[str, int]) -> int:
74
- """Convert parent values to a CPT column index."""
75
  if not self.parents:
76
  return 0
77
 
 
 
 
 
 
 
 
78
  idx = 0
79
  stride = 1
80
 
81
  for p, card in zip(self.parents, self.parent_cardinalities):
82
- value = int(parent_values.get(p, 0))
83
  idx += (value % max(int(card), 1)) * stride
84
  stride *= max(int(card), 1)
85
 
@@ -151,13 +165,21 @@ class StructuralCausalModel:
151
  def add_variable(self, name: str, n_values: int = 4,
152
  parents: Optional[List[str]] = None,
153
  noise_scale: float = 0.1):
154
- """Add a variable with its causal mechanism."""
 
 
 
 
 
155
  parents = parents or []
156
 
157
- for parent in parents:
158
- if parent not in self.graph:
159
- # Auto-create parent as root node with the child's cardinality.
160
- self.add_variable(parent, n_values)
 
 
 
161
 
162
  parent_cardinalities = [self.mechanisms[p].n_values for p in parents]
163
 
@@ -257,7 +279,11 @@ class StructuralCausalModel:
257
 
258
  def counterfactual(self, evidence: Dict[str, int],
259
  interventions: Dict[str, int],
260
- query: List[str]) -> Dict[str, np.ndarray]:
 
 
 
 
261
  """
262
  Rung 3 — Counterfactual: P(Y_{do(x)} | observed evidence).
263
 
@@ -287,6 +313,8 @@ class StructuralCausalModel:
287
  if not posterior_worlds:
288
  return cf_results
289
 
 
 
290
  order = self.topological_order()
291
  affected: Set[str] = set()
292
  for var in interventions:
@@ -327,7 +355,41 @@ class StructuralCausalModel:
327
  updated[var] = int(v)
328
  next_worlds.append((updated, weight * float(p_v)))
329
 
 
 
 
 
 
 
330
  worlds = next_worlds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
  for values, weight in worlds:
333
  for q in cf_results:
@@ -374,14 +436,17 @@ class StructuralCausalModel:
374
 
375
  def _joint_probability(self, assignment: Dict[str, int]) -> float:
376
  """P(assignment) under the SCM, assuming all variables are assigned."""
377
- prob = 1.0
378
  for var in self.topological_order():
379
  mech = self.mechanisms[var]
380
  parent_vals = {p: assignment[p] for p in mech.parents}
381
- prob *= float(np.exp(mech.log_prob(assignment[var], parent_vals)))
382
- if prob <= 0.0:
383
  return 0.0
384
- return float(prob)
 
 
 
385
 
386
  def _enumerate_joint_assignments(
387
  self,
 
25
  from itertools import product
26
 
27
 
28
+ class ControlledExpansionError(RuntimeError):
29
+ """Raised when counterfactual world enumeration exceeds a configured limit."""
30
+
31
+ # Default safeguard for branching in ``StructuralCausalModel.counterfactual``.
32
+ _DEFAULT_MAX_CF_WORLDS = 250_000
33
+
34
+
35
  class CausalMechanism:
36
  """
37
  A single causal mechanism: V_i := f_i(parents, noise).
 
78
  return self.cpt_params / self.cpt_params.sum(axis=0, keepdims=True)
79
 
80
  def parent_config_index(self, parent_values: Dict[str, int]) -> int:
81
+ """Convert parent values to a CPT column index. All listed parents must be present."""
82
  if not self.parents:
83
  return 0
84
 
85
+ missing = [p for p in self.parents if p not in parent_values]
86
+ if missing:
87
+ raise KeyError(
88
+ f"CausalMechanism({self.name!r}): parent_values missing keys {missing}; "
89
+ f"expected all of {list(self.parents)}"
90
+ )
91
+
92
  idx = 0
93
  stride = 1
94
 
95
  for p, card in zip(self.parents, self.parent_cardinalities):
96
+ value = int(parent_values[p])
97
  idx += (value % max(int(card), 1)) * stride
98
  stride *= max(int(card), 1)
99
 
 
165
  def add_variable(self, name: str, n_values: int = 4,
166
  parents: Optional[List[str]] = None,
167
  noise_scale: float = 0.1):
168
+ """Add a variable with its causal mechanism.
169
+
170
+ Every ``parent`` must already exist — call ``add_variable`` for parents first
171
+ with the desired ``n_values``. Parent cardinalities in CPT indexing come from
172
+ ``self.mechanisms[parent].n_values``.
173
+ """
174
  parents = parents or []
175
 
176
+ missing = [p for p in parents if p not in self.graph]
177
+ if missing:
178
+ raise ValueError(
179
+ f"{self.name}: undefined parent variable(s) {missing} for '{name}'. "
180
+ "Declare each parent with add_variable(name, n_values=...) before "
181
+ "listing it in parents=[...]."
182
+ )
183
 
184
  parent_cardinalities = [self.mechanisms[p].n_values for p in parents]
185
 
 
279
 
280
  def counterfactual(self, evidence: Dict[str, int],
281
  interventions: Dict[str, int],
282
+ query: List[str],
283
+ *,
284
+ max_cf_worlds: Optional[int] = None,
285
+ prune_relative_weight_floor: Optional[float] = None,
286
+ prune_worlds_top_k: Optional[int] = None) -> Dict[str, np.ndarray]:
287
  """
288
  Rung 3 — Counterfactual: P(Y_{do(x)} | observed evidence).
289
 
 
313
  if not posterior_worlds:
314
  return cf_results
315
 
316
+ ml = _DEFAULT_MAX_CF_WORLDS if max_cf_worlds is None else max(1, int(max_cf_worlds))
317
+
318
  order = self.topological_order()
319
  affected: Set[str] = set()
320
  for var in interventions:
 
355
  updated[var] = int(v)
356
  next_worlds.append((updated, weight * float(p_v)))
357
 
358
+ if len(next_worlds) > ml:
359
+ raise ControlledExpansionError(
360
+ f"{self.name}: counterfactual branch count {len(next_worlds)} exceeds "
361
+ f"max_cf_worlds={ml}; reduce SCM size / intervention breadth, "
362
+ "or raise max_cf_worlds / use prune_worlds_top_k."
363
+ )
364
  worlds = next_worlds
365
+ if prune_worlds_top_k is not None and int(prune_worlds_top_k) > 0:
366
+ tk = int(prune_worlds_top_k)
367
+ if len(worlds) > tk:
368
+ tot_before = sum(w for _, w in worlds)
369
+ worlds = sorted(worlds, key=lambda t: -t[1])[:tk]
370
+ tot_after = sum(w for _, w in worlds)
371
+ if tot_before > 0 and tot_after > 0 and tot_after < tot_before:
372
+ scale = tot_before / tot_after
373
+ worlds = [(a, float(w * scale)) for a, w in worlds]
374
+ if prune_relative_weight_floor is not None:
375
+ pq = float(prune_relative_weight_floor)
376
+ if pq > 0.0 and worlds:
377
+ mass = sum(w for _, w in worlds)
378
+ if mass > 0:
379
+ thresh = pq * mass
380
+ kept = [(a, w) for a, w in worlds if w >= thresh]
381
+ if kept:
382
+ m2 = sum(w for _, w in kept)
383
+ if m2 < mass > 0 and m2 > 0:
384
+ scale = mass / m2
385
+ worlds = [(a, w * scale) for a, w in kept]
386
+ elif m2 <= 0:
387
+ raise ControlledExpansionError(
388
+ f"{self.name}: counterfactual pruning eliminated all worlds "
389
+ f"(increase prune_relative_weight_floor beyond {thresh})."
390
+ )
391
+ else:
392
+ worlds = kept
393
 
394
  for values, weight in worlds:
395
  for q in cf_results:
 
436
 
437
  def _joint_probability(self, assignment: Dict[str, int]) -> float:
438
  """P(assignment) under the SCM, assuming all variables are assigned."""
439
+ log_joint = 0.0
440
  for var in self.topological_order():
441
  mech = self.mechanisms[var]
442
  parent_vals = {p: assignment[p] for p in mech.parents}
443
+ ell = float(mech.log_prob(assignment[var], parent_vals))
444
+ if not np.isfinite(ell):
445
  return 0.0
446
+ log_joint += ell
447
+ if not np.isfinite(log_joint) or log_joint < -900.0:
448
+ return float(np.exp(max(log_joint, -900.0)))
449
+ return float(np.exp(log_joint))
450
 
451
  def _enumerate_joint_assignments(
452
  self,
tensegrity/core/morton.py CHANGED
@@ -3,8 +3,9 @@
3
  import warnings
4
 
5
  warnings.warn(
6
- "tensegrity.core.morton is legacy V1; use tensegrity.legacy.v1.morton "
7
- "for the old Morton-coded frontend.",
 
8
  DeprecationWarning,
9
  stacklevel=2,
10
  )
 
3
  import warnings
4
 
5
  warnings.warn(
6
+ "tensegrity.core.morton is legacy V1; import from tensegrity.legacy.v1.morton "
7
+ "for the Morton-coded frontend (same API — re-export only). There is no "
8
+ "alternative module beyond legacy.v1 for this shim.",
9
  DeprecationWarning,
10
  stacklevel=2,
11
  )
tensegrity/engine/causal_energy.py CHANGED
@@ -16,6 +16,7 @@ when an energy-based readout of SCM fit is required.
16
 
17
  import numpy as np
18
  from dataclasses import dataclass, field
 
19
  from typing import Dict, List, Any, Optional, Tuple
20
  import networkx as nx
21
  from tensegrity.causal.scm import StructuralCausalModel
@@ -75,7 +76,11 @@ class VirtualParent:
75
  source: str
76
  target: str
77
  layer: int
78
- children: Tuple[str, str]
 
 
 
 
79
 
80
 
81
  @dataclass
@@ -96,16 +101,16 @@ class TopologyMapping:
96
  virtual_parents: Dict[str, VirtualParent] = field(default_factory=dict)
97
  original_edges: List[Tuple[str, str]] = field(default_factory=list)
98
 
99
- @property
100
  def layer_nodes(self) -> Dict[int, List[str]]:
101
  layers: Dict[int, List[str]] = {}
102
-
103
  for node, layer in self.embedded_layers.items():
104
  layers.setdefault(layer, []).append(node)
105
-
106
  for nodes in layers.values():
107
  nodes.sort()
108
-
109
  return dict(sorted(layers.items()))
110
 
111
  def ngc_layer_sizes(self, min_width: int = 1) -> List[int]:
@@ -156,7 +161,17 @@ class TopologyMapper:
156
  """
157
  Embed arbitrary SCM DAG topology into hierarchical predictive-coding wiring.
158
 
159
- The mapper makes the Friston/Pearl handshake explicit:
 
 
 
 
 
 
 
 
 
 
160
 
161
  * A causal edge from layer k to k-1 becomes a direct top-down prediction.
162
  * A bypass edge spanning multiple layers receives relay nodes, one per
@@ -243,7 +258,7 @@ class TopologyMapper:
243
  continue
244
 
245
  virtual_layer = max(source_layer, target_layer) + 1
246
-
247
  if n_layers is not None and virtual_layer >= n_layers and not self.expand_layers:
248
  raise ValueError(
249
  f"edge {source!r}->{target!r} needs virtual parent layer {virtual_layer}, "
@@ -262,7 +277,6 @@ class TopologyMapper:
262
  source=source,
263
  target=target,
264
  layer=virtual_layer,
265
- children=(source, target),
266
  )
267
 
268
  virtual_parents[virtual] = vp
 
16
 
17
  import numpy as np
18
  from dataclasses import dataclass, field
19
+ from functools import cached_property
20
  from typing import Dict, List, Any, Optional, Tuple
21
  import networkx as nx
22
  from tensegrity.causal.scm import StructuralCausalModel
 
76
  source: str
77
  target: str
78
  layer: int
79
+
80
+ @property
81
+ def children(self) -> Tuple[str, str]:
82
+ """The two SCM variables summarized by this virtual parent."""
83
+ return (self.source, self.target)
84
 
85
 
86
  @dataclass
 
101
  virtual_parents: Dict[str, VirtualParent] = field(default_factory=dict)
102
  original_edges: List[Tuple[str, str]] = field(default_factory=list)
103
 
104
+ @cached_property
105
  def layer_nodes(self) -> Dict[int, List[str]]:
106
  layers: Dict[int, List[str]] = {}
107
+
108
  for node, layer in self.embedded_layers.items():
109
  layers.setdefault(layer, []).append(node)
110
+
111
  for nodes in layers.values():
112
  nodes.sort()
113
+
114
  return dict(sorted(layers.items()))
115
 
116
  def ngc_layer_sizes(self, min_width: int = 1) -> List[int]:
 
161
  """
162
  Embed arbitrary SCM DAG topology into hierarchical predictive-coding wiring.
163
 
164
+ Constructor flag ``expand_layers`` interacts with ``n_layers``:
165
+
166
+ - When ``expand_layers`` is **False**, a lateral or same-layer edge requiring a virtual
167
+ parent strictly above ``n_layers - 1`` raises ``ValueError`` (no implicit layer growth).
168
+
169
+ - When ``expand_layers`` is **True**, virtual-parent nodes **may occupy layer indices equal
170
+ to or **greater than** ``n_layers - 1** as needed — the mapper extends the conceptual
171
+ hierarchy upward so horizontal dependencies become shared parents. Caller layer counts
172
+ (e.g. ``ngc_layer_sizes``) must account for the actual maximum embedded layer index.
173
+
174
+ The mapper otherwise makes the Friston/Pearl handshake explicit:
175
 
176
  * A causal edge from layer k to k-1 becomes a direct top-down prediction.
177
  * A bypass edge spanning multiple layers receives relay nodes, one per
 
258
  continue
259
 
260
  virtual_layer = max(source_layer, target_layer) + 1
261
+
262
  if n_layers is not None and virtual_layer >= n_layers and not self.expand_layers:
263
  raise ValueError(
264
  f"edge {source!r}->{target!r} needs virtual parent layer {virtual_layer}, "
 
277
  source=source,
278
  target=target,
279
  layer=virtual_layer,
 
280
  )
281
 
282
  virtual_parents[virtual] = vp
tensegrity/engine/fhrr.py CHANGED
@@ -19,8 +19,10 @@ This is what gives the cognitive layer real semantic knowledge.
19
  """
20
 
21
  import hashlib
 
 
22
  import numpy as np
23
- from typing import Optional, List, Tuple, Dict, Union
24
  import logging
25
 
26
  logger = logging.getLogger(__name__)
@@ -93,6 +95,13 @@ class FHRRCodebook:
93
 
94
  return [(f"#{int(i)}", float(sims[i])) for i in top_idx]
95
 
 
 
 
 
 
 
 
96
 
97
  class SemanticFHRRCodebook(FHRRCodebook):
98
  """
@@ -149,11 +158,34 @@ class SemanticFHRRCodebook(FHRRCodebook):
149
  self._proj /= np.sqrt(self._sbert_dim)
150
  logger.info(f"SemanticFHRR: loaded {self._sbert_model_name} "
151
  f"(dim={self._sbert_dim}) → FHRR(dim={self.dim})")
152
- except Exception as exc:
153
- logger.warning("SemanticFHRR: falling back to deterministic random vectors (%s)", exc)
 
 
 
154
  self._sbert = "FALLBACK"
155
  self._proj = None
156
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  def _embed_to_phasor(self, embedding: np.ndarray) -> np.ndarray:
158
  projected = self._proj @ embedding.astype(np.float32)
159
  proj_std = np.std(projected)
@@ -262,8 +294,9 @@ class FHRREncoder:
262
  self.features = SemanticFHRRCodebook(dim=dim, sbert_model=sbert_model) if semantic \
263
  else FHRRCodebook(n_features, dim, seed=3000)
264
 
265
- self._position_cache: Dict[int, np.ndarray] = {}
266
  self._position_cache_max = 4096
 
267
 
268
  for role in ["position", "value", "type", "attribute", "relation",
269
  "subject", "object", "time", "channel"]:
@@ -285,20 +318,23 @@ class FHRREncoder:
285
 
286
  def encode_position(self, x: int) -> np.ndarray:
287
  x = int(x)
288
- cached = self._position_cache.get(x)
289
-
290
- if cached is not None:
291
- return cached.copy()
292
-
 
293
  result = np.ones(self.dim, dtype=np.complex64)
294
-
295
  for base, m in zip(self._pos_bases, self.moduli):
296
  result = result * (base ** (x % m))
297
-
298
- if len(self._position_cache) < self._position_cache_max:
299
- self._position_cache[x] = result.copy()
300
-
301
- return result
 
 
302
 
303
  def encode_value(self, value: float, precision: int = 100) -> np.ndarray:
304
  return self.encode_position(int(round(value * precision)))
 
19
  """
20
 
21
  import hashlib
22
+ import threading
23
+ from collections import OrderedDict
24
  import numpy as np
25
+ from typing import Any, Optional, List, Tuple, Dict, Union
26
  import logging
27
 
28
  logger = logging.getLogger(__name__)
 
95
 
96
  return [(f"#{int(i)}", float(sims[i])) for i in top_idx]
97
 
98
+ def get_sbert_model(self) -> Optional[Any]:
99
+ """Non-semantic codebook has no SBERT model."""
100
+ return None
101
+
102
+ def has_sbert(self) -> bool:
103
+ return False
104
+
105
 
106
  class SemanticFHRRCodebook(FHRRCodebook):
107
  """
 
158
  self._proj /= np.sqrt(self._sbert_dim)
159
  logger.info(f"SemanticFHRR: loaded {self._sbert_model_name} "
160
  f"(dim={self._sbert_dim}) → FHRR(dim={self.dim})")
161
+ except ImportError as exc:
162
+ logger.warning(
163
+ "SemanticFHRR: sentence_transformers unavailable (%s); deterministic vectors",
164
+ exc,
165
+ )
166
  self._sbert = "FALLBACK"
167
  self._proj = None
168
+ except OSError as exc:
169
+ logger.warning(
170
+ "SemanticFHRR: SBERT model load failed (%s); deterministic vectors",
171
+ exc,
172
+ )
173
+ self._sbert = "FALLBACK"
174
+ self._proj = None
175
+ except Exception:
176
+ logger.exception("SemanticFHRR: unexpected error loading SBERT")
177
+ raise
178
+
179
+ def get_sbert_model(self) -> Optional[Any]:
180
+ """Return the loaded ``SentenceTransformer`` when available; else ``None``."""
181
+ self._ensure_sbert()
182
+ if self._sbert is None or self._sbert == "FALLBACK":
183
+ return None
184
+ return self._sbert
185
+
186
+ def has_sbert(self) -> bool:
187
+ return self.get_sbert_model() is not None
188
+
189
  def _embed_to_phasor(self, embedding: np.ndarray) -> np.ndarray:
190
  projected = self._proj @ embedding.astype(np.float32)
191
  proj_std = np.std(projected)
 
294
  self.features = SemanticFHRRCodebook(dim=dim, sbert_model=sbert_model) if semantic \
295
  else FHRRCodebook(n_features, dim, seed=3000)
296
 
297
+ self._position_cache: OrderedDict[int, np.ndarray] = OrderedDict()
298
  self._position_cache_max = 4096
299
+ self._position_cache_lock = threading.Lock()
300
 
301
  for role in ["position", "value", "type", "attribute", "relation",
302
  "subject", "object", "time", "channel"]:
 
318
 
319
  def encode_position(self, x: int) -> np.ndarray:
320
  x = int(x)
321
+ with self._position_cache_lock:
322
+ cached = self._position_cache.get(x)
323
+ if cached is not None:
324
+ self._position_cache.move_to_end(x)
325
+ return cached.copy()
326
+
327
  result = np.ones(self.dim, dtype=np.complex64)
328
+
329
  for base, m in zip(self._pos_bases, self.moduli):
330
  result = result * (base ** (x % m))
331
+
332
+ copied = result.copy()
333
+ with self._position_cache_lock:
334
+ while len(self._position_cache) >= self._position_cache_max:
335
+ self._position_cache.popitem(last=False)
336
+ self._position_cache[x] = copied
337
+ return copied.copy()
338
 
339
  def encode_value(self, value: float, precision: int = 100) -> np.ndarray:
340
  return self.encode_position(int(round(value * precision)))
tensegrity/engine/ngc.py CHANGED
@@ -390,6 +390,13 @@ class PredictiveCodingCircuit:
390
  """Drop recorded energy / error traces."""
391
  self.energy_history.clear()
392
  self.error_history.clear()
 
 
 
 
 
 
 
393
 
394
  def reinitialize(self, weight_seed: int = 12345) -> None:
395
  """Reset layer states and resample W/E."""
 
390
  """Drop recorded energy / error traces."""
391
  self.energy_history.clear()
392
  self.error_history.clear()
393
+
394
+ def soft_reset(self) -> None:
395
+ """Clear layer activations and history without resampling prediction weights."""
396
+ self.layers = []
397
+ self._initialized = False
398
+ self._last_obs = None
399
+ self.clear_history()
400
 
401
  def reinitialize(self, weight_seed: int = 12345) -> None:
402
  """Reset layer states and resample W/E."""
tensegrity/engine/scoring.py CHANGED
@@ -274,7 +274,6 @@ class ScoringBridge:
274
  if hasattr(features, '_ensure_sbert') and getattr(features, '_sbert', None) is None:
275
  features._ensure_sbert()
276
  if hasattr(features, '_sbert') and features._sbert is not None and features._sbert != "FALLBACK":
277
- features._ensure_sbert()
278
  embs = features._sbert.encode([prompt] + choices, show_progress_bar=False)
279
  pe, pn = embs[0], np.linalg.norm(embs[0])
280
  return [float(np.dot(pe, embs[i+1]) / (pn * np.linalg.norm(embs[i+1])))
@@ -289,6 +288,10 @@ class ScoringBridge:
289
  out.append(self.field.encoder.similarity(pf, enc))
290
  return out
291
 
 
 
 
 
292
  def reset(self):
293
  self.field.ngc.reinitialize(12345)
294
  self.field.memory.patterns.clear()
 
274
  if hasattr(features, '_ensure_sbert') and getattr(features, '_sbert', None) is None:
275
  features._ensure_sbert()
276
  if hasattr(features, '_sbert') and features._sbert is not None and features._sbert != "FALLBACK":
 
277
  embs = features._sbert.encode([prompt] + choices, show_progress_bar=False)
278
  pe, pn = embs[0], np.linalg.norm(embs[0])
279
  return [float(np.dot(pe, embs[i+1]) / (pn * np.linalg.norm(embs[i+1])))
 
288
  out.append(self.field.encoder.similarity(pf, enc))
289
  return out
290
 
291
+ def sentence_similarities(self, prompt, choices):
292
+ """Public alias for SBERT/FHRR sentence-level similarity tie-breaks (see ``_sentence_similarities``)."""
293
+ return self._sentence_similarities(prompt, choices)
294
+
295
  def reset(self):
296
  self.field.ngc.reinitialize(12345)
297
  self.field.memory.patterns.clear()
tensegrity/engine/unified_field.py CHANGED
@@ -25,11 +25,14 @@ 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 numpy as np
29
  from typing import Dict, List, Optional, Any, Tuple, Deque
30
  from dataclasses import dataclass
31
  from collections import deque
32
 
 
 
33
  from .fhrr import FHRREncoder, bind, bundle, unbind
34
  from .ngc import PredictiveCodingCircuit
35
 
@@ -67,7 +70,13 @@ class HopfieldMemoryBank:
67
  self.patterns: deque = deque(maxlen=capacity)
68
  self._matrix: Optional[np.ndarray] = None
69
  self._dirty = True
70
-
 
 
 
 
 
 
71
  def store(self, pattern: np.ndarray, normalize: bool = True):
72
  """Store a pattern (FHRR vector — use real part for Hopfield)."""
73
  p = np.real(pattern).astype(np.float64) if np.iscomplexobj(pattern) else pattern.astype(np.float64)
@@ -111,6 +120,12 @@ class HopfieldMemoryBank:
111
  # Energy
112
  sims = self._matrix.T @ xi
113
  if self.beta <= 1e-12:
 
 
 
 
 
 
114
  energy = float(0.5 * np.dot(xi, xi) - np.mean(sims))
115
  else:
116
  log_sum_exp = np.log(np.sum(np.exp(self.beta * sims - self.beta * sims.max()))) + self.beta * sims.max()
 
25
  from the composition of these local optimizations.
26
  """
27
 
28
+ import logging
29
  import numpy as np
30
  from typing import Dict, List, Optional, Any, Tuple, Deque
31
  from dataclasses import dataclass
32
  from collections import deque
33
 
34
+ _logger = logging.getLogger(__name__)
35
+
36
  from .fhrr import FHRREncoder, bind, bundle, unbind
37
  from .ngc import PredictiveCodingCircuit
38
 
 
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)
 
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()
tensegrity/graft/__init__.py CHANGED
@@ -22,6 +22,11 @@ __all__ = (
22
  )
23
 
24
 
 
 
 
 
 
25
  def __getattr__(name: str) -> Any:
26
  if name == "HybridPipeline":
27
  value = getattr(import_module("tensegrity.graft.pipeline"), name)
 
22
  )
23
 
24
 
25
+ def __dir__():
26
+ merged = set(globals().keys()) | set(__all__)
27
+ return sorted(merged)
28
+
29
+
30
  def __getattr__(name: str) -> Any:
31
  if name == "HybridPipeline":
32
  value = getattr(import_module("tensegrity.graft.pipeline"), name)
tensegrity/graft/logit_bias.py CHANGED
@@ -38,6 +38,28 @@ import logging
38
 
39
  logger = logging.getLogger(__name__)
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  # Import torch lazily — only needed when actually grafting to a local model
42
  torch = None
43
 
@@ -92,8 +114,12 @@ class TensegrityLogitsProcessor:
92
  """
93
  Args:
94
  hypothesis_tokens: {hyp_id: set of token_ids} from VocabularyGrounding
95
- hypothesis_token_scores: optional semantic weights per token from
96
- VocabularyGrounding.from_semantic_projection
 
 
 
 
97
  belief_fn: Callable that returns current posteriors {hyp_id: probability}
98
  Sync mode: called each decode step. Async mode: polled in a worker thread.
99
  vocab_size: LLM vocabulary size
@@ -106,8 +132,6 @@ class TensegrityLogitsProcessor:
106
  async_beliefs: If True, belief_fn runs in a daemon thread; __call__ is O(1) bias add
107
  belief_poll_s: Sleep between async polls (seconds)
108
  """
109
- _ensure_torch()
110
-
111
  self.hypothesis_tokens = hypothesis_tokens
112
  self.hypothesis_token_scores = hypothesis_token_scores or {}
113
  self.belief_fn = belief_fn
@@ -119,6 +143,11 @@ class TensegrityLogitsProcessor:
119
  self.max_bias = max_bias
120
  self.async_beliefs = async_beliefs
121
  self.belief_poll_s = belief_poll_s
 
 
 
 
 
122
 
123
  # State tracking
124
  self.state = GraftState()
@@ -243,6 +272,7 @@ class TensegrityLogitsProcessor:
243
  if 0 <= tid < self.vocab_size:
244
  if not np.isneginf(bias[tid]):
245
  weighted_b = b * float(token_scores.get(tid, 1.0))
 
246
  bias[tid] += weighted_b
247
  if weighted_b > 0:
248
  boosted += 1
@@ -301,6 +331,11 @@ class StaticLogitBiasBuilder:
301
  Builds a static logit_bias dict from the current belief state.
302
  Less powerful than the LogitsProcessor (no per-step updates),
303
  but works with any OpenAI-compatible API.
 
 
 
 
 
304
  """
305
 
306
  def __init__(self, hypothesis_tokens: Dict[str, Set[int]],
@@ -313,7 +348,11 @@ class StaticLogitBiasBuilder:
313
  self.scale = scale
314
  self.suppress_threshold = suppress_threshold
315
  self.max_bias = max_bias
316
-
 
 
 
 
317
  def build(self, posteriors: Dict[str, float]) -> Dict[int, float]:
318
  """
319
  Build a static logit_bias dict for API calls.
@@ -339,6 +378,7 @@ class StaticLogitBiasBuilder:
339
  b = max(-self.max_bias, min(self.max_bias, b))
340
  for tid in token_ids:
341
  weighted_b = b * float(token_scores.get(tid, 1.0))
 
342
  bias[tid] = bias.get(tid, 0.0) + weighted_b
343
 
344
  return bias
 
38
 
39
  logger = logging.getLogger(__name__)
40
 
41
+
42
+ def _validate_hypothesis_token_scores_weights(
43
+ hypothesis_token_scores: Optional[Dict[str, Dict[int, float]]],
44
+ *,
45
+ context: str,
46
+ ) -> None:
47
+ """``hypothesis_token_scores`` weights must lie in **[0.0, 1.0]** (see graft docstrings)."""
48
+ if not hypothesis_token_scores:
49
+ return
50
+ bad = []
51
+ for hyp_id, m in hypothesis_token_scores.items():
52
+ for tid, w in m.items():
53
+ wf = float(w)
54
+ if not (0.0 <= wf <= 1.0):
55
+ bad.append(f"{hyp_id}[{tid}]={wf!r}")
56
+ if bad:
57
+ raise ValueError(
58
+ f"{context}: each hypothesis_token_scores value must be in [0.0, 1.0]; "
59
+ f"misconfigured entries (showing up to 12): {bad[:12]}"
60
+ )
61
+
62
+
63
  # Import torch lazily — only needed when actually grafting to a local model
64
  torch = None
65
 
 
114
  """
115
  Args:
116
  hypothesis_tokens: {hyp_id: set of token_ids} from VocabularyGrounding
117
+ hypothesis_token_scores: optional per-token **weights** in **[0.0, 1.0]**
118
+ from ``VocabularyGrounding.from_semantic_projection`` (stored on
119
+ ``VocabularyGrounding.hypothesis_token_scores``).
120
+ **0.0** applies no incremental bias mass to that token; **1.0** applies the
121
+ full clamped hypothesis bias ``b`` before per-token stacking. Values outside
122
+ ``[0.0, 1.0]`` raise ``ValueError`` at processor construction time.
123
  belief_fn: Callable that returns current posteriors {hyp_id: probability}
124
  Sync mode: called each decode step. Async mode: polled in a worker thread.
125
  vocab_size: LLM vocabulary size
 
132
  async_beliefs: If True, belief_fn runs in a daemon thread; __call__ is O(1) bias add
133
  belief_poll_s: Sleep between async polls (seconds)
134
  """
 
 
135
  self.hypothesis_tokens = hypothesis_tokens
136
  self.hypothesis_token_scores = hypothesis_token_scores or {}
137
  self.belief_fn = belief_fn
 
143
  self.max_bias = max_bias
144
  self.async_beliefs = async_beliefs
145
  self.belief_poll_s = belief_poll_s
146
+
147
+ _validate_hypothesis_token_scores_weights(
148
+ self.hypothesis_token_scores,
149
+ context="TensegrityLogitsProcessor",
150
+ )
151
 
152
  # State tracking
153
  self.state = GraftState()
 
272
  if 0 <= tid < self.vocab_size:
273
  if not np.isneginf(bias[tid]):
274
  weighted_b = b * float(token_scores.get(tid, 1.0))
275
+ weighted_b = max(-self.max_bias, min(self.max_bias, weighted_b))
276
  bias[tid] += weighted_b
277
  if weighted_b > 0:
278
  boosted += 1
 
331
  Builds a static logit_bias dict from the current belief state.
332
  Less powerful than the LogitsProcessor (no per-step updates),
333
  but works with any OpenAI-compatible API.
334
+
335
+ ``hypothesis_token_scores`` matches ``TensegrityLogitsProcessor``: optional
336
+ ``{hyp_id: {token_id: weight}}`` with each **weight** in **[0.0, 1.0]** —
337
+ cosine-style scores must be scaled before injection (see
338
+ ``VocabularyGrounding.from_semantic_projection``, which emits [0.0, 1.0] weights).
339
  """
340
 
341
  def __init__(self, hypothesis_tokens: Dict[str, Set[int]],
 
348
  self.scale = scale
349
  self.suppress_threshold = suppress_threshold
350
  self.max_bias = max_bias
351
+ _validate_hypothesis_token_scores_weights(
352
+ self.hypothesis_token_scores,
353
+ context="StaticLogitBiasBuilder",
354
+ )
355
+
356
  def build(self, posteriors: Dict[str, float]) -> Dict[int, float]:
357
  """
358
  Build a static logit_bias dict for API calls.
 
378
  b = max(-self.max_bias, min(self.max_bias, b))
379
  for tid in token_ids:
380
  weighted_b = b * float(token_scores.get(tid, 1.0))
381
+ weighted_b = max(-self.max_bias, min(self.max_bias, weighted_b))
382
  bias[tid] = bias.get(tid, 0.0) + weighted_b
383
 
384
  return bias
tensegrity/graft/pipeline.py CHANGED
@@ -38,10 +38,18 @@ logger = logging.getLogger(__name__)
38
  class HybridPipeline:
39
  """
40
  Tensegrity+LLM hybrid generation.
41
-
42
  The cognitive layer resolves beliefs. The LLM narrates the resolution.
43
  Logit biases bridge the gap — no beliefs in the prompt, no reasoning
44
  delegated to the LLM.
 
 
 
 
 
 
 
 
45
  """
46
 
47
  def __init__(
@@ -60,6 +68,8 @@ class HybridPipeline:
60
  # not available at runtime we fall back to keyword grounding.
61
  semantic_grounding: bool = True,
62
  semantic_embedding_fn: Optional[Callable[[str], np.ndarray]] = None,
 
 
63
  semantic_top_k: int = 32,
64
  semantic_threshold: Optional[float] = None,
65
  ):
@@ -78,8 +88,12 @@ class HybridPipeline:
78
  async_graft: Local mode only — poll beliefs in a background thread for non-blocking decode
79
  semantic_grounding: If True, build grounding by frozen semantic
80
  phrase/token projection instead of exact keyword tokenization
81
- semantic_embedding_fn: Required when semantic_grounding=True; maps
82
- text to a fixed embedding vector without runtime training
 
 
 
 
83
  semantic_top_k: Semantic vocabulary tokens retained per hypothesis
84
  semantic_threshold: Optional minimum cosine similarity for semantic grounding
85
  """
@@ -92,6 +106,8 @@ class HybridPipeline:
92
  self.async_graft = async_graft
93
  self.semantic_grounding = semantic_grounding
94
  self.semantic_embedding_fn = semantic_embedding_fn
 
 
95
  self.semantic_top_k = semantic_top_k
96
  self.semantic_threshold = semantic_threshold
97
 
@@ -113,6 +129,7 @@ class HybridPipeline:
113
  # Generation tracking
114
  self._generations = 0
115
  self._graft_states: List[GraftState] = []
 
116
 
117
  def _label_phrases(self) -> Dict[str, List[str]]:
118
  phrases = {}
@@ -124,6 +141,9 @@ class HybridPipeline:
124
  def _build_grounding(self) -> VocabularyGrounding:
125
  if self.semantic_grounding:
126
  embed = self.semantic_embedding_fn or self._default_sbert_embed_fn()
 
 
 
127
  if embed is not None:
128
  phrases = self._hypothesis_keywords or self._label_phrases()
129
  try:
@@ -133,6 +153,7 @@ class HybridPipeline:
133
  embedding_fn=embed,
134
  top_k=self.semantic_top_k,
135
  threshold=self.semantic_threshold,
 
136
  )
137
  except Exception as e:
138
  logger.warning(
@@ -145,25 +166,38 @@ class HybridPipeline:
145
  self.hypothesis_labels, self._tokenizer)
146
 
147
  def _default_sbert_embed_fn(self) -> Optional[Callable[[str], np.ndarray]]:
148
- """Build a frozen sbert embedding function. Used when the caller did
149
- not pass an explicit semantic_embedding_fn. No gradient flow.
150
 
151
- Uses a bulk-prefetch cache: on first invocation, batch-encodes the
152
- entire LLM vocabulary in one shot (a few seconds for ~128k tokens
153
- on CPU) so the per-token loop in SemanticProjectionLayer.from_tokenizer
154
- becomes a dict lookup instead of 128k individual sbert calls.
 
155
  """
156
  try:
157
  from sentence_transformers import SentenceTransformer
158
  except Exception as e:
159
  logger.warning("sentence_transformers unavailable (%s); semantic grounding off", e)
 
160
  return None
161
  try:
162
- model = SentenceTransformer("all-MiniLM-L6-v2")
 
 
 
163
  except Exception as e:
164
  logger.warning("could not load sbert (%s); semantic grounding off", e)
 
165
  return None
166
 
 
 
 
 
 
 
 
 
167
  cache: Dict[str, np.ndarray] = {}
168
  bulk_done = [False]
169
 
 
38
  class HybridPipeline:
39
  """
40
  Tensegrity+LLM hybrid generation.
41
+
42
  The cognitive layer resolves beliefs. The LLM narrates the resolution.
43
  Logit biases bridge the gap — no beliefs in the prompt, no reasoning
44
  delegated to the LLM.
45
+
46
+ **Memory:** When using the default SBERT embedder (``semantic_embedding_fn``
47
+ unset with ``semantic_grounding=True``), the returned closure from
48
+ ``_default_sbert_embed_fn`` holds a ``SentenceTransformer`` for the
49
+ pipeline's lifetime. That model can stay resident on GPU and consume
50
+ VRAM. Mitigations: set ``sbert_device='cpu'``, pass a smaller
51
+ ``sbert_model_name``, or supply a custom ``semantic_embedding_fn`` that
52
+ does not pin a large model (e.g. remote API, smaller encoder, on-demand load).
53
  """
54
 
55
  def __init__(
 
68
  # not available at runtime we fall back to keyword grounding.
69
  semantic_grounding: bool = True,
70
  semantic_embedding_fn: Optional[Callable[[str], np.ndarray]] = None,
71
+ sbert_model_name: str = "all-MiniLM-L6-v2",
72
+ sbert_device: Optional[str] = None,
73
  semantic_top_k: int = 32,
74
  semantic_threshold: Optional[float] = None,
75
  ):
 
88
  async_graft: Local mode only — poll beliefs in a background thread for non-blocking decode
89
  semantic_grounding: If True, build grounding by frozen semantic
90
  phrase/token projection instead of exact keyword tokenization
91
+ semantic_embedding_fn: Optional when ``semantic_grounding`` is True. If omitted,
92
+ ``_build_grounding()`` supplies :func:`_default_sbert_embed_fn` using
93
+ ``sbert_model_name`` / ``sbert_device``. Pass an explicit callable to avoid the
94
+ long-lived SBERT model or customize embeddings.
95
+ sbert_model_name: sentence-transformers model ID for the default semantic embedder
96
+ sbert_device: Optional device hint for SBERT (e.g. ``"cpu"`` to avoid GPU residency)
97
  semantic_top_k: Semantic vocabulary tokens retained per hypothesis
98
  semantic_threshold: Optional minimum cosine similarity for semantic grounding
99
  """
 
106
  self.async_graft = async_graft
107
  self.semantic_grounding = semantic_grounding
108
  self.semantic_embedding_fn = semantic_embedding_fn
109
+ self.sbert_model_name = sbert_model_name
110
+ self.sbert_device = sbert_device
111
  self.semantic_top_k = semantic_top_k
112
  self.semantic_threshold = semantic_threshold
113
 
 
129
  # Generation tracking
130
  self._generations = 0
131
  self._graft_states: List[GraftState] = []
132
+ self._sbert_vocab_batch_fn: Optional[Callable[[List[str]], np.ndarray]] = None
133
 
134
  def _label_phrases(self) -> Dict[str, List[str]]:
135
  phrases = {}
 
141
  def _build_grounding(self) -> VocabularyGrounding:
142
  if self.semantic_grounding:
143
  embed = self.semantic_embedding_fn or self._default_sbert_embed_fn()
144
+ vocab_batch = getattr(self, "_sbert_vocab_batch_fn", None)
145
+ if self.semantic_embedding_fn is not None:
146
+ vocab_batch = None
147
  if embed is not None:
148
  phrases = self._hypothesis_keywords or self._label_phrases()
149
  try:
 
153
  embedding_fn=embed,
154
  top_k=self.semantic_top_k,
155
  threshold=self.semantic_threshold,
156
+ vocab_batch_embedding_fn=vocab_batch,
157
  )
158
  except Exception as e:
159
  logger.warning(
 
166
  self.hypothesis_labels, self._tokenizer)
167
 
168
  def _default_sbert_embed_fn(self) -> Optional[Callable[[str], np.ndarray]]:
169
+ """Return a callable that maps text embedding via sentence-transformers.
 
170
 
171
+ The closure captures ``model`` (:class:`~sentence_transformers.SentenceTransformer`)
172
+ for the pipeline's lifetime, so embeddings stay cheap after warm-up but the
173
+ model may hold GPU memory. Use ``sbert_device='cpu'``, a lighter
174
+ ``sbert_model_name``, or pass ``semantic_embedding_fn`` to avoid pinning
175
+ SBERT entirely.
176
  """
177
  try:
178
  from sentence_transformers import SentenceTransformer
179
  except Exception as e:
180
  logger.warning("sentence_transformers unavailable (%s); semantic grounding off", e)
181
+ self._sbert_vocab_batch_fn = None
182
  return None
183
  try:
184
+ st_kw: Dict[str, Any] = {}
185
+ if self.sbert_device is not None:
186
+ st_kw["device"] = self.sbert_device
187
+ model = SentenceTransformer(self.sbert_model_name, **st_kw)
188
  except Exception as e:
189
  logger.warning("could not load sbert (%s); semantic grounding off", e)
190
+ self._sbert_vocab_batch_fn = None
191
  return None
192
 
193
+ def _vocab_batch_encode(batch: List[str]) -> np.ndarray:
194
+ return np.asarray(
195
+ model.encode(batch, batch_size=256, show_progress_bar=False),
196
+ dtype=np.float32,
197
+ )
198
+
199
+ self._sbert_vocab_batch_fn = _vocab_batch_encode
200
+
201
  cache: Dict[str, np.ndarray] = {}
202
  bulk_done = [False]
203
 
tensegrity/graft/vocabulary.py CHANGED
@@ -20,6 +20,7 @@ and the LLM's continuous logit space.
20
  """
21
 
22
  import re
 
23
  from typing import Callable, Dict, Iterable, List, Set, Optional, Tuple
24
  from dataclasses import dataclass, field
25
 
@@ -27,6 +28,22 @@ import numpy as np
27
 
28
 
29
  EmbeddingFn = Callable[[str], np.ndarray]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def _clean_token_text(token: str) -> str:
@@ -48,7 +65,7 @@ def _as_unit_vector(value: np.ndarray) -> np.ndarray:
48
  vec = np.asarray(value, dtype=np.float64).ravel()
49
  norm = float(np.linalg.norm(vec))
50
  if norm <= 1e-12:
51
- return vec
52
  return vec / norm
53
 
54
 
@@ -106,16 +123,66 @@ class SemanticProjectionLayer:
106
  embedding_fn: EmbeddingFn,
107
  projection_matrix: Optional[np.ndarray] = None,
108
  token_texts: Optional[Dict[int, str]] = None,
 
 
 
109
  ) -> "SemanticProjectionLayer":
110
  texts = token_texts or _token_texts_from_tokenizer(tokenizer)
111
- token_vectors: Dict[int, np.ndarray] = {}
112
- for tid, text in texts.items():
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  try:
114
- vec = _as_unit_vector(embedding_fn(text))
115
- except Exception:
116
- continue
117
- if vec.size and np.linalg.norm(vec) > 1e-12:
118
- token_vectors[int(tid)] = vec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  return cls(
120
  token_vectors=token_vectors,
121
  token_texts={int(k): v for k, v in texts.items()},
@@ -182,7 +249,7 @@ class VocabularyGrounding:
182
  # {hypothesis_id: list of grounding keywords}
183
  hypothesis_keywords: Dict[str, List[str]] = field(default_factory=dict)
184
 
185
- # {hypothesis_id: {token_id: semantic proximity in [roughly -1, 1]}}
186
  hypothesis_token_scores: Dict[str, Dict[int, float]] = field(default_factory=dict)
187
 
188
  # Inverse map: {token_id: list of hypothesis_ids it belongs to}
@@ -260,6 +327,8 @@ class VocabularyGrounding:
260
  token_texts: Optional[Dict[int, str]] = None,
261
  top_k: int = 32,
262
  threshold: Optional[float] = None,
 
 
263
  ) -> 'VocabularyGrounding':
264
  """
265
  Build grounding by semantic proximity instead of exact keyword matches.
@@ -273,12 +342,18 @@ class VocabularyGrounding:
273
  token_texts: optional explicit {token_id: token_text} inventory.
274
  top_k: maximum vocabulary tokens retained per hypothesis.
275
  threshold: minimum cosine similarity. ``None`` keeps the best top_k.
 
 
 
 
276
  """
277
  projection = SemanticProjectionLayer.from_tokenizer(
278
  tokenizer,
279
  embedding_fn=embedding_fn,
280
  projection_matrix=projection_matrix,
281
  token_texts=token_texts,
 
 
282
  )
283
  grounding = cls()
284
  grounding.vocab_size = int(getattr(tokenizer, "vocab_size", 0) or 0)
@@ -293,15 +368,30 @@ class VocabularyGrounding:
293
  concept_vector = _mean_unit_vector(
294
  embedding_fn(phrase) for phrase in concept_phrases if str(phrase).strip()
295
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  token_scores = projection.project_phrase_vector(
297
  concept_vector,
298
  top_k=top_k,
299
  threshold=threshold,
300
  )
301
- grounding.hypothesis_token_scores[hyp_id] = token_scores
302
- grounding.hypothesis_tokens[hyp_id] = set(token_scores)
 
 
303
 
304
- for tid in token_scores:
305
  grounding.token_to_hypotheses.setdefault(tid, []).append(hyp_id)
306
 
307
  return grounding
 
20
  """
21
 
22
  import re
23
+ import logging
24
  from typing import Callable, Dict, Iterable, List, Set, Optional, Tuple
25
  from dataclasses import dataclass, field
26
 
 
28
 
29
 
30
  EmbeddingFn = Callable[[str], np.ndarray]
31
+ BatchedEmbedFn = Callable[[List[str]], np.ndarray]
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _cosine_similarity_to_graft_multiplier(score: float) -> float:
38
+ """Map cosine proximity in roughly [-1, 1] to ``hypothesis_token_scores`` weights **[0.0, 1.0]**."""
39
+
40
+ return float(max(0.0, min(1.0, 0.5 * (float(score) + 1.0))))
41
+
42
+
43
+ def _chunks(seq: List, size: int) -> Iterable[List]:
44
+ bs = max(1, size)
45
+ for i in range(0, len(seq), bs):
46
+ yield seq[i:i + bs]
47
 
48
 
49
  def _clean_token_text(token: str) -> str:
 
65
  vec = np.asarray(value, dtype=np.float64).ravel()
66
  norm = float(np.linalg.norm(vec))
67
  if norm <= 1e-12:
68
+ return np.zeros_like(vec, dtype=np.float64)
69
  return vec / norm
70
 
71
 
 
123
  embedding_fn: EmbeddingFn,
124
  projection_matrix: Optional[np.ndarray] = None,
125
  token_texts: Optional[Dict[int, str]] = None,
126
+ *,
127
+ batched_embedding_fn: Optional[BatchedEmbedFn] = None,
128
+ batch_size: int = 256,
129
  ) -> "SemanticProjectionLayer":
130
  texts = token_texts or _token_texts_from_tokenizer(tokenizer)
131
+ token_vectors = {}
132
+ tids_ordered = sorted(texts.keys(), key=lambda x: int(x))
133
+ pairs = [(k, texts[k]) for k in tids_ordered]
134
+
135
+ filled = False
136
+
137
+ def apply_rows(row_tids: List[int], vectors: np.ndarray) -> None:
138
+ if vectors.ndim != 2 or vectors.shape[0] != len(row_tids):
139
+ raise ValueError("batched embeddings must have shape (batch, dim)")
140
+ for tid, row in zip(row_tids, vectors):
141
+ vec = _as_unit_vector(np.asarray(row, dtype=np.float64))
142
+ if vec.size and np.linalg.norm(vec) > 1e-12:
143
+ token_vectors[int(tid)] = vec
144
+
145
+ if batched_embedding_fn is not None and pairs:
146
  try:
147
+ for chunk_pairs in _chunks(pairs, batch_size):
148
+ chunk_strings = [p[1] for p in chunk_pairs]
149
+ chunk_ids = [p[0] for p in chunk_pairs]
150
+ batch_out = batched_embedding_fn(chunk_strings)
151
+ mat = np.asarray(batch_out, dtype=np.float64)
152
+ apply_rows(chunk_ids, mat)
153
+ filled = True
154
+ except Exception as e:
155
+ logger.warning(
156
+ "batched_embedding_fn failed (%s); falling back to per-token embedding_fn",
157
+ e,
158
+ )
159
+ token_vectors.clear()
160
+
161
+ if not filled and pairs:
162
+ try:
163
+ maybe = embedding_fn([p[1] for p in pairs]) # type: ignore[arg-type,misc]
164
+ mat = np.asarray(maybe, dtype=np.float64)
165
+ ids = [p[0] for p in pairs]
166
+ if mat.ndim == 2 and mat.shape[0] == len(ids):
167
+ apply_rows(ids, mat)
168
+ filled = True
169
+ except TypeError:
170
+ filled = False
171
+ except Exception as e:
172
+ logger.debug(
173
+ "embedding_fn batch call unsupported (%s); using per-token path", e
174
+ )
175
+ filled = False
176
+
177
+ if not filled:
178
+ for tid, text in texts.items():
179
+ try:
180
+ vec = _as_unit_vector(embedding_fn(text))
181
+ except Exception:
182
+ continue
183
+ if vec.size and np.linalg.norm(vec) > 1e-12:
184
+ token_vectors[int(tid)] = vec
185
+
186
  return cls(
187
  token_vectors=token_vectors,
188
  token_texts={int(k): v for k, v in texts.items()},
 
249
  # {hypothesis_id: list of grounding keywords}
250
  hypothesis_keywords: Dict[str, List[str]] = field(default_factory=dict)
251
 
252
+ # Per-token multipliers **[0.0, 1.0]** for logit graft (see tensegrity.graft.logit_bias).
253
  hypothesis_token_scores: Dict[str, Dict[int, float]] = field(default_factory=dict)
254
 
255
  # Inverse map: {token_id: list of hypothesis_ids it belongs to}
 
327
  token_texts: Optional[Dict[int, str]] = None,
328
  top_k: int = 32,
329
  threshold: Optional[float] = None,
330
+ vocab_batch_embedding_fn: Optional[BatchedEmbedFn] = None,
331
+ vocab_embedding_batch_size: int = 256,
332
  ) -> 'VocabularyGrounding':
333
  """
334
  Build grounding by semantic proximity instead of exact keyword matches.
 
342
  token_texts: optional explicit {token_id: token_text} inventory.
343
  top_k: maximum vocabulary tokens retained per hypothesis.
344
  threshold: minimum cosine similarity. ``None`` keeps the best top_k.
345
+ vocab_batch_embedding_fn: optional batched vocabulary embed ``list[str] -> ndarray``.
346
+ vocab_embedding_batch_size: batch chunk size for ``SemanticProjectionLayer.from_tokenizer``.
347
+ Token scores returned from cosine matching are normalized to graft weights in **[0.0, 1.0]**
348
+ (affine map from ``[-1, 1]`` cosine range) before storage in ``hypothesis_token_scores``.
349
  """
350
  projection = SemanticProjectionLayer.from_tokenizer(
351
  tokenizer,
352
  embedding_fn=embedding_fn,
353
  projection_matrix=projection_matrix,
354
  token_texts=token_texts,
355
+ batched_embedding_fn=vocab_batch_embedding_fn,
356
+ batch_size=max(1, int(vocab_embedding_batch_size)),
357
  )
358
  grounding = cls()
359
  grounding.vocab_size = int(getattr(tokenizer, "vocab_size", 0) or 0)
 
368
  concept_vector = _mean_unit_vector(
369
  embedding_fn(phrase) for phrase in concept_phrases if str(phrase).strip()
370
  )
371
+ if (
372
+ concept_vector.size == 0
373
+ or float(np.linalg.norm(concept_vector)) <= 1e-12
374
+ ):
375
+ logger.warning(
376
+ "empty or zero-norm concept vector for hypothesis %r phrases=%s; "
377
+ "skipping projection",
378
+ hyp_id,
379
+ hypothesis_phrases.get(hyp_id, phrases),
380
+ )
381
+ grounding.hypothesis_token_scores[hyp_id] = {}
382
+ grounding.hypothesis_tokens[hyp_id] = set()
383
+ continue
384
  token_scores = projection.project_phrase_vector(
385
  concept_vector,
386
  top_k=top_k,
387
  threshold=threshold,
388
  )
389
+ grounding.hypothesis_token_scores[hyp_id] = {
390
+ tid: _cosine_similarity_to_graft_multiplier(s) for tid, s in token_scores.items()
391
+ }
392
+ grounding.hypothesis_tokens[hyp_id] = set(grounding.hypothesis_token_scores[hyp_id])
393
 
394
+ for tid in grounding.hypothesis_token_scores[hyp_id]:
395
  grounding.token_to_hypotheses.setdefault(tid, []).append(hyp_id)
396
 
397
  return grounding
tensegrity/inference/__init__.py CHANGED
@@ -1,3 +0,0 @@
1
-
2
-
3
-
 
 
 
 
tensegrity/legacy/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
  """Legacy compatibility modules for architectures superseded by the unified field."""
2
 
 
 
3
  __all__ = ("v1",)
 
1
  """Legacy compatibility modules for architectures superseded by the unified field."""
2
 
3
+ from . import v1
4
+
5
  __all__ = ("v1",)
tensegrity/legacy/v1/agent.py CHANGED
@@ -17,6 +17,7 @@ disagreement) balanced by the free energy principle.
17
  """
18
 
19
  import hashlib
 
20
  import numpy as np
21
  from typing import Optional, Dict, List, Any, Tuple
22
  import logging
@@ -96,6 +97,41 @@ class TensegrityAgent:
96
  epistemic_tension_threshold: Only run costly intervention search when causal tension exceeds this level
97
  epistemic_info_gain_threshold: Minimum estimated information gain required for epistemic actions
98
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  self.n_states = n_states
100
  self.n_obs = n_observations
101
  self.n_actions = n_actions
@@ -201,7 +237,16 @@ class TensegrityAgent:
201
  self.arena.register_model(model_b)
202
 
203
  def _morton_to_obs_index(self, morton_codes: np.ndarray) -> int:
204
- """Map Morton codes to observation index via modular hashing."""
 
 
 
 
 
 
 
 
 
205
  if isinstance(morton_codes, (int, np.integer)):
206
  return int(morton_codes) % self.n_obs
207
  # For multiple codes, hash the combination
@@ -342,8 +387,7 @@ class TensegrityAgent:
342
 
343
  # Compare epistemic value of experiment vs pragmatic action
344
  if (experiment is not None and
345
- experiment['expected_info_gain'] > self.epistemic_info_gain_threshold and
346
- current_tension >= self.epistemic_tension_threshold):
347
  # Epistemic action: run an experiment to resolve tension
348
  return {
349
  'type': 'epistemic',
@@ -391,12 +435,10 @@ class TensegrityAgent:
391
  Weighted by surprise — surprising experiences teach more.
392
  """
393
  episodes = self.episodic.replay(n_episodes)
394
-
395
- total_update = 0.0
396
  for ep in episodes:
397
  obs_idx = ep.metadata.get('obs_idx', 0)
398
  self.epistemic.update_likelihood(obs_idx, ep.belief_state)
399
- total_update += 1.0
400
 
401
  return {
402
  'episodes_replayed': len(episodes),
@@ -441,8 +483,11 @@ class TensegrityAgent:
441
 
442
  @classmethod
443
  def from_config(cls, config: Dict[str, Any]) -> 'TensegrityAgent':
444
- """Create an agent from a configuration dictionary."""
445
- return cls(**config)
 
 
 
446
 
447
  def __repr__(self):
448
  return (f"TensegrityAgent(states={self.n_states}, obs={self.n_obs}, "
 
17
  """
18
 
19
  import hashlib
20
+ import inspect
21
  import numpy as np
22
  from typing import Optional, Dict, List, Any, Tuple
23
  import logging
 
97
  epistemic_tension_threshold: Only run costly intervention search when causal tension exceeds this level
98
  epistemic_info_gain_threshold: Minimum estimated information gain required for epistemic actions
99
  """
100
+ def _req_pos_int(name: str, v: Any) -> int:
101
+ if not isinstance(v, int) or int(v) < 1:
102
+ raise ValueError(f"{name} must be a positive integer")
103
+ return int(v)
104
+
105
+ n_states = _req_pos_int("n_states", n_states)
106
+ n_observations = _req_pos_int("n_observations", n_observations)
107
+ n_actions = _req_pos_int("n_actions", n_actions)
108
+ sensory_dims = _req_pos_int("sensory_dims", sensory_dims)
109
+ sensory_bits = _req_pos_int("sensory_bits", sensory_bits)
110
+ context_dim = _req_pos_int("context_dim", context_dim)
111
+ associative_dim = _req_pos_int("associative_dim", associative_dim)
112
+ if not isinstance(planning_horizon, int) or planning_horizon < 1:
113
+ raise ValueError("planning_horizon must be a positive integer")
114
+ if precision < 0.0:
115
+ raise ValueError("precision must be non-negative")
116
+ if zipf_exponent < 0.0:
117
+ raise ValueError("zipf_exponent must be non-negative")
118
+ unified_obs_dim = _req_pos_int("unified_obs_dim", unified_obs_dim)
119
+ if unified_hidden_dims is not None:
120
+ if not isinstance(unified_hidden_dims, list) or any(
121
+ not isinstance(x, int) or x < 1 for x in unified_hidden_dims
122
+ ):
123
+ raise ValueError("unified_hidden_dims must be a list of positive integers")
124
+ unified_fhrr_dim = _req_pos_int("unified_fhrr_dim", unified_fhrr_dim)
125
+ if unified_hopfield_beta < 0.0:
126
+ raise ValueError("unified_hopfield_beta must be non-negative")
127
+ unified_ngc_settle_steps = _req_pos_int("unified_ngc_settle_steps", unified_ngc_settle_steps)
128
+ if unified_ngc_learning_rate < 0.0:
129
+ raise ValueError("unified_ngc_learning_rate must be non-negative")
130
+ if not (0.0 <= float(epistemic_tension_threshold) <= 1.0):
131
+ raise ValueError("epistemic_tension_threshold must be in [0, 1]")
132
+ if not (0.0 <= float(epistemic_info_gain_threshold) <= 1.0):
133
+ raise ValueError("epistemic_info_gain_threshold must be in [0, 1]")
134
+
135
  self.n_states = n_states
136
  self.n_obs = n_observations
137
  self.n_actions = n_actions
 
237
  self.arena.register_model(model_b)
238
 
239
  def _morton_to_obs_index(self, morton_codes: np.ndarray) -> int:
240
+ """Map Morton codes to a discrete observation index (legacy hashing).
241
+
242
+ The main ``perceive`` path fingerprints the unified observation vector
243
+ with SHA-256 modulo ``n_obs``; use this routine only where an explicit
244
+ Morton-code → observation-bin mapping is intentional.
245
+ """
246
+ if self.n_obs <= 0:
247
+ raise ValueError(
248
+ "n_observations must be a positive integer for _morton_to_obs_index mapping"
249
+ )
250
  if isinstance(morton_codes, (int, np.integer)):
251
  return int(morton_codes) % self.n_obs
252
  # For multiple codes, hash the combination
 
387
 
388
  # Compare epistemic value of experiment vs pragmatic action
389
  if (experiment is not None and
390
+ experiment["expected_info_gain"] > self.epistemic_info_gain_threshold):
 
391
  # Epistemic action: run an experiment to resolve tension
392
  return {
393
  'type': 'epistemic',
 
435
  Weighted by surprise — surprising experiences teach more.
436
  """
437
  episodes = self.episodic.replay(n_episodes)
438
+
 
439
  for ep in episodes:
440
  obs_idx = ep.metadata.get('obs_idx', 0)
441
  self.epistemic.update_likelihood(obs_idx, ep.belief_state)
 
442
 
443
  return {
444
  'episodes_replayed': len(episodes),
 
483
 
484
  @classmethod
485
  def from_config(cls, config: Dict[str, Any]) -> 'TensegrityAgent':
486
+ """Create an agent from a configuration dictionary (unknown keys ignored)."""
487
+ sig = inspect.signature(cls.__init__)
488
+ allowed = {k for k in sig.parameters if k != "self"}
489
+ kwargs = {k: v for k, v in config.items() if k in allowed}
490
+ return cls(**kwargs)
491
 
492
  def __repr__(self):
493
  return (f"TensegrityAgent(states={self.n_states}, obs={self.n_obs}, "
tensegrity/legacy/v1/blanket.py CHANGED
@@ -34,6 +34,11 @@ class MarkovBlanket:
34
 
35
  The blanket enforces the Markov property: internal states
36
  are conditionally independent of external states given the blanket.
 
 
 
 
 
37
  """
38
 
39
  def __init__(self,
@@ -59,15 +64,16 @@ class MarkovBlanket:
59
  # Observation buffer — recent history for temporal inference
60
  self.observation_buffer: deque = deque(maxlen=observation_buffer_size)
61
 
62
- # Statistics for the blanket boundary (running means/vars for normalization)
63
- self._obs_count = 0
64
- self._obs_sum = None
65
- self._obs_sq_sum = None
 
66
 
67
  # Blanket surprise (how unexpected was the last observation?)
68
  self.surprise: float = 0.0
69
 
70
- def sense(self, raw_observation: np.ndarray) -> np.ndarray:
71
  """
72
  Process a raw observation through the sensory boundary.
73
 
@@ -76,8 +82,11 @@ class MarkovBlanket:
76
  3. Compute surprise (deviation from running statistics)
77
 
78
  Args:
79
- raw_observation: Raw sensory data, shape depends on modality.
80
- Will be reshaped to (n_points, n_dims) for Morton encoding.
 
 
 
81
 
82
  Returns:
83
  Morton-coded observation as integer array
@@ -86,14 +95,22 @@ class MarkovBlanket:
86
  if raw_observation.ndim == 1:
87
  if len(raw_observation) == self.encoder.n_dims:
88
  raw_observation = raw_observation.reshape(1, -1)
89
- else:
90
- # Treat as multiple single-dim observations
91
  raw_observation = raw_observation.reshape(-1, 1)
 
 
 
 
 
 
 
92
 
93
  # Morton encode
94
  morton_codes = self.encoder.encode_continuous(raw_observation)
95
  if isinstance(morton_codes, (int, np.integer)):
96
  morton_codes = np.array([morton_codes])
 
 
97
 
98
  # Update running statistics for surprise computation
99
  self._update_statistics(raw_observation)
@@ -107,7 +124,7 @@ class MarkovBlanket:
107
  'morton': morton_codes.copy(),
108
  'raw': raw_observation.copy(),
109
  'surprise': self.surprise,
110
- 'timestamp': self._obs_count
111
  })
112
 
113
  return morton_codes
@@ -137,17 +154,23 @@ class MarkovBlanket:
137
 
138
  def _update_statistics(self, observation: np.ndarray):
139
  """Update running statistics for surprise computation."""
140
- flat = observation.flatten()
141
- self._obs_count += 1
142
 
143
  if self._obs_sum is None:
144
- self._obs_sum = np.zeros_like(flat, dtype=np.float64)
145
- self._obs_sq_sum = np.zeros_like(flat, dtype=np.float64)
146
-
147
- # Pad or truncate to match
148
- n = min(len(flat), len(self._obs_sum))
 
 
 
 
 
 
149
  self._obs_sum[:n] += flat[:n]
150
  self._obs_sq_sum[:n] += flat[:n] ** 2
 
151
 
152
  def _compute_surprise(self, observation: np.ndarray) -> float:
153
  """
@@ -156,14 +179,15 @@ class MarkovBlanket:
156
  This is a simple proxy — the full surprise comes from the
157
  free energy engine. But this gives a fast heuristic at the boundary.
158
  """
159
- if self._obs_count < 2:
160
- return 0.0
161
-
162
- flat = observation.flatten()
163
  n = min(len(flat), len(self._obs_sum))
164
-
165
- mean = self._obs_sum[:n] / self._obs_count
166
- var = self._obs_sq_sum[:n] / self._obs_count - mean ** 2
 
 
 
167
  var = np.maximum(var, 1e-8) # Prevent division by zero
168
 
169
  # Gaussian log-likelihood (negative = surprise)
@@ -187,7 +211,7 @@ class MarkovBlanket:
187
  'sensory': self.sensory_state,
188
  'active': self.active_state,
189
  'surprise': self.surprise,
190
- 'obs_count': self._obs_count,
191
  'buffer_size': len(self.observation_buffer)
192
  }
193
 
 
34
 
35
  The blanket enforces the Markov property: internal states
36
  are conditionally independent of external states given the blanket.
37
+
38
+ ``n_sensory`` / ``n_active`` mirror constructor channel counts and are
39
+ reserved for future multi-channel I/O; ``sense`` still ingests vectors
40
+ shaped for ``encoder.n_dims``, and ``act`` consumes the full softmax over
41
+ actions passed in.
42
  """
43
 
44
  def __init__(self,
 
64
  # Observation buffer — recent history for temporal inference
65
  self.observation_buffer: deque = deque(maxlen=observation_buffer_size)
66
 
67
+ # Running stats for surprise per-coordinate counts (variable-length obs).
68
+ self._sense_timestep = 0
69
+ self._obs_sum: Optional[np.ndarray] = None
70
+ self._obs_sq_sum: Optional[np.ndarray] = None
71
+ self._obs_elem_count: Optional[np.ndarray] = None
72
 
73
  # Blanket surprise (how unexpected was the last observation?)
74
  self.surprise: float = 0.0
75
 
76
+ def sense(self, raw_observation: np.ndarray, *, allow_multi_point_1d: bool = False) -> np.ndarray:
77
  """
78
  Process a raw observation through the sensory boundary.
79
 
 
82
  3. Compute surprise (deviation from running statistics)
83
 
84
  Args:
85
+ raw_observation: Array shaped ``(n_points, encoder.n_dims)``, or ``(n_dims,)``
86
+ for one point. One-dimensional vectors whose length is not ``n_dims``
87
+ are rejected unless ``allow_multi_point_1d=True`` is set, which treats
88
+ the vector as a column (``reshape(-1, 1)``) of scalar observations —
89
+ callers should prefer supplying an explicit `(n_points, n_dims)` array.
90
 
91
  Returns:
92
  Morton-coded observation as integer array
 
95
  if raw_observation.ndim == 1:
96
  if len(raw_observation) == self.encoder.n_dims:
97
  raw_observation = raw_observation.reshape(1, -1)
98
+ elif allow_multi_point_1d:
 
99
  raw_observation = raw_observation.reshape(-1, 1)
100
+ else:
101
+ raise ValueError(
102
+ f"One-dimensional sensory input length {len(raw_observation)} does not match "
103
+ f"encoder.n_dims ({self.encoder.n_dims}). Pass shape "
104
+ "(n_points, n_dims), a length-n_dims vector for one observation, "
105
+ "or opt in with allow_multi_point_1d=True for reshape(-1, 1)."
106
+ )
107
 
108
  # Morton encode
109
  morton_codes = self.encoder.encode_continuous(raw_observation)
110
  if isinstance(morton_codes, (int, np.integer)):
111
  morton_codes = np.array([morton_codes])
112
+
113
+ self._sense_timestep += 1
114
 
115
  # Update running statistics for surprise computation
116
  self._update_statistics(raw_observation)
 
124
  'morton': morton_codes.copy(),
125
  'raw': raw_observation.copy(),
126
  'surprise': self.surprise,
127
+ 'timestamp': self._sense_timestep
128
  })
129
 
130
  return morton_codes
 
154
 
155
  def _update_statistics(self, observation: np.ndarray):
156
  """Update running statistics for surprise computation."""
157
+ flat = np.asarray(observation, dtype=np.float64).flatten()
 
158
 
159
  if self._obs_sum is None:
160
+ self._obs_sum = np.zeros(len(flat), dtype=np.float64)
161
+ self._obs_sq_sum = np.zeros(len(flat), dtype=np.float64)
162
+ self._obs_elem_count = np.zeros(len(flat), dtype=np.float64)
163
+
164
+ lf, ls = len(flat), len(self._obs_sum)
165
+ if lf > ls:
166
+ self._obs_sum = np.pad(self._obs_sum, (0, lf - ls), mode='constant')
167
+ self._obs_sq_sum = np.pad(self._obs_sq_sum, (0, lf - ls), mode='constant')
168
+ self._obs_elem_count = np.pad(self._obs_elem_count, (0, lf - ls), mode='constant')
169
+
170
+ n = min(lf, len(self._obs_sum))
171
  self._obs_sum[:n] += flat[:n]
172
  self._obs_sq_sum[:n] += flat[:n] ** 2
173
+ self._obs_elem_count[:n] += 1.0
174
 
175
  def _compute_surprise(self, observation: np.ndarray) -> float:
176
  """
 
179
  This is a simple proxy — the full surprise comes from the
180
  free energy engine. But this gives a fast heuristic at the boundary.
181
  """
182
+ flat = np.asarray(observation, dtype=np.float64).flatten()
183
+ assert self._obs_sum is not None and self._obs_elem_count is not None
 
 
184
  n = min(len(flat), len(self._obs_sum))
185
+ cnt = self._obs_elem_count[:n]
186
+ if n < 1 or float(np.min(cnt)) < 2.0:
187
+ return 0.0
188
+
189
+ mean = self._obs_sum[:n] / np.maximum(cnt, 1e-12)
190
+ var = self._obs_sq_sum[:n] / np.maximum(cnt, 1e-12) - mean ** 2
191
  var = np.maximum(var, 1e-8) # Prevent division by zero
192
 
193
  # Gaussian log-likelihood (negative = surprise)
 
211
  'sensory': self.sensory_state,
212
  'active': self.active_state,
213
  'surprise': self.surprise,
214
+ 'sense_timestep': self._sense_timestep,
215
  'buffer_size': len(self.observation_buffer)
216
  }
217
 
tensegrity/legacy/v1/morton.py CHANGED
@@ -24,9 +24,14 @@ Mathematical basis:
24
  """
25
 
26
  import numpy as np
 
27
  from typing import Union, List, Tuple, Optional
28
 
29
 
 
 
 
 
30
  class MortonEncoder:
31
  """
32
  Encodes arbitrary-dimensional data into Morton codes (Z-order curve indices).
@@ -46,16 +51,39 @@ class MortonEncoder:
46
  3 for volumetric, N for embeddings)
47
  bits_per_dim: Resolution per dimension. 10 bits = 1024 levels per dim.
48
  Total Morton code space = 2^(n_dims * bits_per_dim)
 
49
  ranges: Min/max per dimension for quantization. If None, auto-calibrated.
50
  """
51
  self.n_dims = n_dims
52
  self.bits_per_dim = bits_per_dim
53
- self.total_bits = n_dims * bits_per_dim
 
 
 
 
 
 
54
  self.levels = 2 ** bits_per_dim
55
 
56
  # Quantization ranges per dimension
57
  if ranges is not None:
58
- self.ranges = np.array(ranges, dtype=np.float64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  else:
60
  self.ranges = None # Will be set on first encode (auto-calibrate)
61
 
@@ -112,17 +140,24 @@ class MortonEncoder:
112
  # Normalize to [0, 1] then scale to [0, levels-1]
113
  mins = self.ranges[:, 0]
114
  maxs = self.ranges[:, 1]
115
- normalized = (values - mins) / (maxs - mins)
 
116
  normalized = np.clip(normalized, 0.0, 1.0)
117
  quantized = (normalized * (self.levels - 1)).astype(np.int64)
118
  return quantized
119
 
120
  def dequantize(self, quantized: np.ndarray) -> np.ndarray:
121
  """Inverse of quantize — reconstruct continuous approximation."""
 
 
 
 
 
122
  mins = self.ranges[:, 0]
123
  maxs = self.ranges[:, 1]
 
124
  normalized = quantized.astype(np.float64) / (self.levels - 1)
125
- return normalized * (maxs - mins) + mins
126
 
127
  def encode(self, values: np.ndarray) -> np.ndarray:
128
  """
@@ -146,7 +181,16 @@ class MortonEncoder:
146
  if values.dtype in (np.float32, np.float64):
147
  quantized = self.quantize(values)
148
  else:
149
- quantized = values.astype(np.int64)
 
 
 
 
 
 
 
 
 
150
 
151
  # Interleave bits for each point
152
  n_points = quantized.shape[0]
@@ -214,27 +258,32 @@ class MortonEncoder:
214
  def neighborhood(self, code: int, radius: int = 1) -> List[int]:
215
  """
216
  Find Morton codes within a given radius (in quantized coordinates).
217
-
218
- This exploits the locality property: nearby Morton codes correspond
219
- to nearby points in the original space.
220
  """
221
- center = self.decode(code)
222
- neighbors = []
223
-
224
- # Generate all offset combinations within radius
 
 
 
 
 
 
 
 
 
 
 
225
  offsets = range(-radius, radius + 1)
226
-
227
- def _recurse(dim, current_offset):
228
- if dim == self.n_dims:
229
- point = center + np.array(current_offset)
230
- if np.all(point >= 0) and np.all(point < self.levels):
231
- neighbors.append(int(self.encode(point.reshape(1, -1))))
232
- return
233
- for off in offsets:
234
- _recurse(dim + 1, current_offset + [off])
235
-
236
- _recurse(0, [])
237
- return list(set(neighbors))
238
 
239
  @staticmethod
240
  def from_modality(modality: str, **kwargs) -> 'MortonEncoder':
 
24
  """
25
 
26
  import numpy as np
27
+ from itertools import product
28
  from typing import Union, List, Tuple, Optional
29
 
30
 
31
+ # Guard against exponential neighborhood enumeration when radius × dims is large.
32
+ MAX_NEIGHBORHOOD_COMBINATIONS = 50_000
33
+
34
+
35
  class MortonEncoder:
36
  """
37
  Encodes arbitrary-dimensional data into Morton codes (Z-order curve indices).
 
51
  3 for volumetric, N for embeddings)
52
  bits_per_dim: Resolution per dimension. 10 bits = 1024 levels per dim.
53
  Total Morton code space = 2^(n_dims * bits_per_dim)
54
+ Must satisfy n_dims * bits_per_dim <= 63 so codes fit np.int64.
55
  ranges: Min/max per dimension for quantization. If None, auto-calibrated.
56
  """
57
  self.n_dims = n_dims
58
  self.bits_per_dim = bits_per_dim
59
+ total_bits = n_dims * bits_per_dim
60
+ if total_bits > 63:
61
+ raise ValueError(
62
+ f"total_bits (n_dims * bits_per_dim) must be <= 63 to fit in np.int64; "
63
+ f"got total_bits={total_bits}"
64
+ )
65
+ self.total_bits = total_bits
66
  self.levels = 2 ** bits_per_dim
67
 
68
  # Quantization ranges per dimension
69
  if ranges is not None:
70
+ self.ranges = np.asarray(ranges, dtype=np.float64)
71
+ if self.ranges.ndim != 2 or self.ranges.shape[1] != 2:
72
+ raise ValueError("ranges must be a sequence of (min, max) tuples per dimension.")
73
+ spans = self.ranges[:, 1] - self.ranges[:, 0]
74
+ flat_spans = np.asarray(spans).flatten()
75
+ bad = np.where(np.abs(flat_spans) < 1e-15)[0]
76
+ if len(bad):
77
+ dims_list = [int(i) for i in bad.tolist()]
78
+ raise ValueError(
79
+ "Quantization ranges have zero span on dimension index(es) "
80
+ f"{dims_list}; ensure max > min for each dimension "
81
+ "(or omit ranges to auto-calibrate from data)."
82
+ )
83
+ if int(self.ranges.shape[0]) != int(n_dims):
84
+ raise ValueError(
85
+ f"ranges must have length n_dims ({n_dims}), got shape {self.ranges.shape}."
86
+ )
87
  else:
88
  self.ranges = None # Will be set on first encode (auto-calibrate)
89
 
 
140
  # Normalize to [0, 1] then scale to [0, levels-1]
141
  mins = self.ranges[:, 0]
142
  maxs = self.ranges[:, 1]
143
+ spans = np.maximum(maxs - mins, 1e-15)
144
+ normalized = (values - mins) / spans
145
  normalized = np.clip(normalized, 0.0, 1.0)
146
  quantized = (normalized * (self.levels - 1)).astype(np.int64)
147
  return quantized
148
 
149
  def dequantize(self, quantized: np.ndarray) -> np.ndarray:
150
  """Inverse of quantize — reconstruct continuous approximation."""
151
+ if self.ranges is None:
152
+ raise ValueError(
153
+ "ranges not initialized: call encode (or compute_ranges) "
154
+ "before MortonEncoder.dequantize"
155
+ )
156
  mins = self.ranges[:, 0]
157
  maxs = self.ranges[:, 1]
158
+ spans = np.maximum(maxs - mins, 1e-15)
159
  normalized = quantized.astype(np.float64) / (self.levels - 1)
160
+ return normalized * spans + mins
161
 
162
  def encode(self, values: np.ndarray) -> np.ndarray:
163
  """
 
181
  if values.dtype in (np.float32, np.float64):
182
  quantized = self.quantize(values)
183
  else:
184
+ quantized = np.asarray(values, dtype=np.int64)
185
+ qmin = int(np.min(quantized))
186
+ qmax = int(np.max(quantized))
187
+ lo = 0
188
+ hi = int(self.levels - 1)
189
+ if qmin < lo or qmax > hi:
190
+ raise ValueError(
191
+ f"MortonEncoder.encode expects integer coords in [{lo}, {hi}] "
192
+ f"(levels={self.levels}); got range [{qmin}, {qmax}]"
193
+ )
194
 
195
  # Interleave bits for each point
196
  n_points = quantized.shape[0]
 
258
  def neighborhood(self, code: int, radius: int = 1) -> List[int]:
259
  """
260
  Find Morton codes within a given radius (in quantized coordinates).
261
+
262
+ Uses ``decode`` offset enumeration ``encode`` within ``[0, levels)``.
 
263
  """
264
+ decoded = self.decode(code)
265
+ center = (
266
+ decoded.reshape(-1).astype(np.int64)
267
+ if isinstance(decoded, np.ndarray)
268
+ else np.asarray([decoded], dtype=np.int64)
269
+ )
270
+ n_combo = int((2 * radius + 1) ** self.n_dims)
271
+ if n_combo > MAX_NEIGHBORHOOD_COMBINATIONS:
272
+ raise ValueError(
273
+ f"MortonEncoder.neighborhood would enumerate {n_combo} quantized offset "
274
+ f"combinations (n_dims={self.n_dims}, radius={radius}, levels={self.levels}), "
275
+ f"which exceeds MAX_NEIGHBORHOOD_COMBINATIONS={MAX_NEIGHBORHOOD_COMBINATIONS}; "
276
+ "reduce radius or n_dims."
277
+ )
278
+
279
  offsets = range(-radius, radius + 1)
280
+ neighbors: List[int] = []
281
+ for tup in product(offsets, repeat=self.n_dims):
282
+ offset = np.array(tup, dtype=np.int64)
283
+ point = center + offset
284
+ if np.all(point >= 0) and np.all(point < self.levels):
285
+ neighbors.append(int(self.encode(point.reshape(1, -1))))
286
+ return sorted(set(neighbors))
 
 
 
 
 
287
 
288
  @staticmethod
289
  def from_modality(modality: str, **kwargs) -> 'MortonEncoder':
tensegrity/memory/episodic.py CHANGED
@@ -138,6 +138,11 @@ class EpisodicMemory:
138
  if norm > 0:
139
  item_rep /= norm
140
  return item_rep
 
 
 
 
 
141
 
142
  def encode(self, observation: np.ndarray, morton_code: np.ndarray,
143
  belief_state: np.ndarray, action: Optional[int],
 
138
  if norm > 0:
139
  item_rep /= norm
140
  return item_rep
141
+
142
+ def compute_item_representation(self, observation: np.ndarray,
143
+ belief_state: np.ndarray) -> np.ndarray:
144
+ """Public entry point for projecting an observation + belief into context space."""
145
+ return self._compute_item_representation(observation, belief_state)
146
 
147
  def encode(self, observation: np.ndarray, morton_code: np.ndarray,
148
  belief_state: np.ndarray, action: Optional[int],
tensegrity/memory/epistemic.py CHANGED
@@ -201,6 +201,16 @@ class EpistemicMemory:
201
 
202
  self.evidence_log.append(log_lik)
203
  return log_lik
 
 
 
 
 
 
 
 
 
 
204
 
205
  def entropy(self) -> Dict[str, float]:
206
  """Compute non-negative categorical entropy of expected beliefs.
@@ -210,8 +220,8 @@ class EpistemicMemory:
210
  uncertainty dashboard metric. The agent usually wants entropy of the
211
  expected categorical distributions it will actually act on.
212
  """
213
- A = self.A_params / self.A_params.sum(axis=0, keepdims=True)
214
- D = self.D_params / self.D_params.sum()
215
 
216
  a_entropy_by_state = -np.sum(A * np.log(np.maximum(A, 1e-16)), axis=0)
217
  d_entropy = -np.sum(D * np.log(np.maximum(D, 1e-16)))
 
201
 
202
  self.evidence_log.append(log_lik)
203
  return log_lik
204
+
205
+ def _normalize_params_matrix(self, params: np.ndarray, *, axis: int) -> np.ndarray:
206
+ """Map Dirichlet parameters to expected categorical probs (does not touch access counts)."""
207
+ return params / np.maximum(params.sum(axis=axis, keepdims=True), 1e-16)
208
+
209
+ @staticmethod
210
+ def _normalize_params_vector(params: np.ndarray) -> np.ndarray:
211
+ """Normalize a vector Dirichlet parameter to probabilities."""
212
+ s = float(params.sum())
213
+ return params / max(s, 1e-16)
214
 
215
  def entropy(self) -> Dict[str, float]:
216
  """Compute non-negative categorical entropy of expected beliefs.
 
220
  uncertainty dashboard metric. The agent usually wants entropy of the
221
  expected categorical distributions it will actually act on.
222
  """
223
+ A = self._normalize_params_matrix(self.A_params, axis=0)
224
+ D = self._normalize_params_vector(self.D_params)
225
 
226
  a_entropy_by_state = -np.sum(A * np.log(np.maximum(A, 1e-16)), axis=0)
227
  d_entropy = -np.sum(D * np.log(np.maximum(D, 1e-16)))
tensegrity/pipeline/canonical.py CHANGED
@@ -38,6 +38,7 @@ from typing import Any, Dict, List, Optional, Tuple
38
  import numpy as np
39
 
40
  from tensegrity.broca.controller import CognitiveController
 
41
  from tensegrity.bench.tasks import TaskSample
42
  from tensegrity.causal.scm import StructuralCausalModel
43
  from tensegrity.engine.causal_energy import (
@@ -75,6 +76,7 @@ class CommitResult:
75
  final_arena_tension: float
76
  final_energy_arena_tension: float
77
  trace: List[IterationStep] = field(default_factory=list)
 
78
 
79
 
80
  def _alphanum_tokens(text: str, max_tokens: int) -> List[str]:
@@ -179,15 +181,17 @@ class CanonicalPipeline:
179
  # The controller resets itself per item; here we additionally clear the
180
  # Hopfield bank, episodic memory, and energy arena.
181
  try:
182
- self.controller.agent.field.memory.patterns.clear()
183
- self.controller.agent.field.memory._matrix = None
184
- self.controller.agent.field.memory._dirty = True
185
  except Exception as e:
186
- logger.debug("Hopfield clear skipped: %s", e)
187
  try:
188
  self.controller.agent.episodic.clear()
 
 
189
  except Exception as e:
190
- logger.debug("Episodic clear skipped: %s", e)
191
  self.energy_arena = EnergyCausalArena(
192
  precision=self.energy_arena.precision,
193
  beta=self.energy_arena.beta,
@@ -235,13 +239,15 @@ class CanonicalPipeline:
235
  n_ngc_layers = len(self.controller.agent.field.ngc.layer_sizes)
236
  topology = self._topology_mapper.from_scm(scm, n_layers=n_ngc_layers)
237
  self._scm_topologies[scm.name] = topology
238
- except ValueError:
239
- # Already registered (rare; defensive).
240
- pass
 
 
 
241
 
242
  def _soft_reset_in_place(self, labels: List[str]) -> None:
243
  """Reset only what is per-item, keeping the heavy state intact."""
244
- from tensegrity.broca.schemas import BeliefState, Hypothesis
245
 
246
  # Fresh hypotheses with uniform prior over the choice labels.
247
  n = len(labels)
@@ -269,15 +275,13 @@ class CanonicalPipeline:
269
  # NGC working state: clear activations/history but keep the learned
270
  # weights (cross-item priors) and the Hopfield bank.
271
  try:
272
- ngc = self.controller.agent.field.ngc
273
- ngc.layers = []
274
- ngc._initialized = False
275
- ngc._last_obs = None
276
- ngc.clear_history()
277
  self.controller.agent.field.energy_history.clear()
278
  self.controller.agent.field._step_count = 0
 
 
279
  except Exception as e:
280
- logger.debug("NGC soft-reset skipped: %s", e)
281
 
282
  # ---------- per-choice SCM (used by EnergyCausalArena) ----------
283
 
@@ -293,7 +297,7 @@ class CanonicalPipeline:
293
  is exactly what turns the lateral coherence link into a virtual parent
294
  in the NGC hierarchy, addressing the topological-mismatch critique.
295
  """
296
- scm = StructuralCausalModel(name=f"choice_{choice_idx}")
297
  scm.add_variable("prompt_feature", n_values=4, parents=[])
298
  scm.add_variable("coherence", n_values=4, parents=[])
299
  scm.add_variable("choice_match", n_values=4, parents=["prompt_feature"])
@@ -356,8 +360,11 @@ class CanonicalPipeline:
356
  field.ngc.settle(choice_obs, steps=self.falsify_settle_steps)
357
  pe = float(field.ngc.prediction_error(prompt_obs))
358
  except Exception as e:
359
- logger.debug("falsification step failed for choice %d: %s", i, e)
360
- pe = 0.0
 
 
 
361
  scores[i] = -pe
362
 
363
  # Derive a compact discrete observation for the energy arena.
@@ -396,6 +403,8 @@ class CanonicalPipeline:
396
  def _bucket_4(x: float) -> int:
397
  """Map a real-valued summary to a 4-bucket discrete value via tanh."""
398
  v = math.tanh(x / 2.0) # in (-1, 1)
 
 
399
  # Map (-1, 1) to {0, 1, 2, 3}.
400
  return max(0, min(3, int((v + 1.0) * 2.0)))
401
 
@@ -495,12 +504,12 @@ class CanonicalPipeline:
495
  final_energy_arena_tension=1.0,
496
  )
497
 
498
- self._item_index += 1
499
  self.reset_for_item(sample)
 
500
 
501
  # Initial perception — runs the full stack, including Broca SCM proposal
502
  # if causal tension is high (the controller wires this internally).
503
- ing0 = self.ingest_prompt(sample.prompt)
504
 
505
  trace: List[IterationStep] = []
506
  converged = False
@@ -612,6 +621,7 @@ class CanonicalPipeline:
612
  final_arena_tension=final_arena_tension,
613
  final_energy_arena_tension=final_energy_tension,
614
  trace=trace,
 
615
  )
616
 
617
  # ---------- helpers ----------
 
38
  import numpy as np
39
 
40
  from tensegrity.broca.controller import CognitiveController
41
+ from tensegrity.broca.schemas import BeliefState, Hypothesis
42
  from tensegrity.bench.tasks import TaskSample
43
  from tensegrity.causal.scm import StructuralCausalModel
44
  from tensegrity.engine.causal_energy import (
 
76
  final_arena_tension: float
77
  final_energy_arena_tension: float
78
  trace: List[IterationStep] = field(default_factory=list)
79
+ initial_perception: Optional[Dict[str, Any]] = None
80
 
81
 
82
  def _alphanum_tokens(text: str, max_tokens: int) -> List[str]:
 
181
  # The controller resets itself per item; here we additionally clear the
182
  # Hopfield bank, episodic memory, and energy arena.
183
  try:
184
+ self.controller.agent.field.memory.clear()
185
+ except AttributeError as e:
186
+ logger.warning("Hopfield clear failed (missing clear): %s", e)
187
  except Exception as e:
188
+ logger.error("Hopfield clear failed: %s", e, exc_info=True)
189
  try:
190
  self.controller.agent.episodic.clear()
191
+ except AttributeError as e:
192
+ logger.warning("Episodic clear failed: %s", e)
193
  except Exception as e:
194
+ logger.error("Episodic clear failed: %s", e, exc_info=True)
195
  self.energy_arena = EnergyCausalArena(
196
  precision=self.energy_arena.precision,
197
  beta=self.energy_arena.beta,
 
239
  n_ngc_layers = len(self.controller.agent.field.ngc.layer_sizes)
240
  topology = self._topology_mapper.from_scm(scm, n_layers=n_ngc_layers)
241
  self._scm_topologies[scm.name] = topology
242
+ except ValueError as e:
243
+ logger.warning(
244
+ "Topology registration failed for SCM %r: %s",
245
+ getattr(scm, "name", "?"),
246
+ e,
247
+ )
248
 
249
  def _soft_reset_in_place(self, labels: List[str]) -> None:
250
  """Reset only what is per-item, keeping the heavy state intact."""
 
251
 
252
  # Fresh hypotheses with uniform prior over the choice labels.
253
  n = len(labels)
 
275
  # NGC working state: clear activations/history but keep the learned
276
  # weights (cross-item priors) and the Hopfield bank.
277
  try:
278
+ self.controller.agent.field.ngc.soft_reset()
 
 
 
 
279
  self.controller.agent.field.energy_history.clear()
280
  self.controller.agent.field._step_count = 0
281
+ except AttributeError as e:
282
+ logger.warning("NGC soft_reset skipped (API mismatch): %s", e)
283
  except Exception as e:
284
+ logger.error("NGC soft_reset failed: %s", e, exc_info=True)
285
 
286
  # ---------- per-choice SCM (used by EnergyCausalArena) ----------
287
 
 
297
  is exactly what turns the lateral coherence link into a virtual parent
298
  in the NGC hierarchy, addressing the topological-mismatch critique.
299
  """
300
+ scm = StructuralCausalModel(name=f"choice_{choice_idx}_{label}")
301
  scm.add_variable("prompt_feature", n_values=4, parents=[])
302
  scm.add_variable("coherence", n_values=4, parents=[])
303
  scm.add_variable("choice_match", n_values=4, parents=["prompt_feature"])
 
360
  field.ngc.settle(choice_obs, steps=self.falsify_settle_steps)
361
  pe = float(field.ngc.prediction_error(prompt_obs))
362
  except Exception as e:
363
+ logger.error(
364
+ "NGC falsification failed for choice %d: %s",
365
+ i, e, exc_info=True,
366
+ )
367
+ pe = float(1e9)
368
  scores[i] = -pe
369
 
370
  # Derive a compact discrete observation for the energy arena.
 
403
  def _bucket_4(x: float) -> int:
404
  """Map a real-valued summary to a 4-bucket discrete value via tanh."""
405
  v = math.tanh(x / 2.0) # in (-1, 1)
406
+ if math.isnan(x) or math.isnan(v):
407
+ return 2
408
  # Map (-1, 1) to {0, 1, 2, 3}.
409
  return max(0, min(3, int((v + 1.0) * 2.0)))
410
 
 
504
  final_energy_arena_tension=1.0,
505
  )
506
 
 
507
  self.reset_for_item(sample)
508
+ self._item_index += 1
509
 
510
  # Initial perception — runs the full stack, including Broca SCM proposal
511
  # if causal tension is high (the controller wires this internally).
512
+ initial_perception = self.ingest_prompt(sample.prompt)
513
 
514
  trace: List[IterationStep] = []
515
  converged = False
 
621
  final_arena_tension=final_arena_tension,
622
  final_energy_arena_tension=final_energy_tension,
623
  trace=trace,
624
+ initial_perception=initial_perception if n > 0 else None,
625
  )
626
 
627
  # ---------- helpers ----------
tensegrity/pipeline/iterative.py CHANGED
@@ -109,6 +109,10 @@ class IterativeCognitiveScorer:
109
  # noisy to help. The wiring (encode/retrieve) stays so smarter signals
110
  # can be plugged in here without re-plumbing.
111
  w_episodic: float = 0.0,
 
 
 
 
112
  ):
113
  from tensegrity.engine.unified_field import UnifiedField
114
  from tensegrity.memory.episodic import EpisodicMemory
@@ -137,6 +141,8 @@ class IterativeCognitiveScorer:
137
  self.use_episodic = use_episodic
138
  self.episodic_top_k = episodic_top_k
139
  self.w_episodic = w_episodic
 
 
140
  # Dirichlet-style per-channel reliability. Each channel accumulates a
141
  # pseudocount that grows when the channel's top-ranked choice matches
142
  # the committed belief on an item. Fusion weights = normalized counts.
@@ -165,10 +171,9 @@ class IterativeCognitiveScorer:
165
 
166
  def _sbert_similarities(self, prompt: str, choices: List[str]) -> List[float]:
167
  features = self.field.encoder.features
168
- if hasattr(features, "_ensure_sbert") and getattr(features, "_sbert", None) is None:
169
- features._ensure_sbert()
170
- sbert = getattr(features, "_sbert", None)
171
- if sbert is not None and sbert != "FALLBACK":
172
  embs = sbert.encode([prompt] + choices, show_progress_bar=False)
173
  pe = embs[0]
174
  pn = float(np.linalg.norm(pe))
@@ -178,7 +183,11 @@ class IterativeCognitiveScorer:
178
  cn = float(np.linalg.norm(ce))
179
  out.append(float(np.dot(pe, ce) / (pn * cn)) if pn > 1e-8 and cn > 1e-8 else 0.0)
180
  return out
181
- # fallback to FHRR similarity
 
 
 
 
182
  pf = self._encode(self._tokenize(prompt, 64))
183
  return [
184
  self.field.encoder.similarity(pf, self._encode(self._tokenize(c, 32)))
@@ -232,7 +241,7 @@ class IterativeCognitiveScorer:
232
  if self.use_episodic and self.episodic is not None and len(self.episodic.episodes) > 0:
233
  uniform_belief = np.full(n, 1.0 / n, dtype=np.float64)
234
  try:
235
- query_ctx = self.episodic._compute_item_representation(
236
  prompt_obs_vec, uniform_belief
237
  )
238
  retrieved = self.episodic.retrieve_by_context(
@@ -250,13 +259,12 @@ class IterativeCognitiveScorer:
250
  ch_real.append(v / nrm if nrm > 1e-10 else v)
251
  # Only trust episodes whose prompt context strongly matches.
252
  # Below this threshold, "similar past answer" is noise, not signal.
253
- CTX_SIM_THRESHOLD = 0.5
254
  for ep in retrieved:
255
  ans_vec = ep.metadata.get("chosen_fhrr_real") if ep.metadata else None
256
  if ans_vec is None:
257
  continue
258
  ctx_sim = float(np.dot(query_ctx, ep.context_vector))
259
- if ctx_sim < CTX_SIM_THRESHOLD:
260
  continue
261
  # Also discount by past surprise: episodes the agent struggled
262
  # with (low committed confidence) carry less authority.
@@ -276,6 +284,10 @@ class IterativeCognitiveScorer:
276
  iterations_used = 0
277
  last_channel_scores: Dict[str, np.ndarray] = {}
278
 
 
 
 
 
279
  for it in range(self.max_iterations):
280
  iterations_used = it + 1
281
 
@@ -316,10 +328,6 @@ class IterativeCognitiveScorer:
316
  hop_bonus[i] = float(np.dot(q, retrieved / rn))
317
 
318
  # 3b. Fuse z-normalized
319
- def znorm(a: np.ndarray) -> np.ndarray:
320
- s = a.std()
321
- return (a - a.mean()) / s if s > 1e-10 else np.zeros_like(a)
322
-
323
  # Normalized channel weights from accumulated reliability counts.
324
  total = sum(self._channel_counts.values())
325
  w = {c: self._channel_counts[c] / total for c in self._channels}
@@ -362,11 +370,6 @@ class IterativeCognitiveScorer:
362
  self.field.ngc.settle(prompt_obs_vec, steps=self.context_settle_steps)
363
  self.field.ngc.learn(modulation=self.shaping_lr_scale)
364
 
365
- # 3e. Hopfield: store the *prompt* encoding so cross-iteration memory
366
- # accumulates evidence about the question, not the current guess.
367
- if self.use_hopfield:
368
- self.field.memory.store(self._encode(prompt_tokens))
369
-
370
  # Re-base on the prompt-grounded state for next iteration's scoring
371
  base_state = self.field.ngc.save_state()
372
 
@@ -377,6 +380,10 @@ class IterativeCognitiveScorer:
377
  converged = True
378
  break
379
 
 
 
 
 
380
  committed_idx = int(np.argmax(prev_belief))
381
 
382
  # Reliability update via *cross-channel agreement* (not agreement with
@@ -435,8 +442,16 @@ class IterativeCognitiveScorer:
435
  def reset(self):
436
  """Per-item reset. Clears NGC working state but PRESERVES Hopfield
437
  patterns and episodic memory — those carry across items in a session
438
- and provide cross-item learning."""
439
- self.field.ngc.reinitialize(12345)
 
 
 
 
 
 
 
 
440
  self.field.energy_history.clear()
441
  self.field._step_count = 0
442
 
@@ -444,9 +459,7 @@ class IterativeCognitiveScorer:
444
  """Full reset. Use at task / session boundaries to clear all memory
445
  and per-channel reliability priors (which are task-specific)."""
446
  self.reset()
447
- self.field.memory.patterns.clear()
448
- self.field.memory._matrix = None
449
- self.field.memory._dirty = True
450
  if self.episodic is not None:
451
  self.episodic.clear()
452
  for c in self._channels:
 
109
  # noisy to help. The wiring (encode/retrieve) stays so smarter signals
110
  # can be plugged in here without re-plumbing.
111
  w_episodic: float = 0.0,
112
+ # Minimum cosine match between query and episodic context to trust retrieval.
113
+ episodic_ctx_sim_threshold: float = 0.5,
114
+ # Seed for NGC `reinitialize` on `reset`; None chooses a random seed each time.
115
+ reset_seed: Optional[int] = 12345,
116
  ):
117
  from tensegrity.engine.unified_field import UnifiedField
118
  from tensegrity.memory.episodic import EpisodicMemory
 
141
  self.use_episodic = use_episodic
142
  self.episodic_top_k = episodic_top_k
143
  self.w_episodic = w_episodic
144
+ self.episodic_ctx_sim_threshold = episodic_ctx_sim_threshold
145
+ self.reset_seed = reset_seed
146
  # Dirichlet-style per-channel reliability. Each channel accumulates a
147
  # pseudocount that grows when the channel's top-ranked choice matches
148
  # the committed belief on an item. Fusion weights = normalized counts.
 
171
 
172
  def _sbert_similarities(self, prompt: str, choices: List[str]) -> List[float]:
173
  features = self.field.encoder.features
174
+ getter = getattr(features, "get_sbert_model", None)
175
+ sbert = getter() if callable(getter) else None
176
+ if sbert is not None:
 
177
  embs = sbert.encode([prompt] + choices, show_progress_bar=False)
178
  pe = embs[0]
179
  pn = float(np.linalg.norm(pe))
 
183
  cn = float(np.linalg.norm(ce))
184
  out.append(float(np.dot(pe, ce) / (pn * cn)) if pn > 1e-8 and cn > 1e-8 else 0.0)
185
  return out
186
+ if self.field.encoder.semantic and callable(getter) and not getattr(
187
+ self, "_sbert_unavailable_logged", False
188
+ ):
189
+ logger.warning("SBERT sentence similarity unavailable; using FHRR cosine similarity.")
190
+ setattr(self, "_sbert_unavailable_logged", True)
191
  pf = self._encode(self._tokenize(prompt, 64))
192
  return [
193
  self.field.encoder.similarity(pf, self._encode(self._tokenize(c, 32)))
 
241
  if self.use_episodic and self.episodic is not None and len(self.episodic.episodes) > 0:
242
  uniform_belief = np.full(n, 1.0 / n, dtype=np.float64)
243
  try:
244
+ query_ctx = self.episodic.compute_item_representation(
245
  prompt_obs_vec, uniform_belief
246
  )
247
  retrieved = self.episodic.retrieve_by_context(
 
259
  ch_real.append(v / nrm if nrm > 1e-10 else v)
260
  # Only trust episodes whose prompt context strongly matches.
261
  # Below this threshold, "similar past answer" is noise, not signal.
 
262
  for ep in retrieved:
263
  ans_vec = ep.metadata.get("chosen_fhrr_real") if ep.metadata else None
264
  if ans_vec is None:
265
  continue
266
  ctx_sim = float(np.dot(query_ctx, ep.context_vector))
267
+ if ctx_sim < self.episodic_ctx_sim_threshold:
268
  continue
269
  # Also discount by past surprise: episodes the agent struggled
270
  # with (low committed confidence) carry less authority.
 
284
  iterations_used = 0
285
  last_channel_scores: Dict[str, np.ndarray] = {}
286
 
287
+ def znorm(a: np.ndarray) -> np.ndarray:
288
+ s = a.std()
289
+ return (a - a.mean()) / s if s > 1e-10 else np.zeros_like(a)
290
+
291
  for it in range(self.max_iterations):
292
  iterations_used = it + 1
293
 
 
328
  hop_bonus[i] = float(np.dot(q, retrieved / rn))
329
 
330
  # 3b. Fuse z-normalized
 
 
 
 
331
  # Normalized channel weights from accumulated reliability counts.
332
  total = sum(self._channel_counts.values())
333
  w = {c: self._channel_counts[c] / total for c in self._channels}
 
370
  self.field.ngc.settle(prompt_obs_vec, steps=self.context_settle_steps)
371
  self.field.ngc.learn(modulation=self.shaping_lr_scale)
372
 
 
 
 
 
 
373
  # Re-base on the prompt-grounded state for next iteration's scoring
374
  base_state = self.field.ngc.save_state()
375
 
 
380
  converged = True
381
  break
382
 
383
+ # Store prompt encoding once for Hopfield cross-item memory (not each iteration).
384
+ if self.use_hopfield:
385
+ self.field.memory.store(self._encode(prompt_tokens))
386
+
387
  committed_idx = int(np.argmax(prev_belief))
388
 
389
  # Reliability update via *cross-channel agreement* (not agreement with
 
442
  def reset(self):
443
  """Per-item reset. Clears NGC working state but PRESERVES Hopfield
444
  patterns and episodic memory — those carry across items in a session
445
+ and provide cross-item learning.
446
+
447
+ NGC weights are reinitialized using ``reset_seed``: default ``12345``
448
+ matches legacy behavior for reproducibility; pass ``None`` for a random
449
+ seed each reset, or any other integer to pin runs.
450
+ """
451
+ seed = self.reset_seed
452
+ if seed is None:
453
+ seed = int(np.random.randint(0, 2 ** 31))
454
+ self.field.ngc.reinitialize(seed)
455
  self.field.energy_history.clear()
456
  self.field._step_count = 0
457
 
 
459
  """Full reset. Use at task / session boundaries to clear all memory
460
  and per-channel reliability priors (which are task-specific)."""
461
  self.reset()
462
+ self.field.memory.clear()
 
 
463
  if self.episodic is not None:
464
  self.episodic.clear()
465
  for c in self._channels:
tests/test_architecture_alignment.py CHANGED
@@ -67,7 +67,7 @@ def test_topology_mapper_turns_horizontal_edge_into_virtual_parent():
67
 
68
  assert len(mapping.virtual_parents) == 1
69
  vp = next(iter(mapping.virtual_parents.values()))
70
- assert vp.children == ("A", "B")
71
  assert mapping.embedded_layers[vp.name] == 1
72
  assert (vp.name, "A") in mapping.embedded_edges
73
  assert (vp.name, "B") in mapping.embedded_edges
 
67
 
68
  assert len(mapping.virtual_parents) == 1
69
  vp = next(iter(mapping.virtual_parents.values()))
70
+ assert set(vp.children) == {"A", "B"}
71
  assert mapping.embedded_layers[vp.name] == 1
72
  assert (vp.name, "A") in mapping.embedded_edges
73
  assert (vp.name, "B") in mapping.embedded_edges
tests/test_async_graft.py CHANGED
@@ -7,6 +7,7 @@ import sys
7
  import time
8
 
9
  import numpy as np
 
10
 
11
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
12
  if ROOT not in sys.path:
@@ -123,6 +124,7 @@ def test_build_scm_from_proposal():
123
 
124
 
125
  def test_scm_marginalizes_missing_parents_and_counterfactual_changes_descendants():
 
126
  from tensegrity.causal.scm import StructuralCausalModel
127
 
128
  scm = StructuralCausalModel("two_node")
 
7
  import time
8
 
9
  import numpy as np
10
+ import pytest
11
 
12
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
  if ROOT not in sys.path:
 
124
 
125
 
126
  def test_scm_marginalizes_missing_parents_and_counterfactual_changes_descendants():
127
+ pytest.importorskip("networkx")
128
  from tensegrity.causal.scm import StructuralCausalModel
129
 
130
  scm = StructuralCausalModel("two_node")
tests/test_engine.py CHANGED
@@ -3,6 +3,7 @@ Tests for the unified cognitive engine: FHRR, NGC, and UnifiedField.
3
  """
4
 
5
  import numpy as np
 
6
  np.random.seed(42)
7
 
8
 
@@ -68,7 +69,10 @@ def test_fhrr_encoding():
68
  sim_numeric_far = enc.similarity(v_base, v_far)
69
  print(f"\n sim([1,2,3], [1,2,3.1]) = {sim_near:.4f}")
70
  print(f" sim([1,2,3], [9,8,7]) = {sim_numeric_far:.4f}")
71
- assert sim_near > sim_numeric_far
 
 
 
72
  print(f" ✓ Numeric vectors: similar inputs → similar encodings")
73
 
74
 
 
3
  """
4
 
5
  import numpy as np
6
+ import sys
7
  np.random.seed(42)
8
 
9
 
 
69
  sim_numeric_far = enc.similarity(v_base, v_far)
70
  print(f"\n sim([1,2,3], [1,2,3.1]) = {sim_near:.4f}")
71
  print(f" sim([1,2,3], [9,8,7]) = {sim_numeric_far:.4f}")
72
+ assert sim_near > sim_numeric_far, (
73
+ "Numeric vectors should be more similar when inputs are nearer in value space "
74
+ f"(sim_near={sim_near}, sim_far={sim_numeric_far})"
75
+ )
76
  print(f" ✓ Numeric vectors: similar inputs → similar encodings")
77
 
78
 
tests/test_graft.py CHANGED
@@ -9,6 +9,7 @@ Tests:
9
  """
10
 
11
  import numpy as np
 
12
  import json
13
 
14
  np.random.seed(42)
 
9
  """
10
 
11
  import numpy as np
12
+ import sys
13
  import json
14
 
15
  np.random.seed(42)
tests/test_needle.py CHANGED
@@ -170,6 +170,13 @@ def test_ngc_contradiction_signal():
170
  print(f"\n Memory similarity for truth: {r_truth['memory_similarity']:.4f}")
171
  assert np.isfinite(mean_contra_pe)
172
  assert np.isfinite(pe_truth_after)
 
 
 
 
 
 
 
173
 
174
 
175
  def test_needle_in_lies():
 
170
  print(f"\n Memory similarity for truth: {r_truth['memory_similarity']:.4f}")
171
  assert np.isfinite(mean_contra_pe)
172
  assert np.isfinite(pe_truth_after)
173
+ assert not np.isclose(
174
+ mean_contra_pe, pe_truth_after, rtol=0.0, atol=1e-8
175
+ ), (
176
+ "Prediction error on contradictions should differ from prediction error "
177
+ f"when the established truth is re-presented "
178
+ f"(mean_contra_pe={mean_contra_pe:.6g}, pe_truth_after={pe_truth_after:.6g})"
179
+ )
180
 
181
 
182
  def test_needle_in_lies():
tests/test_scoring_bench.py CHANGED
@@ -107,5 +107,3 @@ if __name__ == "__main__":
107
  import traceback; traceback.print_exc()
108
 
109
  print()
110
-
111
-
 
107
  import traceback; traceback.print_exc()
108
 
109
  print()
 
 
tests/test_tensegrity.py CHANGED
@@ -251,7 +251,11 @@ def test_memory_systems():
251
  # Soft retrieval (Boltzmann distribution)
252
  blended, weights = am.retrieve_soft(noisy)
253
  print(f" Soft retrieval weights (top 3): {sorted(weights)[-3:]}")
254
- assert best_match == 3
 
 
 
 
255
  print(f" ✓ Content-addressed retrieval via energy minimization")
256
  print(f" Stats: {am.statistics}")
257
 
 
251
  # Soft retrieval (Boltzmann distribution)
252
  blended, weights = am.retrieve_soft(noisy)
253
  print(f" Soft retrieval weights (top 3): {sorted(weights)[-3:]}")
254
+ assert best_match == 3, (
255
+ "expected best_match == 3 (associative retrieval of pattern 3) with numpy seed "
256
+ "42 set at module load, "
257
+ f"got best_match={best_match}"
258
+ )
259
  print(f" ✓ Content-addressed retrieval via energy minimization")
260
  print(f" Stats: {am.statistics}")
261