grg 0.2.2__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.
grg/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Centralized configuration loader for GRG (Geometric Reasoning Governor)."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ DEFAULT_CONFIG_PATH = Path("config/grg-config.json")
9
+
10
+
11
+ def resolve_config_path() -> Path:
12
+ """Find the config file regardless of current working directory."""
13
+ env_path = os.getenv("GRG_CONFIG")
14
+ if env_path:
15
+ return Path(env_path)
16
+
17
+ # 1. Try relative to CWD
18
+ cwd_path = Path.cwd() / DEFAULT_CONFIG_PATH
19
+ if cwd_path.exists():
20
+ return cwd_path
21
+
22
+ # 2. Try relative to this file (grg/config.py -> ./grg-config.json in same dir)
23
+ module_path = Path(__file__).parent / "grg-config.json"
24
+ if module_path.exists():
25
+ return module_path
26
+
27
+ # 3. Try relative to this file's parent (grg/config.py ->
28
+ # ../config/grg-config.json for installed packages)
29
+ package_path = Path(__file__).parent / "config" / "grg-config.json"
30
+ if package_path.exists():
31
+ return package_path
32
+
33
+ # 4. Try package data location (for development mode from src/)
34
+ dev_path = Path(__file__).parent.parent / "config" / "grg-config.json"
35
+ if dev_path.exists():
36
+ return dev_path
37
+
38
+ return DEFAULT_CONFIG_PATH
39
+
40
+
41
+ def load_config() -> dict[str, Any]:
42
+ """Load the GRG configuration from file or return defaults."""
43
+ # Global Defaults
44
+ config = {
45
+ "global": {
46
+ "ranking_profile": "conservative",
47
+ "grg": {
48
+ "window_size": 20,
49
+ "ema_weight": 0.3,
50
+ "warmup_steps": 50,
51
+ "lr_slash_factor": 0.1,
52
+ "lr_restore_factor": 1.0,
53
+ "thermal_damping_min_temp": 0.01,
54
+ "e_stop_horizon": 5,
55
+ "collapse_v_alpha_threshold": -0.02,
56
+ "stability_v_alpha_threshold": 0.005,
57
+ "trajectory_window_size": 500,
58
+ "trajectory_window_step": 50,
59
+ "v_alpha_panic_threshold": -0.02,
60
+ "alpha_safety_floor": 0.10,
61
+ },
62
+ },
63
+ "profiles": {},
64
+ }
65
+
66
+ config_path = resolve_config_path()
67
+
68
+ if config_path.exists():
69
+ try:
70
+ with open(config_path) as f:
71
+ user_config = json.load(f)
72
+ if "global" in user_config or "profiles" in user_config:
73
+ # New hierarchical format
74
+ if "global" in user_config:
75
+ config["global"].update(user_config["global"])
76
+ if "profiles" in user_config:
77
+ config["profiles"].update(user_config["profiles"])
78
+ else:
79
+ # Legacy flat format - merge into global
80
+ config["global"].update(user_config)
81
+ except Exception:
82
+ # Silently fall back to defaults if config fails to load
83
+ pass
84
+
85
+ return config
86
+
87
+
88
+ # Global config instance
89
+ _config_data = load_config()
90
+
91
+
92
+ class ConfigWrapper:
93
+ """Helper to access config with legacy support and profile awareness."""
94
+
95
+ def __init__(self, data: dict[str, Any]):
96
+ self._data = data
97
+
98
+ def get(self, key: str, default: Any = None) -> Any:
99
+ """Legacy access to global settings."""
100
+ return self._data["global"].get(key, default)
101
+
102
+ def get_profile(self, profile_name: str) -> dict[str, Any]:
103
+ """Get profile merged with global defaults."""
104
+ profile = self._data["global"].copy()
105
+ profile.update(self._data["profiles"].get(profile_name, {}))
106
+ return profile
107
+
108
+ def get_grg_config(self, profile_name: str | None = None) -> dict[str, Any]:
109
+ """Get GRG settings merged: global.grg -> profile.grg."""
110
+ grg = dict(self._data["global"].get("grg", {}))
111
+ if profile_name:
112
+ profile_grg = self._data["profiles"].get(profile_name, {}).get("grg", {})
113
+ grg.update(profile_grg)
114
+ return grg
115
+
116
+ def __getitem__(self, key: str) -> Any:
117
+ return self._data["global"][key]
118
+
119
+
120
+ get_config = ConfigWrapper(_config_data)
grg/early_warning.py ADDED
@@ -0,0 +1,272 @@
1
+ """Real-time streaming early warning + intervention system.
2
+
3
+ Combines macro (AlphaMomentumTracker velocity/momentum) with micro
4
+ (rolling rho_pn_sq decoupling) into a single EWI score. Streams each
5
+ reading to a JSONL log and fires intervention callbacks on threshold
6
+ crossing — before the trend fully manifests.
7
+
8
+ Usage:
9
+ from grg.early_warning import EarlyWarningSystem
10
+
11
+ def my_intervention(reading):
12
+ print(f"INTERVENTION: {reading['ewi']:.4f} at step {reading['step']}")
13
+
14
+ ews = EarlyWarningSystem(
15
+ intervention_fn=my_intervention,
16
+ log_path="reports/ewi_stream.jsonl",
17
+ )
18
+ for value in live_feed:
19
+ ews.push(value)
20
+ """
21
+ import json
22
+ import os
23
+ import threading
24
+ import time
25
+ from collections import deque
26
+ from dataclasses import dataclass, asdict
27
+ from typing import Callable, Optional, List
28
+
29
+ import numpy as np
30
+
31
+ from grg.metrics import AlphaMomentumTracker, calculate_moment_sgo_metrics
32
+
33
+
34
+ @dataclass
35
+ class EWIReading:
36
+ step: int
37
+ timestamp: float
38
+ alpha: float
39
+ v_alpha: float
40
+ m_alpha: float
41
+ rho_pn_sq: float
42
+ ewi: float
43
+ warning: bool
44
+ collapse: bool
45
+ intervention_fired: bool
46
+
47
+
48
+ class EarlyWarningSystem:
49
+ """Streaming early warning + intervention system.
50
+
51
+ Parameters
52
+ ----------
53
+ window_size : int
54
+ AlphaMomentumTracker window size.
55
+ alpha_ema : float
56
+ EMA smoothing for alpha.
57
+ k : int
58
+ Chunk size for rolling rho_pn_sq.
59
+ lambda_ : float
60
+ Weight for fragility (rho_pn_sq).
61
+ mu : float
62
+ Weight for negative velocity.
63
+ nu : float
64
+ Weight for negative momentum.
65
+ threshold : float
66
+ EWI threshold for intervention.
67
+ intervention_fn : callable, optional
68
+ Called with EWIReading when EWI crosses threshold.
69
+ log_path : str, optional
70
+ Path to JSONL log file for streaming output.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ window_size: int = 20,
76
+ alpha_ema: float = 0.3,
77
+ k: int = 3,
78
+ lambda_: float = 2.0,
79
+ mu: float = 3.0,
80
+ nu: float = 0.5,
81
+ safety_floor: float = 0.10,
82
+ panic_threshold: float = -0.02,
83
+ e_stop_horizon: float = 3.0,
84
+ threshold: float = 8.0,
85
+ intervention_fn: Optional[Callable] = None,
86
+ max_deltas: int = 200,
87
+ thread_safe: bool = True,
88
+ fsync_log: bool = False,
89
+ log_path: Optional[str] = None,
90
+ ):
91
+ self.tracker = AlphaMomentumTracker(window_size=window_size, alpha_ema=alpha_ema)
92
+ self.k = k
93
+ self.lambda_ = lambda_
94
+ self.mu = mu
95
+ self.nu = nu
96
+ self.safety_floor = safety_floor
97
+ self.panic_threshold = panic_threshold
98
+ self.e_stop_horizon = e_stop_horizon
99
+ self.threshold = threshold
100
+ self.intervention_fn = intervention_fn
101
+ self.log_path = log_path
102
+
103
+ self._deltas: deque = deque(maxlen=max_deltas)
104
+ self._prev_alpha: Optional[float] = None
105
+ self._step: int = 0
106
+ self._last_warning: bool = False
107
+ self._readings: List[EWIReading] = []
108
+ self._lock = threading.Lock() if thread_safe else None
109
+ self._fsync_log = fsync_log
110
+
111
+ if log_path:
112
+ os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
113
+ # Truncate on start
114
+ with open(log_path, "w"):
115
+ pass
116
+
117
+ def _rolling_rho(self) -> float:
118
+ """Compute rho_pn_sq over the last k deltas."""
119
+ deltas = list(self._deltas)
120
+ if len(deltas) < self.k:
121
+ return 0.0
122
+ n_chunks = len(deltas) // self.k
123
+ if n_chunks < 1:
124
+ return 0.0
125
+ trimmed = np.array(deltas[: n_chunks * self.k]).reshape(n_chunks, self.k)
126
+ units = [row.tolist() for row in trimmed]
127
+ try:
128
+ m = calculate_moment_sgo_metrics(units, utility_power=1.1)
129
+ return float(m["rho_pn_sq"])
130
+ except Exception:
131
+ return 0.0
132
+
133
+ def push(self, value: float) -> EWIReading:
134
+ if self._lock is not None:
135
+ with self._lock:
136
+ return self._push(value)
137
+ return self._push(value)
138
+
139
+ def _push(self, value: float) -> EWIReading:
140
+ """Push a new value through the system. Returns the EWI reading."""
141
+ self.tracker.push(value)
142
+ s = self.tracker.get_stats(v_panic=self.panic_threshold, floor=self.safety_floor)
143
+
144
+ # Track deltas for rolling rho
145
+ if self._prev_alpha is not None:
146
+ self._deltas.append(value - self._prev_alpha)
147
+ self._prev_alpha = value
148
+
149
+ rho = self._rolling_rho()
150
+
151
+ # EWI components
152
+ vel_warning = self.mu * max(0.0, -s["v_alpha"])
153
+ mom_warning = self.nu * max(0.0, -s["m_alpha"])
154
+ fragility = self.lambda_ * rho
155
+ ewi = vel_warning + mom_warning + fragility
156
+
157
+ collapse = self.tracker.should_stop(
158
+ safety_floor=self.safety_floor, panic_threshold=self.panic_threshold, e_stop_horizon=self.e_stop_horizon
159
+ )
160
+
161
+ warning = ewi >= self.threshold
162
+ intervention_fired = False
163
+
164
+ reading = EWIReading(
165
+ step=self._step,
166
+ timestamp=time.time(),
167
+ alpha=s["alpha"],
168
+ v_alpha=s["v_alpha"],
169
+ m_alpha=s["m_alpha"],
170
+ rho_pn_sq=rho,
171
+ ewi=ewi,
172
+ warning=warning,
173
+ collapse=collapse,
174
+ intervention_fired=False,
175
+ )
176
+
177
+ # Fire intervention on threshold crossing (rising edge)
178
+ if warning and not self._last_warning:
179
+ reading.intervention_fired = True
180
+ if self.intervention_fn is not None:
181
+ try:
182
+ self.intervention_fn(reading)
183
+ except Exception as e:
184
+ print(f"[EarlyWarning] Intervention callback error: {e}")
185
+
186
+ self._last_warning = warning
187
+ self._step += 1
188
+
189
+ # Stream to log (push, not batch)
190
+ if self.log_path:
191
+ with open(self.log_path, "a") as f:
192
+ f.write(json.dumps(asdict(reading)) + "\n")
193
+ f.flush()
194
+ if self._fsync_log:
195
+ os.fsync(f.fileno())
196
+
197
+ self._readings.append(reading)
198
+ return reading
199
+
200
+ def reset(self) -> None:
201
+ if self._lock is not None:
202
+ with self._lock:
203
+ self._reset_unlocked()
204
+ return
205
+ self._reset_unlocked()
206
+
207
+ def _reset_unlocked(self) -> None:
208
+ self.tracker = AlphaMomentumTracker(window_size=self.tracker.window_size, alpha_ema=self.tracker.alpha_ema)
209
+ self._deltas.clear()
210
+ self._prev_alpha = None
211
+ self._step = 0
212
+ self._last_warning = False
213
+ self._readings.clear()
214
+
215
+ def push_many(self, values) -> list:
216
+ return [self.push(float(v)) for v in values]
217
+
218
+ def get_history(self) -> List[EWIReading]:
219
+ return list(self._readings)
220
+
221
+ def summary(self) -> dict:
222
+ warnings = [r for r in self._readings if r.warning]
223
+ interventions = [r for r in self._readings if r.intervention_fired]
224
+ return {
225
+ "total_steps": self._step,
226
+ "warning_steps": len(warnings),
227
+ "intervention_count": len(interventions),
228
+ "first_warning_step": warnings[0].step if warnings else None,
229
+ "max_ewi": max((r.ewi for r in self._readings), default=0.0),
230
+ "final_alpha": self._readings[-1].alpha if self._readings else None,
231
+ }
232
+
233
+
234
+ # ------------------------------------------------------------------
235
+ # Demo: stream exponential series through the system
236
+ # ------------------------------------------------------------------
237
+
238
+ def demo():
239
+ import numpy as np
240
+
241
+ def intervention_handler(reading: EWIReading):
242
+ print(f" >>> INTERVENTION FIRED at step {reading.step}: "
243
+ f"EWI={reading.ewi:.4f} (alpha={reading.alpha:.4f}, "
244
+ f"v={reading.v_alpha:.4f}, rho={reading.rho_pn_sq:.4f})")
245
+
246
+ series = {
247
+ "decay": [float(np.exp(-0.03 * t)) for t in range(50)],
248
+ "growth": [float(1.0 - np.exp(-0.03 * t)) for t in range(50)],
249
+ "zigzag": [float(0.5 * np.sin(0.3 * t)) for t in range(50)],
250
+ }
251
+
252
+ for name, vals in series.items():
253
+ print(f"\n{'=' * 60}")
254
+ print(f" Streaming: {name}")
255
+ print(f"{'=' * 60}")
256
+ ews = EarlyWarningSystem(
257
+ intervention_fn=intervention_handler,
258
+ log_path=f"reports/ewi_{name}.jsonl",
259
+ threshold=0.05,
260
+ )
261
+ for v in vals:
262
+ ews.push(v)
263
+ sm = ews.summary()
264
+ print(f" Summary: {sm['total_steps']} steps, "
265
+ f"{sm['warning_steps']} warnings, "
266
+ f"{sm['intervention_count']} interventions, "
267
+ f"first warning at step {sm['first_warning_step']}, "
268
+ f"max EWI={sm['max_ewi']:.4f}")
269
+
270
+
271
+ if __name__ == "__main__":
272
+ demo()
grg/grg-config.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "global": {
3
+ "ranking_profile": "conservative",
4
+ "grg": {
5
+ "window_size": 20,
6
+ "ema_weight": 0.3,
7
+ "warmup_steps": 50,
8
+ "lr_slash_factor": 0.1,
9
+ "lr_restore_factor": 1.0,
10
+ "thermal_damping_min_temp": 0.01,
11
+ "e_stop_horizon": 5,
12
+ "collapse_v_alpha_threshold": -0.02,
13
+ "stability_v_alpha_threshold": 0.005,
14
+ "trajectory_window_size": 500,
15
+ "trajectory_window_step": 50,
16
+ "v_alpha_panic_threshold": -0.02,
17
+ "alpha_safety_floor": 0.10
18
+ }
19
+ },
20
+ "profiles": {
21
+ "default": {
22
+ "grg": {
23
+ "window_size": 20,
24
+ "ema_weight": 0.3,
25
+ "warmup_steps": 50,
26
+ "lr_slash_factor": 0.1,
27
+ "lr_restore_factor": 1.0,
28
+ "thermal_damping_min_temp": 0.01,
29
+ "e_stop_horizon": 5,
30
+ "collapse_v_alpha_threshold": -0.02,
31
+ "stability_v_alpha_threshold": 0.005,
32
+ "trajectory_window_size": 500,
33
+ "trajectory_window_step": 50,
34
+ "v_alpha_panic_threshold": -0.02,
35
+ "alpha_safety_floor": 0.10
36
+ }
37
+ },
38
+ "high_sensitivity": {
39
+ "grg": {
40
+ "window_size": 16,
41
+ "ema_weight": 0.4,
42
+ "warmup_steps": 30,
43
+ "lr_slash_factor": 0.2,
44
+ "lr_restore_factor": 1.0,
45
+ "thermal_damping_min_temp": 0.02,
46
+ "e_stop_horizon": 3,
47
+ "collapse_v_alpha_threshold": -0.015,
48
+ "stability_v_alpha_threshold": 0.003,
49
+ "trajectory_window_size": 300,
50
+ "trajectory_window_step": 30,
51
+ "v_alpha_panic_threshold": -0.01,
52
+ "alpha_safety_floor": 0.15
53
+ }
54
+ },
55
+ "low_latency": {
56
+ "grg": {
57
+ "window_size": 12,
58
+ "ema_weight": 0.5,
59
+ "warmup_steps": 20,
60
+ "lr_slash_factor": 0.5,
61
+ "lr_restore_factor": 1.0,
62
+ "thermal_damping_min_temp": 0.01,
63
+ "e_stop_horizon": 2,
64
+ "collapse_v_alpha_threshold": -0.025,
65
+ "stability_v_alpha_threshold": 0.008,
66
+ "trajectory_window_size": 200,
67
+ "trajectory_window_step": 20,
68
+ "v_alpha_panic_threshold": -0.03,
69
+ "alpha_safety_floor": 0.08
70
+ }
71
+ },
72
+ "rag_grounded": {
73
+ "grg": {
74
+ "window_size": 24,
75
+ "ema_weight": 0.25,
76
+ "warmup_steps": 100,
77
+ "lr_slash_factor": 0.1,
78
+ "lr_restore_factor": 1.0,
79
+ "thermal_damping_min_temp": 0.005,
80
+ "e_stop_horizon": 8,
81
+ "collapse_v_alpha_threshold": -0.01,
82
+ "stability_v_alpha_threshold": 0.002,
83
+ "trajectory_window_size": 1000,
84
+ "trajectory_window_step": 100,
85
+ "v_alpha_panic_threshold": -0.005,
86
+ "alpha_safety_floor": 0.20
87
+ }
88
+ }
89
+ }
90
+ }