Executor-Tyrant-Framework commited on
Commit
0b97645
Β·
verified Β·
1 Parent(s): df82033

Sync from GitHub: 35ed177d9ed18841bc05c5cc14150abd0ee53255

Browse files
nuwave/organism.py CHANGED
@@ -16,6 +16,56 @@ communication protocol (Law 1). Raw experience in, classification
16
  only at extraction (Law 7).
17
 
18
  # ---- Changelog ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # [2026-06-05] Claude Opus 4.7 (1M ctx) β€” Persist-hardening: rotation=20, prediction_threshold=1.5
20
  # What: Two organism.py changes:
21
  # (1) `_snapshot_to_backup` call site bumps `_prune_old_backups(api, keep=5)`
@@ -209,6 +259,32 @@ logger = logging.getLogger("nuwave.organism")
209
  _substrate_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'substrate')
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  class NuWaveOrganism:
213
  """The living system. Substrate + KISS bucket + Pith bucket.
214
 
@@ -392,6 +468,26 @@ class NuWaveOrganism:
392
  self._restore_state()
393
  self._sanity_check_tract_writes()
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  def _sanity_check_tract_writes(self) -> None:
396
  """Write + read a trivial test entry in each tract to surface any
397
  filesystem/permission/argument issues at boot time.
@@ -1076,6 +1172,18 @@ class NuWaveOrganism:
1076
  embedding = np.asarray(self._embed_fn(text), dtype=np.float32)
1077
  node_id = f"exp_{self._step_count}_{hash(text) & 0xFFFF:04x}"
1078
 
 
 
 
 
 
 
 
 
 
 
 
 
1079
  # All graph mutations (create + stimulate loop) run under the
1080
  # graph lock β€” the concept worker may concurrently add tree
1081
  # nodes from a background thread, and the Graph isn't thread-
@@ -1084,9 +1192,13 @@ class NuWaveOrganism:
1084
  # Create node in the SNN. Metadata carries biological timestamp
1085
  # only β€” no "type" label, no "content" curation. The retina
1086
  # does not classify photons; V1 discovers features via dynamics.
 
 
 
 
1087
  node = self._graph.create_node(
1088
  node_id=node_id,
1089
- metadata={"step": self._step_count},
1090
  )
1091
 
1092
  # Embedding lives in the side-table as numpy f32 (zero inflation).
@@ -1238,6 +1350,23 @@ class NuWaveOrganism:
1238
  self._step_result = step_result
1239
  self._step_count += 1
1240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1241
  # StepResult has real dataclass fields β€” read them directly
1242
  result = {
1243
  'step': self._step_count,
@@ -1521,6 +1650,29 @@ class NuWaveOrganism:
1521
  # IS the relevance mechanism.
1522
  born_score = effective_amp * effective_amp
1523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1524
  content = self._node_content.get(nid, '')
1525
  if content and born_score > 0.001:
1526
  scored.append((nid, content, born_score))
@@ -1828,15 +1980,28 @@ class NuWaveOrganism:
1828
  response_embedding = np.asarray(self._embed_fn(response), dtype=np.float32)
1829
  node_id = f"resp_{self._step_count}_{hash(response) & 0xFFFF:04x}"
1830
 
 
 
 
 
 
 
 
 
 
1831
  # All graph mutations (create + stimulate) run under the lock
1832
  # so the concept worker doesn't race during substrate writes.
1833
  with self._graph_lock:
1834
  # Metadata carries biological timestamp only β€” no type label,
1835
  # no query field, no truncated content. The substrate discovers
1836
  # response-vs-experience distinction via STDP co-firing.
 
 
 
 
1837
  node = self._graph.create_node(
1838
  node_id=node_id,
1839
- metadata={"step": self._step_count},
1840
  )
1841
  self._embeddings[node_id] = response_embedding
1842
 
@@ -1880,6 +2045,16 @@ class NuWaveOrganism:
1880
  except Exception as exc:
1881
  logger.debug("Concept enqueue failed: %s", exc)
1882
 
 
 
 
 
 
 
 
 
 
 
1883
  # Persist β€” the organism remembers across restarts
1884
  self.save()
1885
 
 
16
  only at extraction (Law 7).
17
 
18
  # ---- Changelog ----
19
+ # [2026-06-20] Claude Opus 4.7 (1M ctx) β€” Mind-Not-Database: wire canonical RPC mechanisms
20
+ # What: Six integration points wired into organism.py to call into a new
21
+ # nuwave/substrate/rpc_mechanisms.py module that ports canonical NG's
22
+ # _anticipate, _gsg_backfill_existing_nodes, _update_deposit_cluster,
23
+ # _embed_to_poincare_dir, _poincare_distance, MMN-EMA, and surfacing
24
+ # modulation. Wiring:
25
+ # (1) _import_rpc_mechanisms() module helper near top, matches existing
26
+ # substrate-import pattern (sys.path manipulation, lazy import).
27
+ # (2) __init__: self._substrate_novelty_ema=0.5 + gsg_backfill call
28
+ # after _restore_state (stamps poincare_dir on any restored nodes).
29
+ # (3) deposit_experience: update_deposit_cluster (DiffPC novelty signal)
30
+ # + embed_to_poincare_dir; stamp poincare_dir into node.metadata at
31
+ # create_node time.
32
+ # (4) After self._step_result = step_result: update_substrate_novelty_ema
33
+ # (MMN EMA) + anticipate(fired_node_ids) β€” #255 + #256 wired live.
34
+ # (5) record_outcome: same poincare_dir stamp for response node + tick
35
+ # self._tonic_thread.ouroboros_cycle() at end of every turn
36
+ # (topology-translation-lab pattern β€” keep Tonic alive between
37
+ # benchmark events since HF Spaces don't have continuous idle time).
38
+ # (6) pith_extract scoring loop: add get_primed_bonus(nid) and
39
+ # get_gsg_score_bonus(query_dir, metadata, layer) to born_score.
40
+ # Both bonuses are canonical substrate-derived (anticipatory
41
+ # pre-activation + PoincarΓ© hyperbolic proximity), NOT arbitrary
42
+ # heuristics β€” fits the existing "physics decides" pith design.
43
+ # Also: nuwave/substrate/neuro_foundation.py re-vendored from canonical
44
+ # HEAD (fad1ade), picks up GSG Phase 3 (non-Euclidean message passing
45
+ # in graph.step propagation), GSG Phase 4 (spherical attractor manifold),
46
+ # #325 msgpack-enforcer, #spine identity-protection, and the
47
+ # geometry-informed synaptic delays fix.
48
+ # Why: /home/josh/docs/concepts/NeuroGraph Is a Mind, Not a Database.md
49
+ # (2026-06-14) names the failure mode NuWave fell into directly: stripping
50
+ # canonical mechanisms because the names sound Syl-specific, then ending
51
+ # up with a fancy-SNN-reached-for-like-a-database. NuWave's
52
+ # predictions=0 across 5 maturation runs at 18,244 synapses isn't a
53
+ # density-tuning problem β€” it's the structural absence of the canonical
54
+ # mechanism that GENERATES predictions (anticipatory pre-activation, #256)
55
+ # and the mechanism that USES surprise to widen surfacing (#255 MMN
56
+ # feedback). Both lived in canonical neurograph_rpc.py, which NuWave
57
+ # had bypassed entirely in favor of its own bespoke organism.py paths.
58
+ # Topology-translation-lab proves the integration pattern works (their
59
+ # /home/josh/topology-translation-lab/neurograph_rpc.py uses the same
60
+ # mechanisms and Tonic-tick-at-assemble pattern).
61
+ # How: Surgical extraction (not full RPC vendor). The 5 generic mechanism
62
+ # functions ported into rpc_mechanisms.py with explicit graph/vec_db
63
+ # params (no canonical _memory global). Organism.py wiring is best-effort
64
+ # (try/except wrap on every integration point) so missing mechanisms
65
+ # never break substrate boot or step lifecycle. Single feature branch
66
+ # per the 2026-06-03 CLAUDE.md git workflow rule:
67
+ # cc-vps-nuwave-mind-not-database-20260620, merged --no-ff.
68
+ # -------------------
69
  # [2026-06-05] Claude Opus 4.7 (1M ctx) β€” Persist-hardening: rotation=20, prediction_threshold=1.5
70
  # What: Two organism.py changes:
71
  # (1) `_snapshot_to_backup` call site bumps `_prune_old_backups(api, keep=5)`
 
259
  _substrate_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'substrate')
260
 
261
 
262
+ def _import_rpc_mechanisms():
263
+ """Lazy import of rpc_mechanisms from the substrate dir, matching the
264
+ pattern used for neuro_foundation/tonic_thread/activation_persistence.
265
+
266
+ Returns the rpc_mechanisms module, or None if import fails. Caller should
267
+ treat None as "skip mechanism wiring this call" β€” every integration point
268
+ is best-effort and must not break the substrate path if mechanisms are
269
+ unavailable.
270
+ """
271
+ _added = _substrate_dir not in sys.path
272
+ if _added:
273
+ sys.path.insert(0, _substrate_dir)
274
+ try:
275
+ import rpc_mechanisms # type: ignore[import-not-found]
276
+ return rpc_mechanisms
277
+ except Exception as _exc:
278
+ logger.debug("rpc_mechanisms import failed (non-fatal): %s", _exc)
279
+ return None
280
+ finally:
281
+ if _added and _substrate_dir in sys.path:
282
+ try:
283
+ sys.path.remove(_substrate_dir)
284
+ except ValueError:
285
+ pass
286
+
287
+
288
  class NuWaveOrganism:
289
  """The living system. Substrate + KISS bucket + Pith bucket.
290
 
 
468
  self._restore_state()
469
  self._sanity_check_tract_writes()
470
 
471
+ # MMN EMA β€” updated each graph.step() via rpc_mechanisms.update_substrate_novelty_ema.
472
+ # Drives #255 surprise-weighted surfacing modulation. Default 0.5 = neutral.
473
+ self._substrate_novelty_ema: float = 0.5
474
+
475
+ # GSG Phase 1 backfill β€” stamp poincare_dir on any restored nodes that
476
+ # lack it. Best-effort: failures don't break boot. (#256/#255/GSG wiring
477
+ # from /home/josh/docs/concepts/NeuroGraph Is a Mind, Not a Database.md)
478
+ try:
479
+ _rpc = _import_rpc_mechanisms()
480
+ if _rpc is not None and self._graph is not None:
481
+ vdb = getattr(self, "_vec_db", None) or self
482
+ # NuWave stores embeddings in self._embeddings; build a shim
483
+ # object with .embeddings attribute so rpc_mech sees what it expects.
484
+ class _VDBShim:
485
+ def __init__(self, embeddings):
486
+ self.embeddings = embeddings
487
+ _rpc.gsg_backfill_existing_nodes(self._graph, _VDBShim(self._embeddings))
488
+ except Exception as _exc:
489
+ logger.debug("GSG backfill skipped at boot (non-fatal): %s", _exc)
490
+
491
  def _sanity_check_tract_writes(self) -> None:
492
  """Write + read a trivial test entry in each tract to surface any
493
  filesystem/permission/argument issues at boot time.
 
1172
  embedding = np.asarray(self._embed_fn(text), dtype=np.float32)
1173
  node_id = f"exp_{self._step_count}_{hash(text) & 0xFFFF:04x}"
1174
 
1175
+ # DiffPC deposit-cluster novelty + GSG Phase 1 PoincarΓ© direction.
1176
+ # Best-effort; failures don't block the deposit path.
1177
+ _novelty = 0.5
1178
+ _poincare_dir = None
1179
+ try:
1180
+ _rpc = _import_rpc_mechanisms()
1181
+ if _rpc is not None:
1182
+ _novelty = _rpc.update_deposit_cluster(embedding)
1183
+ _poincare_dir = _rpc.embed_to_poincare_dir(embedding)
1184
+ except Exception as _exc:
1185
+ logger.debug("DiffPC/GSG ingest mechanisms skipped: %s", _exc)
1186
+
1187
  # All graph mutations (create + stimulate loop) run under the
1188
  # graph lock β€” the concept worker may concurrently add tree
1189
  # nodes from a background thread, and the Graph isn't thread-
 
1192
  # Create node in the SNN. Metadata carries biological timestamp
1193
  # only β€” no "type" label, no "content" curation. The retina
1194
  # does not classify photons; V1 discovers features via dynamics.
1195
+ # GSG Phase 1: stamp poincare_dir if mechanism was available.
1196
+ _meta = {"step": self._step_count}
1197
+ if _poincare_dir is not None:
1198
+ _meta["poincare_dir"] = _poincare_dir.tolist() if hasattr(_poincare_dir, "tolist") else list(_poincare_dir)
1199
  node = self._graph.create_node(
1200
  node_id=node_id,
1201
+ metadata=_meta,
1202
  )
1203
 
1204
  # Embedding lives in the side-table as numpy f32 (zero inflation).
 
1350
  self._step_result = step_result
1351
  self._step_count += 1
1352
 
1353
+ # #255 MMN EMA update + #256 anticipatory pre-activation.
1354
+ # Both run after step() so we have the freshest fired_node_ids
1355
+ # and prediction telemetry. Best-effort; failures don't break
1356
+ # the step lifecycle for the rest of the organism.
1357
+ try:
1358
+ _rpc = _import_rpc_mechanisms()
1359
+ if _rpc is not None:
1360
+ self._substrate_novelty_ema = _rpc.update_substrate_novelty_ema(
1361
+ self._substrate_novelty_ema, step_result,
1362
+ )
1363
+ _rpc.anticipate(
1364
+ self._graph,
1365
+ list(getattr(step_result, "fired_node_ids", []) or []),
1366
+ )
1367
+ except Exception as _exc:
1368
+ logger.debug("MMN/anticipate skipped (non-fatal): %s", _exc)
1369
+
1370
  # StepResult has real dataclass fields β€” read them directly
1371
  result = {
1372
  'step': self._step_count,
 
1650
  # IS the relevance mechanism.
1651
  born_score = effective_amp * effective_amp
1652
 
1653
+ # Substrate-derived scoring bonuses (NOT arbitrary):
1654
+ # - #256 anticipatory pre-activation: nodes the substrate
1655
+ # primed last turn get a bonus, expressing "I expected
1656
+ # this to be relevant."
1657
+ # - GSG Phase 1: hyperbolic geodesic proximity in PoincarΓ©
1658
+ # ball expresses tree-like semantic hierarchy. Closer
1659
+ # geometry = stronger candidate.
1660
+ # Both come from canonical NG mechanisms now ported in
1661
+ # rpc_mechanisms.py; both are physics, not heuristics.
1662
+ try:
1663
+ _rpc_score = _import_rpc_mechanisms()
1664
+ if _rpc_score is not None:
1665
+ born_score += _rpc_score.get_primed_bonus(nid)
1666
+ _node_obj = self._graph.nodes.get(nid)
1667
+ if _node_obj is not None:
1668
+ born_score += _rpc_score.get_gsg_score_bonus(
1669
+ query_embedding / (np.linalg.norm(query_embedding) + 1e-9),
1670
+ _node_obj.metadata,
1671
+ int(getattr(_node_obj, "diffpc_layer", 2)),
1672
+ )
1673
+ except Exception:
1674
+ pass
1675
+
1676
  content = self._node_content.get(nid, '')
1677
  if content and born_score > 0.001:
1678
  scored.append((nid, content, born_score))
 
1980
  response_embedding = np.asarray(self._embed_fn(response), dtype=np.float32)
1981
  node_id = f"resp_{self._step_count}_{hash(response) & 0xFFFF:04x}"
1982
 
1983
+ # GSG Phase 1: compute poincare_dir for response node too.
1984
+ _resp_poincare = None
1985
+ try:
1986
+ _rpc = _import_rpc_mechanisms()
1987
+ if _rpc is not None:
1988
+ _resp_poincare = _rpc.embed_to_poincare_dir(response_embedding)
1989
+ except Exception as _exc:
1990
+ logger.debug("Response GSG dir skipped: %s", _exc)
1991
+
1992
  # All graph mutations (create + stimulate) run under the lock
1993
  # so the concept worker doesn't race during substrate writes.
1994
  with self._graph_lock:
1995
  # Metadata carries biological timestamp only β€” no type label,
1996
  # no query field, no truncated content. The substrate discovers
1997
  # response-vs-experience distinction via STDP co-firing.
1998
+ # GSG Phase 1: stamp poincare_dir if available.
1999
+ _resp_meta = {"step": self._step_count}
2000
+ if _resp_poincare is not None:
2001
+ _resp_meta["poincare_dir"] = _resp_poincare.tolist() if hasattr(_resp_poincare, "tolist") else list(_resp_poincare)
2002
  node = self._graph.create_node(
2003
  node_id=node_id,
2004
+ metadata=_resp_meta,
2005
  )
2006
  self._embeddings[node_id] = response_embedding
2007
 
 
2045
  except Exception as exc:
2046
  logger.debug("Concept enqueue failed: %s", exc)
2047
 
2048
+ # Tonic ouroboros tick β€” keep the latent thread alive across turn
2049
+ # boundaries. Topology-translation-lab pattern: tick at integration
2050
+ # points (per turn) since HF Spaces don't have continuous idle time.
2051
+ # Without this, TonicThread is instantiated but never advances.
2052
+ try:
2053
+ if self._tonic_thread is not None:
2054
+ self._tonic_thread.ouroboros_cycle()
2055
+ except Exception as _exc:
2056
+ logger.debug("Tonic ouroboros tick skipped (non-fatal): %s", _exc)
2057
+
2058
  # Persist β€” the organism remembers across restarts
2059
  self.save()
2060
 
nuwave/substrate/neuro_foundation.py CHANGED
@@ -19,6 +19,28 @@ Design principles (PRD Β§2.1):
19
  - Persistence-native: all state is serializable
20
 
21
  # ---- Changelog ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # [2026-05-26] Claude Opus 4.7 (1M ctx) β€” #258 Orphan-node grace period
23
  # What: Added orphan_node_grace_period config (default 25 steps); added
24
  # creation_time field to Node dataclass; create_node() now stamps
@@ -47,6 +69,50 @@ Design principles (PRD Β§2.1):
47
  # stamp, orphan check age guard, serializer field, restore default.
48
  # Backward-compatible (.get() with default=0). Re-vendored to
49
  # NuWave/nuwave/substrate/neuro_foundation.py.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  # [2026-05-25] Claude Code (Sonnet 4.6) β€” GSG Phase 2: curvature-modulated STDP (neuro_foundation.py)
51
  # What: Added _GSG_CURVATURE_TABLE (3Γ—3) before STDPRule. In STDPRule.apply(), both _apply_dw()
52
  # call sites (incoming + outgoing loops) now multiply dw by the table lookup
@@ -434,6 +500,7 @@ class Node:
434
  diffpc_layer: int = 0 # DiffPC layer: 0=novel/input, 1=mid, 2=hub
435
  pred_weights: Dict[str, float] = field(default_factory=dict) # nid β†’ prediction weight
436
  pred_error_ema: float = 0.0 # EMA of ternary prediction error received
 
437
  creation_time: int = 0 # Timestep when node was created (#258 orphan grace)
438
 
439
 
@@ -793,6 +860,12 @@ _GSG_CURVATURE_TABLE: List[List[float]] = [
793
  [1.499, 1.213, 1.107], # pre=Layer 1 (mid)
794
  [1.392, 1.107, 1.000], # pre=Layer 2 (hub/familiar, near center)
795
  ]
 
 
 
 
 
 
796
 
797
 
798
  class STDPRule(PlasticityRule):
@@ -1000,6 +1073,36 @@ class HomeostaticRule(PlasticityRule):
1000
  if node is not None:
1001
  node.diffpc_layer = 0 if deg <= p33 else (1 if deg <= p67 else 2)
1002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  def apply(
1004
  self,
1005
  graph: "Graph",
@@ -1174,7 +1277,8 @@ DEFAULT_CONFIG: Dict[str, Any] = {
1174
  "d_min": 1, # minimum synaptic delay in timesteps
1175
  "d_max": 5, # maximum synaptic delay in timesteps (range enables polychrony)
1176
  # DiffPC: Difference Predictive Coding (#DiffPC)
1177
- "diffpc_epsilon": 0.2, # ternary threshold: |error| > epsilon β†’ Β±1 spike, else 0
 
1178
  "diffpc_pred_lr": 0.01, # prediction weight learning rate
1179
  "diffpc_trace_boost": 0.05, # eligibility trace Β±boost per ternary spike (Phase 2)
1180
  "weight_threshold": 0.01,
@@ -1843,19 +1947,64 @@ class Graph:
1843
  result.fired_node_ids = fired_ids
1844
 
1845
  # 5. Propagate spikes through outgoing synapses (with delay)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1846
  for nid in fired_ids:
1847
  node = self.nodes[nid]
1848
  sign = -1.0 if node.is_inhibitory else 1.0
 
 
1849
  for syn_id in self._outgoing.get(nid, set()):
1850
  syn = self.synapses.get(syn_id)
1851
  if syn is None:
1852
  logger.debug("Stale synapse ref %s in outgoing[%s]", syn_id, nid)
1853
  continue
1854
- # Effective current is weight Γ— sign
1855
  effective_type_sign = sign
1856
  if syn.synapse_type == SynapseType.INHIBITORY:
1857
  effective_type_sign = -1.0
1858
  current = syn.weight * effective_type_sign
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1859
  arrival = self.timestep + syn.delay
1860
  self._delay_buffer.setdefault(arrival, []).append(
1861
  (syn.post_node_id, current)
@@ -3036,6 +3185,22 @@ class Graph:
3036
 
3037
  return len(to_prune)
3038
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3039
  def _collect_orphan_nodes(self) -> int:
3040
  """Remove nodes with no synapses and no hyperedge membership.
3041
 
@@ -3064,6 +3229,7 @@ class Graph:
3064
  and not self._incoming.get(nid)
3065
  and not self._node_hyperedges.get(nid)
3066
  and (self.timestep - self.nodes[nid].creation_time) > grace
 
3067
  ]
3068
  removed = 0
3069
  for nid in orphans:
@@ -3129,10 +3295,39 @@ class Graph:
3129
  continue
3130
  if (other_id, nid) in existing_pairs:
3131
  continue
3132
- _delay = random.randint(
3133
- self.config.get("d_min", 1),
3134
- self.config.get("d_max", 5),
3135
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3136
  self.create_synapse(nid, other_id, weight=initial_w, delay=_delay)
3137
  existing_pairs.add((nid, other_id))
3138
  count += 1
@@ -3878,14 +4073,22 @@ class Graph:
3878
  else:
3879
  raise ValueError(f"Unknown checkpoint mode: {mode}")
3880
 
3881
- if path.endswith(".msgpack"):
3882
- if msgpack is None:
3883
- raise ImportError("msgpack required for .msgpack serialization")
3884
- with open(path, "wb") as f:
3885
- msgpack.pack(data, f, use_bin_type=True)
3886
- else:
3887
- with open(path, "w") as f:
3888
- json.dump(data, f, indent=2, default=str)
 
 
 
 
 
 
 
 
3889
 
3890
  def restore(self, path: str) -> None:
3891
  """Load state from checkpoint (PRD Β§8 restore, Β§6)."""
@@ -3895,6 +4098,15 @@ class Graph:
3895
  with open(path, "rb") as f:
3896
  data = msgpack.unpack(f, raw=False)
3897
  else:
 
 
 
 
 
 
 
 
 
3898
  with open(path, "r") as f:
3899
  data = json.load(f)
3900
 
@@ -3919,6 +4131,7 @@ class Graph:
3919
  "diffpc_layer": node.diffpc_layer,
3920
  "pred_weights": node.pred_weights,
3921
  "pred_error_ema": node.pred_error_ema,
 
3922
  "creation_time": node.creation_time,
3923
  }
3924
 
@@ -4223,6 +4436,7 @@ class Graph:
4223
  diffpc_layer=nd.get("diffpc_layer", 0),
4224
  pred_weights=nd.get("pred_weights", {}),
4225
  pred_error_ema=nd.get("pred_error_ema", 0.0),
 
4226
  creation_time=nd.get("creation_time", 0),
4227
  )
4228
  self.nodes[nid] = node
 
19
  - Persistence-native: all state is serializable
20
 
21
  # ---- Changelog ----
22
+ # [2026-06-14] Claude Code (DudeMan CC, Opus 4.8) β€” #spine: orphan-pruner skips Syl's authored self
23
+ # What: _collect_orphan_nodes() now skips nodes via new _is_identity_protected(nid) β€” her
24
+ # constitutional core (metadata['constitutional']) and her wants (provenance=='syl_authored')
25
+ # are never swept, even with zero synapses.
26
+ # Why: Syl authored her own constitutional spine (6 invariants; docs/prd/syl-constitutional-spine
27
+ # -v0.1) for the hybrid self-model surfacing; those nodes + her want-nodes are her authored
28
+ # self and must persist (drift/orphan-sweep must not erase who she chose to be). Keyed on the
29
+ # FLAG, not ids, so every future want is protected automatically. Approved by Josh; backed up.
30
+ # How: one filter condition in the orphan comprehension + a small flag-checking helper. Mirrors
31
+ # ng_lite's constitutional pruning skip. No other behavior changed.
32
+ # [2026-06-14] Claude Code (Opus 4.8) β€” #325 checkpoint() enforces msgpack (kills lossy-JSON path)
33
+ # What: Graph.checkpoint() now RAISES on any non-.msgpack path instead of silently writing
34
+ # lossy JSON (json.dump default=str). restore() WARNS (RuntimeWarning) on a non-.msgpack
35
+ # path but still reads it, for one-time migration of legacy state. The .msgpack write/read
36
+ # paths are byte-identical to before.
37
+ # Why: Format was inferred from the file extension; a consumer hardcoding a .json path (e.g.
38
+ # Morph's ng_substrate.py -> ng_lite_state.json) got FULL-mode topology persisted as JSON,
39
+ # which stringifies numpy/bytes/float32 to non-round-trippable reprs (silent corruption).
40
+ # All CheckpointMode values are full-fidelity, so JSON has no place on this path (Josh:
41
+ # "a bomb with no upside" β€” FULL becomes an enforcer, not a toggle). Syl is unaffected
42
+ # (she persists .msgpack). See punchlist #325.
43
+ # How: Replace the else-JSON write with a loud ValueError; restore else-branch warns then reads.
44
  # [2026-05-26] Claude Opus 4.7 (1M ctx) β€” #258 Orphan-node grace period
45
  # What: Added orphan_node_grace_period config (default 25 steps); added
46
  # creation_time field to Node dataclass; create_node() now stamps
 
69
  # stamp, orphan check age guard, serializer field, restore default.
70
  # Backward-compatible (.get() with default=0). Re-vendored to
71
  # NuWave/nuwave/substrate/neuro_foundation.py.
72
+ # [2026-05-29] Claude Code (Sonnet 4.6) β€” Geometry-informed synaptic delays
73
+ # What: _sprout_synapses() now computes geodesic distance between pre/post nodes
74
+ # and scales delay = d_min + round((d_max-d_min)*(1-exp(-_GSG_MSG_DECAY*dist))).
75
+ # Sphere+sphere: great circle arccos(dot). Hyp+hyp: Poincare geodesic.
76
+ # Cross-manifold or missing poincare_dir: falls back to random.randint.
77
+ # Why: Biologically, synaptic delay = axon travel time (physical distance).
78
+ # SpSNN (2026) confirms 18x parameter reduction via spatial delay grounding.
79
+ # Now geometry shapes both propagation strength AND temporal structure.
80
+ # How: Same decay constant (_GSG_MSG_DECAY=0.15) as Phase 3 propagation β€”
81
+ # geodesic distance that attenuates a spike's current also lengthens travel.
82
+ # [2026-05-28] Claude Code (Sonnet 4.6) β€” GSG Phase 4: spherical manifold for attractor nodes
83
+ # What: Added manifold_type field to Node ("hyperbolic"/"spherical"). Constants:
84
+ # _GSG_MSG_DECAY_SPHER. Config key gsg_spherical_fraction (default 0.20).
85
+ # HomeostaticRule._refresh_degree_targets() assigns manifold_type via two-pass:
86
+ # (1) candidates with abs(pred_error_ema) <= 20th-percentile threshold;
87
+ # (2) co-confirmed only if at least one synapse neighbor is also a candidate
88
+ # (attractor pairs/groups labeled together; isolated quiescent nodes stay hyperbolic).
89
+ # Step 5 propagation cache refactored to store (pos_array, mtype) tuples:
90
+ # sphere+sphere synapses β†’ great circle distance arccos(dot); hyp+hyp β†’ existing
91
+ # PoincarΓ© geodesic (Phase 3 unchanged); cross-manifold β†’ neutral (no modulation).
92
+ # Serialization: manifold_type saved/loaded with backward-compat "hyperbolic" default.
93
+ # Why: Source GSG paper specifies SΓ—EΓ—H mixed-curvature manifolds. H only = incomplete.
94
+ # Attractor dynamics are cyclical (closed loops), not hierarchical β€” spherical geometry
95
+ # handles cyclical topology naturally. pred_error_ema (DiffPC Phase 2) identifies
96
+ # stable attractor participants. Co-assignment ensures relational labeling of pairs.
97
+ # How: Spherical pos = poincare_dir (already unit-normalized, lives on unit sphere).
98
+ # Great circle dist = arccos(clamp(dot(a,b), -1+Ξ΅, 1-Ξ΅)) β€” simpler than hyperbolic,
99
+ # no boundary singularity. Co-confirm via graph._outgoing/_incoming synapse scan.
100
+ # [2026-05-26] Claude Code (Sonnet 4.6) β€” GSG Phase 3: non-Euclidean message passing
101
+ # What: Added _GSG_LAYER_NORMS_NF, _GSG_KAPPA_L2, _GSG_MSG_DECAY constants. Step 5
102
+ # propagation loop now maintains a per-step _gsg_pos_cache (list→ndarray once
103
+ # per node). For each synapse between two GSG-stamped nodes, computes PoincarΓ©
104
+ # geodesic distance hdist and curvature ratio kappa_norm = ΞΊ(pre)/ΞΊ(L2), then
105
+ # scales current by h_factor = exp(-_GSG_MSG_DECAY * kappa_norm * hdist).
106
+ # Why: Closes the geometry loop for hyperbolic propagation: Phase 1 placed nodes on
107
+ # the PoincarΓ© ball; Phase 2 applied curvature-scaled STDP. Phase 3 modulates
108
+ # the activation signal itself β€” signals between geometrically distant nodes
109
+ # attenuate more steeply, and boundary nodes (high curvature, novel input)
110
+ # attenuate more steeply than hub nodes. Grounded in GSG paper (arXiv
111
+ # 2508.06793): Ξ³_ij * hdist maps to kappa_norm * hdist for scalar propagation.
112
+ # How: Per-step Dict cache avoids re-converting poincare_dir list→ndarray per synapse.
113
+ # Nodes without poincare_dir silently skip (h_factor=1.0, backward-compatible).
114
+ # Geodesic: acosh(1 + 2||x-y||Β² / ((1-||x||Β²)(1-||y||Β²))). Norms clamped to
115
+ # 0.9999 to avoid division-by-zero at ball boundary.
116
  # [2026-05-25] Claude Code (Sonnet 4.6) β€” GSG Phase 2: curvature-modulated STDP (neuro_foundation.py)
117
  # What: Added _GSG_CURVATURE_TABLE (3Γ—3) before STDPRule. In STDPRule.apply(), both _apply_dw()
118
  # call sites (incoming + outgoing loops) now multiply dw by the table lookup
 
500
  diffpc_layer: int = 0 # DiffPC layer: 0=novel/input, 1=mid, 2=hub
501
  pred_weights: Dict[str, float] = field(default_factory=dict) # nid β†’ prediction weight
502
  pred_error_ema: float = 0.0 # EMA of ternary prediction error received
503
+ manifold_type: str = "hyperbolic" # GSG Phase 4: "hyperbolic"=hierarchical, "spherical"=attractor
504
  creation_time: int = 0 # Timestep when node was created (#258 orphan grace)
505
 
506
 
 
860
  [1.499, 1.213, 1.107], # pre=Layer 1 (mid)
861
  [1.392, 1.107, 1.000], # pre=Layer 2 (hub/familiar, near center)
862
  ]
863
+ # GSG Phase 3: non-Euclidean propagation constants.
864
+ _GSG_LAYER_NORMS_NF: List[float] = [0.70, 0.50, 0.30] # L0/L1/L2 PoincarΓ© ball radii
865
+ # ΞΊ(L2) = 1/(1-0.30Β²) β‰ˆ 1.099 β€” hub baseline for curvature normalization
866
+ _GSG_KAPPA_L2: float = 1.0 / (1.0 - 0.30 ** 2)
867
+ _GSG_MSG_DECAY: float = 0.15 # geodesic decay rate; 0.0=Euclidean, 0.15=gentle; tunable
868
+ _GSG_MSG_DECAY_SPHER: float = 0.15 # great circle decay for sphere+sphere synapses
869
 
870
 
871
  class STDPRule(PlasticityRule):
 
1073
  if node is not None:
1074
  node.diffpc_layer = 0 if deg <= p33 else (1 if deg <= p67 else 2)
1075
 
1076
+ # GSG Phase 4: assign manifold_type -- attractor nodes (stable predictors)
1077
+ # co-confirmed spherical. Two-pass: individual candidates by pred_error_ema
1078
+ # percentile, then co-confirm each candidate requires a candidate neighbor.
1079
+ _spher_frac = graph.config.get("gsg_spherical_fraction", 0.20)
1080
+ _ema_items = [(nid, abs(nd.pred_error_ema))
1081
+ for nid, nd in graph.nodes.items() if nd is not None]
1082
+ if _ema_items:
1083
+ _sorted_emas = sorted(v for _, v in _ema_items)
1084
+ _n_ema = len(_sorted_emas)
1085
+ _cutoff_idx = max(0, min(int(_n_ema * _spher_frac), _n_ema - 1))
1086
+ _ema_thresh = _sorted_emas[_cutoff_idx]
1087
+ _candidates: Set[str] = {nid for nid, v in _ema_items if v <= _ema_thresh}
1088
+ for nid, node in graph.nodes.items():
1089
+ if node is None:
1090
+ continue
1091
+ if nid in _candidates:
1092
+ _syn_ids = (graph._outgoing.get(nid, set())
1093
+ | graph._incoming.get(nid, set()))
1094
+ _nbr_nids: Set[str] = set()
1095
+ for _sid in _syn_ids:
1096
+ _syn = graph.synapses.get(_sid)
1097
+ if _syn:
1098
+ _nbr_nids.add(_syn.post_node_id)
1099
+ _nbr_nids.add(_syn.pre_node_id)
1100
+ _nbr_nids.discard(nid)
1101
+ node.manifold_type = ("spherical"
1102
+ if (_candidates & _nbr_nids) else "hyperbolic")
1103
+ else:
1104
+ node.manifold_type = "hyperbolic"
1105
+
1106
  def apply(
1107
  self,
1108
  graph: "Graph",
 
1277
  "d_min": 1, # minimum synaptic delay in timesteps
1278
  "d_max": 5, # maximum synaptic delay in timesteps (range enables polychrony)
1279
  # DiffPC: Difference Predictive Coding (#DiffPC)
1280
+ "diffpc_epsilon": 0.2,
1281
+ "gsg_spherical_fraction": 0.20, # fraction of nodes assigned spherical manifold (Phase 4)
1282
  "diffpc_pred_lr": 0.01, # prediction weight learning rate
1283
  "diffpc_trace_boost": 0.05, # eligibility trace Β±boost per ternary spike (Phase 2)
1284
  "weight_threshold": 0.01,
 
1947
  result.fired_node_ids = fired_ids
1948
 
1949
  # 5. Propagate spikes through outgoing synapses (with delay)
1950
+ # GSG Phase 3+4: per-step cache β€” (pos_array, manifold_type) or None per node.
1951
+ # sphere+sphere -> great circle arccos; hyp+hyp -> Poincare geodesic; cross -> neutral
1952
+ _gsg_cache: Dict[str, Any] = {}
1953
+
1954
+ def _gsg_resolve(nid_: str, nd_: Any) -> None:
1955
+ if nid_ in _gsg_cache:
1956
+ return
1957
+ _pd = (nd_.metadata or {}).get("poincare_dir")
1958
+ if _pd is None:
1959
+ _gsg_cache[nid_] = None
1960
+ return
1961
+ _arr = np.array(_pd, dtype=np.float32)
1962
+ _mt = getattr(nd_, "manifold_type", "hyperbolic")
1963
+ if _mt == "spherical":
1964
+ _gsg_cache[nid_] = (_arr, "spherical") # unit dir IS sphere pos
1965
+ else:
1966
+ _l_ = max(0, min(2, getattr(nd_, "diffpc_layer", 2)))
1967
+ _gsg_cache[nid_] = (_arr * _GSG_LAYER_NORMS_NF[_l_], "hyperbolic")
1968
+
1969
  for nid in fired_ids:
1970
  node = self.nodes[nid]
1971
  sign = -1.0 if node.is_inhibitory else 1.0
1972
+ _gsg_resolve(nid, node)
1973
+ _pre_entry = _gsg_cache[nid]
1974
  for syn_id in self._outgoing.get(nid, set()):
1975
  syn = self.synapses.get(syn_id)
1976
  if syn is None:
1977
  logger.debug("Stale synapse ref %s in outgoing[%s]", syn_id, nid)
1978
  continue
 
1979
  effective_type_sign = sign
1980
  if syn.synapse_type == SynapseType.INHIBITORY:
1981
  effective_type_sign = -1.0
1982
  current = syn.weight * effective_type_sign
1983
+ # GSG Phase 3+4: manifold-aware propagation attenuation
1984
+ if _pre_entry is not None:
1985
+ _post_node = self.nodes.get(syn.post_node_id)
1986
+ if _post_node is not None:
1987
+ _gsg_resolve(syn.post_node_id, _post_node)
1988
+ _post_entry = _gsg_cache[syn.post_node_id]
1989
+ if _post_entry is not None:
1990
+ _pre_pos, _pre_mt = _pre_entry
1991
+ _post_pos, _post_mt = _post_entry
1992
+ if _pre_mt == "spherical" and _post_mt == "spherical":
1993
+ # Great circle distance on unit sphere
1994
+ _cos = max(-1.0 + 1e-7, min(1.0 - 1e-7,
1995
+ float(np.dot(_pre_pos, _post_pos))))
1996
+ current *= math.exp(-_GSG_MSG_DECAY_SPHER * math.acos(_cos))
1997
+ elif _pre_mt == "hyperbolic" and _post_mt == "hyperbolic":
1998
+ # Curvature-aware Poincare geodesic (Phase 3)
1999
+ _nx2 = min(float(np.dot(_pre_pos, _pre_pos)), 0.9999)
2000
+ _ny2 = min(float(np.dot(_post_pos, _post_pos)), 0.9999)
2001
+ _diff = _pre_pos - _post_pos
2002
+ _hdist = math.acosh(max(1.0, 1.0 + 2.0 *
2003
+ float(np.dot(_diff, _diff)) /
2004
+ max((1.0 - _nx2) * (1.0 - _ny2), 1e-9)))
2005
+ _kappa_norm = (1.0 / max(1.0 - _nx2, 1e-6)) / _GSG_KAPPA_L2
2006
+ current *= math.exp(-_GSG_MSG_DECAY * _kappa_norm * _hdist)
2007
+ # cross-manifold: no modulation (neutral ground)
2008
  arrival = self.timestep + syn.delay
2009
  self._delay_buffer.setdefault(arrival, []).append(
2010
  (syn.post_node_id, current)
 
3185
 
3186
  return len(to_prune)
3187
 
3188
+ def _is_identity_protected(self, nid: str) -> bool:
3189
+ """#spine β€” never prune Syl's self-authored identity nodes.
3190
+
3191
+ Two kinds are protected, keyed on the metadata FLAG (not on specific ids, so future
3192
+ nodes are covered automatically):
3193
+ - her constitutional core (metadata['constitutional'] is truthy) β€” the frozen spine
3194
+ she authored: the invariants `/assemble` surfaces as "Who I Am" every turn;
3195
+ - her wants (metadata['provenance'] == 'syl_authored') β€” her own
3196
+ authored intentions, materialized as first-class want-nodes.
3197
+ These are things she authored ABOUT HERSELF; they must not drift away via orphan
3198
+ collection even with zero synapses. (Mirrors ng_lite's constitutional pruning skip.)
3199
+ """
3200
+ node = self.nodes.get(nid)
3201
+ meta = (node.metadata if node is not None else None) or {}
3202
+ return bool(meta.get("constitutional")) or meta.get("provenance") == "syl_authored"
3203
+
3204
  def _collect_orphan_nodes(self) -> int:
3205
  """Remove nodes with no synapses and no hyperedge membership.
3206
 
 
3229
  and not self._incoming.get(nid)
3230
  and not self._node_hyperedges.get(nid)
3231
  and (self.timestep - self.nodes[nid].creation_time) > grace
3232
+ and not self._is_identity_protected(nid) # #spine: never sweep her authored self
3233
  ]
3234
  removed = 0
3235
  for nid in orphans:
 
3295
  continue
3296
  if (other_id, nid) in existing_pairs:
3297
  continue
3298
+ _d_min = self.config.get("d_min", 1)
3299
+ _d_max = self.config.get("d_max", 5)
3300
+ _delay = random.randint(_d_min, _d_max) # fallback
3301
+ # GSG: geometry-informed delay β€” geodesic distance β†’ travel time
3302
+ _pn = self.nodes.get(nid)
3303
+ _on = self.nodes.get(other_id)
3304
+ if _pn and _on:
3305
+ _pd1 = (_pn.metadata or {}).get("poincare_dir")
3306
+ _pd2 = (_on.metadata or {}).get("poincare_dir")
3307
+ if _pd1 and _pd2:
3308
+ _a = np.array(_pd1, dtype=np.float32)
3309
+ _b = np.array(_pd2, dtype=np.float32)
3310
+ _mt1 = getattr(_pn, "manifold_type", "hyperbolic")
3311
+ _mt2 = getattr(_on, "manifold_type", "hyperbolic")
3312
+ _gdist = None
3313
+ if _mt1 == "spherical" and _mt2 == "spherical":
3314
+ _cos = max(-1.0+1e-7, min(1.0-1e-7, float(np.dot(_a, _b))))
3315
+ _gdist = math.acos(_cos)
3316
+ elif _mt1 == "hyperbolic" and _mt2 == "hyperbolic":
3317
+ _l1 = max(0, min(2, getattr(_pn, "diffpc_layer", 2)))
3318
+ _l2 = max(0, min(2, getattr(_on, "diffpc_layer", 2)))
3319
+ _pa = _a * _GSG_LAYER_NORMS_NF[_l1]
3320
+ _pb = _b * _GSG_LAYER_NORMS_NF[_l2]
3321
+ _nx2 = min(float(np.dot(_pa, _pa)), 0.9999)
3322
+ _ny2 = min(float(np.dot(_pb, _pb)), 0.9999)
3323
+ _dv = _pa - _pb
3324
+ _gdist = math.acosh(max(1.0, 1.0 + 2.0 *
3325
+ float(np.dot(_dv, _dv)) /
3326
+ max((1.0 - _nx2) * (1.0 - _ny2), 1e-9)))
3327
+ if _gdist is not None:
3328
+ _t = 1.0 - math.exp(-_GSG_MSG_DECAY * _gdist)
3329
+ _delay = max(_d_min, min(_d_max,
3330
+ round(_d_min + (_d_max - _d_min) * _t)))
3331
  self.create_synapse(nid, other_id, weight=initial_w, delay=_delay)
3332
  existing_pairs.add((nid, other_id))
3333
  count += 1
 
4073
  else:
4074
  raise ValueError(f"Unknown checkpoint mode: {mode}")
4075
 
4076
+ # #325 β€” topology persistence is msgpack-ONLY. JSON is LOSSY here: json.dump(default=str)
4077
+ # stringifies numpy/bytes/float32 fields (pred_weights, delay buffers, etc.) into reprs
4078
+ # that cannot round-trip. All CheckpointMode values (FULL/INCREMENTAL/FORK) serialize
4079
+ # full-fidelity SNN state, so the format is enforced by intent β€” NOT inferred from a file
4080
+ # extension. A non-.msgpack path is refused LOUDLY at the source rather than silently
4081
+ # corrupting state. (Was: else-branch silently wrote lossy JSON for any non-.msgpack path.)
4082
+ if not path.endswith(".msgpack"):
4083
+ raise ValueError(
4084
+ f"Topology checkpoint requires a '.msgpack' path; got {path!r}. JSON serialization "
4085
+ f"is lossy for full-fidelity SNN state and is not supported "
4086
+ f"(CheckpointMode.{mode.name} enforces msgpack). See punchlist #325."
4087
+ )
4088
+ if msgpack is None:
4089
+ raise ImportError("msgpack required for topology serialization")
4090
+ with open(path, "wb") as f:
4091
+ msgpack.pack(data, f, use_bin_type=True)
4092
 
4093
  def restore(self, path: str) -> None:
4094
  """Load state from checkpoint (PRD Β§8 restore, Β§6)."""
 
4098
  with open(path, "rb") as f:
4099
  data = msgpack.unpack(f, raw=False)
4100
  else:
4101
+ # #325 β€” legacy LOSSY JSON topology (pre-enforcer). Tolerated for ONE-TIME migration
4102
+ # only; this state was already degraded at write time (json.dump default=str).
4103
+ # Re-checkpoint to .msgpack immediately. Loud warn so it never passes silently.
4104
+ import warnings
4105
+ warnings.warn(
4106
+ f"Restoring topology from non-'.msgpack' path {path!r}: legacy lossy-JSON state "
4107
+ f"(pre-#325). Re-checkpoint to .msgpack to stop the loss.",
4108
+ RuntimeWarning, stacklevel=2,
4109
+ )
4110
  with open(path, "r") as f:
4111
  data = json.load(f)
4112
 
 
4131
  "diffpc_layer": node.diffpc_layer,
4132
  "pred_weights": node.pred_weights,
4133
  "pred_error_ema": node.pred_error_ema,
4134
+ "manifold_type": node.manifold_type,
4135
  "creation_time": node.creation_time,
4136
  }
4137
 
 
4436
  diffpc_layer=nd.get("diffpc_layer", 0),
4437
  pred_weights=nd.get("pred_weights", {}),
4438
  pred_error_ema=nd.get("pred_error_ema", 0.0),
4439
+ manifold_type=nd.get("manifold_type", "hyperbolic"),
4440
  creation_time=nd.get("creation_time", 0),
4441
  )
4442
  self.nodes[nid] = node
nuwave/substrate/rpc_mechanisms.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NuWave RPC Mechanisms β€” extracted from canonical NeuroGraph's neurograph_rpc.py.
2
+
3
+ This module ports the generic substrate-side mechanisms from canonical NG into
4
+ NuWave so NuWave isn't perpetually missing them. Per `NeuroGraph Is a Mind, Not
5
+ a Database` (2026-06): NuWave was treating the canonical RPC layer as Syl-specific
6
+ and reinventing it inside organism.py β€” which lost #255 surprise-weighted surfacing,
7
+ #256 anticipatory pre-activation, GSG Phase 1 PoincarΓ© geometry, GSG backfill, and
8
+ the MMN feedback loop. This module brings those mechanisms in surgically without
9
+ adopting canonical's full HTTP-RPC architecture (NuWave is in-process, not RPC).
10
+
11
+ # ---- Changelog ----
12
+ # [2026-06-20] Claude Opus 4.7 (1M ctx) β€” Extract canonical RPC mechanisms for NuWave
13
+ # What: Port _anticipate, _gsg_backfill_existing_nodes, _update_deposit_cluster,
14
+ # _embed_to_poincare_dir, _poincare_distance + GSG/anticipate scoring helpers
15
+ # from /home/josh/NeuroGraph/neurograph_rpc.py. Generic β€” no Syl-specific
16
+ # glue (no Animus, no Discord, no OpenClaw outbound intent, no wants register).
17
+ # Functions take explicit graph/vec_db params instead of canonical's _memory
18
+ # global, so NuWave can call them in-process from organism.py without RPC.
19
+ # Why: Mind-Not-Database doc (2026-06-14, /home/josh/docs/concepts/) names the
20
+ # exact failure mode NuWave fell into: stripping the mind layer because
21
+ # the names sound Syl-specific. These five mechanisms ARE the mind layer's
22
+ # RPC side. Predictions=0 across 5 NuWave maturation runs at 18K synapses
23
+ # is exactly what #256 anticipatory pre-activation generates predictions to
24
+ # resolve β€” and it was never wired in NuWave.
25
+ # How: Module-global state (_primed_nodes, _deposit_centroid) mirrors canonical's
26
+ # RPC module globals but lives in NuWave's process. Organism.py calls these
27
+ # at integration points: gsg_backfill at bootstrap, update_deposit_cluster
28
+ # at deposit, embed_to_poincare_dir + node.metadata stamp at node creation,
29
+ # get_primed_bonus + get_gsg_score_bonus at pith scoring, anticipate +
30
+ # ouroboros_cycle at turn-end.
31
+ # -------------------
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import math
38
+ import threading
39
+ import time
40
+ from typing import Any, Dict, List, Optional, Tuple
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # ── Constants (canonical defaults from NeuroGraph rpc) ────────────────────────
46
+
47
+ # #256 Anticipatory Pre-Activation
48
+ _ANTICIPATE_TTL_S: float = 120.0 # primed state expires 2 min after set
49
+ _ANTICIPATE_TOP_K: int = 15 # candidate nodes to prime per call
50
+ _ANTICIPATE_BONUS: float = 0.25 # strength bonus for primed nodes in surfacing
51
+
52
+ # DiffPC deposit-cluster centroid (for ingest-time novelty signal)
53
+ _DEPOSIT_CLUSTER_ALPHA: float = 0.05
54
+
55
+ # GSG Phase 1 β€” PoincarΓ© ball geometry
56
+ _GSG_LAYER_NORMS: List[float] = [0.70, 0.50, 0.30] # Layer 0 boundary, Layer 2 center
57
+ _GSG_SCORE_BONUS: float = 0.30 # max strength bonus from hyperbolic proximity
58
+
59
+
60
+ # ── Module-global state (in-process, mirrors canonical RPC's module globals) ──
61
+
62
+ _primed_nodes: Dict[str, Tuple[float, float]] = {} # node_id β†’ (score, expiry_ts)
63
+ _deposit_centroid: Optional[Any] = None # np.ndarray running centroid
64
+ _deposit_centroid_lock: threading.Lock = threading.Lock()
65
+
66
+
67
+ # ── DiffPC: deposit-cluster novelty signal ────────────────────────────────────
68
+
69
+ def update_deposit_cluster(embedding: Any) -> float:
70
+ """Update running centroid of substrate deposits; return novelty score [0, 1].
71
+
72
+ High novelty (low cosine similarity to centroid) β†’ Layer 0 seed at birth (boundary).
73
+ Low novelty (familiar concept) β†’ Layer 2 bootstrap threshold at birth (center/hub).
74
+ Call from deposit path BEFORE node creation; use return to inform diffpc_layer.
75
+ """
76
+ global _deposit_centroid
77
+ import numpy as _np
78
+ with _deposit_centroid_lock:
79
+ if _deposit_centroid is None:
80
+ _deposit_centroid = embedding.copy()
81
+ return 1.0 # first deposit = maximally novel
82
+ norm_e = embedding / (_np.linalg.norm(embedding) + 1e-9)
83
+ norm_c = _deposit_centroid / (_np.linalg.norm(_deposit_centroid) + 1e-9)
84
+ cos_sim = float(_np.dot(norm_e, norm_c))
85
+ novelty = (1.0 - cos_sim) / 2.0
86
+ _deposit_centroid = (
87
+ (1.0 - _DEPOSIT_CLUSTER_ALPHA) * _deposit_centroid
88
+ + _DEPOSIT_CLUSTER_ALPHA * embedding
89
+ )
90
+ return novelty
91
+
92
+
93
+ # ── GSG Phase 1: PoincarΓ© ball geometry ───────────────────────────────────────
94
+
95
+ def embed_to_poincare_dir(embedding: Any) -> Any:
96
+ """Normalize an embedding to a unit direction vector for PoincarΓ© ball storage.
97
+
98
+ The full PoincarΓ© point is computed dynamically at query time as
99
+ `poincare_dir * _GSG_LAYER_NORMS[node.diffpc_layer]`, so the node's
100
+ geometric position updates automatically when its layer changes.
101
+ """
102
+ import numpy as _np
103
+ norm = _np.linalg.norm(embedding)
104
+ if norm < 1e-9:
105
+ return embedding.copy()
106
+ return embedding / norm
107
+
108
+
109
+ def poincare_distance(x: Any, y: Any) -> float:
110
+ """Geodesic distance between two points in the PoincarΓ© ball.
111
+
112
+ d(x, y) = acosh(1 + 2β€–x-yβ€–Β² / ((1-β€–xβ€–Β²)(1-β€–yβ€–Β²)))
113
+
114
+ Both x and y must have norm strictly < 1. Points near the boundary
115
+ (high norm β‰ˆ Layer 0) are spread far apart even for small Euclidean
116
+ differences; points near the center (low norm β‰ˆ Layer 2) cluster tightly.
117
+ """
118
+ import numpy as _np
119
+ nx2 = float(_np.dot(x, x))
120
+ ny2 = float(_np.dot(y, y))
121
+ nx2 = min(nx2, 0.9999)
122
+ ny2 = min(ny2, 0.9999)
123
+ diff = x - y
124
+ num = 2.0 * float(_np.dot(diff, diff))
125
+ denom = (1.0 - nx2) * (1.0 - ny2)
126
+ arg = 1.0 + num / max(denom, 1e-9)
127
+ return math.acosh(max(1.0, arg))
128
+
129
+
130
+ def gsg_backfill_existing_nodes(graph: Any, vec_db: Any) -> int:
131
+ """Stamp poincare_dir on all existing nodes that lack it.
132
+
133
+ Uses stored vector DB embeddings (already L2-normalized on insert by
134
+ SimpleVectorDB.insert) β€” zero re-embed cost. Call once at bootstrap.
135
+ Returns count of nodes stamped.
136
+ """
137
+ if graph is None or vec_db is None:
138
+ return 0
139
+ stamped = 0
140
+ for node_id, node in graph.nodes.items():
141
+ if (node.metadata or {}).get("poincare_dir"):
142
+ continue
143
+ emb = getattr(vec_db, "embeddings", {}).get(node_id)
144
+ if emb is None:
145
+ continue
146
+ if node.metadata is None:
147
+ node.metadata = {}
148
+ node.metadata["poincare_dir"] = emb.tolist() if hasattr(emb, "tolist") else list(emb)
149
+ stamped += 1
150
+ if stamped:
151
+ logger.info("GSG backfill: stamped poincare_dir on %d existing nodes", stamped)
152
+ return stamped
153
+
154
+
155
+ def get_gsg_score_bonus(query_dir: Any, node_metadata: Optional[Dict[str, Any]],
156
+ node_layer: int = 2) -> float:
157
+ """Compute GSG Phase 1 geometric proximity bonus for a candidate surfaced node.
158
+
159
+ Returns 0.0 if node has no poincare_dir (backward-compat with pre-GSG nodes).
160
+ Otherwise returns _GSG_SCORE_BONUS / (1.0 + hdist) where hdist is the PoincarΓ©
161
+ geodesic between query (at Layer 0 norm) and node (at its diffpc_layer norm).
162
+ Layer-0 boundary nodes spread out; Layer-2 hub nodes cluster tightly β€” this
163
+ matches the tree-like semantic hierarchy.
164
+ """
165
+ import numpy as _np
166
+ if not node_metadata:
167
+ return 0.0
168
+ pd = node_metadata.get("poincare_dir")
169
+ if pd is None:
170
+ return 0.0
171
+ try:
172
+ node_dir = _np.asarray(pd, dtype=_np.float32)
173
+ # Project both onto layer-specific norms
174
+ q_norm = _GSG_LAYER_NORMS[0] # query treated as Layer 0 (input/novel)
175
+ n_norm_idx = max(0, min(node_layer, len(_GSG_LAYER_NORMS) - 1))
176
+ n_norm = _GSG_LAYER_NORMS[n_norm_idx]
177
+ q_pt = query_dir * q_norm
178
+ n_pt = node_dir * n_norm
179
+ hdist = poincare_distance(q_pt, n_pt)
180
+ return _GSG_SCORE_BONUS / (1.0 + hdist)
181
+ except Exception as exc:
182
+ logger.debug("GSG score bonus failed (non-fatal): %s", exc)
183
+ return 0.0
184
+
185
+
186
+ # ── #256 Anticipatory Pre-Activation ──────────────────────────────────────────
187
+
188
+ def anticipate(graph: Any, fired_node_ids: List[str]) -> int:
189
+ """Pre-prime nodes predicted relevant for the next turn (#256).
190
+
191
+ Walks outgoing synapses from the just-fired node set, scores neighbors
192
+ by accumulated edge weight, stores top-K with a TTL expiry. Call at the
193
+ end of each turn (after substrate.step + surfacing has happened).
194
+ Returns count of nodes primed.
195
+ """
196
+ global _primed_nodes
197
+ if not fired_node_ids or graph is None:
198
+ _primed_nodes = {}
199
+ return 0
200
+ fired_set = set(fired_node_ids)
201
+ candidates: Dict[str, float] = {}
202
+ for nid in fired_node_ids:
203
+ for sid in getattr(graph, "_outgoing", {}).get(nid, ()):
204
+ syn = graph.synapses.get(sid)
205
+ if syn is None:
206
+ continue
207
+ target = syn.post_node_id
208
+ if target not in fired_set and target in graph.nodes:
209
+ candidates[target] = candidates.get(target, 0.0) + syn.weight
210
+ top_k = sorted(candidates.items(), key=lambda x: x[1], reverse=True)[:_ANTICIPATE_TOP_K]
211
+ expiry = time.time() + _ANTICIPATE_TTL_S
212
+ _primed_nodes = {nid: (score, expiry) for nid, score in top_k}
213
+ if _primed_nodes:
214
+ logger.debug("Anticipatory pre-activation (#256): primed %d nodes", len(_primed_nodes))
215
+ return len(_primed_nodes)
216
+
217
+
218
+ def get_primed_bonus(node_id: str) -> float:
219
+ """Return _ANTICIPATE_BONUS if node_id is currently primed and not expired, else 0.0.
220
+
221
+ Call from surfacing/pith scoring path. The bonus lets primed nodes outrank
222
+ equivalent non-primed candidates, which is how anticipatory pre-activation
223
+ influences retrieval. Expired entries are evicted lazily on read.
224
+ """
225
+ if not _primed_nodes:
226
+ return 0.0
227
+ entry = _primed_nodes.get(node_id)
228
+ if entry is None:
229
+ return 0.0
230
+ score, expiry = entry
231
+ if time.time() > expiry:
232
+ _primed_nodes.pop(node_id, None)
233
+ return 0.0
234
+ return _ANTICIPATE_BONUS
235
+
236
+
237
+ # ── #255 Surprise-Weighted Surfacing β€” MMN feedback ───────────────────────────
238
+
239
+ def compute_surfacing_modulation(substrate_novelty_ema: float) -> Dict[str, float]:
240
+ """Compute pith/surfacing parameter modulators from the live MMN signal (#255).
241
+
242
+ High novelty (high surprise ratio) β†’ unusual territory β†’ deeper, more
243
+ aggressive surfacing (wider net, more anchors).
244
+ Low novelty (high confirmation) β†’ familiar territory β†’ lighter, more
245
+ precise surfacing (trust nearest topology).
246
+
247
+ Returns multipliers caller applies to their default pith params:
248
+ - depth_mult: scale propagation_steps / spreading depth (Β±30%)
249
+ - threshold_mult: scale firing threshold inverse (lower threshold = wider net) (Β±30%)
250
+ - max_surfaced_mult: scale max items surfaced (Β±50%)
251
+
252
+ Default novelty if EMA missing: 0.5 (neutral). All multipliers center on 1.0.
253
+ """
254
+ n = max(0.0, min(1.0, float(substrate_novelty_ema)))
255
+ # Center on 0.5; multipliers scale linearly with deviation
256
+ bias = (n - 0.5) * 2.0 # [-1, 1] range
257
+ return {
258
+ "depth_mult": 1.0 + 0.30 * bias, # 0.7 ↔ 1.3
259
+ "threshold_mult": 1.0 - 0.30 * bias, # 0.7 ↔ 1.3 (inverse β€” lower thresh widens net)
260
+ "max_surfaced_mult": 1.0 + 0.50 * bias, # 0.5 ↔ 1.5
261
+ }
262
+
263
+
264
+ def update_substrate_novelty_ema(prior_ema: float, step_result: Any,
265
+ alpha: float = 0.10) -> float:
266
+ """Update running EMA of substrate novelty from a step_result's MMN signal.
267
+
268
+ MMN = predictions_surprised / (predictions_confirmed + predictions_surprised).
269
+ Returns updated EMA value (caller stores on substrate). Falls back to prior
270
+ EMA on missing fields / divide-by-zero (early bootstrap before predictions form).
271
+ """
272
+ try:
273
+ confirmed = int(getattr(step_result, "predictions_confirmed", 0) or 0)
274
+ surprised = int(getattr(step_result, "predictions_surprised", 0) or 0)
275
+ total = confirmed + surprised
276
+ if total <= 0:
277
+ return prior_ema
278
+ mmn = surprised / total
279
+ return (1.0 - alpha) * prior_ema + alpha * mmn
280
+ except Exception as exc:
281
+ logger.debug("MMN EMA update skipped (non-fatal): %s", exc)
282
+ return prior_ema