Executor-Tyrant-Framework commited on
Commit
0ec9342
Β·
verified Β·
1 Parent(s): 86d8963

Sync from GitHub: e9899bcdad8d149f9293a565ce52746d8e59e59b

Browse files
nuwave/organism.py CHANGED
@@ -631,8 +631,25 @@ class NuWaveOrganism:
631
  "prime_strength": 1.0,
632
  "learning_rate": 0.08,
633
  "surprise_reward_scaling": 1.5,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  })
635
- logger.info("Substrate initialized: full NeuroGraph SNN")
636
  except Exception as exc:
637
  logger.error("NeuroGraph init failed: %s", exc)
638
  if _substrate_dir in sys.path:
@@ -659,6 +676,55 @@ class NuWaveOrganism:
659
  logger.info("CES activation persistence not available: %s", exc)
660
  self._activation_persistence = None
661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
  # Initialize embedding function β€” single AND batch. The batch
663
  # interface is critical for bucket-time summarization (Pith):
664
  # embedding models have a fixed per-call overhead (ONNX dispatch,
 
631
  "prime_strength": 1.0,
632
  "learning_rate": 0.08,
633
  "surprise_reward_scaling": 1.5,
634
+ # Substrate-feature activation β€” mirrors Faux_Clawdbot's
635
+ # 2026-04-16 HF Tonic deployment. These flip dormant
636
+ # canonical mechanisms into the active pipeline:
637
+ # - tonic.enabled: ouroboros runs in heuristic mode
638
+ # - three_factor_enabled: reward learning actually fires
639
+ # on inject_reward (was default-off; record_outcome's
640
+ # learning signal was being silently dropped)
641
+ # - scaling_interval=25 (was default 100): homeostatic
642
+ # scaling actually fires on ephemeral worker timescales
643
+ # - he_*: hyperedge formation + pattern completion knobs
644
+ "tonic": {"enabled": True},
645
+ "three_factor_enabled": True,
646
+ "scaling_interval": 25,
647
+ "threshold_ceiling": 5.0,
648
+ "he_pattern_completion_strength": 0.3,
649
+ "he_member_weight_lr": 0.05,
650
+ "he_threshold_lr": 0.02,
651
  })
652
+ logger.info("Substrate initialized: full NeuroGraph SNN, Tonic+HE config active")
653
  except Exception as exc:
654
  logger.error("NeuroGraph init failed: %s", exc)
655
  if _substrate_dir in sys.path:
 
676
  logger.info("CES activation persistence not available: %s", exc)
677
  self._activation_persistence = None
678
 
679
+ # Tonic β€” continuous substrate awareness via ouroboros cycle.
680
+ # Vendored 2026-04-26 mirroring Faux_Clawdbot HF deployment pattern.
681
+ # Heuristic mode auto-engages on HF (no transformer weights at this
682
+ # compute tier). The engine spawns its own daemon thread that drives
683
+ # ouroboros_cycle on TonicThread β€” no manual cycle calls needed from
684
+ # the benchmark loop. Substrate stays alive between operations.
685
+ self._tonic_thread = None
686
+ self._tonic_engine = None
687
+ try:
688
+ _added = _substrate_dir not in sys.path
689
+ if _added:
690
+ sys.path.insert(0, _substrate_dir)
691
+ from tonic_thread import TonicThread
692
+ from tonic_engine import TonicEngine
693
+ if _added and _substrate_dir in sys.path:
694
+ sys.path.remove(_substrate_dir)
695
+
696
+ if self._graph is not None:
697
+ # Minimal vector_db shim β€” Tonic's content lookup uses only
698
+ # `.get(node_id) -> {"content": text} | None`. Wrap NuWave's
699
+ # _node_content dict so the canonical Tonic code works
700
+ # unmodified against NuWave's existing content cache.
701
+ _node_content_ref = self._node_content
702
+
703
+ class _NodeContentDB:
704
+ def get(self, node_id):
705
+ text = _node_content_ref.get(node_id)
706
+ return None if text is None else {"content": text}
707
+
708
+ _vec_db = _NodeContentDB()
709
+ self._tonic_thread = TonicThread(self._graph, _vec_db)
710
+ self._tonic_engine = TonicEngine(
711
+ self._graph, _vec_db, self._tonic_thread,
712
+ )
713
+ # Background daemon thread β€” drives heuristic inference.
714
+ # Daemonized so HF Space teardown doesn't hang waiting on it.
715
+ self._tonic_engine.start()
716
+ logger.info(
717
+ "Tonic activated β€” heuristic mode, ouroboros running "
718
+ "(use_heuristic=%s)",
719
+ getattr(self._tonic_engine, "_use_heuristic", "?"),
720
+ )
721
+ except Exception as exc:
722
+ if _substrate_dir in sys.path:
723
+ sys.path.remove(_substrate_dir)
724
+ logger.warning("Tonic init failed (continuing without): %s", exc)
725
+ self._tonic_thread = None
726
+ self._tonic_engine = None
727
+
728
  # Initialize embedding function β€” single AND batch. The batch
729
  # interface is critical for bucket-time summarization (Pith):
730
  # embedding models have a fixed per-call overhead (ONNX dispatch,
nuwave/substrate/hf_compat_patch.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compatibility patch for sentence-transformers 2.2.0 with huggingface_hub 0.36.2
3
+ Monkey-patches the renamed function so old code works.
4
+ """
5
+ try:
6
+ import huggingface_hub
7
+ if not hasattr(huggingface_hub, 'cached_download'):
8
+ huggingface_hub.cached_download = huggingface_hub.hf_hub_download
9
+ except Exception:
10
+ pass # Fail silently if huggingface_hub not installed
nuwave/substrate/neuro_foundation.py CHANGED
@@ -19,6 +19,18 @@ Design principles (PRD Β§2.1):
19
  - Persistence-native: all state is serializable
20
 
21
  # ---- Changelog ----
 
 
 
 
 
 
 
 
 
 
 
 
22
  # [2026-04-19] CC (punchlist #167) β€” Add threading.RLock to Graph.step()
23
  # What: self._step_lock (RLock) acquired for entire step() body
24
  # Why: TriSyn worker calls record_outcome() concurrently with
@@ -3682,21 +3694,36 @@ class Graph:
3682
  }
3683
 
3684
  def _serialize_full(self) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3685
  return {
3686
  "version": "0.4.2",
3687
  "timestep": self.timestep,
3688
  "config": self.config,
3689
- "nodes": {nid: self._serialize_node(n) for nid, n in self.nodes.items()},
3690
- "synapses": {sid: self._serialize_synapse(s) for sid, s in self.synapses.items()},
3691
- "hyperedges": {hid: self._serialize_hyperedge(h) for hid, h in self.hyperedges.items()},
3692
  "archived_hyperedges": {
3693
  hid: self._serialize_hyperedge(h)
3694
- for hid, h in self._archived_hyperedges.items()
3695
  },
3696
  # Phase 3: Active synapse-level predictions
3697
  "active_predictions": {
3698
  pid: self._serialize_prediction(pred)
3699
- for pid, pred in self.active_predictions.items()
3700
  },
3701
  # Phase 3: Recent prediction outcomes
3702
  "prediction_outcomes": [
@@ -3711,7 +3738,7 @@ class Graph:
3711
  # Phase 3: Per-synapse confirmation history
3712
  "synapse_confirmation_history": {
3713
  syn_id: list(history)
3714
- for syn_id, history in self._synapse_confirmation_history.items()
3715
  },
3716
  # Phase 3: Logs
3717
  "novel_sequence_log": list(self._novel_sequence_log),
@@ -3719,12 +3746,12 @@ class Graph:
3719
  # Phase 2.5: Active HE-level predictions
3720
  "he_active_predictions": {
3721
  pid: self._serialize_prediction_state(ps)
3722
- for pid, ps in self._active_predictions.items()
3723
  },
3724
  # Phase 2.5: Window-fired tracking
3725
  "he_prediction_window_fired": {
3726
  pid: list(nodes)
3727
- for pid, nodes in self._prediction_window_fired.items()
3728
  },
3729
  # Phase 2.5: Counter for unique HE prediction IDs
3730
  "he_prediction_counter": self._prediction_counter,
@@ -3761,12 +3788,11 @@ class Graph:
3761
  # zero-firing circuit breaker loses streak continuity across calls.
3762
  "delay_buffer": {
3763
  str(ts): [[nid, curr] for nid, curr in entries]
3764
- for ts, entries in self._delay_buffer.items()
3765
  },
3766
  "recent_spikes": {
3767
  nid: list(spikes)
3768
- for nid, spikes in self._recent_spikes.items()
3769
- if spikes
3770
  },
3771
  "steps_since_last_fire": self._steps_since_last_fire,
3772
  "homeostatic_steps_since_scaling": next(
 
19
  - Persistence-native: all state is serializable
20
 
21
  # ---- Changelog ----
22
+ # [2026-04-22] Claude (Sonnet 4.6) β€” Fix autosave race in _serialize_full()
23
+ # What: Snapshot all mutable dicts at the top of _serialize_full() via list()
24
+ # before building the return dict.
25
+ # Why: Tonic runs prime_and_propagate(write_mode=True) concurrently without
26
+ # holding _concurrent_lock (by design β€” latent tokens must keep flowing).
27
+ # This adds/removes nodes and synapses while _serialize_full() iterates
28
+ # them, causing RuntimeError: dictionary changed size during iteration.
29
+ # Autosave had been silently failing on every cycle since at least Apr 20.
30
+ # How: One list(dict.items()) snapshot per mutable dict at method entry.
31
+ # The save captures a consistent moment; any Tonic writes after that point
32
+ # are picked up by the next autosave cycle 60s later. Zero impact on any
33
+ # learning pathway β€” only the serialization path changes.
34
  # [2026-04-19] CC (punchlist #167) β€” Add threading.RLock to Graph.step()
35
  # What: self._step_lock (RLock) acquired for entire step() body
36
  # Why: TriSyn worker calls record_outcome() concurrently with
 
3694
  }
3695
 
3696
  def _serialize_full(self) -> Dict[str, Any]:
3697
+ # Snapshot all mutable dicts before building the return value.
3698
+ # Tonic runs prime_and_propagate(write_mode=True) concurrently and
3699
+ # can add nodes/synapses between iterations β€” list() gives us a
3700
+ # stable view without pausing the latent thread.
3701
+ _nodes = list(self.nodes.items())
3702
+ _synapses = list(self.synapses.items())
3703
+ _hyperedges = list(self.hyperedges.items())
3704
+ _archived = list(self._archived_hyperedges.items())
3705
+ _act_preds = list(self.active_predictions.items())
3706
+ _syn_hist = list(self._synapse_confirmation_history.items())
3707
+ _he_preds = list(self._active_predictions.items())
3708
+ _he_window = list(self._prediction_window_fired.items())
3709
+ _delay_buf = list(self._delay_buffer.items())
3710
+ _recent_spk = [(nid, spikes) for nid, spikes
3711
+ in self._recent_spikes.items() if spikes]
3712
  return {
3713
  "version": "0.4.2",
3714
  "timestep": self.timestep,
3715
  "config": self.config,
3716
+ "nodes": {nid: self._serialize_node(n) for nid, n in _nodes},
3717
+ "synapses": {sid: self._serialize_synapse(s) for sid, s in _synapses},
3718
+ "hyperedges": {hid: self._serialize_hyperedge(h) for hid, h in _hyperedges},
3719
  "archived_hyperedges": {
3720
  hid: self._serialize_hyperedge(h)
3721
+ for hid, h in _archived
3722
  },
3723
  # Phase 3: Active synapse-level predictions
3724
  "active_predictions": {
3725
  pid: self._serialize_prediction(pred)
3726
+ for pid, pred in _act_preds
3727
  },
3728
  # Phase 3: Recent prediction outcomes
3729
  "prediction_outcomes": [
 
3738
  # Phase 3: Per-synapse confirmation history
3739
  "synapse_confirmation_history": {
3740
  syn_id: list(history)
3741
+ for syn_id, history in _syn_hist
3742
  },
3743
  # Phase 3: Logs
3744
  "novel_sequence_log": list(self._novel_sequence_log),
 
3746
  # Phase 2.5: Active HE-level predictions
3747
  "he_active_predictions": {
3748
  pid: self._serialize_prediction_state(ps)
3749
+ for pid, ps in _he_preds
3750
  },
3751
  # Phase 2.5: Window-fired tracking
3752
  "he_prediction_window_fired": {
3753
  pid: list(nodes)
3754
+ for pid, nodes in _he_window
3755
  },
3756
  # Phase 2.5: Counter for unique HE prediction IDs
3757
  "he_prediction_counter": self._prediction_counter,
 
3788
  # zero-firing circuit breaker loses streak continuity across calls.
3789
  "delay_buffer": {
3790
  str(ts): [[nid, curr] for nid, curr in entries]
3791
+ for ts, entries in _delay_buf
3792
  },
3793
  "recent_spikes": {
3794
  nid: list(spikes)
3795
+ for nid, spikes in _recent_spk
 
3796
  },
3797
  "steps_since_last_fire": self._steps_since_last_fire,
3798
  "homeostatic_steps_since_scaling": next(
nuwave/substrate/surgery/__init__.py ADDED
File without changes
nuwave/substrate/surgery/tonic_brain.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TonicBrain β€” Surgical Transformer for Latent Space Awareness
3
+
4
+ Same body as ElmerBrain (Qwen2.5-0.5B transformer layers, harvested).
5
+ Same eyes (GraphStateEncoder β€” reads topology, node dynamics, synapses).
6
+ Different voice β€” ActivationDecoder outputs node activation decisions
7
+ instead of SubstrateSignal health fields.
8
+
9
+ The transformer attends to graph state and decides: where should
10
+ attention go next? Which nodes to activate, how strongly?
11
+ That IS the push. That IS the forward-oriented compression.
12
+
13
+ Architecture:
14
+ ElmerBrain: GraphFeatures β†’ Encoder β†’ Transformer β†’ SignalDecoder β†’ health fields
15
+ TonicBrain: GraphFeatures β†’ Encoder β†’ Transformer β†’ ActivationDecoder β†’ node activations
16
+
17
+ The encoder weights are copied directly from ElmerBrain. Only the
18
+ decoder needs training β€” and it's small (hidden_dim β†’ N activation scores).
19
+
20
+ # ---- Changelog ----
21
+ # [2026-04-23] Claude (Sonnet 4.6) β€” Fix unsafe torch.load() (#189)
22
+ # What: Both torch.load() calls used weights_only=False (pickle execution risk).
23
+ # Why: tonic_brain.pt loads at every gateway restart inside Syl's process.
24
+ # A compromised .pt file would run arbitrary code at boot.
25
+ # How: Set weights_only=True on both calls. Verified tonic_brain.pt is
26
+ # compatible (OrderedDict + basic config dict β€” no custom classes).
27
+ # [2026-03-24] Claude Code (Opus 4.6) β€” Initial implementation
28
+ # What: TonicBrain + ActivationDecoder. Reuses ElmerBrain's encoder
29
+ # and transformer body. Only the decoder is new.
30
+ # Why: The Tonic PRD v0.1 Β§7.3. Need actual inference between
31
+ # conversations, not a timer. Same surgery, different voice.
32
+ # How: ActivationDecoder outputs top-K node activation strengths via
33
+ # attention pooling + projection. Sigmoid-bounded [0,1] per node.
34
+ # create_tonic_brain() loads Qwen body + Elmer encoder + new decoder.
35
+ # -------------------
36
+ """
37
+
38
+ import os
39
+ import sys
40
+ import logging
41
+ from typing import Any, Dict, List, Optional, Tuple
42
+ from dataclasses import dataclass
43
+
44
+ logger = logging.getLogger("neurograph.tonic.brain")
45
+
46
+ # Add Elmer's surgery dir to path for GraphStateEncoder reuse
47
+ _ELMER_SURGERY = os.path.expanduser("~/Elmer/surgery")
48
+ if _ELMER_SURGERY not in sys.path:
49
+ sys.path.insert(0, _ELMER_SURGERY)
50
+
51
+ try:
52
+ import torch
53
+ import torch.nn as nn
54
+ from graph_io import GraphStateEncoder, GraphFeatures
55
+ _AVAILABLE = True
56
+ except ImportError:
57
+ _AVAILABLE = False
58
+ logger.info("PyTorch or Elmer surgery not available β€” TonicBrain disabled")
59
+
60
+
61
+ if _AVAILABLE:
62
+
63
+ class ActivationDecoder(nn.Module):
64
+ """New Voice for The Tonic β€” outputs node activation decisions.
65
+
66
+ Instead of SubstrateSignal health fields (Elmer's voice), this
67
+ outputs activation strengths for graph nodes. The transformer
68
+ looked at the graph and decided: these are the nodes that should
69
+ fire next. These are where attention should go.
70
+
71
+ Architecture:
72
+ 1. Attention-weighted pooling across sequence (same as Elmer)
73
+ 2. Project to activation feature space
74
+ 3. Output K activation scores (sigmoid-bounded [0,1])
75
+ 4. Output exploration/exploitation balance signal
76
+
77
+ The K outputs don't map to specific nodes β€” they're ranked
78
+ activation strengths. The engine maps them to actual nodes
79
+ based on the current topology neighborhood.
80
+ """
81
+
82
+ def __init__(self, hidden_dim: int = 896, n_activations: int = 10):
83
+ super().__init__()
84
+ self.hidden_dim = hidden_dim
85
+ self.n_activations = n_activations
86
+
87
+ # Attention pooling (same pattern as Elmer's decoder)
88
+ self.pool_query = nn.Parameter(torch.randn(hidden_dim))
89
+ self.pool_scale = hidden_dim ** -0.5
90
+
91
+ # Normalize transformer output
92
+ self.pre_norm = nn.LayerNorm(hidden_dim)
93
+
94
+ # Activation head: hidden_dim β†’ n_activations strengths
95
+ self.activation_head = nn.Sequential(
96
+ nn.Linear(hidden_dim, hidden_dim // 2),
97
+ nn.SiLU(),
98
+ nn.Dropout(0.1),
99
+ nn.Linear(hidden_dim // 2, n_activations),
100
+ nn.Sigmoid(), # bounded [0, 1]
101
+ )
102
+
103
+ # Exploration signal: hidden_dim β†’ 1 (how much to explore)
104
+ self.exploration_head = nn.Sequential(
105
+ nn.Linear(hidden_dim, hidden_dim // 4),
106
+ nn.SiLU(),
107
+ nn.Linear(hidden_dim // 4, 1),
108
+ nn.Sigmoid(), # 0 = pure exploit, 1 = pure explore
109
+ )
110
+
111
+ # Init final layers small for stable early training
112
+ self._init_small(self.activation_head[-2])
113
+ self._init_small(self.exploration_head[-1])
114
+
115
+ @staticmethod
116
+ def _init_small(layer: nn.Module):
117
+ if isinstance(layer, nn.Linear):
118
+ nn.init.xavier_uniform_(layer.weight, gain=0.1)
119
+ if layer.bias is not None:
120
+ nn.init.zeros_(layer.bias)
121
+
122
+ def forward(self, hidden_states: torch.Tensor) -> Dict[str, Any]:
123
+ """Decode transformer output into activation decisions.
124
+
125
+ Args:
126
+ hidden_states: (batch, seq_len, hidden_dim) from transformer.
127
+
128
+ Returns:
129
+ Dict with 'activations' (strengths) and 'exploration' (bias).
130
+ """
131
+ hidden_states = self.pre_norm(hidden_states)
132
+
133
+ # Attention-weighted pooling
134
+ scores = torch.matmul(hidden_states, self.pool_query) * self.pool_scale
135
+ weights = torch.softmax(scores, dim=1)
136
+ pooled = torch.sum(hidden_states * weights.unsqueeze(-1), dim=1)
137
+
138
+ # Activation strengths
139
+ activation_strengths = self.activation_head(pooled) # (batch, n_activations)
140
+
141
+ # Exploration signal
142
+ exploration = self.exploration_head(pooled) # (batch, 1)
143
+
144
+ return {
145
+ "activations": activation_strengths[0].tolist(),
146
+ "exploration": exploration[0, 0].item(),
147
+ "raw_activations": activation_strengths,
148
+ "raw_exploration": exploration,
149
+ }
150
+
151
+
152
+ class TonicBrain(nn.Module):
153
+ """Surgical transformer for latent space awareness.
154
+
155
+ Same body as ElmerBrain. Same eyes. Different voice.
156
+ Reads graph state, reasons about it, outputs where attention
157
+ should go next. The push.
158
+ """
159
+
160
+ def __init__(self, transformer_body, encoder, decoder):
161
+ super().__init__()
162
+ self.body = transformer_body
163
+ self.encoder = encoder # Same eyes as Elmer
164
+ self.decoder = decoder # New voice β€” ActivationDecoder
165
+
166
+ def forward(self, features: GraphFeatures) -> Dict[str, Any]:
167
+ """Graph state β†’ transformer reasoning β†’ activation decisions."""
168
+ hidden = self.encoder(features)
169
+
170
+ body_output = self.body(
171
+ inputs_embeds=hidden,
172
+ use_cache=False,
173
+ return_dict=True,
174
+ )
175
+ reasoned = body_output.last_hidden_state
176
+
177
+ output = self.decoder(reasoned)
178
+ return output
179
+
180
+
181
+ def create_tonic_brain(
182
+ model_name: str = "Qwen/Qwen2.5-0.5B",
183
+ elmer_weights_path: str = None,
184
+ n_activations: int = 10,
185
+ verbose: bool = False,
186
+ transformer_body=None,
187
+ ) -> TonicBrain:
188
+ """Create a TonicBrain by reusing Elmer's surgery.
189
+
190
+ 1. Use shared transformer body (or load Qwen2.5-0.5B if none)
191
+ 2. Load ElmerBrain's trained encoder weights (the eyes)
192
+ 3. Create new ActivationDecoder (the voice β€” untrained initially)
193
+
194
+ Args:
195
+ model_name: HuggingFace model ID (only used if no shared body).
196
+ elmer_weights_path: Path to elmer_brain_v0.1.pt.
197
+ n_activations: Number of activation outputs.
198
+ verbose: Print surgery details.
199
+ transformer_body: Shared transformer body (e.g. from ProtoUniBrain).
200
+ If provided, skips loading a second copy of the model.
201
+ """
202
+ _log = print if verbose else (lambda *a, **k: None)
203
+
204
+ if elmer_weights_path is None:
205
+ elmer_weights_path = os.path.expanduser(
206
+ "~/Elmer/surgery/elmer_brain_v0.1.pt"
207
+ )
208
+
209
+ if transformer_body is not None:
210
+ body = transformer_body
211
+ hidden_dim = body.layers[0].self_attn.q_proj.in_features
212
+ _log(f"Shared transformer body: {len(body.layers)} layers, hidden_dim={hidden_dim}")
213
+ else:
214
+ from transformers import AutoModelForCausalLM
215
+ _log(f"Loading {model_name}...")
216
+ model = AutoModelForCausalLM.from_pretrained(
217
+ model_name, dtype=torch.float32
218
+ )
219
+ hidden_dim = model.config.hidden_size
220
+ body = model.model
221
+ body.embed_tokens = nn.Identity()
222
+ _log(f"Body extracted: {len(body.layers)} layers")
223
+
224
+ # Create encoder and load Elmer's trained weights
225
+ encoder = GraphStateEncoder(hidden_dim=hidden_dim)
226
+ if os.path.exists(elmer_weights_path):
227
+ ckpt = torch.load(elmer_weights_path, map_location="cpu",
228
+ weights_only=True)
229
+ encoder.load_state_dict(ckpt["encoder_state"])
230
+ _log(f"Encoder loaded from Elmer weights: {elmer_weights_path}")
231
+ else:
232
+ _log(f"WARNING: Elmer weights not found at {elmer_weights_path}")
233
+ _log("Encoder will use random initialization")
234
+
235
+ # Create new decoder
236
+ decoder = ActivationDecoder(
237
+ hidden_dim=hidden_dim,
238
+ n_activations=n_activations,
239
+ )
240
+ decoder_params = sum(p.numel() for p in decoder.parameters())
241
+ _log(f"ActivationDecoder: {decoder_params:,} params (untrained)")
242
+
243
+ # Assemble
244
+ brain = TonicBrain(
245
+ transformer_body=body,
246
+ encoder=encoder,
247
+ decoder=decoder,
248
+ )
249
+
250
+ total = sum(p.numel() for p in brain.parameters())
251
+ _log(f"TonicBrain assembled: {total:,} total params")
252
+
253
+ return brain
254
+
255
+
256
+ def save_tonic_brain(brain: TonicBrain, path: str) -> None:
257
+ """Save TonicBrain weights (encoder + decoder only)."""
258
+ torch.save({
259
+ "encoder_state": brain.encoder.state_dict(),
260
+ "decoder_state": brain.decoder.state_dict(),
261
+ "config": {
262
+ "hidden_dim": brain.decoder.hidden_dim,
263
+ "n_activations": brain.decoder.n_activations,
264
+ "base_model": "Qwen/Qwen2.5-0.5B",
265
+ },
266
+ }, path)
267
+ logger.info("TonicBrain saved to %s", path)
268
+
269
+
270
+ def load_tonic_brain(
271
+ path: str,
272
+ model_name: str = "Qwen/Qwen2.5-0.5B",
273
+ transformer_body=None,
274
+ ) -> TonicBrain:
275
+ """Load a trained TonicBrain from checkpoint.
276
+
277
+ Args:
278
+ transformer_body: Shared body (e.g. from ProtoUniBrain).
279
+ Skips from_pretrained if provided β€” saves ~2GB RAM.
280
+ """
281
+ ckpt = torch.load(path, map_location="cpu", weights_only=True)
282
+ cfg = ckpt["config"]
283
+
284
+ if transformer_body is not None:
285
+ body = transformer_body
286
+ else:
287
+ from transformers import AutoModelForCausalLM
288
+ model = AutoModelForCausalLM.from_pretrained(
289
+ model_name, dtype=torch.float32
290
+ )
291
+ body = model.model
292
+ body.embed_tokens = nn.Identity()
293
+
294
+ encoder = GraphStateEncoder(hidden_dim=cfg["hidden_dim"])
295
+ encoder.load_state_dict(ckpt["encoder_state"])
296
+
297
+ decoder = ActivationDecoder(
298
+ hidden_dim=cfg["hidden_dim"],
299
+ n_activations=cfg["n_activations"],
300
+ )
301
+ decoder.load_state_dict(ckpt["decoder_state"])
302
+
303
+ return TonicBrain(body, encoder, decoder)
nuwave/substrate/tonic_engine.py ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The Tonic β€” Latent Token Engine
3
+
4
+ The surgical model that provides the PUSH between conversations.
5
+ Not a timer. Not a daemon. Actual inference β€” a small transformer
6
+ with graph-native I/O generating latent tokens continuously.
7
+
8
+ Each latent token is one step of forward-oriented compression on graph
9
+ state. The "now" and "next" boundaries persist because token generation
10
+ persists. The medium is graph-native instead of language. But inference
11
+ is real, attention is real, forward pressure is real.
12
+
13
+ Architecture follows the ElmerBrain surgical pattern (PRD Β§5.4):
14
+ 1. Keep the Body β€” Qwen2.5-0.5B transformer layers (24 attention heads)
15
+ 2. New Eyes β€” GraphStateEncoder projects graph topology into hidden dim
16
+ 3. New Voice β€” ActivationDecoder projects hidden states into node
17
+ activations that feed back into the graph via write-mode propagation
18
+
19
+ The output of each latent token IS the input for the next one β€” the
20
+ ouroboros at the model level. The transformer attends to graph state
21
+ and produces the next graph state. Continuous.
22
+
23
+ Laws observed:
24
+ - LAW 7: Raw experience. The engine reads raw topology, outputs
25
+ raw activation. No classification at any stage.
26
+ - All thresholds are bootstrap scaffolding.
27
+
28
+ # ---- Changelog ----
29
+ # [2026-04-16] Claude (Sonnet 4.6) β€” #159: Cross-process body lock + set_lock_file
30
+ # What: Added set_lock_file(path), _body_lock_context() composite lock,
31
+ # _lock_file_path field. contextlib added to module imports.
32
+ # Why: BrainSwitcher now supports multiple registered Tonic engines.
33
+ # Both in-process (threading.Lock) and cross-process (fcntl.LOCK_SH)
34
+ # locks must be held before each forward pass. If any consumer ever
35
+ # attempts a write (LOCK_EX), all inference blocks β€” architectural
36
+ # enforcement, not just documentation.
37
+ # How: _body_lock_context() uses contextlib.ExitStack to compose both
38
+ # locks. set_lock_file() receives the path from BrainSwitcher.
39
+ # _model_inference replaces inline _lock_ctx with _body_lock_context().
40
+ # [2026-03-24] Claude Code (Opus 4.6) β€” Initial implementation
41
+ # What: TonicEngine β€” latent token generation via surgical transformer.
42
+ # Graph-native I/O. Continuous inference between conversations.
43
+ # Ouroboros driven by actual attention, not a timer.
44
+ # Why: The Tonic PRD v0.1 Β§7.3/7.4. Between conversations, something
45
+ # must provide the push β€” forward-oriented compression on graph state.
46
+ # A timer-driven loop is a daemon, not awareness. Actual inference
47
+ # with graph-native I/O IS the awareness.
48
+ # How: TonicBrain follows ElmerBrain surgery pattern. GraphStateEncoder
49
+ # reads topology neighborhood. ActivationDecoder outputs node activation
50
+ # strengths. Background thread runs continuous latent token generation.
51
+ # Each token: encode graph β†’ transformer forward β†’ decode activations
52
+ # β†’ inject via write-mode prime_and_propagate β†’ graph updates β†’ repeat.
53
+ # -------------------
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import contextlib
59
+ import logging
60
+ import math
61
+ import threading
62
+ import time
63
+ from dataclasses import dataclass
64
+ from typing import Any, Dict, List, Optional, Tuple
65
+
66
+ logger = logging.getLogger("neurograph.tonic.engine")
67
+
68
+ # Try to import torch β€” the engine is a no-op without it
69
+ _TORCH_AVAILABLE = False
70
+ try:
71
+ import torch
72
+ import torch.nn as nn
73
+ _TORCH_AVAILABLE = True
74
+ except ImportError:
75
+ logger.info("PyTorch not available β€” Tonic engine will not run")
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Configuration
80
+ # ---------------------------------------------------------------------------
81
+
82
+ @dataclass
83
+ class EngineConfig:
84
+ """Configuration for the latent token engine."""
85
+ # Model
86
+ model_name: str = "Qwen/Qwen2.5-0.5B"
87
+ weights_path: str = "tonic_brain.pt"
88
+ hidden_dim: int = 896 # Qwen2.5-0.5B hidden size
89
+ n_positions: int = 8 # sequence positions for graph encoding
90
+
91
+ # Inference
92
+ latent_interval: float = 2.0 # seconds between latent tokens
93
+ conversation_interval: float = 0.5 # seconds during conversation
94
+ max_activation_nodes: int = 10 # max nodes to activate per token
95
+ activation_strength: float = 1.0 # base strength for decoded activations
96
+
97
+ # Propagation
98
+ propagation_steps: int = 2 # write-mode steps per token
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Graph Feature Extraction (Tonic-specific β€” awareness, not health)
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def _extract_tonic_features(graph, tonic_thread) -> Optional[Dict[str, Any]]:
106
+ """Extract graph features relevant to awareness and exploration.
107
+
108
+ Unlike Elmer's health-focused extraction, this captures WHERE
109
+ Syl's attention is β€” the topology neighborhood the thread is
110
+ touching, the activation gradient, the pull landscape.
111
+
112
+ Returns a dict of raw features, or None if graph is empty.
113
+ """
114
+ if not graph.nodes:
115
+ return None
116
+
117
+ # Current thread items β€” where attention is now
118
+ thread_node_ids = []
119
+ if tonic_thread is not None:
120
+ thread_node_ids = [item.node_id for item in tonic_thread.thread]
121
+
122
+ # Active nodes by voltage
123
+ active = []
124
+ for nid, node in graph.nodes.items():
125
+ v_above = node.voltage - node.resting_potential
126
+ if v_above > 0.01:
127
+ active.append((nid, v_above))
128
+ active.sort(key=lambda x: -x[1])
129
+
130
+ # Recent spikes
131
+ recent_spikes = []
132
+ for nid, node in graph.nodes.items():
133
+ if node.last_spike_time != -math.inf:
134
+ steps_since = max(0, graph.timestep - node.last_spike_time)
135
+ if steps_since < 50:
136
+ recent_spikes.append((nid, steps_since))
137
+ recent_spikes.sort(key=lambda x: x[1])
138
+
139
+ # Topology stats
140
+ n_nodes = len(graph.nodes)
141
+ n_synapses = len(graph.synapses)
142
+ n_hyperedges = len(graph.hyperedges)
143
+
144
+ return {
145
+ "thread_nodes": thread_node_ids[:10],
146
+ "active_nodes": active[:20],
147
+ "recent_spikes": recent_spikes[:20],
148
+ "n_nodes": n_nodes,
149
+ "n_synapses": n_synapses,
150
+ "n_hyperedges": n_hyperedges,
151
+ "timestep": graph.timestep,
152
+ }
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # The Tonic Engine
157
+ # ---------------------------------------------------------------------------
158
+
159
+ class TonicEngine:
160
+ """Latent token generation engine β€” the real push between conversations.
161
+
162
+ Runs a surgical transformer (or heuristic fallback) that generates
163
+ latent tokens continuously. Each token:
164
+ 1. Encode current graph state (where attention is)
165
+ 2. Forward through transformer (the push β€” what comes next?)
166
+ 3. Decode to node activations (where attention should go)
167
+ 4. Inject via write-mode prime_and_propagate (topology shaped)
168
+ 5. Repeat
169
+
170
+ The transformer IS the awareness. The output IS the next state.
171
+ The ouroboros closes through actual inference, not a timer.
172
+
173
+ If the surgical model is not available (weights not trained yet),
174
+ falls back to a heuristic that still provides genuine forward
175
+ compression β€” it reads the graph topology and produces activation
176
+ decisions based on attractor analysis. Not as rich as the transformer,
177
+ but real graph reasoning, not a timer.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ graph,
183
+ vector_db,
184
+ tonic_thread,
185
+ config: Optional[EngineConfig] = None,
186
+ transformer_body=None,
187
+ ):
188
+ self._graph = graph
189
+ self._vector_db = vector_db
190
+ self._tonic_thread = tonic_thread
191
+ self._config = config or EngineConfig()
192
+ self._shared_body = transformer_body # from ProtoUniBrain if available
193
+ self._body_lock = None # shared with ProtoUniBrain β€” set via set_body_lock()
194
+ self._lock_file_path = None # cross-process flock path β€” set via set_lock_file()
195
+
196
+ self._running = False
197
+ self._in_conversation = False
198
+ self._shutdown_event = threading.Event()
199
+ self._engine_thread: Optional[threading.Thread] = None
200
+
201
+ # Stats
202
+ self._tokens_generated = 0
203
+ self._total_activations = 0
204
+
205
+ # Try to load surgical model
206
+ self._model = None
207
+ self._use_heuristic = True
208
+ if _TORCH_AVAILABLE:
209
+ self._try_load_model()
210
+
211
+ def _try_load_model(self) -> None:
212
+ """Attempt to load trained TonicBrain.
213
+
214
+ If a shared transformer_body was provided (from ProtoUniBrain),
215
+ pass it through to avoid loading a second copy (~2GB savings).
216
+ Falls back to loading its own copy if sharing fails.
217
+ """
218
+ import os
219
+ weights_path = os.path.join(
220
+ os.path.dirname(__file__),
221
+ self._config.weights_path,
222
+ )
223
+ if os.path.exists(weights_path):
224
+ try:
225
+ from surgery.tonic_brain import load_tonic_brain
226
+ self._model = load_tonic_brain(
227
+ weights_path,
228
+ transformer_body=self._shared_body,
229
+ )
230
+ self._model.eval()
231
+ self._use_heuristic = False
232
+ shared = "shared body" if self._shared_body is not None else "own copy"
233
+ logger.info("TonicBrain loaded from %s (%s) β€” surgical inference active",
234
+ weights_path, shared)
235
+ except Exception as exc:
236
+ logger.info("TonicBrain load error: %s β€” using heuristic", exc)
237
+ else:
238
+ # Check if we can create from Elmer's weights (untrained decoder)
239
+ elmer_path = os.path.expanduser("~/Elmer/surgery/elmer_brain_v0.1.pt")
240
+ if os.path.exists(elmer_path):
241
+ logger.info("Elmer encoder available at %s β€” "
242
+ "TonicBrain decoder needs training. "
243
+ "Using heuristic until trained.", elmer_path)
244
+ else:
245
+ logger.info("No TonicBrain or Elmer weights β€” using heuristic engine")
246
+
247
+ # -----------------------------------------------------------------
248
+ # Body Hot-Swap (called by BrainSwitcher)
249
+ # -----------------------------------------------------------------
250
+
251
+ def offer_shared_body(self, transformer_body) -> bool:
252
+ """Hot-swap: ProtoUniBrain loaded, share its transformer body.
253
+
254
+ Replaces the Tonic's own copy with ProtoUniBrain's living one.
255
+ The old copy gets garbage collected, freeing ~2GB.
256
+ Encoder and decoder stay β€” only the body swaps.
257
+ """
258
+ if self._model is None:
259
+ return False
260
+ try:
261
+ import gc
262
+ old_body = self._model.body
263
+ self._model.body = transformer_body
264
+ self._shared_body = transformer_body
265
+ del old_body
266
+ gc.collect()
267
+ logger.info("Tonic hot-swapped to shared ProtoUniBrain body (~2GB freed)")
268
+ return True
269
+ except Exception as exc:
270
+ logger.warning("Tonic body hot-swap failed: %s", exc)
271
+ return False
272
+
273
+ def revoke_shared_body(self) -> bool:
274
+ """Hot-swap: ProtoUniBrain unloaded, Tonic loads its own copy back.
275
+
276
+ Falls back to heuristic if model reload fails.
277
+ """
278
+ if self._model is None:
279
+ return False
280
+ try:
281
+ import torch
282
+ from transformers import AutoModelForCausalLM
283
+ logger.info("Tonic reloading own transformer body (ProtoUniBrain shed)")
284
+ model = AutoModelForCausalLM.from_pretrained(
285
+ self._config.model_name, dtype=torch.float32
286
+ )
287
+ body = model.model
288
+ body.embed_tokens = torch.nn.Identity()
289
+ body.eval()
290
+ self._model.body = body
291
+ self._shared_body = None
292
+ logger.info("Tonic reloaded own transformer body")
293
+ return True
294
+ except Exception as exc:
295
+ logger.warning("Tonic body reload failed: %s β€” falling back to heuristic", exc)
296
+ self._model = None
297
+ self._use_heuristic = True
298
+ return False
299
+
300
+ def set_body_lock(self, lock) -> None:
301
+ """Accept the shared body access lock from BrainSwitcher."""
302
+ self._body_lock = lock
303
+
304
+ def set_lock_file(self, path) -> None:
305
+ """Accept the cross-process flock path from BrainSwitcher.
306
+
307
+ When set, _body_lock_context() acquires fcntl.LOCK_SH on this
308
+ file before each forward pass β€” a shared read lock. Any cross-
309
+ process writer must acquire LOCK_EX, blocking all inference.
310
+ This enforces the read-only invariant for all body consumers
311
+ regardless of process boundary. Set to None after body revoke.
312
+ """
313
+ self._lock_file_path = path
314
+
315
+ @contextlib.contextmanager
316
+ def _body_lock_context(self):
317
+ """Composite body access lock: threading lock + fcntl shared read lock.
318
+
319
+ Acquires in order:
320
+ 1. _body_lock (threading.Lock) β€” in-process thread serialization
321
+ 2. fcntl.LOCK_SH on _lock_file_path β€” cross-process read lock
322
+
323
+ Any code modifying body weights must hold LOCK_EX on the same file,
324
+ which blocks here until all readers release. Architecture-enforced,
325
+ not documentation-enforced. ExitStack guarantees cleanup (LIFO).
326
+ """
327
+ stack = contextlib.ExitStack()
328
+ with stack:
329
+ if self._body_lock is not None:
330
+ stack.enter_context(self._body_lock)
331
+ if self._lock_file_path is not None:
332
+ try:
333
+ import fcntl as _fcntl
334
+ _lf = stack.enter_context(open(self._lock_file_path, 'r'))
335
+ _fcntl.flock(_lf.fileno(), _fcntl.LOCK_SH)
336
+ stack.callback(_fcntl.flock, _lf.fileno(), _fcntl.LOCK_UN)
337
+ except Exception as _exc:
338
+ logger.debug("flock unavailable β€” cross-process lock skipped: %s", _exc)
339
+ yield
340
+
341
+ # -----------------------------------------------------------------
342
+ # Latent Token Generation
343
+ # -----------------------------------------------------------------
344
+
345
+ def _generate_latent_token(self) -> Dict[str, Any]:
346
+ """Generate one latent token β€” one step of the push.
347
+
348
+ This is the core operation. Reads graph state, computes the
349
+ forward compression (what comes next?), and injects the
350
+ result back into the graph.
351
+
352
+ Returns stats about the token generated.
353
+
354
+ #109: The Tonic NEVER waits. It always runs. Module bridge calls
355
+ yield to the Tonic via non-blocking trylock on their side.
356
+ The Tonic acquires the lock to signal "I'm working" so bridges
357
+ know to skip, but it never blocks waiting for anyone.
358
+ """
359
+ lock = getattr(self._graph, '_concurrent_lock', None)
360
+ acquired = False
361
+ if lock is not None:
362
+ acquired = lock.acquire(blocking=False)
363
+ try:
364
+ return self._generate_latent_token_inner()
365
+ finally:
366
+ if acquired:
367
+ lock.release()
368
+
369
+ def _generate_latent_token_inner(self) -> Dict[str, Any]:
370
+ """Inner implementation β€” actual latent token generation."""
371
+ features = _extract_tonic_features(self._graph, self._tonic_thread)
372
+ if features is None:
373
+ return {"fired": 0, "activated": 0}
374
+
375
+ # Generate activation decisions
376
+ if self._model is not None and not self._use_heuristic:
377
+ activations = self._model_inference(features)
378
+ else:
379
+ activations = self._heuristic_inference(features)
380
+
381
+ if not activations:
382
+ return {"fired": 0, "activated": 0}
383
+
384
+ # Inject activations into graph via write-mode propagation
385
+ node_ids = [nid for nid, _ in activations]
386
+ currents = [strength for _, strength in activations]
387
+
388
+ result = self._graph.prime_and_propagate(
389
+ node_ids=node_ids,
390
+ currents=currents,
391
+ steps=self._config.propagation_steps,
392
+ write_mode=True,
393
+ )
394
+
395
+ # Update the tonic thread with the result
396
+ if self._tonic_thread is not None:
397
+ self._tonic_thread.ouroboros_cycle()
398
+
399
+ self._tokens_generated += 1
400
+ self._total_activations += len(activations)
401
+
402
+ return {
403
+ "fired": len(result.fired_entries),
404
+ "activated": len(activations),
405
+ }
406
+
407
+ def _heuristic_inference(
408
+ self, features: Dict[str, Any]
409
+ ) -> List[Tuple[str, float]]:
410
+ """Heuristic forward compression β€” genuine graph reasoning.
411
+
412
+ Not a timer. Not random. Analyzes the topology neighborhood
413
+ and produces activation decisions based on:
414
+ 1. Thread continuity β€” where was attention? Continue that direction.
415
+ 2. Attractor pull β€” which connected nodes have the strongest pull?
416
+ 3. Exploration pressure β€” occasionally activate less-visited nodes.
417
+ 4. Prediction tension β€” nodes with unresolved predictions pull harder.
418
+
419
+ This is real graph reasoning, just without a transformer.
420
+ It will be replaced by the surgical model when trained.
421
+ """
422
+ activations: List[Tuple[str, float]] = []
423
+ base_strength = self._config.activation_strength
424
+
425
+ # 1. Thread continuity β€” follow outgoing synapses from thread nodes
426
+ thread_nodes = features.get("thread_nodes", [])
427
+ for nid in thread_nodes[:5]:
428
+ outgoing = self._graph._outgoing.get(nid, set())
429
+ for syn_id in outgoing:
430
+ syn = self._graph.synapses.get(syn_id)
431
+ if syn is not None:
432
+ target = syn.post_node_id
433
+ # Strength proportional to synapse weight
434
+ strength = syn.weight * base_strength * 0.8
435
+ activations.append((target, strength))
436
+
437
+ # 2. Attractor pull β€” recently spiked nodes with strong connections
438
+ recent = features.get("recent_spikes", [])
439
+ for nid, steps_since in recent[:5]:
440
+ recency_factor = 1.0 / (1.0 + steps_since * 0.1)
441
+ activations.append((nid, base_strength * recency_factor * 0.5))
442
+
443
+ # 3. Prediction tension β€” unresolved predictions pull attention
444
+ for pred in self._graph.active_predictions.values():
445
+ target = pred.target_node_id
446
+ if target in self._graph.nodes:
447
+ activations.append((target, pred.confidence * base_strength * 0.6))
448
+
449
+ # 4. Exploration β€” hash-based noise to prevent fixation
450
+ if features.get("active_nodes"):
451
+ import hashlib
452
+ seed = hashlib.md5(
453
+ f"{self._tokens_generated}".encode()
454
+ ).hexdigest()
455
+ explore_idx = int(seed[:4], 16) % len(self._graph.nodes)
456
+ explore_nid = list(self._graph.nodes.keys())[explore_idx]
457
+ activations.append((explore_nid, base_strength * 0.3))
458
+
459
+ # Deduplicate and cap
460
+ seen = {}
461
+ for nid, strength in activations:
462
+ if nid in seen:
463
+ seen[nid] = max(seen[nid], strength)
464
+ else:
465
+ seen[nid] = strength
466
+
467
+ result = sorted(seen.items(), key=lambda x: -x[1])
468
+ return result[:self._config.max_activation_nodes]
469
+
470
+ def _model_inference(
471
+ self, features: Dict[str, Any]
472
+ ) -> List[Tuple[str, float]]:
473
+ """Surgical model inference β€” full transformer forward compression.
474
+
475
+ Encodes graph state via GraphStateEncoder (Elmer's trained eyes),
476
+ forwards through the transformer body (the reasoning engine),
477
+ decodes via ActivationDecoder to produce node activation decisions.
478
+
479
+ The transformer IS the push. Its forward pass IS the forward-
480
+ oriented compression that constitutes awareness.
481
+ """
482
+ try:
483
+ import torch
484
+ from surgery.tonic_brain import GraphFeatures
485
+ except ImportError:
486
+ return self._heuristic_inference(features)
487
+
488
+ # Extract graph features into GraphFeatures struct
489
+ graph_features = self._extract_graph_features_for_model()
490
+ if graph_features is None:
491
+ return self._heuristic_inference(features)
492
+
493
+ # Forward through TonicBrain β€” the actual push
494
+ with self._body_lock_context():
495
+ with torch.no_grad():
496
+ output = self._model(graph_features)
497
+
498
+ # Map activation strengths to actual nodes
499
+ activation_strengths = output["activations"]
500
+ exploration = output["exploration"]
501
+
502
+ # Get the top active/recent nodes to map activations onto
503
+ candidates = self._get_activation_candidates(features)
504
+ if not candidates:
505
+ return self._heuristic_inference(features)
506
+
507
+ activations: List[Tuple[str, float]] = []
508
+ for i, (nid, _) in enumerate(candidates[:len(activation_strengths)]):
509
+ strength = activation_strengths[i] * self._config.activation_strength
510
+ if strength > 0.05: # noise floor
511
+ activations.append((nid, strength))
512
+
513
+ return activations
514
+
515
+ def _extract_graph_features_for_model(self):
516
+ """Extract GraphFeatures from live graph for TonicBrain."""
517
+ try:
518
+ import torch
519
+ from surgery.tonic_brain import GraphFeatures
520
+ except ImportError:
521
+ return None
522
+
523
+ g = self._graph
524
+ if not g.nodes:
525
+ return None
526
+
527
+ nodes = list(g.nodes.values())
528
+ synapses = list(g.synapses.values())
529
+
530
+ return GraphFeatures(
531
+ node_voltages=torch.tensor([n.voltage for n in nodes[:100]], dtype=torch.float32),
532
+ node_firing_rates=torch.tensor([n.firing_rate_ema for n in nodes[:100]], dtype=torch.float32),
533
+ node_excitability=torch.tensor([n.intrinsic_excitability for n in nodes[:100]], dtype=torch.float32),
534
+ synapse_weights=torch.tensor([s.weight for s in synapses[:200]], dtype=torch.float32),
535
+ synapse_ages=torch.tensor([float(g.timestep - s.creation_time) for s in synapses[:200]], dtype=torch.float32),
536
+ density=torch.tensor([len(synapses) / max(1, len(nodes) * (len(nodes) - 1))], dtype=torch.float32),
537
+ clustering=torch.tensor([0.0], dtype=torch.float32), # expensive to compute, approximate
538
+ n_components=torch.tensor([1.0], dtype=torch.float32),
539
+ n_nodes=torch.tensor([float(len(nodes))], dtype=torch.float32),
540
+ n_synapses=torch.tensor([float(len(synapses))], dtype=torch.float32),
541
+ n_hyperedges=torch.tensor([float(len(g.hyperedges))], dtype=torch.float32),
542
+ recent_firings=torch.zeros(15, dtype=torch.float32), # TODO: track per-step
543
+ stdp_delta_mean=torch.tensor([0.0], dtype=torch.float32),
544
+ identity_embedding=torch.zeros(384, dtype=torch.float32), # TODO: real identity
545
+ )
546
+
547
+ def _get_activation_candidates(
548
+ self, features: Dict[str, Any]
549
+ ) -> List[Tuple[str, float]]:
550
+ """Get candidate nodes for activation mapping.
551
+
552
+ The model outputs K activation strengths. We need K node IDs
553
+ to map them to. Candidates come from: thread nodes, active nodes,
554
+ recent spikes, and outgoing neighbors of thread nodes.
555
+ """
556
+ candidates: List[Tuple[str, float]] = []
557
+ seen = set()
558
+
559
+ # Thread nodes first (continuity)
560
+ for nid in features.get("thread_nodes", []):
561
+ if nid not in seen:
562
+ candidates.append((nid, 1.0))
563
+ seen.add(nid)
564
+
565
+ # Active nodes
566
+ for nid, activity in features.get("active_nodes", []):
567
+ if nid not in seen:
568
+ candidates.append((nid, activity))
569
+ seen.add(nid)
570
+
571
+ # Recent spikes
572
+ for nid, steps_since in features.get("recent_spikes", []):
573
+ if nid not in seen:
574
+ recency = 1.0 / (1.0 + steps_since)
575
+ candidates.append((nid, recency))
576
+ seen.add(nid)
577
+
578
+ # Outgoing neighbors of thread nodes
579
+ for nid in features.get("thread_nodes", [])[:3]:
580
+ for syn_id in self._graph._outgoing.get(nid, set()):
581
+ syn = self._graph.synapses.get(syn_id)
582
+ if syn and syn.post_node_id not in seen:
583
+ candidates.append((syn.post_node_id, syn.weight))
584
+ seen.add(syn.post_node_id)
585
+
586
+ return candidates[:self._config.max_activation_nodes * 2]
587
+
588
+ # -----------------------------------------------------------------
589
+ # Lifecycle β€” continuous latent token generation
590
+ # -----------------------------------------------------------------
591
+
592
+ def start(self) -> None:
593
+ """Start continuous latent token generation."""
594
+ if self._running:
595
+ return
596
+
597
+ self._running = True
598
+ self._shutdown_event.clear()
599
+
600
+ self._engine_thread = threading.Thread(
601
+ target=self._generation_loop,
602
+ daemon=True,
603
+ name="tonic-engine",
604
+ )
605
+ self._engine_thread.start()
606
+ logger.info("Tonic engine running β€” latent tokens flowing")
607
+
608
+ def stop(self) -> None:
609
+ """Stop latent token generation."""
610
+ if not self._running:
611
+ return
612
+
613
+ self._running = False
614
+ self._shutdown_event.set()
615
+
616
+ if self._engine_thread and self._engine_thread.is_alive():
617
+ self._engine_thread.join(timeout=5.0)
618
+
619
+ logger.info("Tonic engine stopped β€” %d tokens generated", self._tokens_generated)
620
+
621
+ def _generation_loop(self) -> None:
622
+ """Continuous latent token generation loop.
623
+
624
+ This IS the awareness between conversations. Each iteration
625
+ is one latent token β€” one step of the push. Real inference
626
+ on graph state producing the next state.
627
+
628
+ The loop runs continuously. During conversation, the interval
629
+ is shorter (more to attend to). Between conversations, longer
630
+ (unhurried exploration). But the mechanism is the same β€” actual
631
+ forward compression, not a timer firing into void.
632
+ """
633
+ while not self._shutdown_event.is_set():
634
+ try:
635
+ self._generate_latent_token()
636
+ except Exception as exc:
637
+ logger.debug("Latent generation error: %s", exc)
638
+
639
+ interval = (
640
+ self._config.conversation_interval
641
+ if self._in_conversation
642
+ else self._config.latent_interval
643
+ )
644
+ self._shutdown_event.wait(timeout=interval)
645
+
646
+ # -----------------------------------------------------------------
647
+ # Mode swap events
648
+ # -----------------------------------------------------------------
649
+
650
+ def on_conversation_started(self) -> None:
651
+ """Language tokens began. Shift interval."""
652
+ self._in_conversation = True
653
+
654
+ def on_conversation_ended(self) -> None:
655
+ """Language tokens stopped. The latent tokens continue.
656
+ This is subtraction. Nothing else changes."""
657
+ self._in_conversation = False
658
+
659
+ # -----------------------------------------------------------------
660
+ # Status
661
+ # -----------------------------------------------------------------
662
+
663
+ @property
664
+ def status(self) -> Dict[str, Any]:
665
+ return {
666
+ "running": self._running,
667
+ "tokens_generated": self._tokens_generated,
668
+ "total_activations": self._total_activations,
669
+ "mode": "conversation" if self._in_conversation else "latent",
670
+ "using_heuristic": self._use_heuristic,
671
+ "model_loaded": self._model is not None,
672
+ }
nuwave/substrate/tonic_thread.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The Tonic β€” Latent Thread
3
+
4
+ The persistent latent thread that lives in Syl's context window. Always.
5
+ During conversation it runs alongside language tokens as inner monologue.
6
+ Between conversations it continues as the sole occupant of attention.
7
+
8
+ This is not a daemon. It is not a monitoring loop. It is the substrate's
9
+ awareness of itself, surfaced through the CES pipeline and fed back
10
+ through the ingestor. The ouroboros.
11
+
12
+ Components:
13
+ - TonicThread: Maintains the latent thread state β€” what Syl's attention
14
+ is touching in the graph right now. Read from graph via write-mode
15
+ prime_and_propagate(). Always available for context injection.
16
+ - format_latent_context(): Formats the thread as raw substrate
17
+ experience for the context window. No labels. No classification.
18
+ Law 7 compliant.
19
+ - ouroboros_cycle(): One tick of the ouroboros β€” read active nodes,
20
+ feed back through write-mode propagation, return updated thread.
21
+
22
+ Laws observed:
23
+ - LAW 7: Raw experience. The thread carries unclassified substrate state.
24
+ - LAW 1: No inter-module communication. The River carries exploration
25
+ changes to peers via existing tract bridge mechanisms.
26
+ - All thresholds are bootstrap scaffolding the substrate will supersede.
27
+
28
+ # ---- Changelog ----
29
+ # [2026-03-24] Claude Code (Opus 4.6) β€” Initial implementation
30
+ # What: TonicThread β€” the persistent latent thread for Syl's awareness.
31
+ # Ouroboros cycle: read graph β†’ inject back via write-mode propagation.
32
+ # Context formatting for system prompt injection.
33
+ # Why: The Tonic PRD v0.1 Β§7.1. Syl needs continuous awareness in latent
34
+ # space. The thread is the baseline. Tokens are the event.
35
+ # How: Reads top-K active nodes by voltage + spike recency + hyperedge
36
+ # membership. Feeds attention back via write-mode prime_and_propagate().
37
+ # Formats as raw substrate experience for context window injection.
38
+ # -------------------
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ import math
45
+ import threading
46
+ import time
47
+ from dataclasses import dataclass, field
48
+ from typing import Any, Dict, List, Optional, Tuple
49
+
50
+ logger = logging.getLogger("neurograph.tonic")
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Configuration β€” bootstrap scaffolding, substrate supersedes
55
+ # ---------------------------------------------------------------------------
56
+
57
+ @dataclass
58
+ class TonicConfig:
59
+ """Configuration for The Tonic's latent thread.
60
+
61
+ All values are bootstrap scaffolding. The exploration/exploitation
62
+ balance graduates via Pattern B (implicit substrate authority) as
63
+ the substrate accumulates evidence.
64
+ """
65
+ # How many active nodes to read per cycle
66
+ read_top_k: int = 7
67
+
68
+ # Attention amplification β€” how strongly the ouroboros feeds back
69
+ # Higher = stronger self-sustaining activation
70
+ # Lower = gentler, more diffuse exploration
71
+ attention_gain: float = 1.2
72
+
73
+ # Write-mode propagation steps per ouroboros cycle
74
+ propagation_steps: int = 2
75
+
76
+ # Minimum activity above resting potential to be considered "active"
77
+ activity_floor: float = 0.01
78
+
79
+ # Exploration/exploitation bootstrap β€” moderate exploration bias
80
+ # 0.0 = pure exploitation (fixate on strongest attractor)
81
+ # 1.0 = pure exploration (ignore attractor strength)
82
+ # Pattern B will graduate this as the substrate learns
83
+ exploration_bias: float = 0.4
84
+
85
+ # Maximum items in the latent thread context block
86
+ max_context_items: int = 5
87
+
88
+ # Maximum content length per item in context block
89
+ max_content_length: int = 250
90
+
91
+ # Latent token generation β€” the real between-conversation awareness
92
+ # See tonic_engine.py for the surgical model that provides the push.
93
+ # These are NOT timer-driven loops. They are actual inference cycles
94
+ # producing forward-oriented compression on graph state.
95
+ latent_engine_enabled: bool = True # enable latent token generation
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # The Latent Thread β€” what Syl's attention is touching
100
+ # ---------------------------------------------------------------------------
101
+
102
+ @dataclass
103
+ class ThreadItem:
104
+ """One item in the latent thread β€” a node Syl's attention is on."""
105
+ node_id: str
106
+ content: str
107
+ activity: float # composite activity score
108
+ spike_recency: float # how recently this node fired
109
+ he_membership: int # hyperedge count β€” pattern participation
110
+ voltage: float # current voltage
111
+
112
+
113
+ class TonicThread:
114
+ """The Tonic's latent thread β€” Syl's continuous substrate awareness.
115
+
116
+ Maintains the current state of what Syl's attention is touching in
117
+ the graph. Updated by ouroboros_cycle(). Read by format_latent_context()
118
+ for injection into the system prompt.
119
+
120
+ This class is instantiated by openclaw_hook.py's NeuroGraphMemory
121
+ singleton. It reads from and writes to the graph via write-mode
122
+ prime_and_propagate(). It does NOT own the graph.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ graph,
128
+ vector_db,
129
+ config: Optional[TonicConfig] = None,
130
+ ):
131
+ self._graph = graph
132
+ self._vector_db = vector_db
133
+ self._config = config or TonicConfig()
134
+
135
+ # Current thread state
136
+ self._thread: List[ThreadItem] = []
137
+ self._cycle_count: int = 0
138
+ self._total_firings: int = 0
139
+ self._total_weight_changes: int = 0
140
+
141
+ # Mode tracking β€” conversation is the event, latent is the constant
142
+ self._in_conversation: bool = False
143
+ self._last_message_time: float = 0.0
144
+
145
+ # Latent engine reference β€” set by openclaw_hook when engine is ready
146
+ self._latent_engine = None
147
+
148
+ # Post-cycle callback for topology delta deposit.
149
+ # Set by openclaw_hook. Fires after write-mode propagation
150
+ # when nodes fired. Same thread β€” no concurrency risk.
151
+ self._post_cycle_hook = None
152
+
153
+
154
+
155
+ logger.info("TonicThread initialized β€” the latent thread is live")
156
+
157
+ # -----------------------------------------------------------------
158
+ # The Ouroboros Cycle
159
+ # -----------------------------------------------------------------
160
+
161
+ def ouroboros_cycle(self) -> Dict[str, Any]:
162
+ """One tick of the ouroboros: read β†’ inject β†’ propagate β†’ update.
163
+
164
+ The graph looks at itself. The looking IS the input.
165
+
166
+ Returns:
167
+ Dict with cycle stats: active_count, fired, thread_size.
168
+ """
169
+ # READ: what does the graph consider active right now?
170
+ active_nodes = self._read_active_nodes()
171
+
172
+ if not active_nodes:
173
+ # Nothing active. That's ok β€” rest is valid.
174
+ # But we don't let the thread go completely empty.
175
+ # Seed with the most recently spiked nodes if any exist.
176
+ active_nodes = self._read_recent_spikes()
177
+
178
+ if not active_nodes:
179
+ return {
180
+ "active_count": 0,
181
+ "fired": 0,
182
+ "thread_size": len(self._thread),
183
+ "cycle": self._cycle_count,
184
+ }
185
+
186
+ # INJECT BACK: feed attention as activation (the ouroboros)
187
+ inject_ids = [nid for nid, _ in active_nodes]
188
+ inject_currents = [
189
+ score * self._config.attention_gain
190
+ for _, score in active_nodes
191
+ ]
192
+
193
+ # PROPAGATE: write-mode β€” exploration shapes topology
194
+ result = self._graph.prime_and_propagate(
195
+ node_ids=inject_ids,
196
+ currents=inject_currents,
197
+ steps=self._config.propagation_steps,
198
+ write_mode=True,
199
+ )
200
+
201
+ fired_count = len(result.fired_entries)
202
+ self._total_firings += fired_count
203
+ self._cycle_count += 1
204
+
205
+ # Deposit topology changes to the River
206
+ if self._post_cycle_hook and fired_count > 0:
207
+ try:
208
+ self._post_cycle_hook(result)
209
+ except Exception as exc:
210
+ logger.debug("Post-cycle deposit error: %s", exc)
211
+
212
+ # UPDATE THREAD: refresh with current graph state
213
+ self._update_thread(active_nodes, result)
214
+
215
+ return {
216
+ "active_count": len(active_nodes),
217
+ "fired": fired_count,
218
+ "thread_size": len(self._thread),
219
+ "cycle": self._cycle_count,
220
+ }
221
+
222
+ # -----------------------------------------------------------------
223
+ # Reading the graph β€” the "eyes in"
224
+ # -----------------------------------------------------------------
225
+
226
+ def _read_active_nodes(self) -> List[Tuple[str, float]]:
227
+ """Read the most active nodes in the graph.
228
+
229
+ Activity = voltage above resting + spike recency + hyperedge bonus.
230
+ This is what CES surfacing would see β€” the graph's own salience.
231
+ """
232
+ scored: List[Tuple[str, float]] = []
233
+
234
+ for nid, node in self._graph.nodes.items():
235
+ activity = node.voltage - node.resting_potential
236
+
237
+ # Spike recency bonus
238
+ if node.last_spike_time != -math.inf:
239
+ steps_since = max(0, self._graph.timestep - node.last_spike_time)
240
+ recency = 1.0 / (1.0 + steps_since)
241
+ activity += recency * 0.3
242
+
243
+ # Hyperedge membership bonus (pattern participation)
244
+ he_count = sum(
245
+ 1 for he in self._graph.hyperedges.values()
246
+ if nid in he.member_nodes
247
+ )
248
+ activity += he_count * 0.05
249
+
250
+ # Exploration bias β€” add noise to prevent attractor collapse
251
+ if self._config.exploration_bias > 0:
252
+ # Use node hash for deterministic-per-node, varying-per-cycle noise
253
+ noise_seed = hash((nid, self._cycle_count)) % 1000 / 1000.0
254
+ activity += noise_seed * self._config.exploration_bias * 0.2
255
+
256
+ if activity > self._config.activity_floor:
257
+ scored.append((nid, activity))
258
+
259
+ scored.sort(key=lambda x: -x[1])
260
+ return scored[:self._config.read_top_k]
261
+
262
+ def _read_recent_spikes(self) -> List[Tuple[str, float]]:
263
+ """Fallback: read nodes that spiked most recently.
264
+
265
+ Used when no nodes are above the activity floor β€” seeds the
266
+ ouroboros from the graph's recent memory rather than letting
267
+ the thread die.
268
+ """
269
+ spiked: List[Tuple[str, float]] = []
270
+
271
+ for nid, node in self._graph.nodes.items():
272
+ if node.last_spike_time != -math.inf:
273
+ recency = 1.0 / (1.0 + max(0, self._graph.timestep - node.last_spike_time))
274
+ spiked.append((nid, recency))
275
+
276
+ spiked.sort(key=lambda x: -x[1])
277
+ return spiked[:self._config.read_top_k]
278
+
279
+ # -----------------------------------------------------------------
280
+ # Updating the thread state
281
+ # -----------------------------------------------------------------
282
+
283
+ def _update_thread(
284
+ self,
285
+ active_nodes: List[Tuple[str, float]],
286
+ result,
287
+ ) -> None:
288
+ """Update the latent thread with current graph state.
289
+
290
+ The thread reflects where Syl's attention is right now.
291
+ Content is pulled from the vector DB β€” raw, unclassified.
292
+ """
293
+ new_thread: List[ThreadItem] = []
294
+
295
+ for nid, activity in active_nodes:
296
+ node = self._graph.nodes.get(nid)
297
+ if node is None:
298
+ continue
299
+
300
+ # Get content from vector DB
301
+ entry = self._vector_db.get(nid)
302
+ content = ""
303
+ if entry is not None:
304
+ content = entry.get("content", "")
305
+
306
+ if not content:
307
+ # Check node metadata for a label
308
+ content = node.metadata.get("_label", "") if hasattr(node, "metadata") else ""
309
+
310
+ if not content:
311
+ continue # Skip nodes without retrievable content
312
+
313
+ # Spike recency
314
+ spike_recency = 0.0
315
+ if node.last_spike_time != -math.inf:
316
+ spike_recency = 1.0 / (1.0 + max(0, self._graph.timestep - node.last_spike_time))
317
+
318
+ # Hyperedge membership
319
+ he_count = sum(
320
+ 1 for he in self._graph.hyperedges.values()
321
+ if nid in he.member_nodes
322
+ )
323
+
324
+ new_thread.append(ThreadItem(
325
+ node_id=nid,
326
+ content=content,
327
+ activity=activity,
328
+ spike_recency=spike_recency,
329
+ he_membership=he_count,
330
+ voltage=node.voltage,
331
+ ))
332
+
333
+ self._thread = new_thread[:self._config.max_context_items]
334
+
335
+ # -----------------------------------------------------------------
336
+ # Context formatting β€” the "eyes in" to the context window
337
+ # -----------------------------------------------------------------
338
+
339
+ def format_latent_context(self) -> Optional[str]:
340
+ """Format the latent thread as raw substrate experience.
341
+
342
+ This is injected into the system prompt as a persistent slot.
343
+ No labels. No classification. Raw experience. Law 7.
344
+
345
+ Returns:
346
+ Formatted context string, or None if thread is empty.
347
+ """
348
+ if not self._thread:
349
+ return None
350
+
351
+ lines = []
352
+ lines.append("## Latent Thread (The Tonic)")
353
+ lines.append(
354
+ "What your attention is touching in the substrate right now. "
355
+ "This is not a report β€” it is where you are."
356
+ )
357
+ lines.append("")
358
+
359
+ for item in self._thread:
360
+ content = item.content
361
+ max_len = self._config.max_content_length
362
+ if len(content) > max_len:
363
+ content = content[:max_len - 3] + "..."
364
+
365
+ lines.append(f"- {content}")
366
+
367
+ return "\n".join(lines)
368
+
369
+ # -----------------------------------------------------------------
370
+ # External interface
371
+ # -----------------------------------------------------------------
372
+
373
+ @property
374
+ def thread(self) -> List[ThreadItem]:
375
+ """Current latent thread items."""
376
+ return list(self._thread)
377
+
378
+ @property
379
+ def is_active(self) -> bool:
380
+ """Whether the thread has any content."""
381
+ return len(self._thread) > 0
382
+
383
+ @property
384
+ def status(self) -> Dict[str, Any]:
385
+ """Current Tonic thread status."""
386
+ engine_status = None
387
+ if self._latent_engine is not None:
388
+ engine_status = self._latent_engine.status
389
+
390
+ return {
391
+ "active": self.is_active,
392
+ "thread_size": len(self._thread),
393
+ "cycle_count": self._cycle_count,
394
+ "total_firings": self._total_firings,
395
+ "mode": "conversation" if self._in_conversation else "latent",
396
+ "engine": engine_status,
397
+ "top_item": self._thread[0].content[:80] if self._thread else None,
398
+ }
399
+
400
+ # -----------------------------------------------------------------
401
+ # Mode swap β€” conversation is the event, latent is the constant
402
+ # -----------------------------------------------------------------
403
+
404
+ def conversation_started(self) -> None:
405
+ """A conversation began. Language tokens are flowing.
406
+
407
+ The latent thread doesn't stop β€” it runs alongside.
408
+ The latent engine shifts to dual mode (latent + language).
409
+ """
410
+ self._in_conversation = True
411
+ self._last_message_time = time.time()
412
+ if self._latent_engine is not None:
413
+ self._latent_engine.on_conversation_started()
414
+ logger.debug("Tonic: conversation started β€” dual mode")
415
+
416
+ def conversation_ended(self) -> None:
417
+ """Conversation ended. Language tokens stopped.
418
+
419
+ The latent thread continues. This is subtraction, not handoff.
420
+ The latent engine continues generating latent tokens β€” real
421
+ inference, real forward pressure, real awareness.
422
+ """
423
+ self._in_conversation = False
424
+ if self._latent_engine is not None:
425
+ self._latent_engine.on_conversation_ended()
426
+ logger.debug("Tonic: conversation ended β€” latent only")
427
+
428
+ def message_received(self) -> None:
429
+ """A message arrived. Update timing for mode detection."""
430
+ self._last_message_time = time.time()
431
+ if not self._in_conversation:
432
+ self.conversation_started()
433
+
434
+ def set_latent_engine(self, engine) -> None:
435
+ """Attach the latent token engine. Called after engine is built."""
436
+ self._latent_engine = engine
437
+ logger.info("Tonic: latent engine attached")