loopgain 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {loopgain-0.2.0 → loopgain-0.3.0}/PKG-INFO +52 -20
- {loopgain-0.2.0 → loopgain-0.3.0}/README.md +51 -19
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/__init__.py +10 -0
- loopgain-0.3.0/loopgain/__main__.py +8 -0
- loopgain-0.3.0/loopgain/_version.py +10 -0
- loopgain-0.3.0/loopgain/classifier.py +323 -0
- loopgain-0.3.0/loopgain/cli.py +109 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/core.py +94 -6
- loopgain-0.3.0/loopgain/funnel.py +572 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/autogen.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/claude_agent_sdk.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/crewai.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/langchain.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/langgraph.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/openai_agents.py +4 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/PKG-INFO +52 -20
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/SOURCES.txt +8 -0
- loopgain-0.3.0/loopgain.egg-info/entry_points.txt +2 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/pyproject.toml +4 -1
- loopgain-0.3.0/tests/test_classifier_mock_validation.py +269 -0
- loopgain-0.3.0/tests/test_classifier_synthetic.py +320 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_core.py +15 -5
- loopgain-0.3.0/tests/test_funnel.py +366 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_stress.py +26 -12
- loopgain-0.2.0/loopgain/_version.py +0 -9
- {loopgain-0.2.0 → loopgain-0.3.0}/LICENSE +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/__init__.py +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/telemetry.py +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/dependency_links.txt +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/requires.txt +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/top_level.txt +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/setup.cfg +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_integrations.py +0 -0
- {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_telemetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loopgain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Barkhausen stability monitor for AI agent loops. Real-time loop-gain (Aβ) monitoring with five named threshold bands, best-so-far rollback, and ETA prediction.
|
|
5
5
|
Author-email: Dave Fitzsimmons <hello@loopgain.ai>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -51,12 +51,12 @@ Dynamic: license-file
|
|
|
51
51
|
|
|
52
52
|
**Barkhausen stability monitor for AI agent loops.**
|
|
53
53
|
|
|
54
|
-
Replace `max_iterations=5` with a real-time loop
|
|
54
|
+
Replace `max_iterations=5` with a real-time trajectory classifier that reads four features off the loop's error series and routes it into one of five named states — knowing whether your agent loop is converging, stalling, oscillating, or diverging, and what to do in each case.
|
|
55
55
|
|
|
56
56
|
[](https://pypi.org/project/loopgain/)
|
|
57
57
|
[](https://pypi.org/project/loopgain/)
|
|
58
58
|
[](LICENSE)
|
|
59
|
-
[](tests/)
|
|
60
60
|
|
|
61
61
|
**Home:** [loopgain.ai](https://loopgain.ai)
|
|
62
62
|
|
|
@@ -97,7 +97,7 @@ while lg.should_continue():
|
|
|
97
97
|
output = reviser.revise(output, errors)
|
|
98
98
|
|
|
99
99
|
result = lg.result
|
|
100
|
-
print(result.outcome) # "converged" | "oscillating" | "diverged" | "max_iterations"
|
|
100
|
+
print(result.outcome) # "converged" | "oscillating" | "diverged" | "stalled" | "max_iterations"
|
|
101
101
|
print(result.best_output) # the lowest-error iteration's output
|
|
102
102
|
print(result.iterations_used)
|
|
103
103
|
print(result.gain_margin) # 1 / max(Aβ_smooth)
|
|
@@ -110,28 +110,32 @@ print(result.savings_vs_fixed_cap)
|
|
|
110
110
|
|
|
111
111
|
## How it works
|
|
112
112
|
|
|
113
|
-
LoopGain measures empirical loop gain at every iteration
|
|
113
|
+
LoopGain measures empirical loop gain (`Aβ = E(n) / E(n-1)`) at every iteration and exposes it as a smoothed time series for visualization. The decision engine, however, classifies the **full error trajectory** using four features:
|
|
114
114
|
|
|
115
115
|
```
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
E_ratio = E_current / E_first # cumulative reduction
|
|
117
|
+
slope_log = OLS slope of log10(E) # geometric trend direction
|
|
118
|
+
slope_p = t-test p-value of slope # statistical significance
|
|
119
|
+
osc_std = std of detrended log10(E) # oscillation magnitude
|
|
118
120
|
```
|
|
119
121
|
|
|
120
|
-
It
|
|
122
|
+
It routes the trajectory into one of five named states:
|
|
121
123
|
|
|
122
|
-
|
|
|
124
|
+
| State | Condition | Action |
|
|
123
125
|
| --- | --- | --- |
|
|
124
|
-
|
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
|
|
|
126
|
+
| `FAST_CONVERGE` | cumulative reduction to ≤ 10% of E_first | Continue, predict ETA |
|
|
127
|
+
| `CONVERGING` | negative slope with `p < 0.05`, OR cumulative ≤ 50% | Continue, watch for upward drift |
|
|
128
|
+
| `STALLING` | no significant slope, no detectable oscillation | Stop after 2 consecutive readings — return best-so-far |
|
|
129
|
+
| `OSCILLATING` | high residual variance with flat trend | Stop — return best-so-far |
|
|
130
|
+
| `DIVERGING` | positive slope with `p < 0.05` AND cumulative > 110% | Abort — roll back to best-so-far |
|
|
129
131
|
|
|
130
132
|
Plus a short-circuit: if observed error drops at or below `target_error`, the loop stops immediately with state `TARGET_MET`. The default `target_error=0.0` short-circuits on exactly zero error — the natural completion signal for verifier-driven loops. Pass `target_error=None` to disable the short-circuit and rely on stability detection alone.
|
|
131
133
|
|
|
132
|
-
The
|
|
134
|
+
The decision is **conservative by design**: requiring both statistical significance and meaningful cumulative motion before terminating prevents false-positive aborts on noisy real-LLM error series. Validated at 98.8% macro-averaged accuracy across 5 regimes on N=1000 deterministic-mock trajectories (see `RESULTS_v2_classifier.md`). The STALLING ceiling of ~94% is the t-test's irreducible 5% type-I error rate, not a classifier weakness.
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
**Recommended minimum: 6 iterations** for reliable trend significance. At n≤4 the t-test is severely underpowered (df=2 requires |t|>4.3 for p<0.05) — the classifier conservatively falls back to STALLING when evidence is thin. The thresholds are derived analytically (control theory + statistical convention), not fitted; tune them per domain via the `TrajectoryThresholds` argument once you have production traces.
|
|
137
|
+
|
|
138
|
+
**Legacy single-feature classifier:** the original v0.1 single-Aβ-band classifier (thresholds 0.3 / 0.85 / 0.95 / 1.05) is still available via `LoopGain(classifier='legacy_bands')` for callers that have empirically tuned the bands to a specific workload.
|
|
135
139
|
|
|
136
140
|
---
|
|
137
141
|
|
|
@@ -163,14 +167,16 @@ This transforms divergence detection from "abort with garbage" into "abort with
|
|
|
163
167
|
|
|
164
168
|
## API reference
|
|
165
169
|
|
|
166
|
-
### `LoopGain(target_error=0.0, max_iterations=None, thresholds=None, smoothing_window=3, assumed_fixed_cap=10)`
|
|
170
|
+
### `LoopGain(target_error=0.0, max_iterations=None, thresholds=None, trajectory_thresholds=None, classifier='trajectory', smoothing_window=3, assumed_fixed_cap=10)`
|
|
167
171
|
|
|
168
172
|
Construct the monitor.
|
|
169
173
|
|
|
170
174
|
- `target_error` — Stop when an observed error drops at or below this. Default `0.0` short-circuits on exactly zero error (the natural completion signal for verifier-driven loops). Pass `None` to disable the short-circuit entirely.
|
|
171
175
|
- `max_iterations` — Hard safety cap. Default `None` (rely on stability detection). Recommended ~20–50 for production.
|
|
172
|
-
- `thresholds` — Custom `ThresholdBands`
|
|
173
|
-
- `
|
|
176
|
+
- `thresholds` — Custom `ThresholdBands` for the legacy single-Aβ-band classifier. Ignored when `classifier='trajectory'`.
|
|
177
|
+
- `trajectory_thresholds` — Custom `TrajectoryThresholds` for the multi-feature classifier (the default). Override only with workload-specific evidence.
|
|
178
|
+
- `classifier` — `'trajectory'` (default, v0.2 multi-feature classifier) or `'legacy_bands'` (v0.1 single-Aβ-band classifier).
|
|
179
|
+
- `smoothing_window` — EMA window for the smoothed Aβ series (always maintained for visualization, regardless of classifier choice). Default 3.
|
|
174
180
|
- `assumed_fixed_cap` — Used to compute `savings_vs_fixed_cap`. Default 10.
|
|
175
181
|
|
|
176
182
|
### `lg.observe(errors, output=None) -> str`
|
|
@@ -183,7 +189,7 @@ Returns `False` once a terminal state fires.
|
|
|
183
189
|
|
|
184
190
|
### `lg.state -> str`
|
|
185
191
|
|
|
186
|
-
Current state name. One of `INIT`, `FAST_CONVERGE`, `CONVERGING`, `STALLING`, `OSCILLATING`, `DIVERGING`, `TARGET_MET`, `MAX_ITERATIONS`.
|
|
192
|
+
Current state name. One of `INIT`, `FAST_CONVERGE`, `CONVERGING`, `STALLING`, `OSCILLATING`, `DIVERGING`, `TARGET_MET`, `MAX_ITERATIONS`. The corresponding terminal `result.outcome` values are `converged`, `oscillating`, `diverged`, `stalled` (v0.2 trajectory mode only — STALLING terminating after 2 consecutive readings), `max_iterations`, or `in_progress`.
|
|
187
193
|
|
|
188
194
|
### `lg.eta -> int | None`
|
|
189
195
|
|
|
@@ -233,6 +239,32 @@ What is sent: state transitions, Aβ summary (min/max/median), gain margin, roll
|
|
|
233
239
|
|
|
234
240
|
The hosted endpoint at `telemetry.loopgain.ai` is one acceptable destination. The [receiver](https://github.com/loopgain-ai/telemetry-receiver) and [dashboard](https://github.com/loopgain-ai/dashboard) are both open-source — self-host to keep telemetry fully under your control.
|
|
235
241
|
|
|
242
|
+
> **This is not the same as anonymous usage telemetry.** `send_telemetry` sends *your* loop data to *your* dashboard, and only when you call it. There's a separate, opt-in **funnel** telemetry described below. The two never share data or code.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Anonymous funnel telemetry (opt-in, off by default)
|
|
247
|
+
|
|
248
|
+
LoopGain can report **anonymous usage counts** so a solo maintainer can tell whether the library is actually being used — install → first `observe()` → recurring use. **It is opt-in and default-decline: nothing is sent unless you explicitly turn it on.**
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
loopgain telemetry --show # status + exactly what would be sent
|
|
252
|
+
loopgain telemetry --enable # opt in (or: export LOOPGAIN_TELEMETRY=1)
|
|
253
|
+
loopgain telemetry --disable # opt out (or: export LOOPGAIN_TELEMETRY=0)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`DO_NOT_TRACK=1` is honored as a hard opt-out, and CI environments are auto-detected and declined silently. When enabled, payloads carry only a locally-generated random id (not derived from your machine), hour-bucketed timestamps, library/Python/OS versions, the adapter in use, and a coarse outcome count. **Prompts, outputs, error contents, keys, paths, and IPs are never collected.** Delivery is batched, async, https-only, and fail-silent — it can never break your loop. Full details and the privacy contract: **[TELEMETRY.md](TELEMETRY.md)**.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Command-line interface
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
loopgain --version # or: loopgain version
|
|
264
|
+
loopgain telemetry --show # inspect / control anonymous funnel telemetry
|
|
265
|
+
python -m loopgain telemetry --show # equivalent, without the console script
|
|
266
|
+
```
|
|
267
|
+
|
|
236
268
|
---
|
|
237
269
|
|
|
238
270
|
## Framework adapters
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
**Barkhausen stability monitor for AI agent loops.**
|
|
4
4
|
|
|
5
|
-
Replace `max_iterations=5` with a real-time loop
|
|
5
|
+
Replace `max_iterations=5` with a real-time trajectory classifier that reads four features off the loop's error series and routes it into one of five named states — knowing whether your agent loop is converging, stalling, oscillating, or diverging, and what to do in each case.
|
|
6
6
|
|
|
7
7
|
[](https://pypi.org/project/loopgain/)
|
|
8
8
|
[](https://pypi.org/project/loopgain/)
|
|
9
9
|
[](LICENSE)
|
|
10
|
-
[](tests/)
|
|
11
11
|
|
|
12
12
|
**Home:** [loopgain.ai](https://loopgain.ai)
|
|
13
13
|
|
|
@@ -48,7 +48,7 @@ while lg.should_continue():
|
|
|
48
48
|
output = reviser.revise(output, errors)
|
|
49
49
|
|
|
50
50
|
result = lg.result
|
|
51
|
-
print(result.outcome) # "converged" | "oscillating" | "diverged" | "max_iterations"
|
|
51
|
+
print(result.outcome) # "converged" | "oscillating" | "diverged" | "stalled" | "max_iterations"
|
|
52
52
|
print(result.best_output) # the lowest-error iteration's output
|
|
53
53
|
print(result.iterations_used)
|
|
54
54
|
print(result.gain_margin) # 1 / max(Aβ_smooth)
|
|
@@ -61,28 +61,32 @@ print(result.savings_vs_fixed_cap)
|
|
|
61
61
|
|
|
62
62
|
## How it works
|
|
63
63
|
|
|
64
|
-
LoopGain measures empirical loop gain at every iteration
|
|
64
|
+
LoopGain measures empirical loop gain (`Aβ = E(n) / E(n-1)`) at every iteration and exposes it as a smoothed time series for visualization. The decision engine, however, classifies the **full error trajectory** using four features:
|
|
65
65
|
|
|
66
66
|
```
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
E_ratio = E_current / E_first # cumulative reduction
|
|
68
|
+
slope_log = OLS slope of log10(E) # geometric trend direction
|
|
69
|
+
slope_p = t-test p-value of slope # statistical significance
|
|
70
|
+
osc_std = std of detrended log10(E) # oscillation magnitude
|
|
69
71
|
```
|
|
70
72
|
|
|
71
|
-
It
|
|
73
|
+
It routes the trajectory into one of five named states:
|
|
72
74
|
|
|
73
|
-
|
|
|
75
|
+
| State | Condition | Action |
|
|
74
76
|
| --- | --- | --- |
|
|
75
|
-
|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
|
|
|
77
|
+
| `FAST_CONVERGE` | cumulative reduction to ≤ 10% of E_first | Continue, predict ETA |
|
|
78
|
+
| `CONVERGING` | negative slope with `p < 0.05`, OR cumulative ≤ 50% | Continue, watch for upward drift |
|
|
79
|
+
| `STALLING` | no significant slope, no detectable oscillation | Stop after 2 consecutive readings — return best-so-far |
|
|
80
|
+
| `OSCILLATING` | high residual variance with flat trend | Stop — return best-so-far |
|
|
81
|
+
| `DIVERGING` | positive slope with `p < 0.05` AND cumulative > 110% | Abort — roll back to best-so-far |
|
|
80
82
|
|
|
81
83
|
Plus a short-circuit: if observed error drops at or below `target_error`, the loop stops immediately with state `TARGET_MET`. The default `target_error=0.0` short-circuits on exactly zero error — the natural completion signal for verifier-driven loops. Pass `target_error=None` to disable the short-circuit and rely on stability detection alone.
|
|
82
84
|
|
|
83
|
-
The
|
|
85
|
+
The decision is **conservative by design**: requiring both statistical significance and meaningful cumulative motion before terminating prevents false-positive aborts on noisy real-LLM error series. Validated at 98.8% macro-averaged accuracy across 5 regimes on N=1000 deterministic-mock trajectories (see `RESULTS_v2_classifier.md`). The STALLING ceiling of ~94% is the t-test's irreducible 5% type-I error rate, not a classifier weakness.
|
|
84
86
|
|
|
85
|
-
|
|
87
|
+
**Recommended minimum: 6 iterations** for reliable trend significance. At n≤4 the t-test is severely underpowered (df=2 requires |t|>4.3 for p<0.05) — the classifier conservatively falls back to STALLING when evidence is thin. The thresholds are derived analytically (control theory + statistical convention), not fitted; tune them per domain via the `TrajectoryThresholds` argument once you have production traces.
|
|
88
|
+
|
|
89
|
+
**Legacy single-feature classifier:** the original v0.1 single-Aβ-band classifier (thresholds 0.3 / 0.85 / 0.95 / 1.05) is still available via `LoopGain(classifier='legacy_bands')` for callers that have empirically tuned the bands to a specific workload.
|
|
86
90
|
|
|
87
91
|
---
|
|
88
92
|
|
|
@@ -114,14 +118,16 @@ This transforms divergence detection from "abort with garbage" into "abort with
|
|
|
114
118
|
|
|
115
119
|
## API reference
|
|
116
120
|
|
|
117
|
-
### `LoopGain(target_error=0.0, max_iterations=None, thresholds=None, smoothing_window=3, assumed_fixed_cap=10)`
|
|
121
|
+
### `LoopGain(target_error=0.0, max_iterations=None, thresholds=None, trajectory_thresholds=None, classifier='trajectory', smoothing_window=3, assumed_fixed_cap=10)`
|
|
118
122
|
|
|
119
123
|
Construct the monitor.
|
|
120
124
|
|
|
121
125
|
- `target_error` — Stop when an observed error drops at or below this. Default `0.0` short-circuits on exactly zero error (the natural completion signal for verifier-driven loops). Pass `None` to disable the short-circuit entirely.
|
|
122
126
|
- `max_iterations` — Hard safety cap. Default `None` (rely on stability detection). Recommended ~20–50 for production.
|
|
123
|
-
- `thresholds` — Custom `ThresholdBands`
|
|
124
|
-
- `
|
|
127
|
+
- `thresholds` — Custom `ThresholdBands` for the legacy single-Aβ-band classifier. Ignored when `classifier='trajectory'`.
|
|
128
|
+
- `trajectory_thresholds` — Custom `TrajectoryThresholds` for the multi-feature classifier (the default). Override only with workload-specific evidence.
|
|
129
|
+
- `classifier` — `'trajectory'` (default, v0.2 multi-feature classifier) or `'legacy_bands'` (v0.1 single-Aβ-band classifier).
|
|
130
|
+
- `smoothing_window` — EMA window for the smoothed Aβ series (always maintained for visualization, regardless of classifier choice). Default 3.
|
|
125
131
|
- `assumed_fixed_cap` — Used to compute `savings_vs_fixed_cap`. Default 10.
|
|
126
132
|
|
|
127
133
|
### `lg.observe(errors, output=None) -> str`
|
|
@@ -134,7 +140,7 @@ Returns `False` once a terminal state fires.
|
|
|
134
140
|
|
|
135
141
|
### `lg.state -> str`
|
|
136
142
|
|
|
137
|
-
Current state name. One of `INIT`, `FAST_CONVERGE`, `CONVERGING`, `STALLING`, `OSCILLATING`, `DIVERGING`, `TARGET_MET`, `MAX_ITERATIONS`.
|
|
143
|
+
Current state name. One of `INIT`, `FAST_CONVERGE`, `CONVERGING`, `STALLING`, `OSCILLATING`, `DIVERGING`, `TARGET_MET`, `MAX_ITERATIONS`. The corresponding terminal `result.outcome` values are `converged`, `oscillating`, `diverged`, `stalled` (v0.2 trajectory mode only — STALLING terminating after 2 consecutive readings), `max_iterations`, or `in_progress`.
|
|
138
144
|
|
|
139
145
|
### `lg.eta -> int | None`
|
|
140
146
|
|
|
@@ -184,6 +190,32 @@ What is sent: state transitions, Aβ summary (min/max/median), gain margin, roll
|
|
|
184
190
|
|
|
185
191
|
The hosted endpoint at `telemetry.loopgain.ai` is one acceptable destination. The [receiver](https://github.com/loopgain-ai/telemetry-receiver) and [dashboard](https://github.com/loopgain-ai/dashboard) are both open-source — self-host to keep telemetry fully under your control.
|
|
186
192
|
|
|
193
|
+
> **This is not the same as anonymous usage telemetry.** `send_telemetry` sends *your* loop data to *your* dashboard, and only when you call it. There's a separate, opt-in **funnel** telemetry described below. The two never share data or code.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Anonymous funnel telemetry (opt-in, off by default)
|
|
198
|
+
|
|
199
|
+
LoopGain can report **anonymous usage counts** so a solo maintainer can tell whether the library is actually being used — install → first `observe()` → recurring use. **It is opt-in and default-decline: nothing is sent unless you explicitly turn it on.**
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
loopgain telemetry --show # status + exactly what would be sent
|
|
203
|
+
loopgain telemetry --enable # opt in (or: export LOOPGAIN_TELEMETRY=1)
|
|
204
|
+
loopgain telemetry --disable # opt out (or: export LOOPGAIN_TELEMETRY=0)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`DO_NOT_TRACK=1` is honored as a hard opt-out, and CI environments are auto-detected and declined silently. When enabled, payloads carry only a locally-generated random id (not derived from your machine), hour-bucketed timestamps, library/Python/OS versions, the adapter in use, and a coarse outcome count. **Prompts, outputs, error contents, keys, paths, and IPs are never collected.** Delivery is batched, async, https-only, and fail-silent — it can never break your loop. Full details and the privacy contract: **[TELEMETRY.md](TELEMETRY.md)**.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Command-line interface
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
loopgain --version # or: loopgain version
|
|
215
|
+
loopgain telemetry --show # inspect / control anonymous funnel telemetry
|
|
216
|
+
python -m loopgain telemetry --show # equivalent, without the console script
|
|
217
|
+
```
|
|
218
|
+
|
|
187
219
|
---
|
|
188
220
|
|
|
189
221
|
## Framework adapters
|
|
@@ -10,6 +10,12 @@ Public API:
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from loopgain._version import __version__
|
|
13
|
+
from loopgain.classifier import (
|
|
14
|
+
TrajectoryFeatures,
|
|
15
|
+
TrajectoryThresholds,
|
|
16
|
+
classify_trajectory,
|
|
17
|
+
extract_features,
|
|
18
|
+
)
|
|
13
19
|
from loopgain.core import (
|
|
14
20
|
LoopGain,
|
|
15
21
|
LoopGainResult,
|
|
@@ -29,6 +35,10 @@ __all__ = [
|
|
|
29
35
|
"LoopGain",
|
|
30
36
|
"LoopGainResult",
|
|
31
37
|
"ThresholdBands",
|
|
38
|
+
"TrajectoryThresholds",
|
|
39
|
+
"TrajectoryFeatures",
|
|
40
|
+
"classify_trajectory",
|
|
41
|
+
"extract_features",
|
|
32
42
|
"INIT",
|
|
33
43
|
"FAST_CONVERGE",
|
|
34
44
|
"CONVERGING",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Single source of truth for the package version.
|
|
2
|
+
|
|
3
|
+
``loopgain/__init__.py``, ``loopgain/telemetry.py`` (product receiver), and
|
|
4
|
+
``loopgain/funnel.py`` (opt-in funnel telemetry) all import ``__version__``
|
|
5
|
+
from here so the value never drifts between ``__version__`` and the
|
|
6
|
+
``library_version`` field on any telemetry payload. Update this file (and
|
|
7
|
+
``pyproject.toml``) for each release.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Multi-feature trajectory classifier for LoopGain.
|
|
2
|
+
|
|
3
|
+
The v0.1 classifier maps a single instantaneous smoothed loop-gain Aβ_smooth
|
|
4
|
+
into one of five named states using fixed thresholds. Empirical validation
|
|
5
|
+
on real GVR loops (Component Algebra Experiment 3, 2026-04-10, n=150) showed
|
|
6
|
+
37.3% accuracy against intended ground truth — the single-feature design
|
|
7
|
+
cannot disambiguate floor-noise convergence, slow monotone improvement, and
|
|
8
|
+
mild drift-style divergence from one another.
|
|
9
|
+
|
|
10
|
+
This module replaces that with a multi-feature classifier that operates on
|
|
11
|
+
the full error trajectory. See ``PROTOCOL_v2_classifier.md`` for the
|
|
12
|
+
pre-registered design, threshold derivations, and validation plan.
|
|
13
|
+
|
|
14
|
+
The five state names are preserved (FAST_CONVERGE / CONVERGING / STALLING /
|
|
15
|
+
OSCILLATING / DIVERGING) so the telemetry schema, dashboard, and integrations
|
|
16
|
+
contract are not broken.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import statistics
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional, Sequence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# State constants — re-imported from core to avoid a circular import.
|
|
28
|
+
# These strings must stay in lockstep with core.py.
|
|
29
|
+
INIT = "INIT"
|
|
30
|
+
FAST_CONVERGE = "FAST_CONVERGE"
|
|
31
|
+
CONVERGING = "CONVERGING"
|
|
32
|
+
STALLING = "STALLING"
|
|
33
|
+
OSCILLATING = "OSCILLATING"
|
|
34
|
+
DIVERGING = "DIVERGING"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ----- Pre-registered thresholds (PROTOCOL_v2_classifier.md §"Thresholds")
|
|
38
|
+
#
|
|
39
|
+
# Do not tune these to make individual workloads pass. The whole point of the
|
|
40
|
+
# pre-registration is that the thresholds are derived from textbook control
|
|
41
|
+
# theory and statistical convention, not fit. If a workload needs different
|
|
42
|
+
# behavior, pass a custom TrajectoryThresholds instance rather than editing
|
|
43
|
+
# these defaults.
|
|
44
|
+
|
|
45
|
+
# Cumulative E_current/E_first reduction below which we call FAST_CONVERGE.
|
|
46
|
+
# Derivation: one decade reduction = standard step-response 90% criterion.
|
|
47
|
+
DEFAULT_E_RATIO_FAST = 0.1
|
|
48
|
+
|
|
49
|
+
# E_current/E_first reduction below which we call CONVERGING even if the
|
|
50
|
+
# slope p-value is not significant (the cumulative reduction is enough
|
|
51
|
+
# evidence). Derivation: -3 dB / half-life.
|
|
52
|
+
DEFAULT_E_RATIO_CONV = 0.5
|
|
53
|
+
|
|
54
|
+
# Two-sided p-value below which the trend is "significant". Standard.
|
|
55
|
+
DEFAULT_P_SIG = 0.05
|
|
56
|
+
|
|
57
|
+
# Cumulative growth above which a positive slope counts as divergence. Below
|
|
58
|
+
# this margin a positive slope is treated as noise around stalling.
|
|
59
|
+
DEFAULT_DIV_MARGIN = 0.10
|
|
60
|
+
|
|
61
|
+
# Detrended log10(E) residual std above which we call OSCILLATING. Derivation:
|
|
62
|
+
# 0.30 log10 units ≈ ±2× ripple, matching an underdamped Q≈3 response.
|
|
63
|
+
DEFAULT_OSC_STD_THRESHOLD = 0.30
|
|
64
|
+
|
|
65
|
+
# Per-iteration log10 slope magnitude below which we call the trend flat
|
|
66
|
+
# for the oscillation gate.
|
|
67
|
+
DEFAULT_SLOPE_TOL = 0.05
|
|
68
|
+
|
|
69
|
+
# Numerical floor to avoid log(0).
|
|
70
|
+
_EPS = 1e-12
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class TrajectoryThresholds:
|
|
75
|
+
"""Pre-registered thresholds for the multi-feature classifier.
|
|
76
|
+
|
|
77
|
+
Defaults match ``PROTOCOL_v2_classifier.md``. Override only when you have
|
|
78
|
+
workload-specific evidence; do not tune to inflate accuracy numbers
|
|
79
|
+
against held-out scenarios.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
e_ratio_fast: float = DEFAULT_E_RATIO_FAST
|
|
83
|
+
e_ratio_conv: float = DEFAULT_E_RATIO_CONV
|
|
84
|
+
p_sig: float = DEFAULT_P_SIG
|
|
85
|
+
div_margin: float = DEFAULT_DIV_MARGIN
|
|
86
|
+
osc_std_threshold: float = DEFAULT_OSC_STD_THRESHOLD
|
|
87
|
+
slope_tol: float = DEFAULT_SLOPE_TOL
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class TrajectoryFeatures:
|
|
92
|
+
"""Computed features for one trajectory at a point in time.
|
|
93
|
+
|
|
94
|
+
Returned by :func:`extract_features` so callers (e.g., telemetry, the
|
|
95
|
+
dashboard, downstream tests) can inspect the inputs to the classification
|
|
96
|
+
decision.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
e_current: float
|
|
100
|
+
e_first: float
|
|
101
|
+
e_min: float
|
|
102
|
+
e_ratio: float
|
|
103
|
+
slope_log: float
|
|
104
|
+
slope_p: float
|
|
105
|
+
osc_std: float
|
|
106
|
+
n: int
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ols_slope_and_p(
|
|
110
|
+
x: Sequence[float], y: Sequence[float]
|
|
111
|
+
) -> tuple[float, float]:
|
|
112
|
+
"""Closed-form OLS slope + two-sided t-test p-value for the slope.
|
|
113
|
+
|
|
114
|
+
Pure stdlib — no scipy dependency in the core package.
|
|
115
|
+
Returns (0.0, 1.0) if n < 3 or x has zero variance.
|
|
116
|
+
|
|
117
|
+
The p-value uses a Student-t CDF approximation via the regularized
|
|
118
|
+
incomplete beta function from the math module (Python 3.12+:
|
|
119
|
+
``math.lgamma`` is enough to build the survival function we need with
|
|
120
|
+
Wilson-Hilferty for any df ≥ 3).
|
|
121
|
+
"""
|
|
122
|
+
n = len(x)
|
|
123
|
+
if n < 3:
|
|
124
|
+
# Need at least 3 points to estimate slope with any degrees of freedom.
|
|
125
|
+
if n == 2:
|
|
126
|
+
# Degenerate: slope is well defined, p-value is not.
|
|
127
|
+
dx = x[1] - x[0]
|
|
128
|
+
if dx == 0:
|
|
129
|
+
return 0.0, 1.0
|
|
130
|
+
return (y[1] - y[0]) / dx, 1.0
|
|
131
|
+
return 0.0, 1.0
|
|
132
|
+
|
|
133
|
+
mean_x = sum(x) / n
|
|
134
|
+
mean_y = sum(y) / n
|
|
135
|
+
sxx = sum((xi - mean_x) ** 2 for xi in x)
|
|
136
|
+
if sxx == 0:
|
|
137
|
+
return 0.0, 1.0
|
|
138
|
+
sxy = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(x, y))
|
|
139
|
+
slope = sxy / sxx
|
|
140
|
+
intercept = mean_y - slope * mean_x
|
|
141
|
+
|
|
142
|
+
# Residual sum of squares; SE of slope; t-stat.
|
|
143
|
+
rss = sum((yi - (intercept + slope * xi)) ** 2 for xi, yi in zip(x, y))
|
|
144
|
+
df = n - 2
|
|
145
|
+
if df <= 0 or rss <= 0:
|
|
146
|
+
# Perfect fit (rss=0) — slope is exact; p ≈ 0 if slope != 0.
|
|
147
|
+
return slope, 0.0 if slope != 0 else 1.0
|
|
148
|
+
s2 = rss / df
|
|
149
|
+
se = math.sqrt(s2 / sxx)
|
|
150
|
+
if se == 0:
|
|
151
|
+
return slope, 0.0 if slope != 0 else 1.0
|
|
152
|
+
t_stat = slope / se
|
|
153
|
+
p = _two_sided_t_p(abs(t_stat), df)
|
|
154
|
+
return slope, p
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _two_sided_t_p(t_abs: float, df: int) -> float:
|
|
158
|
+
"""Two-sided Student-t p-value via a Wilson-Hilferty normal approximation.
|
|
159
|
+
|
|
160
|
+
Accurate enough for the classifier's purpose (decision threshold at
|
|
161
|
+
p=0.05) for df ≥ 3. Returns a value in [0, 1].
|
|
162
|
+
|
|
163
|
+
For df=2 (n=4 observations of x,y), uses the exact closed form
|
|
164
|
+
P(|T| > t) = 2 / (2 + t²)^(1/2) for one-sided, doubled.
|
|
165
|
+
"""
|
|
166
|
+
if df <= 0:
|
|
167
|
+
return 1.0
|
|
168
|
+
if df == 1:
|
|
169
|
+
# exact: cdf_t(t,1) = 0.5 + arctan(t)/pi
|
|
170
|
+
return 2.0 * (0.5 - math.atan(t_abs) / math.pi)
|
|
171
|
+
if df == 2:
|
|
172
|
+
# exact one-sided survival: 1 - (1 + t²/2)^(-1) doubled
|
|
173
|
+
return min(1.0, 2.0 * (1.0 - t_abs / math.sqrt(2.0 + t_abs * t_abs) / 1.0) * 0.5
|
|
174
|
+
+ 2.0 * (0.5 - 0.5 * t_abs / math.sqrt(2.0 + t_abs * t_abs)))
|
|
175
|
+
# Wilson-Hilferty: transform t² ~ F(1, df), then F → chi-square via
|
|
176
|
+
# cube-root approximation. For our purposes the simpler normal-approx
|
|
177
|
+
# to the t with the Hill / Abramowitz adjustment is enough.
|
|
178
|
+
# Use the standard correction: z = t * (1 - 1/(4·df)) / sqrt(1 + t²/(2·df))
|
|
179
|
+
z = t_abs * (1.0 - 1.0 / (4.0 * df)) / math.sqrt(1.0 + t_abs * t_abs / (2.0 * df))
|
|
180
|
+
# Two-sided normal survival via erfc.
|
|
181
|
+
return math.erfc(z / math.sqrt(2.0))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def extract_features(error_history: Sequence[float]) -> TrajectoryFeatures:
|
|
185
|
+
"""Compute trajectory-level features from the error history.
|
|
186
|
+
|
|
187
|
+
Operates on log10(max(E, ε)) so geometric (multiplicative) trends become
|
|
188
|
+
linear. This is the standard transformation for any signal that obeys
|
|
189
|
+
Barkhausen's E_n = Aβ · E_{n−1}.
|
|
190
|
+
"""
|
|
191
|
+
n = len(error_history)
|
|
192
|
+
if n == 0:
|
|
193
|
+
return TrajectoryFeatures(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0)
|
|
194
|
+
|
|
195
|
+
e_first = error_history[0]
|
|
196
|
+
e_current = error_history[-1]
|
|
197
|
+
e_min = min(error_history)
|
|
198
|
+
e_ratio = e_current / max(abs(e_first), _EPS)
|
|
199
|
+
|
|
200
|
+
if n < 2:
|
|
201
|
+
return TrajectoryFeatures(
|
|
202
|
+
e_current=e_current,
|
|
203
|
+
e_first=e_first,
|
|
204
|
+
e_min=e_min,
|
|
205
|
+
e_ratio=e_ratio,
|
|
206
|
+
slope_log=0.0,
|
|
207
|
+
slope_p=1.0,
|
|
208
|
+
osc_std=0.0,
|
|
209
|
+
n=n,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
xs = list(range(n))
|
|
213
|
+
log_e = [math.log10(max(e, _EPS)) for e in error_history]
|
|
214
|
+
slope, p = _ols_slope_and_p(xs, log_e)
|
|
215
|
+
|
|
216
|
+
# Detrended residual std (sample std).
|
|
217
|
+
intercept = sum(log_e) / n - slope * (sum(xs) / n)
|
|
218
|
+
residuals = [log_e[i] - (intercept + slope * xs[i]) for i in range(n)]
|
|
219
|
+
if n >= 2:
|
|
220
|
+
osc_std = statistics.pstdev(residuals)
|
|
221
|
+
else:
|
|
222
|
+
osc_std = 0.0
|
|
223
|
+
|
|
224
|
+
return TrajectoryFeatures(
|
|
225
|
+
e_current=e_current,
|
|
226
|
+
e_first=e_first,
|
|
227
|
+
e_min=e_min,
|
|
228
|
+
e_ratio=e_ratio,
|
|
229
|
+
slope_log=slope,
|
|
230
|
+
slope_p=p,
|
|
231
|
+
osc_std=osc_std,
|
|
232
|
+
n=n,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def classify_trajectory(
|
|
237
|
+
error_history: Sequence[float],
|
|
238
|
+
*,
|
|
239
|
+
target_error: Optional[float] = None,
|
|
240
|
+
thresholds: Optional[TrajectoryThresholds] = None,
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Classify a full error history into one of the five named states.
|
|
243
|
+
|
|
244
|
+
Decision rule (pre-registered, see PROTOCOL_v2_classifier.md):
|
|
245
|
+
|
|
246
|
+
TARGET_MET if E_current ≤ target_error
|
|
247
|
+
INIT if n < 2
|
|
248
|
+
FAST_CONVERGE if E_ratio ≤ E_RATIO_FAST
|
|
249
|
+
CONVERGING if slope_log < 0 AND (slope_p < P_SIG OR E_ratio ≤ E_RATIO_CONV)
|
|
250
|
+
DIVERGING if slope_log > 0 AND slope_p < P_SIG AND E_ratio > 1 + DIV_MARGIN
|
|
251
|
+
OSCILLATING if osc_std ≥ OSC_STD_THRESHOLD AND |slope_log| < SLOPE_TOL
|
|
252
|
+
STALLING otherwise
|
|
253
|
+
|
|
254
|
+
Note: TARGET_MET is returned only when ``target_error`` is supplied AND
|
|
255
|
+
``E_current ≤ target_error``. This module does not own the TARGET_MET
|
|
256
|
+
short-circuit; ``LoopGain.observe`` handles that, and the classifier is
|
|
257
|
+
called only when the short-circuit has not fired. We accept the
|
|
258
|
+
``target_error`` parameter so callers that want to classify a stored
|
|
259
|
+
trajectory get the same answer the live engine would have produced.
|
|
260
|
+
"""
|
|
261
|
+
th = thresholds or TrajectoryThresholds()
|
|
262
|
+
if not error_history:
|
|
263
|
+
return INIT
|
|
264
|
+
|
|
265
|
+
e_current = error_history[-1]
|
|
266
|
+
if target_error is not None and e_current <= target_error:
|
|
267
|
+
# State name for "target met" is exposed by core, not this module.
|
|
268
|
+
# Callers that want the literal "TARGET_MET" string should check
|
|
269
|
+
# target_error themselves; we return FAST_CONVERGE as the classifier's
|
|
270
|
+
# opinion of a trajectory that's already at its floor.
|
|
271
|
+
return FAST_CONVERGE
|
|
272
|
+
|
|
273
|
+
n = len(error_history)
|
|
274
|
+
if n < 2:
|
|
275
|
+
return INIT
|
|
276
|
+
|
|
277
|
+
f = extract_features(error_history)
|
|
278
|
+
|
|
279
|
+
# n == 2 special case: with two observations, the slope is well defined
|
|
280
|
+
# but its p-value is not (zero residual degrees of freedom). Fall back to
|
|
281
|
+
# the sign of the change. This is the same conservatism as a Wilcoxon
|
|
282
|
+
# signed-rank test with n=1: insufficient evidence for a significance
|
|
283
|
+
# claim, but the *direction* is unambiguous.
|
|
284
|
+
if n == 2:
|
|
285
|
+
if f.e_ratio <= th.e_ratio_fast:
|
|
286
|
+
return FAST_CONVERGE
|
|
287
|
+
if f.e_ratio < 1.0:
|
|
288
|
+
return CONVERGING
|
|
289
|
+
if f.e_ratio > 1.0 + th.div_margin:
|
|
290
|
+
return DIVERGING
|
|
291
|
+
return STALLING
|
|
292
|
+
|
|
293
|
+
# Order matters: FAST_CONVERGE precedes CONVERGING; both precede the
|
|
294
|
+
# remaining gates.
|
|
295
|
+
if f.e_ratio <= th.e_ratio_fast:
|
|
296
|
+
return FAST_CONVERGE
|
|
297
|
+
|
|
298
|
+
slope_significant = f.slope_p < th.p_sig
|
|
299
|
+
|
|
300
|
+
if f.slope_log < 0 and (slope_significant or f.e_ratio <= th.e_ratio_conv):
|
|
301
|
+
return CONVERGING
|
|
302
|
+
|
|
303
|
+
if f.slope_log > 0 and slope_significant and f.e_ratio > 1.0 + th.div_margin:
|
|
304
|
+
return DIVERGING
|
|
305
|
+
|
|
306
|
+
if f.osc_std >= th.osc_std_threshold and abs(f.slope_log) < th.slope_tol:
|
|
307
|
+
return OSCILLATING
|
|
308
|
+
|
|
309
|
+
return STALLING
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
__all__ = [
|
|
313
|
+
"TrajectoryThresholds",
|
|
314
|
+
"TrajectoryFeatures",
|
|
315
|
+
"extract_features",
|
|
316
|
+
"classify_trajectory",
|
|
317
|
+
"DEFAULT_E_RATIO_FAST",
|
|
318
|
+
"DEFAULT_E_RATIO_CONV",
|
|
319
|
+
"DEFAULT_P_SIG",
|
|
320
|
+
"DEFAULT_DIV_MARGIN",
|
|
321
|
+
"DEFAULT_OSC_STD_THRESHOLD",
|
|
322
|
+
"DEFAULT_SLOPE_TOL",
|
|
323
|
+
]
|