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.
Files changed (34) hide show
  1. {loopgain-0.2.0 → loopgain-0.3.0}/PKG-INFO +52 -20
  2. {loopgain-0.2.0 → loopgain-0.3.0}/README.md +51 -19
  3. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/__init__.py +10 -0
  4. loopgain-0.3.0/loopgain/__main__.py +8 -0
  5. loopgain-0.3.0/loopgain/_version.py +10 -0
  6. loopgain-0.3.0/loopgain/classifier.py +323 -0
  7. loopgain-0.3.0/loopgain/cli.py +109 -0
  8. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/core.py +94 -6
  9. loopgain-0.3.0/loopgain/funnel.py +572 -0
  10. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/autogen.py +4 -0
  11. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/claude_agent_sdk.py +4 -0
  12. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/crewai.py +4 -0
  13. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/langchain.py +4 -0
  14. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/langgraph.py +4 -0
  15. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/openai_agents.py +4 -0
  16. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/PKG-INFO +52 -20
  17. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/SOURCES.txt +8 -0
  18. loopgain-0.3.0/loopgain.egg-info/entry_points.txt +2 -0
  19. {loopgain-0.2.0 → loopgain-0.3.0}/pyproject.toml +4 -1
  20. loopgain-0.3.0/tests/test_classifier_mock_validation.py +269 -0
  21. loopgain-0.3.0/tests/test_classifier_synthetic.py +320 -0
  22. {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_core.py +15 -5
  23. loopgain-0.3.0/tests/test_funnel.py +366 -0
  24. {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_stress.py +26 -12
  25. loopgain-0.2.0/loopgain/_version.py +0 -9
  26. {loopgain-0.2.0 → loopgain-0.3.0}/LICENSE +0 -0
  27. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/integrations/__init__.py +0 -0
  28. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain/telemetry.py +0 -0
  29. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/dependency_links.txt +0 -0
  30. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/requires.txt +0 -0
  31. {loopgain-0.2.0 → loopgain-0.3.0}/loopgain.egg-info/top_level.txt +0 -0
  32. {loopgain-0.2.0 → loopgain-0.3.0}/setup.cfg +0 -0
  33. {loopgain-0.2.0 → loopgain-0.3.0}/tests/test_integrations.py +0 -0
  34. {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.2.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-gain (`Aβ`) monitor that knows whether your agent loop is converging, stalling, oscillating, or diverging and what to do in each case.
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
  [![PyPI](https://img.shields.io/pypi/v/loopgain.svg)](https://pypi.org/project/loopgain/)
57
57
  [![Python](https://img.shields.io/pypi/pyversions/loopgain.svg)](https://pypi.org/project/loopgain/)
58
58
  [![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE)
59
- [![Tests](https://img.shields.io/badge/tests-119_passing-brightgreen.svg)](tests/)
59
+ [![Tests](https://img.shields.io/badge/tests-157_passing-brightgreen.svg)](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, then smooths it with an EMA:
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
- Aβ(n) = E(n) / E(n-1)
117
- Aβ_smooth = EMA(Aβ, w=3)
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 classifies `Aβ_smooth` into five named bands:
122
+ It routes the trajectory into one of five named states:
121
123
 
122
- | `Aβ_smooth` range | State | Action |
124
+ | State | Condition | Action |
123
125
  | --- | --- | --- |
124
- | `< 0.3` | `FAST_CONVERGE` | Continue, predict ETA |
125
- | `0.3 < 0.85` | `CONVERGING` | Continue, watch for upward drift |
126
- | `0.85 < 0.95` | `STALLING` | Warndiminishing returns |
127
- | `0.95 1.05` | `OSCILLATING` | Break — return best-so-far |
128
- | `> 1.05` | `DIVERGING` | Abort — roll back to best-so-far |
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 `±0.05` noise band around `Aβ=1` absorbs stochastic jitter from agent outputs without triggering false-positive aborts. The `0.85` `STALLING` boundary is an early warning by the time `Aβ` crosses `1.0`, you've already wasted iterations.
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
- These threshold defaults are derived from the Barkhausen-stability analysis and serve as reasonable starting points. Tune them per domain (via the `ThresholdBands` argument) once you have production traces.
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` if defaults don't fit your domain.
173
- - `smoothing_window` — EMA window for the smoothed Aβ. Default 3.
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-gain (`Aβ`) monitor that knows whether your agent loop is converging, stalling, oscillating, or diverging and what to do in each case.
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
  [![PyPI](https://img.shields.io/pypi/v/loopgain.svg)](https://pypi.org/project/loopgain/)
8
8
  [![Python](https://img.shields.io/pypi/pyversions/loopgain.svg)](https://pypi.org/project/loopgain/)
9
9
  [![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE)
10
- [![Tests](https://img.shields.io/badge/tests-119_passing-brightgreen.svg)](tests/)
10
+ [![Tests](https://img.shields.io/badge/tests-157_passing-brightgreen.svg)](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, then smooths it with an EMA:
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
- Aβ(n) = E(n) / E(n-1)
68
- Aβ_smooth = EMA(Aβ, w=3)
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 classifies `Aβ_smooth` into five named bands:
73
+ It routes the trajectory into one of five named states:
72
74
 
73
- | `Aβ_smooth` range | State | Action |
75
+ | State | Condition | Action |
74
76
  | --- | --- | --- |
75
- | `< 0.3` | `FAST_CONVERGE` | Continue, predict ETA |
76
- | `0.3 < 0.85` | `CONVERGING` | Continue, watch for upward drift |
77
- | `0.85 < 0.95` | `STALLING` | Warndiminishing returns |
78
- | `0.95 1.05` | `OSCILLATING` | Break — return best-so-far |
79
- | `> 1.05` | `DIVERGING` | Abort — roll back to best-so-far |
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 `±0.05` noise band around `Aβ=1` absorbs stochastic jitter from agent outputs without triggering false-positive aborts. The `0.85` `STALLING` boundary is an early warning by the time `Aβ` crosses `1.0`, you've already wasted iterations.
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
- These threshold defaults are derived from the Barkhausen-stability analysis and serve as reasonable starting points. Tune them per domain (via the `ThresholdBands` argument) once you have production traces.
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` if defaults don't fit your domain.
124
- - `smoothing_window` — EMA window for the smoothed Aβ. Default 3.
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,8 @@
1
+ """Enable ``python -m loopgain`` to invoke the CLI."""
2
+
3
+ import sys
4
+
5
+ from loopgain.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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
+ ]