loopgain 0.4.3__tar.gz → 0.5.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.4.3 → loopgain-0.5.0}/PKG-INFO +2 -11
  2. {loopgain-0.4.3 → loopgain-0.5.0}/README.md +1 -10
  3. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/_version.py +1 -1
  4. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/core.py +1 -76
  5. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/telemetry.py +13 -20
  6. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/PKG-INFO +2 -11
  7. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_core.py +1 -121
  8. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_stress.py +1 -15
  9. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_telemetry.py +14 -41
  10. {loopgain-0.4.3 → loopgain-0.5.0}/LICENSE +0 -0
  11. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/__init__.py +0 -0
  12. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/__main__.py +0 -0
  13. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/classifier.py +0 -0
  14. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/cli.py +0 -0
  15. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/funnel.py +0 -0
  16. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/__init__.py +0 -0
  17. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/autogen.py +0 -0
  18. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/claude_agent_sdk.py +0 -0
  19. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/crewai.py +0 -0
  20. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/langchain.py +0 -0
  21. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/langgraph.py +0 -0
  22. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain/integrations/openai_agents.py +0 -0
  23. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/SOURCES.txt +0 -0
  24. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/dependency_links.txt +0 -0
  25. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/entry_points.txt +0 -0
  26. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/requires.txt +0 -0
  27. {loopgain-0.4.3 → loopgain-0.5.0}/loopgain.egg-info/top_level.txt +0 -0
  28. {loopgain-0.4.3 → loopgain-0.5.0}/pyproject.toml +0 -0
  29. {loopgain-0.4.3 → loopgain-0.5.0}/setup.cfg +0 -0
  30. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_classifier_mock_validation.py +0 -0
  31. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_classifier_synthetic.py +0 -0
  32. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_funnel.py +0 -0
  33. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_integrations.py +0 -0
  34. {loopgain-0.4.3 → loopgain-0.5.0}/tests/test_termination_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.4.3
3
+ Version: 0.5.0
4
4
  Summary: An open-source cost controller for AI agent loops. Stops a loop when it has actually converged and rolls back before it degrades — replacing the max_iterations guess with a real-time loop-gain (Aβ) monitor with five named threshold bands and best-so-far rollback.
5
5
  Author-email: Dave Fitzsimmons <hello@loopgain.ai>
6
6
  License: Apache-2.0
@@ -102,7 +102,6 @@ result = lg.result
102
102
  print(result.outcome) # "converged" | "oscillating" | "diverged" | "stalled" | "max_iterations"
103
103
  print(result.best_output) # the lowest-error iteration's output
104
104
  print(result.iterations_used)
105
- print(result.gain_margin) # 1 / max(Aβ_smooth)
106
105
  print(result.savings_vs_fixed_cap)
107
106
  ```
108
107
 
@@ -213,17 +212,9 @@ Returns `False` once a terminal state fires.
213
212
 
214
213
  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`.
215
214
 
216
- ### `lg.eta -> int | None`
217
-
218
- Best-effort closed-form estimate of iterations remaining, exposed for instrumentation. Returns `None` whenever it isn't well-defined — which is most of the time on real, jump-dominated loops, so don't depend on it for control.
219
-
220
- ### `lg.gain_margin -> float | None`
221
-
222
- `1 / max(Aβ_smooth)`. `> 1` means stable headroom across the entire run.
223
-
224
215
  ### `lg.result -> LoopGainResult`
225
216
 
226
- Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `gain_margin`, `savings_vs_fixed_cap`. Safe to call mid-loop.
217
+ Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `savings_vs_fixed_cap`. Safe to call mid-loop.
227
218
 
228
219
  ### `lg.send_telemetry(endpoint, token, workload_id=None, timeout=2.0, allow_insecure=False, framework=None, loop_type=None, team=None, include_per_iteration=True) -> bool`
229
220
 
@@ -53,7 +53,6 @@ result = lg.result
53
53
  print(result.outcome) # "converged" | "oscillating" | "diverged" | "stalled" | "max_iterations"
54
54
  print(result.best_output) # the lowest-error iteration's output
55
55
  print(result.iterations_used)
56
- print(result.gain_margin) # 1 / max(Aβ_smooth)
57
56
  print(result.savings_vs_fixed_cap)
58
57
  ```
59
58
 
@@ -164,17 +163,9 @@ Returns `False` once a terminal state fires.
164
163
 
165
164
  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`.
166
165
 
167
- ### `lg.eta -> int | None`
168
-
169
- Best-effort closed-form estimate of iterations remaining, exposed for instrumentation. Returns `None` whenever it isn't well-defined — which is most of the time on real, jump-dominated loops, so don't depend on it for control.
170
-
171
- ### `lg.gain_margin -> float | None`
172
-
173
- `1 / max(Aβ_smooth)`. `> 1` means stable headroom across the entire run.
174
-
175
166
  ### `lg.result -> LoopGainResult`
176
167
 
177
- Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `gain_margin`, `savings_vs_fixed_cap`. Safe to call mid-loop.
168
+ Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `savings_vs_fixed_cap`. Safe to call mid-loop.
178
169
 
179
170
  ### `lg.send_telemetry(endpoint, token, workload_id=None, timeout=2.0, allow_insecure=False, framework=None, loop_type=None, team=None, include_per_iteration=True) -> bool`
180
171
 
@@ -7,4 +7,4 @@ from here so the value never drifts between ``__version__`` and the
7
7
  ``pyproject.toml``) for each release.
8
8
  """
9
9
 
10
- __version__ = "0.4.3"
10
+ __version__ = "0.5.0"
@@ -9,8 +9,7 @@ real-time loop-gain monitor that classifies the loop into one of five
9
9
  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
- threshold bands, the best-so-far buffer, the ETA prediction, and the
13
- clean Python API.
12
+ threshold bands, the best-so-far buffer, and the clean Python API.
14
13
 
15
14
  License: Apache-2.0
16
15
  """
@@ -121,28 +120,10 @@ class LoopGainResult:
121
120
  error_history: list[float] = field(default_factory=list)
122
121
  """All observed error magnitudes, in order."""
123
122
 
124
- gain_margin: Optional[float] = None
125
- """``1 / max(Aβ_smooth)``. > 1 means stable headroom; < 1 means the
126
- loop crossed into oscillation/divergence at some point."""
127
-
128
123
  savings_vs_fixed_cap: Optional[int] = None
129
124
  """Iterations saved versus the assumed fixed cap (default 10).
130
125
  Zero if the loop hit ``max_iterations``; otherwise non-negative."""
131
126
 
132
- first_eta_prediction: Optional[int] = None
133
- """First non-None ``eta`` snapshot captured during the loop —
134
- the predicted iterations-remaining at the moment the prediction
135
- became computable. ``None`` if no prediction was ever made
136
- (e.g., ``target_error`` is ``None`` or ``0`` so the prediction
137
- formula is undefined, loop never converged toward target, or the
138
- loop terminated before two observations)."""
139
-
140
- first_eta_at_iteration: Optional[int] = None
141
- """Iteration count when ``first_eta_prediction`` was captured.
142
- ``None`` if no prediction was ever made. Predicted *total*
143
- iterations = ``first_eta_at_iteration + first_eta_prediction``,
144
- comparable to ``iterations_used`` for calibration."""
145
-
146
127
 
147
128
  class LoopGain:
148
129
  """Barkhausen stability monitor for AI agent loops.
@@ -239,8 +220,6 @@ class LoopGain:
239
220
  self._state: str = INIT
240
221
  self._state_history: list[str] = []
241
222
  self._terminal: bool = False
242
- self._first_eta_prediction: Optional[int] = None
243
- self._first_eta_at_iteration: Optional[int] = None
244
223
 
245
224
  # Opt-in anonymous funnel telemetry (see loopgain.funnel). No-op
246
225
  # unless the user has explicitly opted in; fully fail-silent and
@@ -348,17 +327,6 @@ class LoopGain:
348
327
  self._state = MAX_ITERATIONS
349
328
  self._terminal = True
350
329
 
351
- # Snapshot the first computable eta prediction for calibration.
352
- # eta is None until smoothing settles and the loop looks convergent;
353
- # we capture the *first* value it produces and the iteration it was
354
- # produced at, so predicted_total = at_iter + eta is comparable to
355
- # iterations_used.
356
- if self._first_eta_prediction is None:
357
- eta_now = self.eta
358
- if eta_now is not None and eta_now > 0:
359
- self._first_eta_prediction = eta_now
360
- self._first_eta_at_iteration = len(self._error_history)
361
-
362
330
  # Funnel telemetry: if this observation drove the loop terminal
363
331
  # (oscillating / diverging / stalled / max-iterations), count the
364
332
  # coarse outcome. The TARGET_MET case is handled at its early return.
@@ -382,46 +350,6 @@ class LoopGain:
382
350
  """Current state name."""
383
351
  return self._state
384
352
 
385
- @property
386
- def eta(self) -> Optional[int]:
387
- """Predicted iterations remaining to reach ``target_error``.
388
-
389
- Closed-form Barkhausen prediction:
390
-
391
- n_remaining = log(E_target / E_current) / log(Aβ_smooth)
392
-
393
- Returns ``None`` when the prediction isn't well-defined:
394
- no Aβ yet, ``target_error`` is ``None`` (no target to predict
395
- against) or ``0`` (the formula has a log-of-zero singularity),
396
- target already met, or ``Aβ_smooth >= 1`` (non-converging gain).
397
- """
398
- if not self._smoothed_history or not self._error_history:
399
- return None
400
- if self.target_error is None or self.target_error <= 0:
401
- return None
402
- e_current = self._error_history[-1]
403
- if e_current <= self.target_error:
404
- return 0
405
- ab_smooth = self._smoothed_history[-1]
406
- if ab_smooth >= 1.0 or ab_smooth <= 0:
407
- return None
408
- n = math.log(self.target_error / e_current) / math.log(ab_smooth)
409
- return max(0, math.ceil(n))
410
-
411
- @property
412
- def gain_margin(self) -> Optional[float]:
413
- """Gain margin ``GM = 1 / max(Aβ_smooth)``.
414
-
415
- ``GM > 1`` means the loop never crossed into oscillation. The
416
- larger, the more headroom. Returns ``None`` if no Aβ data yet.
417
- """
418
- if not self._smoothed_history:
419
- return None
420
- max_g = max(self._smoothed_history)
421
- if max_g == 0:
422
- return float("inf")
423
- return 1.0 / max_g
424
-
425
353
  @property
426
354
  def result(self) -> LoopGainResult:
427
355
  """Construct the terminal result. Safe to call any time."""
@@ -468,10 +396,7 @@ class LoopGain:
468
396
  best_error=best_error,
469
397
  convergence_profile=list(self._smoothed_history),
470
398
  error_history=list(self._error_history),
471
- gain_margin=self.gain_margin,
472
399
  savings_vs_fixed_cap=savings,
473
- first_eta_prediction=self._first_eta_prediction,
474
- first_eta_at_iteration=self._first_eta_at_iteration,
475
400
  )
476
401
 
477
402
  # ----- Internal helpers -----
@@ -37,12 +37,11 @@ def _safe_float(x: Any) -> Any:
37
37
 
38
38
  Standard JSON (RFC 8259) forbids Infinity and NaN literals. Python's
39
39
  json.dumps emits them by default, and strict parsers — including the
40
- Cloudflare-side receiver — reject the payload. gain_margin in particular
41
- is 1/max(Aβ_smooth) and goes to +inf whenever the smoothed gain is zero
42
- (e.g. a constant-error trajectory). values themselves can go to inf
43
- if a previous error is exactly zero. Collapsing to None keeps the
44
- dashboard's "no data" semantics intact instead of dropping the whole
45
- payload.
40
+ Cloudflare-side receiver — reject the payload. values can go to inf
41
+ if a previous error is exactly zero, so the convergence-profile summary
42
+ and per-iteration arrays are wrapped in this coercion. Collapsing to None
43
+ keeps the dashboard's "no data" semantics intact instead of dropping the
44
+ whole payload.
46
45
  """
47
46
  if isinstance(x, float) and not math.isfinite(x):
48
47
  return None
@@ -86,12 +85,14 @@ if TYPE_CHECKING:
86
85
 
87
86
 
88
87
  # Schema version is incremented when the payload format breaks compatibility.
89
- # v2 (2026-05-13) adds first_eta_prediction + first_eta_at_iteration for the
90
- # ETA Accuracy dashboard panel. v3 (2026-05-14) adds the optional
91
- # per_iteration block (capped trajectories) and the framework/loop_type/team
92
- # classification fields. Receiver remains backward-compatible: v1/v2 payloads
93
- # are still accepted (new fields default to None / NULL).
94
- SCHEMA_VERSION = 3
88
+ # v2 (2026-05-13) added an ETA-calibration block and v1 carried a gain_margin
89
+ # field; both were removed in v4 (2026-06-09) when ETA and gain margin were
90
+ # discontinued (neither fired reliably on real trajectories). v3 (2026-05-14)
91
+ # added the optional per_iteration block (capped trajectories) and the
92
+ # framework/loop_type/team classification fields. The receiver remains
93
+ # backward-compatible: older payloads are still accepted and the dropped
94
+ # fields are simply ignored.
95
+ SCHEMA_VERSION = 4
95
96
 
96
97
 
97
98
  # Library version sourced from loopgain._version so there's exactly one
@@ -176,7 +177,6 @@ def build_payload(
176
177
  "loop": {
177
178
  "outcome": result.outcome,
178
179
  "iterations_used": result.iterations_used,
179
- "gain_margin": _safe_float(result.gain_margin),
180
180
  "savings_vs_fixed_cap": result.savings_vs_fixed_cap,
181
181
  "convergence_profile_summary": profile_summary,
182
182
  "rollback_triggered": result.outcome in ("oscillating", "diverged"),
@@ -185,13 +185,6 @@ def build_payload(
185
185
  # (iterations_used-1-best_index) — the "Iteration Waste" view.
186
186
  # Privacy-safe: an integer position, no output/error content.
187
187
  "best_index": result.best_index,
188
- # v2: first computable eta snapshot, for ETA calibration dashboard.
189
- # Predicted total iterations = first_eta_at_iteration +
190
- # first_eta_prediction; compare to iterations_used to compute the
191
- # calibration error. Both are None when no prediction was made
192
- # (target_error=0, loop never looked convergent, etc.).
193
- "first_eta_prediction": result.first_eta_prediction,
194
- "first_eta_at_iteration": result.first_eta_at_iteration,
195
188
  },
196
189
  "thresholds": {
197
190
  "fast_converge": lg.thresholds.fast_converge,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.4.3
3
+ Version: 0.5.0
4
4
  Summary: An open-source cost controller for AI agent loops. Stops a loop when it has actually converged and rolls back before it degrades — replacing the max_iterations guess with a real-time loop-gain (Aβ) monitor with five named threshold bands and best-so-far rollback.
5
5
  Author-email: Dave Fitzsimmons <hello@loopgain.ai>
6
6
  License: Apache-2.0
@@ -102,7 +102,6 @@ result = lg.result
102
102
  print(result.outcome) # "converged" | "oscillating" | "diverged" | "stalled" | "max_iterations"
103
103
  print(result.best_output) # the lowest-error iteration's output
104
104
  print(result.iterations_used)
105
- print(result.gain_margin) # 1 / max(Aβ_smooth)
106
105
  print(result.savings_vs_fixed_cap)
107
106
  ```
108
107
 
@@ -213,17 +212,9 @@ Returns `False` once a terminal state fires.
213
212
 
214
213
  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`.
215
214
 
216
- ### `lg.eta -> int | None`
217
-
218
- Best-effort closed-form estimate of iterations remaining, exposed for instrumentation. Returns `None` whenever it isn't well-defined — which is most of the time on real, jump-dominated loops, so don't depend on it for control.
219
-
220
- ### `lg.gain_margin -> float | None`
221
-
222
- `1 / max(Aβ_smooth)`. `> 1` means stable headroom across the entire run.
223
-
224
215
  ### `lg.result -> LoopGainResult`
225
216
 
226
- Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `gain_margin`, `savings_vs_fixed_cap`. Safe to call mid-loop.
217
+ Terminal result with `outcome`, `iterations_used`, `best_index`, `best_output`, `best_error`, `convergence_profile`, `error_history`, `savings_vs_fixed_cap`. Safe to call mid-loop.
227
218
 
228
219
  ### `lg.send_telemetry(endpoint, token, workload_id=None, timeout=2.0, allow_insecure=False, framework=None, loop_type=None, team=None, include_per_iteration=True) -> bool`
229
220
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Covers the five band transitions, the three canonical scenarios from the
4
4
  spec (converging / oscillating / diverging), TARGET_MET short-circuit,
5
- best-so-far buffer correctness, and ETA calibration.
5
+ and best-so-far buffer correctness.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -179,109 +179,6 @@ def test_best_so_far_works_without_outputs():
179
179
  assert result.best_error == 2.0
180
180
 
181
181
 
182
- # ----- ETA prediction calibration -----
183
-
184
-
185
- def test_eta_prediction_calibration():
186
- """Synthetic monotonic decay: predicted iterations matches actual ±1."""
187
- target = 0.1
188
- ab = 0.5
189
- errors = _decay(ab, e0=100.0)
190
- # Iteration index where error first drops below target.
191
- actual_n = next(i for i, e in enumerate(errors) if e < target)
192
-
193
- lg = LoopGain(target_error=target, max_iterations=100)
194
- # Feed a few errors so smoothed Aβ stabilizes.
195
- lg.observe(errors[0])
196
- lg.observe(errors[1])
197
- lg.observe(errors[2])
198
- # ETA at this point should predict ~(actual_n - 2) more iterations.
199
- eta = lg.eta
200
- assert eta is not None
201
- assert abs(eta - (actual_n - 2)) <= 1, f"eta={eta}, expected ~{actual_n - 2}"
202
-
203
-
204
- def test_eta_none_when_not_converging():
205
- """ETA is None when Aβ_smooth >= 1 (non-converging)."""
206
- lg = LoopGain(target_error=0.5)
207
- for _ in range(3):
208
- lg.observe(10.0) # constant errors -> Aβ = 1
209
- if not lg.should_continue():
210
- break
211
- assert lg.eta is None
212
-
213
-
214
- def test_eta_none_when_target_is_zero():
215
- lg = LoopGain(target_error=0.0)
216
- lg.observe(100.0)
217
- lg.observe(50.0)
218
- assert lg.eta is None
219
-
220
-
221
- # ----- First-eta snapshot (for ETA Accuracy dashboard panel) -----
222
-
223
-
224
- def test_first_eta_snapshot_captured_during_converging_run():
225
- """Result carries the first non-None eta and the iter it was made at."""
226
- target = 0.1
227
- errors = _decay(0.5, e0=100.0)
228
- lg = LoopGain(target_error=target, max_iterations=100)
229
- for e in errors:
230
- lg.observe(e)
231
- if not lg.should_continue():
232
- break
233
-
234
- result = lg.result
235
- # First eta becomes computable on the 2nd observation (smoothed_history
236
- # exists, target > 0, current > target, Aβ_smooth < 1).
237
- assert result.first_eta_at_iteration == 2
238
- assert result.first_eta_prediction is not None
239
- assert result.first_eta_prediction > 0
240
- # Predicted total iterations should be within ±2 of actual (the prediction
241
- # is made early, before smoothing has fully settled).
242
- predicted_total = result.first_eta_at_iteration + result.first_eta_prediction
243
- assert abs(predicted_total - result.iterations_used) <= 2
244
-
245
-
246
- def test_first_eta_snapshot_none_when_target_is_zero():
247
- """No prediction is captured when target_error=0 (eta is always None)."""
248
- lg = LoopGain(target_error=0.0, max_iterations=5)
249
- for _ in range(5):
250
- lg.observe(10.0)
251
- result = lg.result
252
- assert result.first_eta_prediction is None
253
- assert result.first_eta_at_iteration is None
254
-
255
-
256
- def test_first_eta_snapshot_none_when_loop_never_converges():
257
- """Oscillating loop (Aβ ≈ 1) never produces a positive eta."""
258
- lg = LoopGain(target_error=0.5)
259
- for _ in range(5):
260
- lg.observe(10.0)
261
- if not lg.should_continue():
262
- break
263
- result = lg.result
264
- assert result.first_eta_prediction is None
265
- assert result.first_eta_at_iteration is None
266
-
267
-
268
- def test_first_eta_snapshot_is_idempotent():
269
- """Subsequent observations don't overwrite the first prediction."""
270
- target = 0.1
271
- errors = _decay(0.5, e0=100.0)
272
- lg = LoopGain(target_error=target, max_iterations=100)
273
- lg.observe(errors[0])
274
- lg.observe(errors[1])
275
- first = lg._first_eta_prediction
276
- first_iter = lg._first_eta_at_iteration
277
- assert first is not None
278
- # Run a few more iterations; the snapshot should not change.
279
- for e in errors[2:6]:
280
- lg.observe(e)
281
- assert lg._first_eta_prediction == first
282
- assert lg._first_eta_at_iteration == first_iter
283
-
284
-
285
182
  # ----- observe() input coercion -----
286
183
 
287
184
 
@@ -353,23 +250,6 @@ def test_max_iterations_with_converging_loop():
353
250
  assert not lg.should_continue()
354
251
 
355
252
 
356
- # ----- gain_margin -----
357
-
358
-
359
- def test_gain_margin_greater_than_one_for_converging():
360
- lg = LoopGain(max_iterations=10)
361
- for e in _decay(0.5)[:5]:
362
- lg.observe(e)
363
- gm = lg.gain_margin
364
- assert gm is not None
365
- assert gm > 1.0
366
-
367
-
368
- def test_gain_margin_none_before_observations():
369
- lg = LoopGain()
370
- assert lg.gain_margin is None
371
-
372
-
373
253
  # ----- result before any observations -----
374
254
 
375
255
 
@@ -130,8 +130,7 @@ def test_very_large_error_magnitudes():
130
130
  lg.observe(e)
131
131
  # Aβ = 0.4 → FAST_CONVERGE.
132
132
  assert lg.state in (FAST_CONVERGE, CONVERGING)
133
- assert lg.gain_margin is not None
134
- assert lg.gain_margin > 1.0
133
+ assert lg.result.convergence_profile # computed without overflow
135
134
 
136
135
 
137
136
  def test_zero_error_in_middle_of_run_anomalous():
@@ -245,19 +244,6 @@ def test_result_callable_mid_loop():
245
244
  assert r2.iterations_used == 3
246
245
 
247
246
 
248
- def test_eta_before_any_observation():
249
- """eta on a fresh instance is None, not a crash."""
250
- lg = LoopGain(target_error=0.5)
251
- assert lg.eta is None
252
-
253
-
254
- def test_gain_margin_with_single_observation():
255
- """Single observation has no Aβ yet → gain_margin is None."""
256
- lg = LoopGain()
257
- lg.observe(10.0)
258
- assert lg.gain_margin is None
259
-
260
-
261
247
  # ----- Mixed input types -----
262
248
 
263
249
 
@@ -53,7 +53,6 @@ def test_payload_loop_section_has_outcome_and_stats():
53
53
  loop = p["loop"]
54
54
  assert loop["outcome"] == "converged"
55
55
  assert loop["iterations_used"] == 4
56
- assert loop["gain_margin"] is not None
57
56
  assert loop["savings_vs_fixed_cap"] is not None
58
57
  assert "convergence_profile_summary" in loop
59
58
  assert "rollback_triggered" in loop
@@ -120,7 +119,7 @@ def test_payload_workload_id_optional():
120
119
 
121
120
 
122
121
  def test_payload_serializes_strict_json_for_constant_error_trajectory():
123
- """A constant-error trajectory pushes gain_margin to +inf (1/max()=1/0).
122
+ """A zero-error trajectory pushes to +inf (E(n)/E(n-1) with E(n-1)=0).
124
123
 
125
124
  Standard JSON forbids Infinity / NaN, and the receiver rejects payloads
126
125
  that include them. The build_payload sanitizer must coerce non-finite
@@ -135,8 +134,8 @@ def test_payload_serializes_strict_json_for_constant_error_trajectory():
135
134
  # Strict round-trip: allow_nan=False raises on inf/nan.
136
135
  encoded = json.dumps(p, allow_nan=False)
137
136
  decoded = json.loads(encoded)
138
- assert decoded["loop"]["gain_margin"] is None
139
- # Per-iteration values can also be non-finite (E(n)/E(n-1) with E(n-1)=0).
137
+ # Per-iteration values can be non-finite (E(n)/E(n-1) with E(n-1)=0)
138
+ # and the convergence-profile summary must stay finite-or-None too.
140
139
  for v in decoded["per_iteration"]["convergence_profile"]:
141
140
  assert v is None or isinstance(v, (int, float))
142
141
 
@@ -190,50 +189,24 @@ def test_payload_for_not_started_loop():
190
189
  assert p["loop"]["convergence_profile_summary"]["samples"] == 0
191
190
 
192
191
 
193
- # ----- v2 schema: ETA calibration fields -----
192
+ # ----- schema version -----
194
193
 
195
194
 
196
- def test_payload_schema_version_is_v3():
197
- """Schema bumped to v3 with the addition of per_iteration + classification."""
198
- assert SCHEMA_VERSION == 3
195
+ def test_payload_schema_version_is_v4():
196
+ """Schema bumped to v4 when ETA + gain_margin were removed from the payload."""
197
+ assert SCHEMA_VERSION == 4
199
198
  lg = _make_terminated_loop()
200
199
  p = build_payload(lg)
201
- assert p["schema_version"] == 3
200
+ assert p["schema_version"] == 4
202
201
 
203
202
 
204
- def test_payload_includes_first_eta_fields_when_loop_converged():
205
- """A converging loop produces a captured eta snapshot."""
203
+ def test_payload_loop_section_drops_eta_and_gain_margin():
204
+ """v4 no longer carries the discontinued ETA / gain_margin fields."""
206
205
  lg = _make_terminated_loop()
207
- p = build_payload(lg)
208
- loop = p["loop"]
209
- assert "first_eta_prediction" in loop
210
- assert "first_eta_at_iteration" in loop
211
- assert loop["first_eta_prediction"] is not None
212
- assert loop["first_eta_at_iteration"] is not None
213
- assert loop["first_eta_prediction"] > 0
214
- assert loop["first_eta_at_iteration"] >= 2
215
-
216
-
217
- def test_payload_first_eta_none_for_target_zero():
218
- """target_error=0 means eta is never computable; both fields are None."""
219
- lg = LoopGain(target_error=0.0, max_iterations=4)
220
- for _ in range(4):
221
- lg.observe(10.0)
222
- p = build_payload(lg)
223
- assert p["loop"]["first_eta_prediction"] is None
224
- assert p["loop"]["first_eta_at_iteration"] is None
225
-
226
-
227
- def test_payload_first_eta_none_for_diverging_loop():
228
- """A divergent loop never produces a positive eta."""
229
- lg = LoopGain(target_error=0.5, max_iterations=20)
230
- for e in [10.0, 12.0, 15.0, 20.0, 30.0]:
231
- if not lg.should_continue():
232
- break
233
- lg.observe(e)
234
- p = build_payload(lg)
235
- assert p["loop"]["first_eta_prediction"] is None
236
- assert p["loop"]["first_eta_at_iteration"] is None
206
+ loop = build_payload(lg)["loop"]
207
+ assert "gain_margin" not in loop
208
+ assert "first_eta_prediction" not in loop
209
+ assert "first_eta_at_iteration" not in loop
237
210
 
238
211
 
239
212
  # ----- send_payload behavior -----
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes