topologist 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topologist/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Topologist: hyperdimensional neuro-symbolic topology engine."""
2
+
3
+ from topologist.config import TopologistConfig
4
+ from topologist.engine import Topologist
5
+ from topologist.hdc import HyperVectorSpace
6
+ from topologist.models import EdgeRecord, NodeRecord, ReasoningRule
7
+
8
+ __all__ = [
9
+ "Topologist",
10
+ "TopologistConfig",
11
+ "HyperVectorSpace",
12
+ "NodeRecord",
13
+ "EdgeRecord",
14
+ "ReasoningRule",
15
+ ]
16
+
17
+ __version__ = "0.1.0"
topologist/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from topologist.engine import Topologist
11
+ from topologist.models import ReasoningRule
12
+ from topologist.visualization import export_mermaid
13
+
14
+ app = typer.Typer(help="Topologist CLI")
15
+ console = Console()
16
+
17
+
18
+ @app.command()
19
+ def demo(output: Path = Path("topology.json")) -> None:
20
+ """Create a demo hyperdimensional neuro-symbolic topology."""
21
+ topo = Topologist()
22
+ topo.add_node("Neuron", "biological_system")
23
+ topo.add_node("Synapse", "biological_system")
24
+ topo.add_node("Memory", "cognitive_system")
25
+ topo.add_node("HDC", "computational_model")
26
+ topo.add_node("KnowledgeGraph", "symbolic_model")
27
+ topo.add_node("Reasoning", "cognitive_process")
28
+ topo.add_edge("Neuron", "connects_to", "Synapse", confidence=0.95)
29
+ topo.add_edge("Synapse", "supports", "Memory", confidence=0.90)
30
+ topo.add_edge("HDC", "models", "Memory", confidence=0.85)
31
+ topo.add_edge("KnowledgeGraph", "supports", "Reasoning", confidence=0.92)
32
+ topo.add_edge("HDC", "enhances", "KnowledgeGraph", confidence=0.80)
33
+ topo.add_edge("Reasoning", "uses", "Memory", confidence=0.88)
34
+ topo.apply_rule(
35
+ ReasoningRule(
36
+ relation_a="connects_to",
37
+ relation_b="supports",
38
+ inferred_relation="indirectly_supports",
39
+ min_confidence=0.5,
40
+ )
41
+ )
42
+ topo.update_global_state(take_snapshot=True)
43
+ topo.save(output)
44
+ console.print(f"[green]Saved demo topology to {output}[/green]")
45
+
46
+
47
+ @app.command()
48
+ def inspect(path: Path) -> None:
49
+ """Print summary metrics for a saved topology."""
50
+ topo = Topologist.load(path)
51
+ table = Table(title="Topologist Summary")
52
+ table.add_column("Metric")
53
+ table.add_column("Value")
54
+ table.add_row("Nodes", str(topo.graph.number_of_nodes()))
55
+ table.add_row("Edges", str(topo.graph.number_of_edges()))
56
+ table.add_row("Communities", json.dumps(topo.communities()))
57
+ table.add_row("Drift", f"{topo.topology_drift():.4f}")
58
+ console.print(table)
59
+
60
+
61
+ @app.command()
62
+ def mermaid(path: Path, output: Path = Path("topology.mmd")) -> None:
63
+ """Export a saved topology as Mermaid."""
64
+ topo = Topologist.load(path)
65
+ export_mermaid(topo, output)
66
+ console.print(f"[green]Saved Mermaid graph to {output}[/green]")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ app()
topologist/config.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field, ConfigDict
4
+
5
+
6
+ class TopologistConfig(BaseModel):
7
+ """Runtime configuration for the topology engine."""
8
+
9
+ model_config = ConfigDict(frozen=True)
10
+
11
+ dim: int = Field(default=10_000, ge=128, description="Hypervector dimensionality.")
12
+ seed: int = Field(default=42, description="Random seed for reproducible item memory.")
13
+ similarity_floor: float = Field(default=-1.0, ge=-1.0, le=1.0)
14
+ anomaly_threshold: float = Field(default=0.85, ge=0.0, le=2.0)
15
+ decay_rate: float = Field(default=0.98, gt=0.0, le=1.0)
16
+ max_snapshots: int = Field(default=100, ge=1)
17
+ default_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
18
+ default_weight: float = Field(default=1.0, ge=0.0)
topologist/engine.py ADDED
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ import networkx as nx
9
+ import numpy as np
10
+
11
+ from topologist.config import TopologistConfig
12
+ from topologist.exceptions import NodeNotFoundError, PersistenceError
13
+ from topologist.hdc import BipolarVector, HyperVectorSpace
14
+ from topologist.models import EdgeRecord, NodeRecord, ReasoningRule
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Topologist:
20
+ """Production-oriented hyperdimensional neuro-symbolic topology engine."""
21
+
22
+ def __init__(self, config: TopologistConfig | None = None) -> None:
23
+ self.config = config or TopologistConfig()
24
+ self.hdc = HyperVectorSpace(dim=self.config.dim, seed=self.config.seed)
25
+ self.graph: nx.MultiDiGraph = nx.MultiDiGraph()
26
+ self.global_state: BipolarVector | None = None
27
+ self.snapshots: list[BipolarVector] = []
28
+
29
+ # -----------------------------
30
+ # Node and edge mutation
31
+ # -----------------------------
32
+
33
+ def add_node(self, name: str, kind: str = "concept", **metadata: Any) -> None:
34
+ record = NodeRecord(name=name, kind=kind, metadata=metadata)
35
+ name_hv = self.hdc.get(f"node::{record.name}")
36
+ kind_hv = self.hdc.get(f"kind::{record.kind}")
37
+ hv = self.hdc.bind(kind_hv, name_hv)
38
+ self.graph.add_node(
39
+ record.name,
40
+ kind=record.kind,
41
+ metadata=record.metadata,
42
+ name_hv=name_hv,
43
+ kind_hv=kind_hv,
44
+ hv=hv,
45
+ )
46
+ self.global_state = None
47
+ logger.debug("Added node %s kind=%s", record.name, record.kind)
48
+
49
+ def add_edge(
50
+ self,
51
+ source: str,
52
+ relation: str,
53
+ target: str,
54
+ weight: float | None = None,
55
+ confidence: float | None = None,
56
+ **metadata: Any,
57
+ ) -> bool:
58
+ record = EdgeRecord(
59
+ source=source,
60
+ relation=relation,
61
+ target=target,
62
+ weight=self.config.default_weight if weight is None else weight,
63
+ confidence=self.config.default_confidence if confidence is None else confidence,
64
+ metadata=metadata,
65
+ )
66
+ if record.source not in self.graph:
67
+ self.add_node(record.source)
68
+ if record.target not in self.graph:
69
+ self.add_node(record.target)
70
+ if self._has_edge(
71
+ record.source,
72
+ record.relation,
73
+ record.target,
74
+ record.weight,
75
+ record.confidence,
76
+ record.metadata,
77
+ ):
78
+ self._update_edge_confidence(
79
+ record.source,
80
+ record.target,
81
+ record.relation,
82
+ record.confidence,
83
+ )
84
+ logger.debug(
85
+ "Updated confidence for edge %s --%s--> %s to %.3f",
86
+ record.source,
87
+ record.relation,
88
+ record.target,
89
+ record.confidence,
90
+ )
91
+ return False
92
+ hv = self.hdc.encode_relation(record.source, record.relation, record.target)
93
+ self.graph.add_edge(
94
+ record.source,
95
+ record.target,
96
+ relation=record.relation,
97
+ weight=record.weight,
98
+ confidence=record.confidence,
99
+ metadata=record.metadata,
100
+ hv=hv,
101
+ )
102
+ self.global_state = None
103
+ logger.debug(
104
+ "Added edge %s --%s--> %s confidence=%.3f",
105
+ record.source,
106
+ record.relation,
107
+ record.target,
108
+ record.confidence,
109
+ )
110
+ return True
111
+
112
+ def remove_node(self, name: str) -> None:
113
+ if name not in self.graph:
114
+ raise NodeNotFoundError(f"node not found: {name}")
115
+ self.graph.remove_node(name)
116
+ self.global_state = None
117
+
118
+ def _has_edge(
119
+ self,
120
+ source: str,
121
+ relation: str,
122
+ target: str,
123
+ weight: float,
124
+ confidence: float,
125
+ metadata: dict[str, Any],
126
+ ) -> bool:
127
+ """Check if an edge exists by symbolic key (source, relation, target).
128
+ For inferred edges, also match rule identity if present.
129
+ """
130
+ edge_data = self.graph.get_edge_data(source, target, default={})
131
+ for data in edge_data.values():
132
+ if data.get("relation") != relation:
133
+ continue
134
+ if not metadata and not data.get("metadata"):
135
+ return True
136
+ if metadata and data.get("metadata"):
137
+ existing_rule = data.get("metadata", {}).get("rule")
138
+ new_rule = metadata.get("rule")
139
+ if existing_rule == new_rule:
140
+ return True
141
+ return False
142
+
143
+ def _update_edge_confidence(
144
+ self,
145
+ source: str,
146
+ target: str,
147
+ relation: str,
148
+ new_confidence: float,
149
+ ) -> None:
150
+ """Update an existing edge's confidence to the max of current and new."""
151
+ edge_data = self.graph.get_edge_data(source, target, default={})
152
+ for data in edge_data.values():
153
+ if data.get("relation") == relation:
154
+ data["confidence"] = max(float(data.get("confidence", 1.0)), new_confidence)
155
+ self.global_state = None
156
+ return
157
+
158
+ # -----------------------------
159
+ # HDC state and snapshots
160
+ # -----------------------------
161
+
162
+ def update_global_state(self, take_snapshot: bool = False) -> BipolarVector:
163
+ vectors: list[BipolarVector] = []
164
+ for _, data in self.graph.nodes(data=True):
165
+ # include typed node vector (already kind-bound)
166
+ vectors.append(data["hv"])
167
+ for _, _, data in self.graph.edges(data=True):
168
+ # incorporate confidence into the edge vector so confidence decay
169
+ # affects the global topology memory. We quantize confidence to
170
+ # a single decimal to reduce item-memory growth.
171
+ edge_hv = data.get("hv")
172
+ try:
173
+ conf = float(data.get("confidence", 1.0))
174
+ except Exception:
175
+ conf = 1.0
176
+ # encode confidence as a continuous permuted vector
177
+ conf_hv = self.hdc.encode_confidence(conf)
178
+ vectors.append(self.hdc.bind(edge_hv, conf_hv))
179
+ self.global_state = self.hdc.bundle(vectors)
180
+ if take_snapshot:
181
+ self.snapshots.append(self.global_state.copy())
182
+ self.snapshots = self.snapshots[-self.config.max_snapshots :]
183
+ return self.global_state
184
+
185
+ def state_similarity(self, other: "Topologist") -> float:
186
+ left = self.global_state if self.global_state is not None else self.update_global_state()
187
+ right = other.global_state if other.global_state is not None else other.update_global_state()
188
+ return self.hdc.similarity(left, right)
189
+
190
+ def topology_drift(self) -> float:
191
+ if len(self.snapshots) < 2:
192
+ return 0.0
193
+ return 1.0 - self.hdc.similarity(self.snapshots[-2], self.snapshots[-1])
194
+
195
+ # -----------------------------
196
+ # Retrieval and reasoning
197
+ # -----------------------------
198
+
199
+ def nearest_nodes(self, query: str, top_k: int = 5) -> list[tuple[str, float]]:
200
+ query_name_hv = self.hdc.get(f"node::{query}")
201
+ scores = []
202
+ for node, data in self.graph.nodes(data=True):
203
+ name_hv = data.get("name_hv", data["hv"])
204
+ typed_hv = data.get("hv", name_hv)
205
+ score = max(
206
+ self.hdc.similarity(query_name_hv, name_hv),
207
+ self.hdc.similarity(query_name_hv, typed_hv),
208
+ )
209
+ if score >= self.config.similarity_floor:
210
+ scores.append((node, score))
211
+ return sorted(scores, key=lambda item: item[1], reverse=True)[:top_k]
212
+
213
+ def neighbors(self, node: str) -> list[dict[str, Any]]:
214
+ if node not in self.graph:
215
+ raise NodeNotFoundError(f"node not found: {node}")
216
+ return [
217
+ {
218
+ "source": source,
219
+ "relation": data["relation"],
220
+ "target": target,
221
+ "weight": data.get("weight", 1.0),
222
+ "confidence": data.get("confidence", 1.0),
223
+ "metadata": data.get("metadata", {}),
224
+ }
225
+ for source, target, data in self.graph.out_edges(node, data=True)
226
+ ]
227
+
228
+ def apply_rule(self, rule: ReasoningRule) -> int:
229
+ proposed: list[tuple[str, str, str, float]] = []
230
+ for a, b, data_ab in self.graph.edges(data=True):
231
+ if data_ab.get("relation") != rule.relation_a:
232
+ continue
233
+ for _, c, data_bc in self.graph.out_edges(b, data=True):
234
+ if data_bc.get("relation") != rule.relation_b:
235
+ continue
236
+ confidence = data_ab.get("confidence", 1.0) * data_bc.get("confidence", 1.0)
237
+ if confidence >= rule.min_confidence:
238
+ proposed.append((a, rule.inferred_relation, c, confidence))
239
+ created = 0
240
+ for source, relation, target, confidence in proposed:
241
+ if self.add_edge(
242
+ source,
243
+ relation,
244
+ target,
245
+ confidence=confidence,
246
+ inferred=True,
247
+ rule=rule.model_dump(),
248
+ ):
249
+ created += 1
250
+ return created
251
+
252
+ def apply_rules(self, rules: list[ReasoningRule]) -> int:
253
+ return sum(self.apply_rule(rule) for rule in rules)
254
+
255
+ # -----------------------------
256
+ # Topological analytics
257
+ # -----------------------------
258
+
259
+ def centrality(self) -> dict[str, float]:
260
+ simple = nx.DiGraph()
261
+ for source, target, data in self.graph.edges(data=True):
262
+ simple.add_edge(source, target, weight=data.get("weight", 1.0))
263
+ if simple.number_of_nodes() == 0:
264
+ return {}
265
+ return cast(dict[str, float], nx.pagerank(simple, weight="weight"))
266
+
267
+ def communities(self) -> list[list[str]]:
268
+ if self.graph.number_of_nodes() == 0:
269
+ return []
270
+ undirected = self.graph.to_undirected()
271
+ groups = nx.algorithms.community.greedy_modularity_communities(undirected)
272
+ return [sorted(group) for group in groups]
273
+
274
+ def shortest_path(self, source: str, target: str) -> list[str] | None:
275
+ try:
276
+ return cast(list[str], nx.shortest_path(self.graph, source=source, target=target))
277
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
278
+ return None
279
+
280
+ def relation_anomaly_score(self, source: str, relation: str, target: str) -> float:
281
+ """Compute an anomaly score for a candidate relation by comparing it
282
+ against local topology (source outgoing edges, target incoming edges)
283
+ and relation-type vector. Returns anomaly in range [0, 1], where 1 is
284
+ most anomalous.
285
+ """
286
+ candidate = self.hdc.encode_relation(source, relation, target)
287
+
288
+ # build local outgoing bundle from source
289
+ outs: list[BipolarVector] = []
290
+ for _, _, data in self.graph.out_edges(source, data=True):
291
+ edge_hv = data.get("hv")
292
+ try:
293
+ conf = float(data.get("confidence", 1.0))
294
+ except Exception:
295
+ conf = 1.0
296
+ conf_hv = self.hdc.encode_confidence(conf)
297
+ outs.append(self.hdc.bind(edge_hv, conf_hv))
298
+
299
+ # build local incoming bundle to target
300
+ ins: list[BipolarVector] = []
301
+ for _, _, data in self.graph.in_edges(target, data=True):
302
+ edge_hv = data.get("hv")
303
+ try:
304
+ conf = float(data.get("confidence", 1.0))
305
+ except Exception:
306
+ conf = 1.0
307
+ conf_hv = self.hdc.encode_confidence(conf)
308
+ ins.append(self.hdc.bind(edge_hv, conf_hv))
309
+
310
+ # relation template vector
311
+ relation_hv = self.hdc.get(f"relation::{relation}")
312
+
313
+ # helper to bundle or make zero-vector
314
+ def _bundle_or_zero(vs: list[BipolarVector]) -> BipolarVector:
315
+ if vs:
316
+ return self.hdc.bundle(vs)
317
+ return np.zeros(self.hdc.dim, dtype=np.int8)
318
+
319
+ s_bundle = _bundle_or_zero(outs)
320
+ t_bundle = _bundle_or_zero(ins)
321
+
322
+ # compare candidate to local bundles and relation prototype
323
+ scores = [
324
+ self.hdc.similarity(candidate, s_bundle),
325
+ self.hdc.similarity(candidate, t_bundle),
326
+ self.hdc.similarity(candidate, relation_hv),
327
+ ]
328
+
329
+ # Use the strongest local match as evidence; anomaly inversely
330
+ # proportional to the max similarity. Clamp to [0, 1] since cosine
331
+ # similarity can be negative in bipolar space.
332
+ best_sim = max(scores)
333
+ score = 1.0 - best_sim
334
+ return max(0.0, min(1.0, score))
335
+
336
+ def is_anomalous_relation(self, source: str, relation: str, target: str) -> bool:
337
+ return self.relation_anomaly_score(source, relation, target) >= self.config.anomaly_threshold
338
+
339
+ def decay_confidence(self) -> None:
340
+ """Apply confidence decay to all edges, useful for stale knowledge."""
341
+ for _, _, data in self.graph.edges(data=True):
342
+ data["confidence"] = max(0.0, float(data.get("confidence", 1.0)) * self.config.decay_rate)
343
+ self.global_state = None
344
+
345
+ # -----------------------------
346
+ # Serialization
347
+ # -----------------------------
348
+
349
+ def to_dict(self, include_hdc_memory: bool = True) -> dict[str, Any]:
350
+ nodes = [
351
+ NodeRecord(
352
+ name=node,
353
+ kind=data.get("kind", "concept"),
354
+ metadata=data.get("metadata", {}),
355
+ ).model_dump()
356
+ for node, data in self.graph.nodes(data=True)
357
+ ]
358
+ edges = [
359
+ EdgeRecord(
360
+ source=source,
361
+ relation=data.get("relation", "related_to"),
362
+ target=target,
363
+ weight=float(data.get("weight", 1.0)),
364
+ confidence=float(data.get("confidence", 1.0)),
365
+ metadata=data.get("metadata", {}),
366
+ ).model_dump()
367
+ for source, target, data in self.graph.edges(data=True)
368
+ ]
369
+ payload: dict[str, Any] = {
370
+ "config": self.config.model_dump(),
371
+ "nodes": nodes,
372
+ "edges": edges,
373
+ "snapshots": [snapshot.tolist() for snapshot in self.snapshots],
374
+ }
375
+ if include_hdc_memory:
376
+ payload["hdc"] = self.hdc.to_jsonable()
377
+ return payload
378
+
379
+ @classmethod
380
+ def from_dict(cls, payload: dict[str, Any]) -> "Topologist":
381
+ config = TopologistConfig(**payload.get("config", {}))
382
+ engine = cls(config=config)
383
+ if "hdc" in payload:
384
+ engine.hdc = HyperVectorSpace.from_jsonable(payload["hdc"])
385
+ for node in payload.get("nodes", []):
386
+ node_record = NodeRecord(**node)
387
+ engine.add_node(node_record.name, node_record.kind, **node_record.metadata)
388
+ for edge in payload.get("edges", []):
389
+ edge_record = EdgeRecord(**edge)
390
+ engine.add_edge(
391
+ edge_record.source,
392
+ edge_record.relation,
393
+ edge_record.target,
394
+ weight=edge_record.weight,
395
+ confidence=edge_record.confidence,
396
+ **edge_record.metadata,
397
+ )
398
+ engine.snapshots = [np.asarray(snapshot, dtype=np.int8) for snapshot in payload.get("snapshots", [])]
399
+ engine.update_global_state()
400
+ return engine
401
+
402
+ def save(self, path: str | Path) -> None:
403
+ try:
404
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
405
+ except Exception as exc: # pragma: no cover
406
+ raise PersistenceError(f"failed to save topology: {exc}") from exc
407
+
408
+ @classmethod
409
+ def load(cls, path: str | Path) -> "Topologist":
410
+ try:
411
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
412
+ return cls.from_dict(payload)
413
+ except Exception as exc: # pragma: no cover
414
+ raise PersistenceError(f"failed to load topology: {exc}") from exc
415
+
416
+ def export_graphml(self, path: str | Path) -> None:
417
+ """Export symbolic topology to GraphML without raw hypervectors."""
418
+ export_graph = nx.MultiDiGraph()
419
+ for node, data in self.graph.nodes(data=True):
420
+ export_graph.add_node(
421
+ node,
422
+ kind=data.get("kind", "concept"),
423
+ metadata=json.dumps(data.get("metadata", {})),
424
+ )
425
+ for source, target, data in self.graph.edges(data=True):
426
+ export_graph.add_edge(
427
+ source,
428
+ target,
429
+ relation=data.get("relation", "related_to"),
430
+ weight=data.get("weight", 1.0),
431
+ confidence=data.get("confidence", 1.0),
432
+ metadata=json.dumps(data.get("metadata", {})),
433
+ )
434
+ nx.write_graphml(export_graph, path)
@@ -0,0 +1,14 @@
1
+ class TopologistError(Exception):
2
+ """Base error for Topologist."""
3
+
4
+
5
+ class NodeNotFoundError(TopologistError):
6
+ """Raised when a required node is missing."""
7
+
8
+
9
+ class PersistenceError(TopologistError):
10
+ """Raised when save/load fails."""
11
+
12
+
13
+ class ValidationError(TopologistError):
14
+ """Raised when graph or rule data is invalid."""
topologist/hdc.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import math
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+ BipolarVector = NDArray[np.int8]
11
+
12
+
13
+ class HyperVectorSpace:
14
+ """Bipolar hyperdimensional vector space.
15
+
16
+ Operations:
17
+ - item memory: stable random vectors per symbol
18
+ - bind: elementwise multiplication
19
+ - bundle: majority superposition
20
+ - permute: cyclic shift for order/role encoding
21
+ """
22
+
23
+ def __init__(self, dim: int = 10_000, seed: int = 42) -> None:
24
+ if dim < 128:
25
+ raise ValueError("dim must be at least 128")
26
+ self.dim = dim
27
+ self.seed = seed
28
+ self._rng = np.random.default_rng(seed)
29
+ self.item_memory: dict[str, BipolarVector] = {}
30
+
31
+ def random(self) -> BipolarVector:
32
+ return self._rng.choice([-1, 1], size=self.dim).astype(np.int8)
33
+
34
+ def get(self, symbol: str) -> BipolarVector:
35
+ key = symbol.strip()
36
+ if not key:
37
+ raise ValueError("symbol cannot be blank")
38
+ if key not in self.item_memory:
39
+ self.item_memory[key] = self.random()
40
+ return self.item_memory[key]
41
+
42
+ @staticmethod
43
+ def bind(a: BipolarVector, b: BipolarVector) -> BipolarVector:
44
+ return (a * b).astype(np.int8)
45
+
46
+ def bundle(self, vectors: list[BipolarVector]) -> BipolarVector:
47
+ if not vectors:
48
+ return np.zeros(self.dim, dtype=np.int8)
49
+ stacked = np.vstack(vectors).astype(np.int16)
50
+ summed = np.sum(stacked, axis=0)
51
+ return np.where(summed >= 0, 1, -1).astype(np.int8)
52
+
53
+ @staticmethod
54
+ def permute(vector: BipolarVector, shifts: int = 1) -> BipolarVector:
55
+ return np.roll(vector, shifts).astype(np.int8)
56
+
57
+ @staticmethod
58
+ def similarity(a: BipolarVector, b: BipolarVector) -> float:
59
+ denom = float(np.linalg.norm(a) * np.linalg.norm(b))
60
+ if denom == 0.0:
61
+ return 0.0
62
+ return float(np.dot(a.astype(np.float32), b.astype(np.float32)) / denom)
63
+
64
+ def encode_relation(self, source: str, relation: str, target: str) -> BipolarVector:
65
+ return self.bind(
66
+ self.bind(self.get(f"node::{source}"), self.get(f"relation::{relation}")),
67
+ self.get(f"node::{target}"),
68
+ )
69
+
70
+ def encode_typed_node(self, name: str, kind: str) -> BipolarVector:
71
+ return self.bind(self.get(f"kind::{kind}"), self.get(f"node::{name}"))
72
+
73
+ def encode_sequence(self, symbols: list[str]) -> BipolarVector:
74
+ vectors = []
75
+ position = self.get("role::position")
76
+ for idx, symbol in enumerate(symbols):
77
+ role = self.permute(position, idx)
78
+ vectors.append(self.bind(role, self.get(symbol)))
79
+ return self.bundle(vectors)
80
+
81
+ def encode_confidence(self, confidence: float) -> BipolarVector:
82
+ """Encode a continuous confidence value as a permuted base vector.
83
+
84
+ Confidence is expected in [0.0, 1.0]. We create a base confidence
85
+ vector in item memory and apply a cyclic permutation proportional to
86
+ the confidence value. This gives a smooth, continuous encoding that
87
+ changes as confidence decays.
88
+ """
89
+ try:
90
+ c = float(confidence)
91
+ except Exception:
92
+ c = 1.0
93
+ c = max(0.0, min(1.0, c))
94
+ base = self.get("confidence::base")
95
+ # map confidence to integer shift within [0, dim-1]
96
+ # avoid round() overload ambiguity by using explicit float math
97
+ shift = math.floor(float(c) * (self.dim - 1) + 0.5)
98
+ return self.permute(base, shift)
99
+
100
+ def to_jsonable(self) -> dict[str, object]:
101
+ return {
102
+ "dim": self.dim,
103
+ "seed": self.seed,
104
+ "item_memory": {key: value.tolist() for key, value in self.item_memory.items()},
105
+ }
106
+
107
+ @classmethod
108
+ def from_jsonable(cls, payload: dict[str, object]) -> "HyperVectorSpace":
109
+ space = cls(dim=int(str(payload["dim"])), seed=int(str(payload["seed"])))
110
+ raw_memory = payload.get("item_memory", {})
111
+ if not isinstance(raw_memory, dict):
112
+ raise ValueError("item_memory must be an object")
113
+ space.item_memory = {
114
+ str(key): np.asarray(value, dtype=np.int8) for key, value in raw_memory.items()
115
+ }
116
+ return space
117
+
118
+ def save_item_memory(self, path: str | Path) -> None:
119
+ Path(path).write_text(json.dumps(self.to_jsonable()), encoding="utf-8")
120
+
121
+ @classmethod
122
+ def load_item_memory(cls, path: str | Path) -> "HyperVectorSpace":
123
+ return cls.from_jsonable(json.loads(Path(path).read_text(encoding="utf-8")))
topologist/io.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from topologist.engine import Topologist
9
+ from topologist.models import EdgeRecord, NodeRecord
10
+
11
+
12
+ def load_edges_csv(path: str | Path) -> list[EdgeRecord]:
13
+ with Path(path).open("r", newline="", encoding="utf-8") as handle:
14
+ reader = csv.DictReader(handle)
15
+ edges: list[EdgeRecord] = []
16
+ for row in reader:
17
+ metadata = {}
18
+ metadata_json = row.get("metadata_json")
19
+ if metadata_json:
20
+ metadata = json.loads(metadata_json)
21
+ elif row.get("metadata"):
22
+ raw_metadata = row["metadata"]
23
+ try:
24
+ metadata = json.loads(raw_metadata)
25
+ except json.JSONDecodeError:
26
+ metadata = {"metadata": raw_metadata}
27
+ weight = float(row["weight"]) if row.get("weight") not in (None, "") else 1.0
28
+ confidence = float(row["confidence"]) if row.get("confidence") not in (None, "") else 1.0
29
+ edges.append(
30
+ EdgeRecord(
31
+ source=row["source"],
32
+ relation=row["relation"],
33
+ target=row["target"],
34
+ weight=weight,
35
+ confidence=confidence,
36
+ metadata=metadata,
37
+ )
38
+ )
39
+ return edges
40
+
41
+
42
+ def save_edges_csv(path: str | Path, edges: Iterable[EdgeRecord]) -> None:
43
+ rows: list[dict[str, object]] = []
44
+ for edge in edges:
45
+ edge_data = edge.model_dump()
46
+ edge_data["metadata_json"] = json.dumps(edge_data.pop("metadata"))
47
+ rows.append(edge_data)
48
+ if not rows:
49
+ return
50
+ with Path(path).open("w", newline="", encoding="utf-8") as handle:
51
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys()))
52
+ writer.writeheader()
53
+ writer.writerows(rows)
54
+
55
+
56
+ def build_from_records(nodes: Iterable[NodeRecord], edges: Iterable[EdgeRecord]) -> Topologist:
57
+ topology = Topologist()
58
+ for node in nodes:
59
+ topology.add_node(node.name, node.kind, **node.metadata)
60
+ for edge in edges:
61
+ topology.add_edge(
62
+ edge.source,
63
+ edge.relation,
64
+ edge.target,
65
+ weight=edge.weight,
66
+ confidence=edge.confidence,
67
+ **edge.metadata,
68
+ )
69
+ topology.update_global_state(take_snapshot=True)
70
+ return topology
topologist/models.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+
8
+ class NodeRecord(BaseModel):
9
+ """Serializable symbolic node record."""
10
+
11
+ model_config = ConfigDict(extra="forbid")
12
+
13
+ name: str = Field(min_length=1)
14
+ kind: str = Field(default="concept", min_length=1)
15
+ metadata: dict[str, Any] = Field(default_factory=dict)
16
+
17
+ @field_validator("name", "kind")
18
+ @classmethod
19
+ def strip_text(cls, value: str) -> str:
20
+ value = value.strip()
21
+ if not value:
22
+ raise ValueError("value cannot be blank")
23
+ return value
24
+
25
+
26
+ class EdgeRecord(BaseModel):
27
+ """Serializable symbolic edge record."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ source: str = Field(min_length=1)
32
+ relation: str = Field(min_length=1)
33
+ target: str = Field(min_length=1)
34
+ weight: float = Field(default=1.0, ge=0.0)
35
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
36
+ metadata: dict[str, Any] = Field(default_factory=dict)
37
+
38
+ @field_validator("source", "relation", "target")
39
+ @classmethod
40
+ def strip_text(cls, value: str) -> str:
41
+ value = value.strip()
42
+ if not value:
43
+ raise ValueError("value cannot be blank")
44
+ return value
45
+
46
+
47
+ class ReasoningRule(BaseModel):
48
+ """A simple two-hop symbolic inference rule.
49
+
50
+ Example:
51
+ if A --connects_to--> B and B --supports--> C,
52
+ infer A --indirectly_supports--> C.
53
+ """
54
+
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ relation_a: str = Field(min_length=1)
58
+ relation_b: str = Field(min_length=1)
59
+ inferred_relation: str = Field(min_length=1)
60
+ min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
61
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from topologist.engine import Topologist
6
+
7
+
8
+ def export_mermaid(topology: Topologist, path: str | Path) -> None:
9
+ """Export graph as a Mermaid flowchart."""
10
+ lines = ["flowchart LR"]
11
+ for source, target, data in topology.graph.edges(data=True):
12
+ relation = str(data.get("relation", "related_to")).replace('"', "'")
13
+ lines.append(f' "{source}" -- "{relation}" --> "{target}"')
14
+ Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: topologist
3
+ Version: 0.1.0
4
+ Summary: A production-hardened hyperdimensional neuro-symbolic topology system.
5
+ Author: Robert McMenemy
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/EasyTees/Topologist
8
+ Project-URL: Repository, https://github.com/EasyTees/Topologist
9
+ Project-URL: Documentation, https://github.com/EasyTees/Topologist#readme
10
+ Project-URL: Issues, https://github.com/EasyTees/Topologist/issues
11
+ Keywords: hyperdimensional-computing,neuro-symbolic,topology,reasoning,knowledge-graph
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: networkx>=3.1
27
+ Requires-Dist: pydantic>=2.5
28
+ Requires-Dist: typer>=0.9
29
+ Requires-Dist: rich>=13.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.4; extra == "dev"
32
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
33
+ Requires-Dist: ruff>=0.5; extra == "dev"
34
+ Requires-Dist: mypy>=1.8; extra == "dev"
35
+ Requires-Dist: build>=1.0; extra == "dev"
36
+ Requires-Dist: twine>=4.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # Topologist
40
+
41
+ A production-hardened **hyperdimensional neuro-symbolic topology system** in Python.
42
+
43
+ Topologist combines:
44
+
45
+ - **Hyperdimensional Computing / Vector Symbolic Architecture** for robust distributed representations.
46
+ - **Neuro-symbolic graph topology** using NetworkX.
47
+ - **Rule-based inference** over symbolic relations.
48
+ - **Topology analytics** including PageRank centrality, communities, shortest paths, drift, and anomaly scoring.
49
+ - **Persistence and export** to JSON, GraphML, and Mermaid.
50
+ - **CLI tooling** for demos and inspection.
51
+
52
+ ---
53
+
54
+ ## Why this exists
55
+
56
+ Most symbolic graph systems are explainable but brittle. Most neural/vector systems are robust but opaque.
57
+
58
+ Topologist sits between the two:
59
+
60
+ ```text
61
+ Symbolic entities and relations
62
+
63
+ Hyperdimensional encoding
64
+
65
+ Topology graph
66
+
67
+ Reasoning + analytics + anomaly detection
68
+ ```
69
+
70
+ Each node and relation is stored symbolically, but also encoded into a high-dimensional bipolar hypervector. This gives you a graph that is queryable and explainable while also having a distributed topology-level memory state.
71
+
72
+ ---
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ cd topologist
78
+ python -m venv .venv
79
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
80
+ pip install -e ".[dev]"
81
+ ```
82
+
83
+ For development without installing, ensure the package is in the Python path:
84
+
85
+ ```bash
86
+ pip install -e .
87
+ python examples/demo.py
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Quick start
93
+
94
+ ```python
95
+ from topologist import Topologist
96
+ from topologist.models import ReasoningRule
97
+
98
+ system = Topologist()
99
+
100
+ system.add_edge("Neuron", "connects_to", "Synapse", confidence=0.95)
101
+ system.add_edge("Synapse", "supports", "Memory", confidence=0.90)
102
+ system.add_edge("HDC", "models", "Memory", confidence=0.85)
103
+
104
+ created = system.apply_rule(
105
+ ReasoningRule(
106
+ relation_a="connects_to",
107
+ relation_b="supports",
108
+ inferred_relation="indirectly_supports",
109
+ min_confidence=0.5,
110
+ )
111
+ )
112
+
113
+ system.update_global_state(take_snapshot=True)
114
+
115
+ print("Created inferred edges:", created)
116
+ print("Centrality:", system.centrality())
117
+ print("Communities:", system.communities())
118
+ print("Nearest nodes:", system.nearest_nodes("Memory"))
119
+ print("Path:", system.shortest_path("Neuron", "Memory"))
120
+
121
+ system.save("topology.json")
122
+ ```
123
+
124
+ Streaming example
125
+
126
+ ```bash
127
+ # Run the streaming demo which ingests events, applies inference,
128
+ # snapshots state, computes drift, and scores anomalies.
129
+ python examples/streaming_topology.py
130
+ ```
131
+
132
+ ---
133
+
134
+ ## CLI
135
+
136
+ Create a demo topology:
137
+
138
+ ```bash
139
+ topologist demo --output topology.json
140
+ ```
141
+
142
+ Inspect it:
143
+
144
+ ```bash
145
+ topologist inspect topology.json
146
+ ```
147
+
148
+ Export Mermaid:
149
+
150
+ ```bash
151
+ topologist mermaid topology.json --output topology.mmd
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Main features
157
+
158
+ ### 1. Hyperdimensional item memory
159
+
160
+ Stable symbols are encoded into bipolar vectors:
161
+
162
+ ```text
163
+ symbol → {-1, +1}^D
164
+ ```
165
+
166
+ The engine supports:
167
+
168
+ - Binding: elementwise multiplication
169
+ - Bundling: majority superposition
170
+ - Permutation: cyclic shifts for order/role encoding
171
+ - Similarity: cosine similarity
172
+
173
+ ### 2. Symbolic topology graph
174
+
175
+ The graph is a `networkx.MultiDiGraph`, so it supports multiple relation types between the same source and target.
176
+
177
+ Example:
178
+
179
+ ```text
180
+ HDC --models--> Memory
181
+ HDC --enhances--> KnowledgeGraph
182
+ KnowledgeGraph --supports--> Reasoning
183
+ ```
184
+
185
+ ### 3. Rule-based inference
186
+
187
+ Rules operate over two-hop motifs:
188
+
189
+ ```text
190
+ A --relation_a--> B
191
+ B --relation_b--> C
192
+ ----------------------
193
+ A --inferred_relation--> C
194
+ ```
195
+
196
+ ### 4. Drift detection
197
+
198
+ The global graph state is bundled into a single hypervector snapshot.
199
+
200
+ ```python
201
+ system.update_global_state(take_snapshot=True)
202
+ drift = system.topology_drift()
203
+ ```
204
+
205
+ This lets you measure how much the topology has changed over time.
206
+
207
+ ### 5. Anomaly scoring
208
+
209
+ Candidate relations can be compared against the global topology state:
210
+
211
+ ```python
212
+ score = system.relation_anomaly_score("A", "unexpected_relation", "B")
213
+ ```
214
+
215
+ Higher scores mean the relation is less aligned with the current topology memory.
216
+
217
+ ### 6. Confidence decay
218
+
219
+ Knowledge that is not reinforced can gradually lose confidence:
220
+
221
+ ```python
222
+ system.decay_confidence()
223
+ ```
224
+
225
+ This is useful for agent memory, dynamic knowledge graphs, cybersecurity events, medical evidence tracking, and live topology streams.
226
+
227
+ ---
228
+
229
+ ## Project structure
230
+
231
+ ```text
232
+ topologist/
233
+ ├── topologist/
234
+ │ ├── __init__.py
235
+ │ ├── cli.py
236
+ │ ├── config.py
237
+ │ ├── engine.py
238
+ │ ├── exceptions.py
239
+ │ ├── hdc.py
240
+ │ ├── io.py
241
+ │ ├── models.py
242
+ │ └── visualization.py
243
+ ├── examples/
244
+ │ ├── demo.py
245
+ │ └── streaming_topology.py
246
+ ├── tests/
247
+ │ ├── test_engine.py
248
+ │ └── test_hdc.py
249
+ ├── pyproject.toml
250
+ └── README.md
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Run tests
256
+
257
+ ```bash
258
+ pytest -q
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Production hardening included
264
+
265
+ This package includes:
266
+
267
+ - Typed modules
268
+ - Pydantic validation
269
+ - Custom exceptions
270
+ - Save/load roundtrip support
271
+ - CLI entrypoint
272
+ - Config object
273
+ - Test suite
274
+ - Export helpers
275
+ - No notebook-only assumptions
276
+ - No hidden API dependency
277
+ - Deterministic seed support
278
+ - Dimension validation
279
+ - Confidence decay
280
+ - Snapshot capping
281
+
282
+ ---
283
+
284
+ ## Good next upgrades
285
+
286
+ Useful next layers would be:
287
+
288
+ 1. PyTorch Geometric bridge for GNN message passing.
289
+ 2. Streaming event ingestion from Kafka, Redis Streams, or WebSockets.
290
+ 3. Approximate nearest-neighbour search for large item memories.
291
+ 4. Rule DSL with richer multi-hop inference.
292
+ 5. OpenTelemetry tracing.
293
+ 6. FastAPI service wrapper.
294
+ 7. SQLite/Postgres persistence adapter.
295
+ 8. Agent memory adapter for Claude Code, OpenClaw, or local LLM agents.
296
+
297
+ ---
298
+
299
+ ## License
300
+
301
+ MIT
@@ -0,0 +1,15 @@
1
+ topologist/__init__.py,sha256=desvdhtnzcFN0IKEU7fnYsuDHCtL4_WoCupCO0t7n9s,429
2
+ topologist/cli.py,sha256=QRoHeqW7gGEopOXEg4n2Q5BQ-KBkRx3JZ5__VL-rwIU,2412
3
+ topologist/config.py,sha256=8K0eWpo4jQ5YFWMtUcjdWs6MAbBNaHjcyUngBzeenQs,770
4
+ topologist/engine.py,sha256=Tj4dkU5wgeuaJxWpcyHcDqfs06ziPsonEwnCSGgKS1o,16940
5
+ topologist/exceptions.py,sha256=xiu4Nv-0cm1ldgssPRff1SQv_lmifXfujWvRJWfi2nU,342
6
+ topologist/hdc.py,sha256=HGMjWPb9DpbeZPwpRl8tfRss82X3C91CHLgo7SUkLrA,4583
7
+ topologist/io.py,sha256=0bIuy_yfxhBS7zbHWqa_pitHT5gnrxk2Vjzt9o3O6Uw,2459
8
+ topologist/models.py,sha256=JGEqB7ycyz0KC0MZr-gnOWAJuv-hBfT42KerDBKlYO4,1781
9
+ topologist/visualization.py,sha256=rjsIzkBDLnydNtE3ff2_vabmgjY-SXsfl1TfawM2mGs,531
10
+ topologist-0.1.0.dist-info/licenses/LICENSE,sha256=35xim7D8bs26EtC_a3-kD7iy6_fynbcSyj3waYYD6Rc,1070
11
+ topologist-0.1.0.dist-info/METADATA,sha256=3GgxoK2NEAczbI_MX3UCatb0Cw0Khx2B7PgeMdfSOio,7197
12
+ topologist-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ topologist-0.1.0.dist-info/entry_points.txt,sha256=hZip_p1rAqPOvlkFMJfLYX_2uTCDF0010aMYtFLeTWk,50
14
+ topologist-0.1.0.dist-info/top_level.txt,sha256=ljH5-HroXMEudy8ScSlLuaEpjxsySoANa0NjLxFJ0bw,11
15
+ topologist-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ topologist = topologist.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JadeyGraham96
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ topologist