loopgain 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.1.2
3
+ Version: 0.1.4
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 <dave_fitz@icloud.com>
6
6
  License: Apache-2.0
@@ -101,7 +101,7 @@ Plus a short-circuit: if observed error drops at or below `target_error`, the lo
101
101
 
102
102
  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.
103
103
 
104
- These threshold defaults work well for typical agent loops out of the box. Tune them per domain (via the `ThresholdBands` argument) once you have production traces.
104
+ 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.
105
105
 
106
106
  ---
107
107
 
@@ -234,5 +234,3 @@ Loop types this applies to in practice:
234
234
  - **Code generation with linter/test feedback** — generate, run tests/linter, fix, repeat. Error = failing test count or linter violation count.
235
235
  - **Multi-step reasoning loops** — ReAct-style think/act/observe iterations. Error = whatever the agent's quality assessor returns.
236
236
  - **Custom feedback loops** — anything where you can produce a number that should drop toward zero as the loop succeeds.
237
-
238
- See [loopgain.ai](https://loopgain.ai) for the longer write-up.
@@ -73,7 +73,7 @@ Plus a short-circuit: if observed error drops at or below `target_error`, the lo
73
73
 
74
74
  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.
75
75
 
76
- These threshold defaults work well for typical agent loops out of the box. Tune them per domain (via the `ThresholdBands` argument) once you have production traces.
76
+ 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.
77
77
 
78
78
  ---
79
79
 
@@ -206,5 +206,3 @@ Loop types this applies to in practice:
206
206
  - **Code generation with linter/test feedback** — generate, run tests/linter, fix, repeat. Error = failing test count or linter violation count.
207
207
  - **Multi-step reasoning loops** — ReAct-style think/act/observe iterations. Error = whatever the agent's quality assessor returns.
208
208
  - **Custom feedback loops** — anything where you can produce a number that should drop toward zero as the loop succeeds.
209
-
210
- See [loopgain.ai](https://loopgain.ai) for the longer write-up.
@@ -24,7 +24,7 @@ from loopgain.core import (
24
24
  )
25
25
  from loopgain.telemetry import build_payload as build_telemetry_payload
26
26
 
27
- __version__ = "0.1.2"
27
+ __version__ = "0.1.4"
28
28
 
29
29
  __all__ = [
30
30
  "LoopGain",
@@ -10,7 +10,7 @@ named states and decides whether to continue, stop, or roll back.
10
10
 
11
11
  The math is foundational EE control theory. The product layer is the
12
12
  threshold bands, the best-so-far buffer, the ETA prediction, and the
13
- clean Python API. See https://loopgain.ai for the long-form write-up.
13
+ clean Python API.
14
14
 
15
15
  License: Apache-2.0
16
16
  """
@@ -109,6 +109,19 @@ class LoopGainResult:
109
109
  """Iterations saved versus the assumed fixed cap (default 10).
110
110
  Zero if the loop hit ``max_iterations``; otherwise non-negative."""
111
111
 
112
+ first_eta_prediction: Optional[int] = None
113
+ """First non-None ``eta`` snapshot captured during the loop —
114
+ the predicted iterations-remaining at the moment the prediction
115
+ became computable. ``None`` if no prediction was ever made
116
+ (e.g., ``target_error == 0``, loop never converged toward target,
117
+ or the loop terminated before two observations)."""
118
+
119
+ first_eta_at_iteration: Optional[int] = None
120
+ """Iteration count when ``first_eta_prediction`` was captured.
121
+ ``None`` if no prediction was ever made. Predicted *total*
122
+ iterations = ``first_eta_at_iteration + first_eta_prediction``,
123
+ comparable to ``iterations_used`` for calibration."""
124
+
112
125
 
113
126
  class LoopGain:
114
127
  """Barkhausen stability monitor for AI agent loops.
@@ -173,6 +186,8 @@ class LoopGain:
173
186
  self._outputs: list[Any] = []
174
187
  self._state: str = INIT
175
188
  self._terminal: bool = False
189
+ self._first_eta_prediction: Optional[int] = None
190
+ self._first_eta_at_iteration: Optional[int] = None
176
191
 
177
192
  # ----- Public observation API -----
178
193
 
@@ -237,6 +252,17 @@ class LoopGain:
237
252
  self._state = MAX_ITERATIONS
238
253
  self._terminal = True
239
254
 
255
+ # Snapshot the first computable eta prediction for calibration.
256
+ # eta is None until smoothing settles and the loop looks convergent;
257
+ # we capture the *first* value it produces and the iteration it was
258
+ # produced at, so predicted_total = at_iter + eta is comparable to
259
+ # iterations_used.
260
+ if self._first_eta_prediction is None:
261
+ eta_now = self.eta
262
+ if eta_now is not None and eta_now > 0:
263
+ self._first_eta_prediction = eta_now
264
+ self._first_eta_at_iteration = len(self._error_history)
265
+
240
266
  return self._state
241
267
 
242
268
  def should_continue(self) -> bool:
@@ -333,6 +359,8 @@ class LoopGain:
333
359
  error_history=list(self._error_history),
334
360
  gain_margin=self.gain_margin,
335
361
  savings_vs_fixed_cap=savings,
362
+ first_eta_prediction=self._first_eta_prediction,
363
+ first_eta_at_iteration=self._first_eta_at_iteration,
336
364
  )
337
365
 
338
366
  # ----- Internal helpers -----
@@ -25,10 +25,13 @@ if TYPE_CHECKING:
25
25
 
26
26
 
27
27
  # Schema version is incremented when the payload format breaks compatibility.
28
- SCHEMA_VERSION = 1
28
+ # v2 (2026-05-13) adds first_eta_prediction + first_eta_at_iteration for the
29
+ # ETA Accuracy dashboard panel. Receiver remains backward-compatible: v1
30
+ # payloads are still accepted (new fields default to None).
31
+ SCHEMA_VERSION = 2
29
32
 
30
33
  # Library version (kept in sync with __init__.py).
31
- LIBRARY_VERSION = "0.1.2"
34
+ LIBRARY_VERSION = "0.1.4"
32
35
 
33
36
 
34
37
  def build_payload(
@@ -83,6 +86,13 @@ def build_payload(
83
86
  "savings_vs_fixed_cap": result.savings_vs_fixed_cap,
84
87
  "convergence_profile_summary": profile_summary,
85
88
  "rollback_triggered": result.outcome in ("oscillating", "diverged"),
89
+ # v2: first computable eta snapshot, for ETA calibration dashboard.
90
+ # Predicted total iterations = first_eta_at_iteration +
91
+ # first_eta_prediction; compare to iterations_used to compute the
92
+ # calibration error. Both are None when no prediction was made
93
+ # (target_error=0, loop never looked convergent, etc.).
94
+ "first_eta_prediction": result.first_eta_prediction,
95
+ "first_eta_at_iteration": result.first_eta_at_iteration,
86
96
  },
87
97
  "thresholds": {
88
98
  "fast_converge": lg.thresholds.fast_converge,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.1.2
3
+ Version: 0.1.4
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 <dave_fitz@icloud.com>
6
6
  License: Apache-2.0
@@ -101,7 +101,7 @@ Plus a short-circuit: if observed error drops at or below `target_error`, the lo
101
101
 
102
102
  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.
103
103
 
104
- These threshold defaults work well for typical agent loops out of the box. Tune them per domain (via the `ThresholdBands` argument) once you have production traces.
104
+ 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.
105
105
 
106
106
  ---
107
107
 
@@ -234,5 +234,3 @@ Loop types this applies to in practice:
234
234
  - **Code generation with linter/test feedback** — generate, run tests/linter, fix, repeat. Error = failing test count or linter violation count.
235
235
  - **Multi-step reasoning loops** — ReAct-style think/act/observe iterations. Error = whatever the agent's quality assessor returns.
236
236
  - **Custom feedback loops** — anything where you can produce a number that should drop toward zero as the loop succeeds.
237
-
238
- See [loopgain.ai](https://loopgain.ai) for the longer write-up.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loopgain"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "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."
9
9
  authors = [{name = "Dave Fitzsimmons", email = "dave_fitz@icloud.com"}]
10
10
  readme = "README.md"
@@ -199,6 +199,70 @@ def test_eta_none_when_target_is_zero():
199
199
  assert lg.eta is None
200
200
 
201
201
 
202
+ # ----- First-eta snapshot (for ETA Accuracy dashboard panel) -----
203
+
204
+
205
+ def test_first_eta_snapshot_captured_during_converging_run():
206
+ """Result carries the first non-None eta and the iter it was made at."""
207
+ target = 0.1
208
+ errors = _decay(0.5, e0=100.0)
209
+ lg = LoopGain(target_error=target, max_iterations=100)
210
+ for e in errors:
211
+ lg.observe(e)
212
+ if not lg.should_continue():
213
+ break
214
+
215
+ result = lg.result
216
+ # First eta becomes computable on the 2nd observation (smoothed_history
217
+ # exists, target > 0, current > target, Aβ_smooth < 1).
218
+ assert result.first_eta_at_iteration == 2
219
+ assert result.first_eta_prediction is not None
220
+ assert result.first_eta_prediction > 0
221
+ # Predicted total iterations should be within ±2 of actual (the prediction
222
+ # is made early, before smoothing has fully settled).
223
+ predicted_total = result.first_eta_at_iteration + result.first_eta_prediction
224
+ assert abs(predicted_total - result.iterations_used) <= 2
225
+
226
+
227
+ def test_first_eta_snapshot_none_when_target_is_zero():
228
+ """No prediction is captured when target_error=0 (eta is always None)."""
229
+ lg = LoopGain(target_error=0.0, max_iterations=5)
230
+ for _ in range(5):
231
+ lg.observe(10.0)
232
+ result = lg.result
233
+ assert result.first_eta_prediction is None
234
+ assert result.first_eta_at_iteration is None
235
+
236
+
237
+ def test_first_eta_snapshot_none_when_loop_never_converges():
238
+ """Oscillating loop (Aβ ≈ 1) never produces a positive eta."""
239
+ lg = LoopGain(target_error=0.5)
240
+ for _ in range(5):
241
+ lg.observe(10.0)
242
+ if not lg.should_continue():
243
+ break
244
+ result = lg.result
245
+ assert result.first_eta_prediction is None
246
+ assert result.first_eta_at_iteration is None
247
+
248
+
249
+ def test_first_eta_snapshot_is_idempotent():
250
+ """Subsequent observations don't overwrite the first prediction."""
251
+ target = 0.1
252
+ errors = _decay(0.5, e0=100.0)
253
+ lg = LoopGain(target_error=target, max_iterations=100)
254
+ lg.observe(errors[0])
255
+ lg.observe(errors[1])
256
+ first = lg._first_eta_prediction
257
+ first_iter = lg._first_eta_at_iteration
258
+ assert first is not None
259
+ # Run a few more iterations; the snapshot should not change.
260
+ for e in errors[2:6]:
261
+ lg.observe(e)
262
+ assert lg._first_eta_prediction == first
263
+ assert lg._first_eta_at_iteration == first_iter
264
+
265
+
202
266
  # ----- observe() input coercion -----
203
267
 
204
268
 
@@ -172,6 +172,52 @@ def test_payload_for_not_started_loop():
172
172
  assert p["loop"]["convergence_profile_summary"]["samples"] == 0
173
173
 
174
174
 
175
+ # ----- v2 schema: ETA calibration fields -----
176
+
177
+
178
+ def test_payload_schema_version_is_v2():
179
+ """Schema bumped to v2 with the addition of first_eta_* fields."""
180
+ assert SCHEMA_VERSION == 2
181
+ lg = _make_terminated_loop()
182
+ p = build_payload(lg)
183
+ assert p["schema_version"] == 2
184
+
185
+
186
+ def test_payload_includes_first_eta_fields_when_loop_converged():
187
+ """A converging loop produces a captured eta snapshot."""
188
+ lg = _make_terminated_loop()
189
+ p = build_payload(lg)
190
+ loop = p["loop"]
191
+ assert "first_eta_prediction" in loop
192
+ assert "first_eta_at_iteration" in loop
193
+ assert loop["first_eta_prediction"] is not None
194
+ assert loop["first_eta_at_iteration"] is not None
195
+ assert loop["first_eta_prediction"] > 0
196
+ assert loop["first_eta_at_iteration"] >= 2
197
+
198
+
199
+ def test_payload_first_eta_none_for_target_zero():
200
+ """target_error=0 means eta is never computable; both fields are None."""
201
+ lg = LoopGain(target_error=0.0, max_iterations=4)
202
+ for _ in range(4):
203
+ lg.observe(10.0)
204
+ p = build_payload(lg)
205
+ assert p["loop"]["first_eta_prediction"] is None
206
+ assert p["loop"]["first_eta_at_iteration"] is None
207
+
208
+
209
+ def test_payload_first_eta_none_for_diverging_loop():
210
+ """A divergent loop never produces a positive eta."""
211
+ lg = LoopGain(target_error=0.5, max_iterations=20)
212
+ for e in [10.0, 12.0, 15.0, 20.0, 30.0]:
213
+ if not lg.should_continue():
214
+ break
215
+ lg.observe(e)
216
+ p = build_payload(lg)
217
+ assert p["loop"]["first_eta_prediction"] is None
218
+ assert p["loop"]["first_eta_at_iteration"] is None
219
+
220
+
175
221
  # ----- send_payload behavior -----
176
222
 
177
223
 
File without changes
File without changes
File without changes