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 +17 -0
- topologist/cli.py +70 -0
- topologist/config.py +18 -0
- topologist/engine.py +434 -0
- topologist/exceptions.py +14 -0
- topologist/hdc.py +123 -0
- topologist/io.py +70 -0
- topologist/models.py +61 -0
- topologist/visualization.py +14 -0
- topologist-0.1.0.dist-info/METADATA +301 -0
- topologist-0.1.0.dist-info/RECORD +15 -0
- topologist-0.1.0.dist-info/WHEEL +5 -0
- topologist-0.1.0.dist-info/entry_points.txt +2 -0
- topologist-0.1.0.dist-info/licenses/LICENSE +21 -0
- topologist-0.1.0.dist-info/top_level.txt +1 -0
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)
|
topologist/exceptions.py
ADDED
|
@@ -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,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
|