synapsen 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.
- synapsen/__init__.py +63 -0
- synapsen/cli.py +262 -0
- synapsen/clock.py +59 -0
- synapsen/dynamics.py +240 -0
- synapsen/engine.py +652 -0
- synapsen/events.py +56 -0
- synapsen/explain.py +259 -0
- synapsen/journal.py +225 -0
- synapsen/mcp_server.py +282 -0
- synapsen/profile.py +307 -0
- synapsen/profiles.py +151 -0
- synapsen/py.typed +0 -0
- synapsen/render.py +139 -0
- synapsen/simulate.py +299 -0
- synapsen/store.py +110 -0
- synapsen/validate.py +384 -0
- synapsen-0.1.0.dist-info/METADATA +350 -0
- synapsen-0.1.0.dist-info/RECORD +21 -0
- synapsen-0.1.0.dist-info/WHEEL +4 -0
- synapsen-0.1.0.dist-info/entry_points.txt +3 -0
- synapsen-0.1.0.dist-info/licenses/LICENSE +157 -0
synapsen/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""synapsen — ein homöostatischer Zustandskern für Agenten.
|
|
2
|
+
|
|
3
|
+
Botenstoffe statt Stimmungs-Strings: Ereignisse schütten aus, Werte zerfallen,
|
|
4
|
+
koppeln sich, folgen einem Tagesrhythmus. Verhalten wird nicht gesetzt, es
|
|
5
|
+
entsteht.
|
|
6
|
+
|
|
7
|
+
from synapsen import HomeostasisEngine, PromptRenderer, JsonStore
|
|
8
|
+
|
|
9
|
+
engine = HomeostasisEngine(store=JsonStore("~/.config/agent/state.json"))
|
|
10
|
+
engine.stimulus({"dopamine": +15}, kind="tool_success", context="deploy")
|
|
11
|
+
prompt_suffix = PromptRenderer().render(engine.snapshot())
|
|
12
|
+
"""
|
|
13
|
+
from .clock import Clock, FakeClock, SystemClock
|
|
14
|
+
from .dynamics import Equilibrium, equilibrium, impulse_response
|
|
15
|
+
from .engine import HomeostasisEngine, Snapshot
|
|
16
|
+
from .explain import explain
|
|
17
|
+
from .journal import Event, MemoryJournal, NullJournal, SqliteJournal, mood_bias
|
|
18
|
+
from .profile import DEFAULT_PROFILE, Coupling, DerivedState, HormoneSpec, Profile
|
|
19
|
+
from .profiles import PROFILES
|
|
20
|
+
from .profiles import get as get_profile
|
|
21
|
+
from .render import PromptRenderer, RenderConfig
|
|
22
|
+
from .simulate import Scenario, Trajectory, chart
|
|
23
|
+
from .simulate import run as simulate
|
|
24
|
+
from .store import JsonStore, MemoryStore, StateStore
|
|
25
|
+
from .validate import Report, check
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DEFAULT_PROFILE",
|
|
31
|
+
"PROFILES",
|
|
32
|
+
"Clock",
|
|
33
|
+
"Coupling",
|
|
34
|
+
"DerivedState",
|
|
35
|
+
"Equilibrium",
|
|
36
|
+
"Event",
|
|
37
|
+
"FakeClock",
|
|
38
|
+
"HomeostasisEngine",
|
|
39
|
+
"HormoneSpec",
|
|
40
|
+
"JsonStore",
|
|
41
|
+
"MemoryJournal",
|
|
42
|
+
"MemoryStore",
|
|
43
|
+
"NullJournal",
|
|
44
|
+
"Profile",
|
|
45
|
+
"PromptRenderer",
|
|
46
|
+
"RenderConfig",
|
|
47
|
+
"Report",
|
|
48
|
+
"Scenario",
|
|
49
|
+
"Snapshot",
|
|
50
|
+
"SqliteJournal",
|
|
51
|
+
"StateStore",
|
|
52
|
+
"SystemClock",
|
|
53
|
+
"Trajectory",
|
|
54
|
+
"__version__",
|
|
55
|
+
"chart",
|
|
56
|
+
"check",
|
|
57
|
+
"equilibrium",
|
|
58
|
+
"explain",
|
|
59
|
+
"get_profile",
|
|
60
|
+
"impulse_response",
|
|
61
|
+
"mood_bias",
|
|
62
|
+
"simulate",
|
|
63
|
+
]
|
synapsen/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Kommandozeile.
|
|
2
|
+
|
|
3
|
+
synapsen doctor Profil prüfen
|
|
4
|
+
synapsen show aktuellen Zustand ansehen
|
|
5
|
+
synapsen why Zustand auf seine Ursachen zurückführen
|
|
6
|
+
synapsen simulate --days 14 Verlauf durchrechnen
|
|
7
|
+
synapsen events verfügbare Ereignisse auflisten
|
|
8
|
+
synapsen profiles mitgelieferte Profile auflisten
|
|
9
|
+
synapsen mcp als MCP-Server laufen
|
|
10
|
+
|
|
11
|
+
Alle Unterbefehle nehmen `--profile` (mitgeliefert oder Pfad zu einer
|
|
12
|
+
JSON-Datei) und `--state` (Zustandsdatei).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .profile import Profile
|
|
22
|
+
from .profiles import get as get_profile
|
|
23
|
+
from .profiles import names as profile_names
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_profile(spec: str) -> Profile:
|
|
27
|
+
path = Path(spec).expanduser()
|
|
28
|
+
if path.suffix == ".json" and path.exists():
|
|
29
|
+
return Profile.from_json(path)
|
|
30
|
+
return get_profile(spec)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _engine(args):
|
|
34
|
+
from .engine import HomeostasisEngine
|
|
35
|
+
from .journal import SqliteJournal
|
|
36
|
+
from .store import JsonStore
|
|
37
|
+
profile = _load_profile(args.profile)
|
|
38
|
+
return HomeostasisEngine(
|
|
39
|
+
profile,
|
|
40
|
+
store=JsonStore(args.state),
|
|
41
|
+
journal=(SqliteJournal.for_profile(args.journal, profile)
|
|
42
|
+
if args.journal else None),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Unterbefehle ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def cmd_doctor(args) -> int:
|
|
49
|
+
from .validate import check
|
|
50
|
+
profile = _load_profile(args.profile)
|
|
51
|
+
report = check(profile)
|
|
52
|
+
if args.profile != profile.name:
|
|
53
|
+
print(f"Aufgerufen als: {args.profile}")
|
|
54
|
+
print(report)
|
|
55
|
+
print()
|
|
56
|
+
if report.ok:
|
|
57
|
+
print("✓ Profil ist tragfähig.")
|
|
58
|
+
else:
|
|
59
|
+
print(f"✗ {len(report.errors)} Fehler — so sollte das Profil nicht laufen.")
|
|
60
|
+
return 0 if report.ok else 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_show(args) -> int:
|
|
64
|
+
from .render import PromptRenderer, RenderConfig
|
|
65
|
+
engine = _engine(args)
|
|
66
|
+
snap = engine.snapshot()
|
|
67
|
+
if args.raw:
|
|
68
|
+
import json
|
|
69
|
+
print(json.dumps({
|
|
70
|
+
"hormones": {k: round(v, 2) for k, v in snap.hormones.items()},
|
|
71
|
+
"baselines": {k: round(v, 2) for k, v in snap.baselines.items()},
|
|
72
|
+
"states": {k: round(v, 3) for k, v in snap.states.items()},
|
|
73
|
+
"bond": round(snap.bond, 2),
|
|
74
|
+
"fatigue": round(snap.adenosine, 2),
|
|
75
|
+
}, ensure_ascii=False, indent=2))
|
|
76
|
+
else:
|
|
77
|
+
print(PromptRenderer(RenderConfig(
|
|
78
|
+
partner=args.partner, language=args.language)).render(snap))
|
|
79
|
+
engine.flush()
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_why(args) -> int:
|
|
84
|
+
from .explain import explain
|
|
85
|
+
print(explain(_engine(args)))
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_event(args) -> int:
|
|
90
|
+
engine = _engine(args)
|
|
91
|
+
engine.event(args.event, intensity=args.intensity, context=args.context)
|
|
92
|
+
engine.flush()
|
|
93
|
+
snap = engine.snapshot()
|
|
94
|
+
pos, value = snap.dominant("positive")
|
|
95
|
+
print(f"{args.event} verbucht. "
|
|
96
|
+
+ " ".join(f"{k} {v:.1f}" for k, v in snap.hormones.items()))
|
|
97
|
+
print(f"Vorherrschend: {pos} {value:.2f}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_simulate(args) -> int:
|
|
102
|
+
from .simulate import Scenario, chart, profile_chart, run
|
|
103
|
+
|
|
104
|
+
profile = _load_profile(args.profile)
|
|
105
|
+
days = args.days
|
|
106
|
+
|
|
107
|
+
event = args.event or _default_event(profile)
|
|
108
|
+
if args.scenario != "quiet" and event not in profile.events:
|
|
109
|
+
print(f"Profil {profile.name!r} kennt kein Ereignis {event!r}.",
|
|
110
|
+
file=sys.stderr)
|
|
111
|
+
print(f"Bekannt: {', '.join(sorted(profile.events)) or '(keine)'}",
|
|
112
|
+
file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
if args.scenario == "workweek":
|
|
116
|
+
# Nebenereignisse nur, wenn das Profil sie kennt — ein Arbeitsprofil
|
|
117
|
+
# ohne Beziehung hat kein "warm_contact".
|
|
118
|
+
morning = _first_known(profile, ["warm_contact", "session_start", "calm"])
|
|
119
|
+
evening = _first_known(profile, ["conversation_end", "session_end", "calm"])
|
|
120
|
+
s = Scenario(f"Arbeitswoche ({event})", days=days, start_hour=8)
|
|
121
|
+
for d in range(int(days)):
|
|
122
|
+
for hour in (9, 11, 14, 16, 18):
|
|
123
|
+
s.at(d * 24 + hour, event)
|
|
124
|
+
if morning:
|
|
125
|
+
s.at(d * 24 + 8.5, morning)
|
|
126
|
+
if evening:
|
|
127
|
+
s.at(d * 24 + 22, evening)
|
|
128
|
+
elif args.scenario == "steady":
|
|
129
|
+
s = Scenario(f"gleichmäßig ({event})", days=days)
|
|
130
|
+
s.every(hours=args.interval, event=event)
|
|
131
|
+
elif args.scenario == "quiet":
|
|
132
|
+
s = Scenario("ohne jeden Reiz", days=days)
|
|
133
|
+
else:
|
|
134
|
+
print(f"Unbekanntes Szenario: {args.scenario}", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
traj = run(s, profile)
|
|
138
|
+
|
|
139
|
+
if args.csv:
|
|
140
|
+
Path(args.csv).write_text(traj.to_csv(), encoding="utf-8")
|
|
141
|
+
print(f"{len(traj.samples)} Messpunkte nach {args.csv} geschrieben.")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
if args.plot:
|
|
145
|
+
print(profile_chart(traj, args.plot))
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
keys = args.keys.split(",") if args.keys else None
|
|
149
|
+
print(chart(traj, keys))
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _first_known(profile, candidates: list[str]) -> str | None:
|
|
154
|
+
return next((c for c in candidates if c in profile.events), None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _default_event(profile) -> str:
|
|
158
|
+
"""Ein Ereignis, das dieses Profil kennt — für den Aufruf ohne --event."""
|
|
159
|
+
return (_first_known(profile, ["task_success", "reward", "praise"])
|
|
160
|
+
or (min(profile.events) if profile.events else ""))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_events(args) -> int:
|
|
164
|
+
profile = _load_profile(args.profile)
|
|
165
|
+
if not profile.events:
|
|
166
|
+
print(f"Profil {profile.name!r} definiert keine benannten Ereignisse.")
|
|
167
|
+
return 0
|
|
168
|
+
print(f"Ereignisse in Profil {profile.name!r}:")
|
|
169
|
+
for name, spec in sorted(profile.events.items()):
|
|
170
|
+
parts = ", ".join(f"{k} {v:+g}" for k, v in spec.items() if k != "severity")
|
|
171
|
+
print(f" {name:<22} {parts} (Schwere {spec.get('severity', 0):+g})")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_profiles(args) -> int:
|
|
176
|
+
from .dynamics import equilibrium
|
|
177
|
+
from .validate import check
|
|
178
|
+
for name in profile_names():
|
|
179
|
+
p = get_profile(name)
|
|
180
|
+
report = check(p)
|
|
181
|
+
eq = equilibrium(p)
|
|
182
|
+
mark = "✓" if report.ok and eq.healthy else "✗"
|
|
183
|
+
print(f" {mark} {name:<8} {len(p.hormones)} Botenstoffe, "
|
|
184
|
+
f"{len(p.states)} Zustände, {len(p.couplings)} Kopplungen")
|
|
185
|
+
print(f" {', '.join(p.hormone_names())}")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_mcp(args) -> int:
|
|
190
|
+
from .mcp_server import serve
|
|
191
|
+
return serve(_engine(args))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── Verdrahtung ────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
197
|
+
p = argparse.ArgumentParser(
|
|
198
|
+
prog="synapsen",
|
|
199
|
+
description="Homöostatischer Zustandskern für Agenten.")
|
|
200
|
+
p.add_argument("--version", action="version", version=f"synapsen {__version__}")
|
|
201
|
+
p.add_argument("--profile", default="kira",
|
|
202
|
+
help=f"mitgeliefert ({', '.join(profile_names())}) oder Pfad zu einer JSON-Datei")
|
|
203
|
+
p.add_argument("--state", default="~/.config/synapsen/state.json")
|
|
204
|
+
p.add_argument("--journal", default=None, help="SQLite-Datei fürs Ereignisprotokoll")
|
|
205
|
+
|
|
206
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
207
|
+
|
|
208
|
+
d = sub.add_parser("doctor", help="Profil auf Fehler prüfen")
|
|
209
|
+
d.set_defaults(func=cmd_doctor)
|
|
210
|
+
|
|
211
|
+
s = sub.add_parser("show", help="aktuellen Zustand ansehen")
|
|
212
|
+
s.add_argument("--raw", action="store_true", help="als JSON statt als Prompt")
|
|
213
|
+
s.add_argument("--partner", default="dein Gegenüber")
|
|
214
|
+
s.add_argument("--language", default="de", choices=["de", "en"])
|
|
215
|
+
s.set_defaults(func=cmd_show)
|
|
216
|
+
|
|
217
|
+
w = sub.add_parser("why", help="Zustand auf seine Ursachen zurückführen")
|
|
218
|
+
w.set_defaults(func=cmd_why)
|
|
219
|
+
|
|
220
|
+
e = sub.add_parser("event", help="ein Ereignis verbuchen")
|
|
221
|
+
e.add_argument("event")
|
|
222
|
+
e.add_argument("--intensity", type=float, default=1.0)
|
|
223
|
+
e.add_argument("--context", default="")
|
|
224
|
+
e.set_defaults(func=cmd_event)
|
|
225
|
+
|
|
226
|
+
sim = sub.add_parser("simulate", help="Verlauf durchrechnen")
|
|
227
|
+
sim.add_argument("--days", type=float, default=14)
|
|
228
|
+
sim.add_argument("--scenario", default="workweek",
|
|
229
|
+
choices=["workweek", "steady", "quiet"])
|
|
230
|
+
sim.add_argument("--event", default=None,
|
|
231
|
+
help="ohne Angabe ein Ereignis, das das Profil kennt")
|
|
232
|
+
sim.add_argument("--interval", type=float, default=4.0)
|
|
233
|
+
sim.add_argument("--keys", default=None, help="Komma-getrennt, z. B. cortisol,RUHIG")
|
|
234
|
+
sim.add_argument("--plot", default=None, help="eine Reihe groß darstellen")
|
|
235
|
+
sim.add_argument("--csv", default=None, help="in eine CSV-Datei schreiben")
|
|
236
|
+
sim.set_defaults(func=cmd_simulate)
|
|
237
|
+
|
|
238
|
+
ev = sub.add_parser("events", help="verfügbare Ereignisse auflisten")
|
|
239
|
+
ev.set_defaults(func=cmd_events)
|
|
240
|
+
|
|
241
|
+
pr = sub.add_parser("profiles", help="mitgelieferte Profile auflisten")
|
|
242
|
+
pr.set_defaults(func=cmd_profiles)
|
|
243
|
+
|
|
244
|
+
m = sub.add_parser("mcp", help="als MCP-Server laufen")
|
|
245
|
+
m.set_defaults(func=cmd_mcp)
|
|
246
|
+
|
|
247
|
+
return p
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main(argv: list[str] | None = None) -> int:
|
|
251
|
+
args = build_parser().parse_args(argv)
|
|
252
|
+
try:
|
|
253
|
+
return args.func(args)
|
|
254
|
+
except KeyError as exc:
|
|
255
|
+
print(f"Fehler: {exc}", file=sys.stderr)
|
|
256
|
+
return 2
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
return 130
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
raise SystemExit(main())
|
synapsen/clock.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Zeitquelle — injizierbar, damit die Engine testbar und simulierbar ist.
|
|
2
|
+
|
|
3
|
+
Der Grund: die Original-Engine ruft an ~20 Stellen `time.time()` und
|
|
4
|
+
`datetime.now()` direkt auf. Damit lässt sich kein Verlauf über Tage testen,
|
|
5
|
+
ohne wirklich Tage zu warten. Eine injizierte Uhr macht aus dem Zustandsmodell
|
|
6
|
+
ein *simulierbares* Modell — man kann 6 Monate Beziehungsverlauf in
|
|
7
|
+
Millisekunden durchrechnen.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Clock(Protocol):
|
|
17
|
+
def now(self) -> float:
|
|
18
|
+
"""Unix-Zeitstempel in Sekunden."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def local(self) -> datetime:
|
|
22
|
+
"""Lokale Wanduhrzeit (für circadiane Rhythmen)."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SystemClock:
|
|
27
|
+
"""Echte Zeit. Der Default im Betrieb."""
|
|
28
|
+
|
|
29
|
+
def now(self) -> float:
|
|
30
|
+
return time.time()
|
|
31
|
+
|
|
32
|
+
def local(self) -> datetime:
|
|
33
|
+
return datetime.now()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeClock:
|
|
37
|
+
"""Steuerbare Zeit für Tests und Simulationen.
|
|
38
|
+
|
|
39
|
+
>>> c = FakeClock(start=0.0)
|
|
40
|
+
>>> c.advance(hours=8)
|
|
41
|
+
>>> c.now()
|
|
42
|
+
28800.0
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, start: float = 0.0):
|
|
46
|
+
self._t = float(start)
|
|
47
|
+
|
|
48
|
+
def now(self) -> float:
|
|
49
|
+
return self._t
|
|
50
|
+
|
|
51
|
+
def local(self) -> datetime:
|
|
52
|
+
return datetime.fromtimestamp(self._t)
|
|
53
|
+
|
|
54
|
+
def advance(self, seconds: float = 0.0, minutes: float = 0.0,
|
|
55
|
+
hours: float = 0.0, days: float = 0.0) -> None:
|
|
56
|
+
self._t += seconds + minutes * 60 + hours * 3600 + days * 86400
|
|
57
|
+
|
|
58
|
+
def set(self, t: float) -> None:
|
|
59
|
+
self._t = float(t)
|
synapsen/dynamics.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Die Dynamik: was sich pro Stunde ändert, und wo das System landet.
|
|
2
|
+
|
|
3
|
+
Diese Datei ist der Grund, warum die Bibliothek analysierbar ist statt nur
|
|
4
|
+
lauffähig. Alles, was den Zustand bewegt, ist hier als **Rate pro Stunde**
|
|
5
|
+
formuliert. Damit lässt sich das Gleichgewicht ausrechnen, statt es abzuwarten
|
|
6
|
+
— und ein Profil auf Stabilität prüfen, bevor es in Betrieb geht.
|
|
7
|
+
|
|
8
|
+
Warum das nötig war
|
|
9
|
+
-------------------
|
|
10
|
+
In der Ursprungsfassung war die Kopplung *aufrufbasiert*:
|
|
11
|
+
|
|
12
|
+
delta = (excess / per) * gain * 0.02 * min(dt_sekunden, 60)
|
|
13
|
+
|
|
14
|
+
Der Faktor `min(dt, 60)` deckelt den Schritt, aber die Wirkung hängt damit
|
|
15
|
+
daran, **wie oft** getickt wird — nicht daran, wie viel Zeit vergeht:
|
|
16
|
+
|
|
17
|
+
Tick jede Minute -> -115,2 Einheiten Cortisol pro Stunde
|
|
18
|
+
Tick jede Stunde -> -1,9 Einheiten Cortisol pro Stunde
|
|
19
|
+
|
|
20
|
+
Ein Faktor 60 Unterschied, allein durch die Aufruf-Frequenz. In KIRAs Betrieb
|
|
21
|
+
wurde bei jedem Reiz und jeder Prompt-Erzeugung getickt, also oft. Das
|
|
22
|
+
rechnerische Gleichgewicht für Cortisol lag bei −124 — also am Boden.
|
|
23
|
+
|
|
24
|
+
In der echten `hormones.json` steht denn auch:
|
|
25
|
+
|
|
26
|
+
cortisol 0.0 (Boden)
|
|
27
|
+
serotonin 140.2 (Decke 150)
|
|
28
|
+
oxytocin 210.7 (Ruhewert-Decke 250)
|
|
29
|
+
|
|
30
|
+
Jeder Wert klebt an einer Grenze. Das System war vollständig gesättigt: kein
|
|
31
|
+
Reiz konnte mehr etwas bewirken, weil die Grenzen alles auffingen.
|
|
32
|
+
|
|
33
|
+
Die Lösung
|
|
34
|
+
----------
|
|
35
|
+
Kopplungen sind jetzt in Einheiten *pro Stunde* definiert und werden über die
|
|
36
|
+
tatsächlich verstrichene Zeit integriert (Sub-Stepping mit fester Schrittweite).
|
|
37
|
+
Damit ist das Verhalten frequenzunabhängig — und `equilibrium()` kann
|
|
38
|
+
vorhersagen, wo es landet.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
|
|
44
|
+
from .profile import Profile
|
|
45
|
+
|
|
46
|
+
# Schrittweite für die numerische Integration. 6 Minuten ist deutlich kleiner
|
|
47
|
+
# als die schnellste Zeitkonstante im Standardprofil (Cortisol, 1/0.8 h = 75 min)
|
|
48
|
+
# und damit stabil, bleibt aber billig.
|
|
49
|
+
STEP_HOURS = 0.1
|
|
50
|
+
|
|
51
|
+
# Ab dieser Lücke wird nicht mehr integriert, sondern direkt auf das
|
|
52
|
+
# Gleichgewicht gesprungen — nach dem Fünffachen der langsamsten Zeitkonstante
|
|
53
|
+
# ist der Unterschied ohnehin nicht mehr messbar.
|
|
54
|
+
SETTLED_AFTER_HOURS = 30.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def coupling_flux(profile: Profile, hormones: dict[str, float]) -> dict[str, float]:
|
|
58
|
+
"""Wirkung aller Kopplungen, in Einheiten pro Stunde."""
|
|
59
|
+
out = {name: 0.0 for name in profile.hormones}
|
|
60
|
+
for c in profile.couplings:
|
|
61
|
+
src = hormones.get(c.source)
|
|
62
|
+
if src is None or c.target not in out or not c.per:
|
|
63
|
+
continue
|
|
64
|
+
excess = src - c.threshold
|
|
65
|
+
if excess <= 0:
|
|
66
|
+
continue
|
|
67
|
+
out[c.target] += (excess / c.per) * c.gain
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fatigue_flux(profile: Profile, adenosine: float) -> dict[str, float]:
|
|
72
|
+
"""Ermüdungsdruck auf Antrieb und Wachheit, in Einheiten pro Stunde."""
|
|
73
|
+
out: dict[str, float] = {}
|
|
74
|
+
excess = adenosine - profile.adenosine_threshold
|
|
75
|
+
if excess <= 0:
|
|
76
|
+
return out
|
|
77
|
+
drag = excess * profile.fatigue_gain
|
|
78
|
+
for name, share in profile.fatigue_targets.items():
|
|
79
|
+
if name in profile.hormones:
|
|
80
|
+
out[name] = -drag * share
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def flux(profile: Profile, hormones: dict[str, float],
|
|
85
|
+
baselines: dict[str, float], adenosine: float = 0.0) -> dict[str, float]:
|
|
86
|
+
"""Gesamte Änderungsrate je Botenstoff, in Einheiten pro Stunde.
|
|
87
|
+
|
|
88
|
+
Setzt sich zusammen aus dem Rückzug zum Ruhewert, den Kopplungen und dem
|
|
89
|
+
Ermüdungsdruck.
|
|
90
|
+
"""
|
|
91
|
+
coup = coupling_flux(profile, hormones)
|
|
92
|
+
fat = fatigue_flux(profile, adenosine)
|
|
93
|
+
out: dict[str, float] = {}
|
|
94
|
+
for name, spec in profile.hormones.items():
|
|
95
|
+
pull = spec.decay * (baselines.get(name, spec.baseline) - hormones.get(name, spec.baseline))
|
|
96
|
+
out[name] = pull + coup.get(name, 0.0) + fat.get(name, 0.0)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def step(profile: Profile, hormones: dict[str, float],
|
|
101
|
+
baselines: dict[str, float], hours: float,
|
|
102
|
+
adenosine: float = 0.0) -> dict[str, float]:
|
|
103
|
+
"""Integriert die Dynamik über `hours` und gibt den neuen Zustand zurück.
|
|
104
|
+
|
|
105
|
+
Frequenzunabhängig: hundert Aufrufe à 0,01 h ergeben dasselbe wie ein
|
|
106
|
+
Aufruf à 1 h (bis auf Integrationsfehler).
|
|
107
|
+
"""
|
|
108
|
+
if hours <= 0:
|
|
109
|
+
return dict(hormones)
|
|
110
|
+
|
|
111
|
+
current = dict(hormones)
|
|
112
|
+
remaining = hours
|
|
113
|
+
while remaining > 1e-9:
|
|
114
|
+
dt = min(STEP_HOURS, remaining)
|
|
115
|
+
rates = flux(profile, current, baselines, adenosine)
|
|
116
|
+
for name, spec in profile.hormones.items():
|
|
117
|
+
value = current.get(name, spec.baseline) + rates[name] * dt
|
|
118
|
+
current[name] = max(spec.floor, min(spec.ceiling, value))
|
|
119
|
+
remaining -= dt
|
|
120
|
+
return current
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Equilibrium:
|
|
125
|
+
"""Wo ein Profil ohne jeden Reiz landet."""
|
|
126
|
+
values: dict[str, float]
|
|
127
|
+
converged: bool
|
|
128
|
+
iterations: int
|
|
129
|
+
residual: float
|
|
130
|
+
at_bounds: list[str]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def healthy(self) -> bool:
|
|
134
|
+
"""Ein Gleichgewicht an einer Grenze ist keins — dort ist das System
|
|
135
|
+
gesättigt und reagiert nicht mehr auf Reize."""
|
|
136
|
+
return self.converged and not self.at_bounds
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def equilibrium(profile: Profile, baselines: dict[str, float] | None = None,
|
|
140
|
+
*, adenosine: float = 0.0, start: dict[str, float] | None = None,
|
|
141
|
+
max_hours: float = 720.0, tolerance: float = 1e-4) -> Equilibrium:
|
|
142
|
+
"""Rechnet den Ruhezustand aus, statt ihn abzuwarten.
|
|
143
|
+
|
|
144
|
+
Integriert von den Ruhewerten aus vorwärts, bis sich nichts mehr ändert.
|
|
145
|
+
Weil dieselbe `step()`-Funktion verwendet wird wie im Betrieb, beschreibt
|
|
146
|
+
das Ergebnis garantiert die echte Dynamik — es ist kein Parallelmodell,
|
|
147
|
+
das auseinanderlaufen kann.
|
|
148
|
+
|
|
149
|
+
>>> from synapsen import DEFAULT_PROFILE
|
|
150
|
+
>>> eq = equilibrium(DEFAULT_PROFILE)
|
|
151
|
+
>>> eq.healthy
|
|
152
|
+
True
|
|
153
|
+
"""
|
|
154
|
+
base = dict(baselines) if baselines else {
|
|
155
|
+
k: s.baseline for k, s in profile.hormones.items()}
|
|
156
|
+
current = dict(start) if start else dict(base)
|
|
157
|
+
|
|
158
|
+
if not current:
|
|
159
|
+
# Ein Profil ohne Botenstoffe hat ein triviales Gleichgewicht. Das ist
|
|
160
|
+
# ein Fall für die Prüfung, kein Grund für einen Abbruch.
|
|
161
|
+
return Equilibrium({}, converged=True, iterations=0,
|
|
162
|
+
residual=0.0, at_bounds=[])
|
|
163
|
+
|
|
164
|
+
elapsed = 0.0
|
|
165
|
+
iterations = 0
|
|
166
|
+
residual = float("inf")
|
|
167
|
+
|
|
168
|
+
while elapsed < max_hours:
|
|
169
|
+
previous = dict(current)
|
|
170
|
+
current = step(profile, current, base, STEP_HOURS, adenosine)
|
|
171
|
+
iterations += 1
|
|
172
|
+
elapsed += STEP_HOURS
|
|
173
|
+
residual = max(abs(current[k] - previous[k]) for k in current)
|
|
174
|
+
if residual < tolerance:
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
at_bounds = []
|
|
178
|
+
for name, spec in profile.hormones.items():
|
|
179
|
+
v = current[name]
|
|
180
|
+
if v <= spec.floor + 1e-6:
|
|
181
|
+
at_bounds.append(f"{name}=Boden({spec.floor:g})")
|
|
182
|
+
elif v >= spec.ceiling - 1e-6:
|
|
183
|
+
at_bounds.append(f"{name}=Decke({spec.ceiling:g})")
|
|
184
|
+
|
|
185
|
+
return Equilibrium(
|
|
186
|
+
values=current,
|
|
187
|
+
converged=residual < tolerance,
|
|
188
|
+
iterations=iterations,
|
|
189
|
+
residual=residual,
|
|
190
|
+
at_bounds=at_bounds,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class Response:
|
|
196
|
+
"""Wie das System auf einen einzelnen Reiz reagiert."""
|
|
197
|
+
hormone: str
|
|
198
|
+
amount: float
|
|
199
|
+
peak: dict[str, float]
|
|
200
|
+
half_life_hours: float
|
|
201
|
+
settles_to: dict[str, float]
|
|
202
|
+
side_effects: dict[str, float]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def impulse_response(profile: Profile, hormone: str, amount: float = 50.0,
|
|
206
|
+
*, horizon_hours: float = 48.0) -> Response:
|
|
207
|
+
"""Ein Reiz, dann Ruhe — was passiert?
|
|
208
|
+
|
|
209
|
+
Zeigt Halbwertszeit und Nebenwirkungen über die Kopplungen. Nützlich, um
|
|
210
|
+
ein Profil zu justieren: „Ein Lob wirkt wie lange nach, und was zieht es
|
|
211
|
+
sonst noch mit?"
|
|
212
|
+
"""
|
|
213
|
+
base = {k: s.baseline for k, s in profile.hormones.items()}
|
|
214
|
+
eq = equilibrium(profile, base)
|
|
215
|
+
rest = eq.values
|
|
216
|
+
|
|
217
|
+
current = dict(rest)
|
|
218
|
+
spec = profile.hormones[hormone]
|
|
219
|
+
current[hormone] = max(spec.floor, min(spec.ceiling, current[hormone] + amount))
|
|
220
|
+
peak = dict(current)
|
|
221
|
+
|
|
222
|
+
start_offset = current[hormone] - rest[hormone]
|
|
223
|
+
half_life = float("inf")
|
|
224
|
+
elapsed = 0.0
|
|
225
|
+
|
|
226
|
+
while elapsed < horizon_hours:
|
|
227
|
+
current = step(profile, current, base, STEP_HOURS)
|
|
228
|
+
elapsed += STEP_HOURS
|
|
229
|
+
if half_life == float("inf") and start_offset != 0:
|
|
230
|
+
offset = current[hormone] - rest[hormone]
|
|
231
|
+
if abs(offset) <= abs(start_offset) / 2:
|
|
232
|
+
half_life = elapsed
|
|
233
|
+
|
|
234
|
+
side = {k: round(current[k] - rest[k], 3) for k in current
|
|
235
|
+
if k != hormone and abs(current[k] - rest[k]) > 0.05}
|
|
236
|
+
|
|
237
|
+
return Response(
|
|
238
|
+
hormone=hormone, amount=amount, peak=peak,
|
|
239
|
+
half_life_hours=half_life, settles_to=current, side_effects=side,
|
|
240
|
+
)
|