flywire-coding-cortex 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.
@@ -0,0 +1,335 @@
1
+ """Leaky integrate-and-fire on a FlyWire-derived circuit JSON."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+
12
+ @dataclass
13
+ class Circuit:
14
+ neurons: list[dict[str, Any]]
15
+ edges: list[list[float]]
16
+ source: str = ""
17
+
18
+ @property
19
+ def n(self) -> int:
20
+ return len(self.neurons)
21
+
22
+ @classmethod
23
+ def load(cls, path: Path) -> "Circuit":
24
+ data = json.loads(path.read_text(encoding="utf-8"))
25
+ return cls(
26
+ neurons=data["neurons"],
27
+ edges=data["edges"],
28
+ source=str(data.get("source", "")),
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class LIFSim:
34
+ circuit: Circuit
35
+ weight_scale: float = 0.0008
36
+ decay: float = 0.9512 # ~20 ms tau at 1 ms step
37
+ threshold: float = 1.0
38
+ refractory_ms: float = 2.0
39
+ p_noise: float = 0.0022
40
+ noise_kick: float = 0.42
41
+ rate_alpha: float = 1.0 / 120.0
42
+ inh_delay_ms: int = 4
43
+ gap_junction_boost: float = 6.0
44
+ rng: np.random.Generator = field(default_factory=lambda: np.random.default_rng(0))
45
+
46
+ def __post_init__(self) -> None:
47
+ n = self.circuit.n
48
+ self.v = np.zeros(n, dtype=np.float64)
49
+ self.refr = np.zeros(n, dtype=np.float64)
50
+ self.baseline = np.zeros(n, dtype=np.float64)
51
+ self.roles = [str(nr.get("role", "other")) for nr in self.circuit.neurons]
52
+ self.types = [str(nr.get("type", "?")) for nr in self.circuit.neurons]
53
+ self.sides = [str(nr.get("side", "")) for nr in self.circuit.neurons]
54
+ self._build_groups()
55
+ self._build_baselines()
56
+ self._build_csr()
57
+ self.inh_queue = np.zeros((self.inh_delay_ms + 1, n), dtype=np.float64)
58
+ self.q_head = 0
59
+ self.sim_ms = 0
60
+ self.total_spikes = 0
61
+ self.gf_latch = False
62
+ self.rates = {
63
+ "loom": 0.0,
64
+ "dna_l": 0.0,
65
+ "dna_r": 0.0,
66
+ "mdn": 0.0,
67
+ "fwd": 0.0,
68
+ "groom": 0.0,
69
+ "escw": 0.0,
70
+ "pop": 0.0,
71
+ }
72
+ self.inputs = {
73
+ "loom_l": 0.0,
74
+ "loom_r": 0.0,
75
+ "risk": 0.0,
76
+ "ambiguity": 0.0,
77
+ "test_red": 0.0,
78
+ "urgency": 0.0,
79
+ "air_puff": 0.0,
80
+ }
81
+ self._active_stims: list[dict[str, Any]] = []
82
+
83
+ def _build_groups(self) -> None:
84
+ self.loom_left: list[int] = []
85
+ self.loom_right: list[int] = []
86
+ self.gf: list[int] = []
87
+ self.dna_l: list[int] = []
88
+ self.dna_r: list[int] = []
89
+ self.mdn: list[int] = []
90
+ self.fwd: list[int] = []
91
+ self.groom: list[int] = []
92
+ self.escw: list[int] = []
93
+ self.ascend: list[int] = []
94
+ self.sens: list[int] = []
95
+ for i, nr in enumerate(self.circuit.neurons):
96
+ role = self.roles[i]
97
+ side = self.sides[i]
98
+ if role in ("lc4", "lplc2"):
99
+ (self.loom_left if side == "left" else self.loom_right).append(i)
100
+ elif role == "gf":
101
+ self.gf.append(i)
102
+ elif role in ("dna01", "dna02"):
103
+ (self.dna_l if side == "left" else self.dna_r).append(i)
104
+ elif role == "mdn":
105
+ self.mdn.append(i)
106
+ elif role == "dnp09":
107
+ self.fwd.append(i)
108
+ elif role == "dng11":
109
+ self.groom.append(i)
110
+ elif role == "escw":
111
+ self.escw.append(i)
112
+ elif role == "other":
113
+ if self.types[i] == "ascending":
114
+ self.ascend.append(i)
115
+ elif self.types[i] == "sensory":
116
+ self.sens.append(i)
117
+ self.groups = {
118
+ "gf": self.gf,
119
+ "lc4": [i for i, r in enumerate(self.roles) if r == "lc4"],
120
+ "lplc2": [i for i, r in enumerate(self.roles) if r == "lplc2"],
121
+ "dnp09": self.fwd,
122
+ "mdn": self.mdn,
123
+ "dng11": self.groom,
124
+ "escw": self.escw,
125
+ "dna01": [i for i, r in enumerate(self.roles) if r == "dna01"],
126
+ "dna02": [i for i, r in enumerate(self.roles) if r == "dna02"],
127
+ "ascending": self.ascend,
128
+ "sensory": self.sens,
129
+ }
130
+
131
+ def _build_baselines(self) -> None:
132
+ for i, role in enumerate(self.roles):
133
+ if role == "other":
134
+ self.baseline[i] = float(self.rng.uniform(0.010, 0.070))
135
+ elif role in ("lc4", "lplc2"):
136
+ self.baseline[i] = 0.004
137
+ elif role in ("dna01", "dna02", "mdn", "dng11", "escw"):
138
+ self.baseline[i] = 0.036
139
+ elif role == "dnp09":
140
+ self.baseline[i] = 0.038
141
+ else:
142
+ self.baseline[i] = 0.002 # gf quiet unless driven
143
+
144
+ def _build_csr(self) -> None:
145
+ n = self.circuit.n
146
+ counts = np.zeros(n, dtype=np.int32)
147
+ for e in self.circuit.edges:
148
+ counts[int(e[0])] += 1
149
+ self.row_start = np.zeros(n + 1, dtype=np.int32)
150
+ for i in range(n):
151
+ self.row_start[i + 1] = self.row_start[i] + counts[i]
152
+ m = len(self.circuit.edges)
153
+ self.col_idx = np.zeros(m, dtype=np.int32)
154
+ self.w = np.zeros(m, dtype=np.float64)
155
+ fill = self.row_start.copy()
156
+ for e in self.circuit.edges:
157
+ pre, post = int(e[0]), int(e[1])
158
+ weight = float(e[2]) * self.weight_scale
159
+ electrical = self.roles[pre] in ("lc4", "lplc2") or (
160
+ self.roles[pre] == "other" and self.types[pre] == "sensory"
161
+ )
162
+ if electrical and self.roles[post] == "gf":
163
+ weight *= self.gap_junction_boost
164
+ slot = int(fill[pre])
165
+ self.col_idx[slot] = post
166
+ self.w[slot] = weight
167
+ fill[pre] += 1
168
+
169
+ def set_inputs(self, **kwargs: float) -> None:
170
+ for k, v in kwargs.items():
171
+ if k in self.inputs:
172
+ self.inputs[k] = float(v)
173
+
174
+ def stimulate(self, indices: list[int], strength: float = 0.25, duration_ms: int = 400) -> None:
175
+ if not indices:
176
+ return
177
+ self._active_stims.append(
178
+ {
179
+ "idx": [int(i) for i in indices if 0 <= int(i) < self.circuit.n],
180
+ "strength": float(strength),
181
+ "until": self.sim_ms + int(duration_ms),
182
+ }
183
+ )
184
+
185
+ def stimulate_group(self, name: str, strength: float = 0.25, duration_ms: int = 400) -> int:
186
+ idx = self.groups.get(name, [])
187
+ self.stimulate(idx, strength=strength, duration_ms=duration_ms)
188
+ return len(idx)
189
+
190
+ def consume_gf(self) -> bool:
191
+ flag = self.gf_latch
192
+ self.gf_latch = False
193
+ return flag
194
+
195
+ def step(self, ms: int) -> dict[str, Any]:
196
+ ms = max(0, int(ms))
197
+ spiked_total = 0
198
+ for _ in range(ms):
199
+ self.sim_ms += 1
200
+ self._active_stims = [s for s in self._active_stims if self.sim_ms < s["until"]]
201
+ # leak + baseline + noise
202
+ active = self.refr <= 0
203
+ self.v[active] = self.v[active] * self.decay + self.baseline[active]
204
+ self.v[~active] *= self.decay
205
+ self.refr = np.maximum(0, self.refr - 1)
206
+ noise_mask = (self.rng.random(self.circuit.n) < self.p_noise) & active
207
+ self.v[noise_mask] += self.noise_kick
208
+
209
+ # task / sensory currents
210
+ loom = max(self.inputs["loom_l"], self.inputs["loom_r"], self.inputs["risk"])
211
+ if loom > 0.001:
212
+ gain = 0.30 * loom
213
+ for i in self.loom_left:
214
+ self.v[i] += gain * (1.0 if self.inputs["loom_l"] or self.inputs["risk"] else 0.0)
215
+ for i in self.loom_right:
216
+ self.v[i] += gain * (1.0 if self.inputs["loom_r"] or self.inputs["risk"] else 0.0)
217
+ if self.inputs["test_red"] > 0.001:
218
+ for i in self.sens:
219
+ self.v[i] += self.inputs["test_red"] * 0.10
220
+ if self.inputs["ambiguity"] > 0.001:
221
+ for i in self.ascend:
222
+ self.v[i] += self.inputs["ambiguity"] * 0.06
223
+ if self.inputs["urgency"] > 0.001:
224
+ for i in self.fwd:
225
+ self.v[i] += self.inputs["urgency"] * 0.05
226
+ if self.inputs["air_puff"] > 0.001:
227
+ for i in self.sens:
228
+ self.v[i] += self.inputs["air_puff"] * 0.12
229
+
230
+ for stim in self._active_stims:
231
+ for i in stim["idx"]:
232
+ self.v[i] += stim["strength"]
233
+
234
+ # delayed inhibition
235
+ slot = self.inh_queue[self.q_head]
236
+ nz = slot != 0
237
+ self.v[nz] = np.maximum(-2.0, self.v[nz] + slot[nz])
238
+ slot[:] = 0
239
+
240
+ fire = active & (self.v >= self.threshold)
241
+ spiked = np.flatnonzero(fire)
242
+ if spiked.size:
243
+ self.v[spiked] = 0.0
244
+ self.refr[spiked] = self.refractory_ms
245
+ spiked_total += int(spiked.size)
246
+ self.total_spikes += int(spiked.size)
247
+ inh_slot = (self.q_head + self.inh_delay_ms) % self.inh_queue.shape[0]
248
+ for i in spiked:
249
+ for k in range(self.row_start[i], self.row_start[i + 1]):
250
+ j = int(self.col_idx[k])
251
+ w = self.w[k]
252
+ if w >= 0:
253
+ self.v[j] = max(-2.0, self.v[j] + w)
254
+ else:
255
+ self.inh_queue[inh_slot, j] += w
256
+ if self.roles[int(i)] == "gf":
257
+ self.gf_latch = True
258
+ self.q_head = (self.q_head + 1) % self.inh_queue.shape[0]
259
+ self._update_rates(spiked)
260
+
261
+ return {
262
+ "sim_ms": self.sim_ms,
263
+ "spikes": spiked_total,
264
+ "total_spikes": self.total_spikes,
265
+ "rates": dict(self.rates),
266
+ "gf_latched": self.gf_latch,
267
+ "n": self.circuit.n,
268
+ "edges": len(self.circuit.edges),
269
+ }
270
+
271
+ def _update_rates(self, spiked: np.ndarray) -> None:
272
+ c_loom = c_dl = c_dr = c_m = c_f = c_g = c_w = 0
273
+ for i in spiked:
274
+ role = self.roles[int(i)]
275
+ if role in ("lc4", "lplc2"):
276
+ c_loom += 1
277
+ elif role in ("dna01", "dna02"):
278
+ if int(i) in self.dna_l:
279
+ c_dl += 1
280
+ else:
281
+ c_dr += 1
282
+ elif role == "mdn":
283
+ c_m += 1
284
+ elif role == "dnp09":
285
+ c_f += 1
286
+ elif role == "dng11":
287
+ c_g += 1
288
+ elif role == "escw":
289
+ c_w += 1
290
+ a = self.rate_alpha
291
+
292
+ def ema(key: str, count: int, denom: int) -> None:
293
+ inst = count * 1000.0 / max(1, denom)
294
+ self.rates[key] += (inst - self.rates[key]) * a
295
+
296
+ ema("loom", c_loom, len(self.loom_left) + len(self.loom_right))
297
+ ema("dna_l", c_dl, len(self.dna_l))
298
+ ema("dna_r", c_dr, len(self.dna_r))
299
+ ema("mdn", c_m, len(self.mdn))
300
+ ema("fwd", c_f, len(self.fwd))
301
+ ema("groom", c_g, len(self.groom))
302
+ ema("escw", c_w, len(self.escw))
303
+ ema("pop", int(spiked.size), self.circuit.n)
304
+
305
+ def to_state(self) -> dict:
306
+ return {
307
+ "sim_ms": self.sim_ms,
308
+ "total_spikes": self.total_spikes,
309
+ "gf_latch": self.gf_latch,
310
+ "v": self.v.tolist(),
311
+ "refr": self.refr.tolist(),
312
+ "rates": dict(self.rates),
313
+ "inputs": dict(self.inputs),
314
+ "q_head": self.q_head,
315
+ "inh_queue": self.inh_queue.tolist(),
316
+ "stims": self._active_stims,
317
+ "dna_baseline": getattr(self, "dna_baseline", 0.0),
318
+ }
319
+
320
+ def load_state(self, state: dict) -> None:
321
+ if not state or len(state.get("v", [])) != self.circuit.n:
322
+ return
323
+ self.sim_ms = int(state.get("sim_ms", 0))
324
+ self.total_spikes = int(state.get("total_spikes", 0))
325
+ self.gf_latch = bool(state.get("gf_latch", False))
326
+ self.v = np.asarray(state["v"], dtype=np.float64)
327
+ self.refr = np.asarray(state["refr"], dtype=np.float64)
328
+ self.rates.update(state.get("rates") or {})
329
+ self.inputs.update(state.get("inputs") or {})
330
+ self.q_head = int(state.get("q_head", 0))
331
+ iq = state.get("inh_queue")
332
+ if iq is not None:
333
+ self.inh_queue = np.asarray(iq, dtype=np.float64)
334
+ self._active_stims = list(state.get("stims") or [])
335
+ self.dna_baseline = float(state.get("dna_baseline", 0.0))
@@ -0,0 +1,208 @@
1
+ """Minimal MCP stdio server (JSON-RPC) for Cursor / agent connectors."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from typing import Any
7
+
8
+ from . import bridge, memory, sense
9
+ from .cli import _save_sim, get_sim
10
+ from .paths import active_profile, circuit_path, home
11
+
12
+
13
+ TOOLS = [
14
+ {
15
+ "name": "cortex_status",
16
+ "description": "Show active FlyWire cortex profile and circuit stats",
17
+ "inputSchema": {"type": "object", "properties": {}},
18
+ },
19
+ {
20
+ "name": "cortex_sense",
21
+ "description": "Encode a coding task into sensory currents on the connectome",
22
+ "inputSchema": {
23
+ "type": "object",
24
+ "properties": {"text": {"type": "string"}},
25
+ "required": ["text"],
26
+ },
27
+ },
28
+ {
29
+ "name": "cortex_step",
30
+ "description": "Advance the FlyWire-derived LIF simulation",
31
+ "inputSchema": {
32
+ "type": "object",
33
+ "properties": {
34
+ "ms": {"type": "integer", "default": 50},
35
+ "text": {"type": "string"},
36
+ },
37
+ },
38
+ },
39
+ {
40
+ "name": "cortex_stimulate",
41
+ "description": "Stimulate a role population (gf, dnp09, mdn, …)",
42
+ "inputSchema": {
43
+ "type": "object",
44
+ "properties": {
45
+ "group": {"type": "string"},
46
+ "strength": {"type": "number", "default": 0.25},
47
+ "ms": {"type": "integer", "default": 400},
48
+ },
49
+ "required": ["group"],
50
+ },
51
+ },
52
+ {
53
+ "name": "cortex_signals",
54
+ "description": "Read clamped coding drives from population rates",
55
+ "inputSchema": {
56
+ "type": "object",
57
+ "properties": {"ms": {"type": "integer", "default": 0}},
58
+ },
59
+ },
60
+ {
61
+ "name": "cortex_remember",
62
+ "description": "Query or update Hebbian coding memory",
63
+ "inputSchema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "action": {"type": "string", "enum": ["query", "strengthen", "weaken"]},
67
+ "text": {"type": "string"},
68
+ "pre": {"type": "string"},
69
+ "post": {"type": "string"},
70
+ "why": {"type": "string"},
71
+ },
72
+ "required": ["action"],
73
+ },
74
+ },
75
+ ]
76
+
77
+
78
+ def _result(obj: Any) -> dict[str, Any]:
79
+ text = obj if isinstance(obj, str) else json.dumps(obj, indent=2)
80
+ return {"content": [{"type": "text", "text": text}]}
81
+
82
+
83
+ def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
84
+ if name == "cortex_status":
85
+ path = circuit_path()
86
+ info = {
87
+ "home": str(home()),
88
+ "active_profile": active_profile(),
89
+ "circuit": str(path),
90
+ "exists": path.exists(),
91
+ }
92
+ if path.exists():
93
+ data = json.loads(path.read_text(encoding="utf-8"))
94
+ info["neurons"] = len(data["neurons"])
95
+ info["edges"] = len(data["edges"])
96
+ return _result(info)
97
+ if name == "cortex_sense":
98
+ scores = sense.sense_text(arguments.get("text", ""))
99
+ sim = get_sim()
100
+ sim.set_inputs(**scores.as_inputs())
101
+ _save_sim(sim)
102
+ return _result(scores.as_inputs())
103
+ if name == "cortex_step":
104
+ sim = get_sim()
105
+ text = arguments.get("text") or ""
106
+ if text:
107
+ sim.set_inputs(**sense.sense_text(text).as_inputs())
108
+ out = sim.step(int(arguments.get("ms", 50)))
109
+ _save_sim(sim)
110
+ return _result(out)
111
+ if name == "cortex_stimulate":
112
+ sim = get_sim()
113
+ n = sim.stimulate_group(
114
+ arguments["group"],
115
+ strength=float(arguments.get("strength", 0.25)),
116
+ duration_ms=int(arguments.get("ms", 400)),
117
+ )
118
+ _save_sim(sim)
119
+ return _result({"group": arguments["group"], "neurons": n})
120
+ if name == "cortex_signals":
121
+ sim = get_sim()
122
+ ms = int(arguments.get("ms", 0))
123
+ if ms:
124
+ sim.step(ms)
125
+ gf = sim.consume_gf()
126
+ out = bridge.build_signals(sim.rates, gf_spike=gf).to_dict()
127
+ _save_sim(sim)
128
+ return _result(out)
129
+ if name == "cortex_remember":
130
+ action = arguments["action"]
131
+ if action == "query":
132
+ return _result(memory.query(arguments.get("text", "")))
133
+ if action == "strengthen":
134
+ return _result(
135
+ memory.strengthen(
136
+ arguments.get("pre", ""),
137
+ arguments.get("post", ""),
138
+ why=arguments.get("why", ""),
139
+ )
140
+ )
141
+ if action == "weaken":
142
+ return _result(
143
+ memory.weaken(
144
+ arguments.get("pre", ""),
145
+ arguments.get("post", ""),
146
+ why=arguments.get("why", ""),
147
+ )
148
+ )
149
+ raise ValueError(f"Unknown tool: {name}")
150
+
151
+
152
+ def run_stdio() -> None:
153
+ """Very small MCP subset: initialize, tools/list, tools/call."""
154
+
155
+ def reply(msg_id: Any, result: Any) -> None:
156
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": result}) + "\n")
157
+ sys.stdout.flush()
158
+
159
+ def error(msg_id: Any, message: str) -> None:
160
+ sys.stdout.write(
161
+ json.dumps(
162
+ {
163
+ "jsonrpc": "2.0",
164
+ "id": msg_id,
165
+ "error": {"code": -32000, "message": message},
166
+ }
167
+ )
168
+ + "\n"
169
+ )
170
+ sys.stdout.flush()
171
+
172
+ for line in sys.stdin:
173
+ line = line.strip()
174
+ if not line:
175
+ continue
176
+ try:
177
+ req = json.loads(line)
178
+ except json.JSONDecodeError:
179
+ continue
180
+ method = req.get("method")
181
+ msg_id = req.get("id")
182
+ params = req.get("params") or {}
183
+ try:
184
+ if method == "initialize":
185
+ reply(
186
+ msg_id,
187
+ {
188
+ "protocolVersion": "2024-11-05",
189
+ "capabilities": {"tools": {}},
190
+ "serverInfo": {"name": "flywire-coding-cortex", "version": "0.1.0"},
191
+ },
192
+ )
193
+ elif method == "notifications/initialized":
194
+ continue
195
+ elif method == "tools/list":
196
+ reply(msg_id, {"tools": TOOLS})
197
+ elif method == "tools/call":
198
+ name = params.get("name")
199
+ arguments = params.get("arguments") or {}
200
+ reply(msg_id, call_tool(name, arguments))
201
+ elif method == "ping":
202
+ reply(msg_id, {})
203
+ else:
204
+ if msg_id is not None:
205
+ error(msg_id, f"Method not found: {method}")
206
+ except Exception as exc: # noqa: BLE001 — surface to MCP client
207
+ if msg_id is not None:
208
+ error(msg_id, str(exc))
@@ -0,0 +1,106 @@
1
+ """Hebbian coding-memory graph (not fabricated FlyWire edges)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .paths import ensure_memory, memory_path
11
+
12
+
13
+ def _load() -> dict[str, Any]:
14
+ path = ensure_memory()
15
+ return json.loads(path.read_text(encoding="utf-8"))
16
+
17
+
18
+ def _save(graph: dict[str, Any]) -> None:
19
+ memory_path().write_text(json.dumps(graph, indent=2) + "\n", encoding="utf-8")
20
+
21
+
22
+ def query(text: str, limit: int = 8) -> list[dict[str, Any]]:
23
+ graph = _load()
24
+ tokens = set(re.findall(r"[a-z0-9_/-]+", text.lower()))
25
+ scored: list[tuple[float, dict[str, Any]]] = []
26
+ nodes = {n["id"]: n for n in graph.get("nodes", [])}
27
+ for e in graph.get("edges", []):
28
+ pre, post = e.get("pre"), e.get("post")
29
+ blob = f"{pre} {post} {e.get('why', '')} {nodes.get(pre, {}).get('label', '')} {nodes.get(post, {}).get('label', '')}".lower()
30
+ hit = sum(1 for t in tokens if t in blob)
31
+ if hit:
32
+ scored.append((hit * float(e.get("weight", 1)), e))
33
+ scored.sort(key=lambda x: -x[0])
34
+ out = []
35
+ for score, e in scored[:limit]:
36
+ out.append(
37
+ {
38
+ "score": score,
39
+ "pre": e.get("pre"),
40
+ "post": e.get("post"),
41
+ "weight": e.get("weight"),
42
+ "why": e.get("why", ""),
43
+ "pre_label": nodes.get(e.get("pre"), {}).get("label"),
44
+ "post_label": nodes.get(e.get("post"), {}).get("label"),
45
+ }
46
+ )
47
+ return out
48
+
49
+
50
+ def add_node(node_id: str, label: str, kind: str = "concept") -> dict[str, Any]:
51
+ graph = _load()
52
+ nodes = graph.setdefault("nodes", [])
53
+ for n in nodes:
54
+ if n["id"] == node_id:
55
+ n["label"] = label
56
+ n["kind"] = kind
57
+ n["lastSeen"] = time.time()
58
+ _save(graph)
59
+ return n
60
+ node = {"id": node_id, "label": label, "kind": kind, "lastSeen": time.time()}
61
+ nodes.append(node)
62
+ _save(graph)
63
+ return node
64
+
65
+
66
+ def add_edge(pre: str, post: str, weight: float = 1.0, why: str = "") -> dict[str, Any]:
67
+ graph = _load()
68
+ edges = graph.setdefault("edges", [])
69
+ for e in edges:
70
+ if e.get("pre") == pre and e.get("post") == post:
71
+ e["weight"] = float(weight)
72
+ if why:
73
+ e.setdefault("evidence", []).append(why)
74
+ e["why"] = why
75
+ _save(graph)
76
+ return e
77
+ edge = {"pre": pre, "post": post, "weight": float(weight), "why": why, "evidence": [why] if why else []}
78
+ edges.append(edge)
79
+ _save(graph)
80
+ return edge
81
+
82
+
83
+ def strengthen(pre: str, post: str, delta: float = 0.25, why: str = "") -> dict[str, Any]:
84
+ graph = _load()
85
+ for e in graph.get("edges", []):
86
+ if e.get("pre") == pre and e.get("post") == post:
87
+ e["weight"] = float(e.get("weight", 1.0)) + float(delta)
88
+ if why:
89
+ e.setdefault("evidence", []).append(why)
90
+ e["why"] = why
91
+ _save(graph)
92
+ return e
93
+ return add_edge(pre, post, weight=1.0 + delta, why=why)
94
+
95
+
96
+ def weaken(pre: str, post: str, delta: float = 0.25, why: str = "") -> dict[str, Any]:
97
+ graph = _load()
98
+ for e in graph.get("edges", []):
99
+ if e.get("pre") == pre and e.get("post") == post:
100
+ e["weight"] = float(e.get("weight", 1.0)) - float(delta)
101
+ if why:
102
+ e.setdefault("evidence", []).append(f"weaken:{why}")
103
+ e["why"] = why
104
+ _save(graph)
105
+ return e
106
+ return add_edge(pre, post, weight=-abs(delta), why=why or "inhibitory")