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 +37 -1
- scripts/chat.py +1 -3
- scripts/compare_iterative.py +39 -15
- scripts/smoke_extract.py +64 -8
- tensegrity/__init__.py +7 -3
- tensegrity/bench/runner.py +29 -8
- tensegrity/bench/tasks.py +1 -1
- tensegrity/broca/benchmark.py +0 -2
- tensegrity/broca/controller.py +5 -2
- tensegrity/broca/schemas.py +1 -0
- tensegrity/causal/arena.py +22 -13
- tensegrity/causal/from_proposal.py +2 -2
- tensegrity/causal/scm.py +77 -12
- tensegrity/core/morton.py +3 -2
- tensegrity/engine/causal_energy.py +22 -8
- tensegrity/engine/fhrr.py +52 -16
- tensegrity/engine/ngc.py +7 -0
- tensegrity/engine/scoring.py +4 -1
- tensegrity/engine/unified_field.py +16 -1
- tensegrity/graft/__init__.py +5 -0
- tensegrity/graft/logit_bias.py +45 -5
- tensegrity/graft/pipeline.py +44 -10
- tensegrity/graft/vocabulary.py +102 -12
- tensegrity/inference/__init__.py +0 -3
- tensegrity/legacy/__init__.py +2 -0
- tensegrity/legacy/v1/agent.py +53 -8
- tensegrity/legacy/v1/blanket.py +49 -25
- tensegrity/legacy/v1/morton.py +73 -24
- tensegrity/memory/episodic.py +5 -0
- tensegrity/memory/epistemic.py +12 -2
- tensegrity/pipeline/canonical.py +30 -20
- tensegrity/pipeline/iterative.py +35 -22
- tests/test_architecture_alignment.py +1 -1
- tests/test_async_graft.py +2 -0
- tests/test_engine.py +5 -1
- tests/test_graft.py +1 -0
- tests/test_needle.py +7 -0
- tests/test_scoring_bench.py +0 -2
- tests/test_tensegrity.py +5 -1
|
@@ -45,9 +45,14 @@ cycle = field.observe(
|
|
| 45 |
)
|
| 46 |
|
| 47 |
print(cycle["energy"].total)
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|
| 25 |
import argparse
|
| 26 |
import json
|
| 27 |
import sys
|
| 28 |
-
|
| 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
|
|
@@ -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 |
-
|
| 39 |
-
|
| 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 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
use_hopfield=True,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -12,16 +12,24 @@ roles bound to actual phrases.
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 15 |
import time
|
| 16 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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()
|
|
@@ -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
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -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 |
-
#
|
| 304 |
-
#
|
| 305 |
-
#
|
|
|
|
|
|
|
| 306 |
self._canonical = CanonicalPipeline(
|
| 307 |
-
hypothesis_labels=
|
| 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, "
|
| 454 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 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):
|
|
@@ -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.
|
| 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
|
|
@@ -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 |
|
|
@@ -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(
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
|
|
@@ -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=
|
| 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 |
-
|
| 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[:
|
| 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(
|
| 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 |
|
|
@@ -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 |
|
|
@@ -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
|
| 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
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 378 |
for var in self.topological_order():
|
| 379 |
mech = self.mechanisms[var]
|
| 380 |
parent_vals = {p: assignment[p] for p in mech.parents}
|
| 381 |
-
|
| 382 |
-
if
|
| 383 |
return 0.0
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -3,8 +3,9 @@
|
|
| 3 |
import warnings
|
| 4 |
|
| 5 |
warnings.warn(
|
| 6 |
-
"tensegrity.core.morton is legacy V1;
|
| 7 |
-
"for the
|
|
|
|
| 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 |
)
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
@
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 153 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 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 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
| 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)))
|
|
@@ -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."""
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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:
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 149 |
-
not pass an explicit semantic_embedding_fn. No gradient flow.
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
try:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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] =
|
| 302 |
-
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
for tid in
|
| 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
|
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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",)
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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}, "
|
|
@@ -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 |
-
#
|
| 63 |
-
self.
|
| 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:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 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.
|
| 145 |
-
self._obs_sq_sum = np.
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
flat = observation.flatten()
|
| 163 |
n = min(len(flat), len(self._obs_sum))
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
'
|
| 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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
self.levels = 2 ** bits_per_dim
|
| 55 |
|
| 56 |
# Quantization ranges per dimension
|
| 57 |
if ranges is not None:
|
| 58 |
-
self.ranges = np.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 *
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 219 |
-
to nearby points in the original space.
|
| 220 |
"""
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
offsets = range(-radius, radius + 1)
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 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':
|
|
@@ -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],
|
|
@@ -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.
|
| 214 |
-
D = self.
|
| 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)))
|
|
@@ -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.
|
| 183 |
-
|
| 184 |
-
|
| 185 |
except Exception as e:
|
| 186 |
-
logger.
|
| 187 |
try:
|
| 188 |
self.controller.agent.episodic.clear()
|
|
|
|
|
|
|
| 189 |
except Exception as e:
|
| 190 |
-
logger.
|
| 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 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 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.
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 ----------
|
|
@@ -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 |
-
|
| 169 |
-
|
| 170 |
-
sbert
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 <
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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:
|
|
@@ -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 ==
|
| 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
|
|
@@ -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")
|
|
@@ -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 |
|
|
@@ -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)
|
|
@@ -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():
|
|
@@ -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()
|
|
|
|
|
|
|
@@ -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 |
|