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 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
+ )