loopmath 0.2.7__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loopmath/__init__.py CHANGED
@@ -5,4 +5,4 @@ The command line (`loopmath`, or its alias `loop`) is the interface; see the
5
5
  README and `loopmath --help`.
6
6
  """
7
7
 
8
- __version__ = "0.2.7"
8
+ __version__ = "0.2.8"
@@ -18,6 +18,13 @@ simulated per draw (run noise and gate outcomes), and the interval is its 10th a
18
18
  percentiles. Rounds, success and `ell` are parameter-draw quantities. Without a generator,
19
19
  `compose` computes the expectations only (the mean path in state.py).
20
20
 
21
+ Tokens (0.2.8): one execution's dollars are `U = exp(eta_u + sigma_u z1)` and its price per token
22
+ `R = exp(eta_r + sigma_r z2)`, from the rate head; its tokens are `T = U / max(R, f)`, with `f` the model's
23
+ cache-read rate (its floor, none for a model without one). So `T f <= U` in every draw, and every shown pair of
24
+ dollars and tokens clears the floor: per draw, in the mean, at every percentile and summed over pieces. The two
25
+ normals are drawn in 0.2.7's order (dollars, then the second one), so the simulated dollars and the gates after
26
+ them are 0.2.7's. The expected tokens are `E[U] E[1 / max(R, f)]` (`inv_rate_mean`).
27
+
21
28
  Per piece, `PieceDraws.exp_*` is the piece's expected full-run contribution, summed over
22
29
  the rounds it runs, so pieces add up to the run. `cost_per_round()` gives the expected cost of
23
30
  one execution: the per-execution prediction when the piece's cost does not depend on the round
@@ -28,8 +35,11 @@ from __future__ import annotations
28
35
 
29
36
  from dataclasses import dataclass, field
30
37
 
38
+ import math
39
+ from collections.abc import Mapping
40
+
31
41
  import numpy as np
32
- from scipy.special import expit
42
+ from scipy.special import expit, log_ndtr
33
43
 
34
44
  from .design import Structure
35
45
 
@@ -64,12 +74,33 @@ class RunDraws:
64
74
  reach: dict[tuple[str, int], np.ndarray] = field(default_factory=dict) # (piece, k) -> P(piece runs in round k)
65
75
 
66
76
 
67
- def compose(st: Structure, cost_eta: dict, tokens_eta: dict, gate_eta: dict, *, sigma_usd: float,
68
- sigma_tokens: float, rng: np.random.Generator | None) -> RunDraws:
69
- """Compose one configuration. `*_eta[(piece, k)]` and `gate_eta[(gate index, k)]` are draw arrays.
70
- With `rng` None nothing is simulated: the `sim_*` arrays stay zero."""
77
+ def inv_rate_mean(m, v, floor: float | None) -> np.ndarray:
78
+ """`E[1 / max(R, f)]` for `log R ~ N(m, v)` and the floor `f` ($ per token; None or 0: no floor), per entry
79
+ of `m`. With `l = log f`: `exp(-m + v/2) Phi((m - l)/sqrt(v) - sqrt(v)) + exp(-l) Phi((l - m)/sqrt(v))`,
80
+ each term in logs (`log Phi`), so a far tail never multiplies an overflow by 0. With `v = 0` the rate is
81
+ certain: `1 / max(exp(m), f)`."""
82
+ m = np.asarray(m, float)
83
+ v = np.broadcast_to(np.asarray(v, float), m.shape)
84
+ if not floor or floor <= 0:
85
+ return np.exp(-m + 0.5 * v)
86
+ lf = math.log(floor)
87
+ certain = v <= 0
88
+ sd = np.sqrt(np.where(certain, 1.0, v))
89
+ spread = np.exp(-m + 0.5 * v + log_ndtr((m - lf) / sd - sd)) + np.exp(log_ndtr((lf - m) / sd) - lf)
90
+ return np.where(certain, np.exp(-np.maximum(m, lf)), spread)
91
+
92
+
93
+ def compose(st: Structure, cost_eta: dict, rate_eta: dict, gate_eta: dict, *, sigma_usd: float,
94
+ sigma_rate: float, floors: Mapping[str, float] | None = None, rate_var: Mapping | None = None,
95
+ rng: np.random.Generator | None) -> RunDraws:
96
+ """Compose one configuration. `cost_eta[(piece, k)]`, `rate_eta[(piece, k)]` and `gate_eta[(gate index, k)]`
97
+ are draw arrays; `floors[piece]` is the piece model's floor in $ per token (absent or 0: none). The expected
98
+ tokens take `log R ~ N(rate_eta, rate_var[(piece, k)])`, `sigma_rate ** 2` when `rate_var` is None (the mean
99
+ path passes the rate rows' known parts and adds their unseen variance there). With `rng` None nothing is
100
+ simulated: the `sim_*` arrays stay zero."""
71
101
  n = len(next(iter(cost_eta.values())))
72
- half_u, half_t = 0.5 * sigma_usd ** 2, 0.5 * sigma_tokens ** 2
102
+ half_u, var_r = 0.5 * sigma_usd ** 2, sigma_rate ** 2
103
+ floors = floors or {}
73
104
  exp_u, exp_t = np.zeros(n), np.zeros(n)
74
105
  sim_u, sim_t = np.zeros(n), np.zeros(n)
75
106
  rounds = np.ones(n)
@@ -79,7 +110,7 @@ def compose(st: Structure, cost_eta: dict, tokens_eta: dict, gate_eta: dict, *,
79
110
  pieces[p] = PieceDraws(np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n), np.ones(n), sim_runs=np.zeros(n))
80
111
  if st.piece_loop.get(p) is not None:
81
112
  pieces[p].round_free = all(
82
- np.array_equal(cost_eta[(p, k)], cost_eta[(p, 1)]) and np.array_equal(tokens_eta[(p, k)], tokens_eta[(p, 1)])
113
+ np.array_equal(cost_eta[(p, k)], cost_eta[(p, 1)]) and np.array_equal(rate_eta[(p, k)], rate_eta[(p, 1)])
83
114
  for k in range(2, st.k_max + 1))
84
115
  gate_after = {g.after: gi for gi, g in enumerate(st.gates)}
85
116
  for piece, gi in gate_after.items():
@@ -88,17 +119,20 @@ def compose(st: Structure, cost_eta: dict, tokens_eta: dict, gate_eta: dict, *,
88
119
 
89
120
  def run_piece(piece: str, k: int, weight: np.ndarray, alive: np.ndarray):
90
121
  w = st.widths[piece]
91
- eu, et = cost_eta[(piece, k)], tokens_eta[(piece, k)]
122
+ eu, er = cost_eta[(piece, k)], rate_eta[(piece, k)]
123
+ f = float(floors.get(piece) or 0.0)
92
124
  cu = w * np.exp(eu + half_u)
93
- ct = w * np.exp(et + half_t)
125
+ ct = cu * inv_rate_mean(er, var_r if rate_var is None else rate_var[(piece, k)], f)
94
126
  pd = pieces[piece]
95
127
  pd.exp_usd += weight * cu
96
128
  pd.exp_tokens += weight * ct
97
129
  su = np.zeros(n)
98
130
  stt = np.zeros(n)
99
131
  for _ in range(w if rng is not None else 0):
100
- su += np.exp(eu + sigma_usd * rng.standard_normal(n))
101
- stt += np.exp(et + sigma_tokens * rng.standard_normal(n))
132
+ u = np.exp(eu + sigma_usd * rng.standard_normal(n))
133
+ r = np.exp(er + sigma_rate * rng.standard_normal(n))
134
+ su += u
135
+ stt += u / (np.maximum(r, f) if f > 0 else r)
102
136
  pd.sim_usd += alive * su
103
137
  pd.sim_tokens += alive * stt
104
138
  pd.sim_runs += alive
loopmath/belief/design.py CHANGED
@@ -30,8 +30,12 @@ from .pricing import price_term
30
30
  Term = tuple[str, "str | None", float]
31
31
 
32
32
  # Node names of this design (spec 04 section 4). 2 (loopmath 0.2): shape keys, psrc nodes and role groups.
33
- # 3 (0.2): `horizon:log2h` is relative to the fit's reference horizon. A fit of another version is not read.
34
- DESIGN_VERSION = 3
33
+ # 3 (0.2): `horizon:log2h` is relative to the fit's reference horizon. 4 (0.2.8): a `rate` head on the price
34
+ # per token replaces the tokens head. A fit of another version is not read.
35
+ DESIGN_VERSION = 4
36
+ # The version the seed key hashes (spec 04 section 3). It stays 3 from 0.2.8: the same data seeds the same
37
+ # draws as in 0.2.7, so every number that is not a token count is 0.2.7's.
38
+ SEED_VERSION = 3
35
39
  HEURISTIC_COST_WEIGHT = 0.7 # spec 04 section 2 and D27: allocated (heuristic) cost rows
36
40
  USER_SOURCES = ("live", "backlog", "history", "user", "orchestrator", "onboard", "habit", "")
37
41
  HORIZON_NODES = ("horizon:timebox", "horizon:log2h") # fixed terms of a timeboxed run (spec 04 section 1)
@@ -35,6 +35,8 @@ LN2 = math.log(2.0)
35
35
  # cost and tokens scale with an elasticity of 1 +- 0.5; logit and score heads start flat.
36
36
  HORIZON_PRIOR = {
37
37
  "cost": {"horizon:log2h": (LN2, 0.35), "horizon:timebox": (0.0, 1.5)},
38
+ # the price per token (0.2.8): a longer horizon buys more tokens, not dearer ones
39
+ "rate": {"horizon:log2h": (0.0, 0.35), "horizon:timebox": (0.0, 1.5)},
38
40
  "logit": {"horizon:log2h": (0.0, 0.25), "horizon:timebox": (0.0, 1.0)},
39
41
  "score": {"horizon:log2h": (0.0, 0.25), "horizon:timebox": (0.0, 1.0)},
40
42
  }
loopmath/belief/fit.py CHANGED
@@ -31,12 +31,12 @@ from scipy import sparse
31
31
  from ..taskmodel import FeatureConfigError, FeatureSet
32
32
  from . import design as design_rows
33
33
  from . import priors as prior_data
34
- from .design import (DESIGN_VERSION, Term, Unusable, cost_row, gate_row, parse_run, run_row, structure,
35
- task_features, task_terms)
34
+ from .design import (DESIGN_VERSION, SEED_VERSION, Term, Unusable, cost_row, gate_row, parse_run, run_row,
35
+ structure, task_features, task_terms)
36
36
  from .features import FeatureTally, add_horizon, drop_features, horizon_nodes, horizon_specs
37
37
  from .block import leaf_split
38
38
  from .forest import Forest, default_scale, fixed_sd, scale_group
39
- from .pricing import PRICE_NODE, PRICE_PRIOR_SD, STREAMS, fit_offsets, price_term
39
+ from .pricing import PRICE_NODE, PRICE_PRIOR_SD, STREAMS, fit_offsets, price_term, rate_floors
40
40
  from .gaussian import N_DRAWS, Factors, GaussianHead, HeadFit, LogisticHead, Prior
41
41
  from .state import SCORE_CLAMP, inverse_transform, transform # noqa: F401 (re-exported)
42
42
 
@@ -44,8 +44,12 @@ FIT_LOCK = ".fit.lock"
44
44
  KEEP_FITS = 5 # The newest fits kept, besides the ones a receipt or stored recommendation names
45
45
  TASK_LEVELS = ("org", "type", "repo", "subtype", "task")
46
46
  MIN_SCORE_RUNS = 3 # spec 04 section 2: a score head needs at least 3 measured runs
47
- HEAD_KIND = {"cost": ("gaussian", "cost"), "tokens": ("gaussian", "cost"),
47
+ # `tokens` (log tokens) is 0.2.7's head: from 0.2.8 its rows are built for the seed key and the node index
48
+ # only, never fitted. `rate` (log usd - log tokens, the price per token) is fitted in its place (spec 04 section 2).
49
+ HEAD_KIND = {"cost": ("gaussian", "cost"), "tokens": ("gaussian", "cost"), "rate": ("gaussian", "cost"),
48
50
  "gate": ("logistic", "logit"), "success": ("logistic", "logit")}
51
+ KEY_ONLY = ("tokens",) # heads whose rows the seed key and the node index read, and no fit
52
+ NOT_KEYED = ("rate",) # heads the seed key leaves out, so 0.2.8 seeds every draw as 0.2.7 did
49
53
 
50
54
 
51
55
  class FitBusy(RuntimeError):
@@ -273,6 +277,9 @@ def collect_rows(docs: Iterable[dict | tuple[str | None, dict]], *, without: tup
273
277
  acc[i] += v
274
278
  if a.tokens:
275
279
  heads["tokens"].add(terms, math.log(a.tokens), a.weight, pr.run_id, pr.source, ttype)
280
+ if a.usd and a.tokens:
281
+ heads["rate"].add(terms, math.log(a.usd) - math.log(a.tokens), a.weight, pr.run_id, pr.source,
282
+ ttype)
276
283
  for gi, k, passed in pr.gates:
277
284
  heads["gate"].add(gate_row(pr.task, pr.source, st, st.gates[gi], k, features), 1.0 if passed else 0.0,
278
285
  1.0, pr.run_id, pr.source, ttype)
@@ -305,6 +312,8 @@ def collect_rows(docs: Iterable[dict | tuple[str | None, dict]], *, without: tup
305
312
  for hr in heads.values():
306
313
  hr.rows = add_horizon(drop_features(hr.rows, tally.nodes), hr.runs, tally.run_horizon, coding)
307
314
  add_prices(heads["cost"], {m: len(r) for m, r in model_runs.items()}, model_streams)
315
+ heads["rate"].prices = heads["cost"].prices # the price per token carries the same offset (spec 04 section 2)
316
+ _price_rows(heads["rate"])
308
317
  return (heads, score_info, dict(dropped), dict(runs_by_source), {c: dict(n) for c, n in config_support.items()},
309
318
  dict(checks), dict(overlap), dict(labels), tally)
310
319
 
@@ -313,7 +322,12 @@ def add_prices(hr: HeadRows, runs: dict[str, int], streams: dict[str, list[float
313
322
  """The price offset (spec 04 section 2): the fit's offsets from its runs and token mix under the current price
314
323
  table, and each cost row with its model's `price:offset` term (`pricing.price_term`, as at prediction)."""
315
324
  hr.prices = fit_offsets(runs, streams, design_rows._price_table())
316
- offsets = hr.prices["models"]
325
+ _price_rows(hr)
326
+
327
+
328
+ def _price_rows(hr: HeadRows) -> None:
329
+ """Each row with its model's `price:offset` term under `hr.prices`."""
330
+ offsets = hr.prices.get("models")
317
331
  if not offsets:
318
332
  return
319
333
  rows = []
@@ -325,10 +339,11 @@ def add_prices(hr: HeadRows, runs: dict[str, int], streams: dict[str, list[float
325
339
 
326
340
 
327
341
  def price_spec(hr: HeadRows) -> list:
328
- """The prior that holds `price:offset` at 1, on the cost head of a fit with offsets, --no-prior included."""
342
+ """The prior that holds `price:offset` at 1, on the cost and rate heads of a fit with offsets, --no-prior
343
+ included."""
329
344
  if not hr.prices.get("models"):
330
345
  return []
331
- return [prior_data.FactorSpec("cost", [(PRICE_NODE, None, 1.0)], 1.0, PRICE_PRIOR_SD ** 2,
346
+ return [prior_data.FactorSpec(hr.name, [(PRICE_NODE, None, 1.0)], 1.0, PRICE_PRIOR_SD ** 2,
332
347
  f"{PRICE_NODE} held at 1, prior N(1, {PRICE_PRIOR_SD}^2)")]
333
348
 
334
349
 
@@ -417,18 +432,21 @@ def seed_for(fit_id: str, head: str) -> int:
417
432
 
418
433
 
419
434
  def input_key(heads_rows: dict[str, HeadRows], specs: list, settings: dict) -> str:
420
- """The fit's `seed_key` (spec 04 section 3): the sha256 of the design version, the settings that change the
435
+ """The fit's `seed_key` (spec 04 section 3): the sha256 of the seed version, the settings that change the
421
436
  posterior, the prior factors and every head's rows (terms, response, weight, run, source), each head's rows
422
437
  sorted, so the same data and settings seed the same draws whatever the store's reading order.
423
438
 
424
439
  A success row's weight is its q. With `outcome.q` configured, the key takes each row's q under the built-in
425
- map (`HeadRows.seed_w`), so setting q redraws nothing: only the success head's posterior moves (0.2.7)."""
440
+ map (`HeadRows.seed_w`), so setting q redraws nothing: only the success head's posterior moves (0.2.7).
441
+
442
+ From 0.2.8 the key reads 0.2.7's heads: the tokens rows, which no head fits any more, and not the rate rows,
443
+ with the seed version 0.2.7 hashed as its design version. The same data seeds the same draws as in 0.2.7."""
426
444
  h = hashlib.sha256()
427
- h.update(repr((DESIGN_VERSION, design_rows.TIMEBOX_EFFORT_COST, design_rows.TIMEBOX_LEVELS,
445
+ h.update(repr((SEED_VERSION, design_rows.TIMEBOX_EFFORT_COST, design_rows.TIMEBOX_LEVELS,
428
446
  sorted(settings.items()))).encode())
429
447
  for spec in specs:
430
448
  h.update(repr((spec.head, spec.terms, spec.mean, spec.var)).encode())
431
- for name in sorted(heads_rows):
449
+ for name in sorted(n for n in heads_rows if n not in NOT_KEYED):
432
450
  hr = heads_rows[name]
433
451
  h.update(f"\n{name}:{len(hr.rows)}".encode())
434
452
  weights = hr.seed_w or hr.w
@@ -618,9 +636,12 @@ def fit(home: Path, *, no_prior: bool = False, without: tuple[str, ...] = (), fu
618
636
  continue
619
637
  nodes = horizon_nodes(hr.rows)
620
638
  if nodes: # the horizon priors, --no-prior included (spec 04 section 1)
621
- head_specs += horizon_specs(name, hr.kind, nodes)
622
- if name == "cost": # the price offset (spec 04 section 2)
639
+ head_specs += horizon_specs(name, "rate" if name == "rate" else hr.kind, nodes)
640
+ if name in ("cost", "rate"): # the price offset (spec 04 section 2)
623
641
  head_specs += price_spec(hr)
642
+ if name in KEY_ONLY: # 0.2.7's tokens head: its nodes enter the index where they did, unfitted
643
+ build_matrix(hr.rows, forest, [(n, p) for spec in head_specs for n, p, _ in spec.terms])
644
+ continue
624
645
  fitted[name] = fit_head(hr, forest, head_specs, eb=eb)
625
646
  fit_id = new_fit_id(fits, now)
626
647
  partial = fits / f"{fit_id}.partial"
@@ -685,6 +706,8 @@ def fit(home: Path, *, no_prior: bool = False, without: tuple[str, ...] = (), fu
685
706
  "features": {**tally.report, **({"error": features_error} if features_error else {})},
686
707
  "horizons": tally.horizon_report(),
687
708
  "price_offsets": heads_rows["cost"].prices,
709
+ # the cheapest rate of each priced model, $ per token: a draw's tokens never cost less (spec 04 section 3)
710
+ "rate_floors": rate_floors(design_rows._price_table()),
688
711
  }
689
712
  if full:
690
713
  meta["full"] = _full_check(fitted)
@@ -394,7 +394,7 @@ def _condition_head(head: HeadState, terms: Sequence[tuple], tau2: float) -> Hea
394
394
  def conditioned(state: FitState, task: Task, config: Configuration, rule: AcceptanceRule | None = None) -> FitState:
395
395
  heads = dict(state.heads)
396
396
  terms, n_obs = _explore_cost_obs(state, task, config)
397
- for name in ("cost", "tokens"):
397
+ for name in ("cost", "rate"):
398
398
  heads[name] = _condition_head(state.heads[name], terms, state.heads[name].sigma ** 2 / n_obs)
399
399
  run_terms = state.task_terms(task) + list(state._plan(config)["run"])
400
400
  succ = state.heads["success"]
@@ -1,7 +1,8 @@
1
- """The price offset on the cost head (spec 04 section 2, 0.2.2).
1
+ """The price offset on the cost and rate heads (spec 04 section 2, 0.2.2), and the rate floors (0.2.8).
2
2
 
3
- A model's run cost moves with its list price. Every cost row of a model version `m` (not the tokens, gate,
4
- success or score rows) carries the fixed term `price:offset` with the value
3
+ A model's run cost moves with its list price. Every cost row of a model version `m`, and from 0.2.8 every rate
4
+ row (the price per token), carries the fixed term `price:offset` (not the gate, success or score rows), with
5
+ the value
5
6
 
6
7
  o_m = s_m * log(P_m / R_m), s_m = n0 / (n0 + n_m),
7
8
 
@@ -18,6 +19,10 @@ the same at fit and at prediction:
18
19
 
19
20
  `fit_offsets` computes the offset of every model with cost rows and of every model in the price table once per
20
21
  fit; `meta.json` `price_offsets` stores them, and prediction and the workflow search read them from there.
22
+
23
+ `rate_floors` gives each priced model's cache-read rate in $ per token, the cheapest rate any of its tokens
24
+ bills at (in every row of the packaged table, no nonzero rate is lower). A draw's price per token is held at or
25
+ above it, so its tokens never cost less than its dollars (spec 04 section 3). `meta.json` stores them too.
21
26
  """
22
27
 
23
28
  from __future__ import annotations
@@ -140,3 +145,17 @@ def price_term(offsets: Mapping[str, float] | None, model: str) -> tuple[tuple[s
140
145
  return ()
141
146
  o = offsets.get(model)
142
147
  return ((PRICE_NODE, None, float(o)),) if o else ()
148
+
149
+
150
+ def rate_floors(table) -> dict[str, float]:
151
+ """Model (canonical id) to its cache-read rate in $ per token, for every model with a positive one; empty
152
+ without a table. Two table keys of one model keep the lower rate."""
153
+ out: dict[str, float] = {}
154
+ if table is None:
155
+ return out
156
+ for key, rate in table.rates.items():
157
+ floor = float(rate.get("cache_read") or 0.0) / 1e6
158
+ if floor > 0:
159
+ model = canonical_model_id(key)
160
+ out[model] = min(floor, out.get(model, floor))
161
+ return dict(sorted(out.items()))
loopmath/belief/state.py CHANGED
@@ -16,15 +16,21 @@ Prediction:
16
16
  (setting, role, topology, position, round, control) is cached per fit, so `predict_many`
17
17
  over thousands of candidates reuses rows.
18
18
  - The composition (compose.py) runs over the draws. Cost intervals are predictive.
19
- - Means do not sample the unseen nodes: their effects are normal, so each cost and
20
- tokens row's expected cost is log-normal in closed form over the known draws, the unseen
21
- task effect on the gates (one normal shared by every gate row of the task) and on success
22
- or a score are integrated by Gauss-Hermite, and a score's reach chance is a normal CDF.
19
+ - Tokens (0.2.8): a draw's tokens are its dollars over its price per token (the rate head),
20
+ the rate held at or above the model's cache-read rate (`meta.rate_floors`), so no shown
21
+ tokens cost less than the dollars beside them (compose.py).
22
+ - Means do not sample the unseen nodes: their effects are normal, so each cost row's
23
+ expected cost is log-normal in closed form over the known draws, and the expected tokens
24
+ are that times `E[1 / max(R, f)]` with the rate row's unseen variance added to its own
25
+ (`compose.inv_rate_mean`). The unseen task effect on the gates (one normal shared by every
26
+ gate row of the task) and on success or a score are integrated by Gauss-Hermite, and a
27
+ score's reach chance is a normal CDF.
23
28
  The `ell.tokens` mean prices the rescue at each draw's own tokens per dollar, as its draws
24
- do: `E[1 / C_run]` is integrated over every unseen cost effect, the task's and each piece's
25
- (a new model, role or position), in closed form when the pieces share them and otherwise
26
- over their differences, by Gauss-Hermite up to four axes and a fixed Sobol set beyond, never
27
- more than 256 points.
29
+ do: `E[T_run / C_run]`, the mean of the run's tokens per dollar, an average of each group's
30
+ (pieces with the same unseen cost nodes) weighted by its cost (`_tokens_per_usd`). It is
31
+ integrated over every unseen cost effect, the task's and each piece's (a new model, role or
32
+ position): exact when the pieces share them (the task's cancels), otherwise over their
33
+ differences, by Gauss-Hermite up to four axes and a fixed Sobol set beyond, never more than 256 points.
28
34
  A new task's means therefore do not depend on its id, and the Monte Carlo error of 400
29
35
  seeded draws of a large unseen effect (task, repo, org) leaves them. Intervals, the draws
30
36
  (`success_draws`, `ell_draws`) and the simulated runs keep the sampled effects. Unseen
@@ -57,14 +63,14 @@ from ..types import (
57
63
  )
58
64
  from .compose import Intervals, RunDraws, compose, cost_per_round, interval
59
65
  from .design import Structure, cost_rest, gate_rest, other_design, run_rest, structure, task_features, task_terms
60
- from .forest import LEVEL_ALIASES, default_scale, display_level, level_of, node_key, scale_group
66
+ from .forest import LEVEL_ALIASES, canonical_model_id, default_scale, display_level, level_of, node_key, scale_group
61
67
  from .block import BlockFactor, blocks, leading_diagonal
62
68
  from .gaussian import _cho, cov_factor
63
69
 
64
70
  N_DRAWS = 400
65
71
  PREDICT_SOURCE = "user"
66
72
  MIN_SCORE_SWITCH = 5 # spec 04 section 2: g = p_reach once the score head has 5 runs in the task's type
67
- BASE_HEADS = {"cost": ("gaussian", "cost"), "tokens": ("gaussian", "cost"), "gate": ("logistic", "logit"),
73
+ BASE_HEADS = {"cost": ("gaussian", "cost"), "rate": ("gaussian", "cost"), "gate": ("logistic", "logit"),
68
74
  "success": ("logistic", "logit")}
69
75
  TASK_CHAIN = ("task", "subtype", "repo", "type")
70
76
  FIXED_LEVELS = ("fixed", "round", "control", "price")
@@ -123,7 +129,7 @@ def normal_mean(f: Callable[[np.ndarray], np.ndarray], eta: np.ndarray, s2: floa
123
129
  MAX_POINTS = 256 # The most points `_rule` gives, whatever the number of axes
124
130
  _GRID_NODES = {1: MEAN_NODES, 2: 8, 3: 6, 4: 4} # Gauss-Hermite nodes per axis; nodes ** k <= MAX_POINTS
125
131
  _SOBOL_SEED = 106
126
- _CHUNK = 64 # points evaluated at once in `_inv_cost`
132
+ _CHUNK = 64 # points evaluated at once in `_ratio_mean`
127
133
 
128
134
 
129
135
  @lru_cache(maxsize=32)
@@ -146,6 +152,32 @@ def _rule(k: int) -> tuple[np.ndarray, np.ndarray]:
146
152
  return ndtri(u), np.full(MAX_POINTS, 1.0 / MAX_POINTS)
147
153
 
148
154
 
155
+
156
+ def _ratio_mean(A: np.ndarray, B: np.ndarray, L: np.ndarray) -> np.ndarray:
157
+ """`E[sum_i B_i exp(L_i . z) / sum_i A_i exp(L_i . z)]` per column of `A` and `B` (groups by entries), `z`
158
+ standard normal: a mean of the `B_i / A_i` weighted by `A_i exp(L_i . z)`, so it lies between their least and
159
+ greatest. Against a reference group `r` the ratio depends only on `d = (L_G - L_r) . z ~ N(0, Cov(d))`,
160
+ integrated on the principal axes of `Cov(d)` by `_rule`: at most `MAX_POINTS` points, evaluated `_CHUNK` at a
161
+ time, so memory and work stay bounded for any number of pieces. The reference is the group with the largest
162
+ cost. With one group the ratio is `B / A`."""
163
+ ref = int(np.argmax(A.mean(axis=1)))
164
+ others = [i for i in range(len(A)) if i != ref]
165
+ if not others:
166
+ return np.divide(B[ref], A[ref], out=np.zeros_like(A[ref]), where=A[ref] > 0)
167
+ D = L[others] - L[ref]
168
+ w, V = np.linalg.eigh(D @ D.T)
169
+ keep = w > 1e-12 * max(float(w.max()), 1e-300)
170
+ pts, wts = _rule(int(keep.sum()))
171
+ axes = (V[:, keep] * np.sqrt(w[keep]))[:, ::-1] # the largest axis first
172
+ out = np.zeros(A.shape[1])
173
+ for i in range(0, len(wts), _CHUNK):
174
+ E = np.exp(pts[i:i + _CHUNK] @ axes.T)
175
+ S = A[ref][None, :] + E @ A[others]
176
+ N = B[ref][None, :] + E @ B[others]
177
+ out += wts[i:i + _CHUNK] @ np.divide(N, S, out=np.zeros_like(S), where=S > 0)
178
+ return out
179
+
180
+
149
181
  class HeadState:
150
182
  """One head's posterior: node ids, mean, covariance factor and joint draws (lazy).
151
183
 
@@ -341,6 +373,8 @@ class FitState:
341
373
  self.timebox_effort = float(self.meta.get("timebox_effort", 1.0))
342
374
  # spec 04 section 2: the fit's price offset per model (log scale); none in fits before 0.2.2
343
375
  self.price_offsets: dict[str, float] = dict((self.meta.get("price_offsets") or {}).get("models") or {})
376
+ # spec 04 section 3 (0.2.8): each priced model's cache-read rate, $ per token; a draw's rate stays above it
377
+ self.rate_floors: dict[str, float] = dict(self.meta.get("rate_floors") or {})
344
378
  if heads is None:
345
379
  heads = {}
346
380
  for name, hmeta in (self.meta.get("heads") or {}).items():
@@ -426,7 +460,9 @@ class FitState:
426
460
  for gi, g in enumerate(st.gates):
427
461
  for k in range(1, (st.k_max if st.gate_loop.get(gi) is not None else 1) + 1):
428
462
  gate[(gi, k)] = gate_rest(st, g, k)
429
- return {"config": config, "st": st, "cost": cost, "gate": gate, "run": run_rest(st)}
463
+ floors = {piece: float(self.rate_floors.get(canonical_model_id(st.settings[piece].model), 0.0))
464
+ for piece in st.pieces}
465
+ return {"config": config, "st": st, "cost": cost, "gate": gate, "run": run_rest(st), "floors": floors}
430
466
 
431
467
  # ------------------------------------------------------------ the task as the fit reads it
432
468
 
@@ -554,7 +590,7 @@ class FitState:
554
590
  gate_keys = [k for p in plans for k in p["gate"].values()]
555
591
  run_keys = [p["run"] for p in plans]
556
592
  self._cached(self.heads["cost"], cost_keys)
557
- self._cached(self.heads["tokens"], cost_keys)
593
+ self._cached(self.heads["rate"], cost_keys)
558
594
  if gate_keys:
559
595
  self._cached(self.heads["gate"], gate_keys)
560
596
  for name, head in self.heads.items():
@@ -652,8 +688,9 @@ class FitState:
652
688
  """The composition's expectations with the unseen effects integrated out, and the map
653
689
  from its arrays to one value per known draw.
654
690
 
655
- A cost or tokens row enters the composition only through `exp(eta)`, times weights that
656
- depend on the gates alone, so its unseen effect `N(0, s2)` becomes `eta + s2 / 2`. The
691
+ A cost row enters the composition only through `exp(eta)`, times weights that depend on
692
+ the gates alone, so its unseen effect `N(0, s2)` becomes `eta + s2 / 2`. A rate row enters
693
+ through `E[1 / max(R, f)]`: its known part goes in with the variance `sigma^2 + s2`. The
657
694
  unseen task effect on the gates is one normal added to every gate row, integrated by
658
695
  Gauss-Hermite (the loop's reach is not linear in it): the draws are tiled once per node.
659
696
  """
@@ -665,7 +702,10 @@ class FitState:
665
702
  return known + 0.5 * s2
666
703
 
667
704
  cost = {k: lognormal("cost", r) for k, r in plan["cost"].items()}
668
- tok = {k: lognormal("tokens", r) for k, r in plan["cost"].items()}
705
+ rate, rate_var = {}, {}
706
+ for k, r in plan["cost"].items():
707
+ rate[k], s2 = self._known(heads["rate"], tparts["rate"], r)
708
+ rate_var[k] = heads["rate"].sigma ** 2 + s2
669
709
  tg: Row = tparts["gate"]
670
710
  gate = {k: tg.known + self._row(heads["gate"], r).draws for k, r in plan["gate"].items()}
671
711
  nodes = MEAN_NODES if gate and tg.s2 > 0 else 1
@@ -673,57 +713,45 @@ class FitState:
673
713
  shift = math.sqrt(tg.s2) * _GX[:, None]
674
714
  gate = {k: (v[None, :] + shift).ravel() for k, v in gate.items()}
675
715
  cost = {k: np.tile(v, nodes) for k, v in cost.items()}
676
- tok = {k: np.tile(v, nodes) for k, v in tok.items()}
677
- run = compose(st, cost, tok, gate, sigma_usd=heads["cost"].sigma, sigma_tokens=heads["tokens"].sigma, rng=None)
716
+ rate = {k: np.tile(v, nodes) for k, v in rate.items()}
717
+ run = compose(st, cost, rate, gate, sigma_usd=heads["cost"].sigma, sigma_rate=heads["rate"].sigma,
718
+ floors=plan["floors"], rate_var=rate_var, rng=None)
678
719
  if nodes == 1:
679
720
  return run, lambda a: a
680
721
  return run, lambda a: _GW @ np.reshape(a, (nodes, -1))
681
722
 
682
- def _inv_cost(self, plan: dict, tparts: dict, mr: RunDraws) -> np.ndarray:
683
- """`E[1 / C_run]` per entry of the mean composition `mr`, every unseen cost effect
723
+ def _tokens_per_usd(self, plan: dict, tparts: dict, mr: RunDraws) -> np.ndarray:
724
+ """`E[T_run / C_run]` per entry of the mean composition `mr`, every unseen cost and rate effect
684
725
  integrated out (the task's, and a new model's, role's or position's).
685
726
 
686
727
  `C_run = sum over pieces of A_p exp(u_p)`: `A_p` is the piece's expected cost at its known
687
728
  nodes (its `mr` cost without the log-normal factor), `u_p` its unseen effect. Every round of
688
729
  a piece has the same unseen nodes (round and control terms are fixed effects), and pieces
689
- with the same unseen nodes form one group. The task part is common to every group and
690
- gives `exp(s2 / 2)`. With one group, `E[1 / C] = exp(Var(u) / 2) / A` exactly. Otherwise,
691
- with `d_G = u_G - u_r` against a reference group `r`, a change of measure gives
692
- `E[1 / C] = exp(Var(u_r) / 2) E[1 / (A_r + sum A_G exp(d_G))]`, `d ~ N(-Cov(d, u_r), Cov(d))`,
693
- integrated on the principal axes of `Cov(d)` by `_rule`: at most `MAX_POINTS` points,
694
- evaluated `_CHUNK` at a time, so memory and work stay bounded for any number of pieces.
695
- The reference is the group with the largest cost, which bounds the integrand by
696
- `1 / A_r`.
730
+ with the same unseen nodes form one group G. A piece's tokens are its cost times
731
+ `Q = 1 / max(R, f)`, and Q is independent of every cost effect, so given those effects the run's
732
+ tokens are `sum over G of B_G exp(u_G)`, `B_G` the group's `mr` tokens without the same factor.
733
+ So `E[T / C] = E[sum B_G exp(u_G) / sum A_G exp(u_G)]`: the run's tokens per dollar, the groups'
734
+ `B_G / A_G` weighted by their cost. The task's unseen effect is common and cancels; with one group
735
+ this is `B / A`. `_ratio_mean` integrates the rest.
697
736
  """
698
737
  head = self.heads["cost"]
738
+ s2 = tparts["cost"].s2
699
739
  groups: dict[tuple, np.ndarray] = {}
740
+ tokens: dict[tuple, np.ndarray] = {}
700
741
  for piece in plan["st"].pieces:
701
742
  load = self._unseen(head, plan["cost"][(piece, 1)])
702
- a = mr.pieces[piece].exp_usd * math.exp(-0.5 * (tparts["cost"].s2 + sum(b * b for _, b in load)))
743
+ known = math.exp(-0.5 * (s2 + sum(b * b for _, b in load)))
744
+ a = mr.pieces[piece].exp_usd * known
745
+ t = mr.pieces[piece].exp_tokens * known
703
746
  groups[load] = groups[load] + a if load in groups else a
747
+ tokens[load] = tokens[load] + t if load in tokens else t
704
748
  loads = list(groups)
705
- A = np.stack([groups[g] for g in loads])
706
749
  names = {n: j for j, n in enumerate(sorted({n for g in loads for n, _ in g}))}
707
750
  L = np.zeros((len(loads), len(names)))
708
751
  for i, g in enumerate(loads):
709
752
  for n, b in g:
710
753
  L[i, names[n]] = b
711
- ref = int(np.argmax(A.mean(axis=1)))
712
- scale = math.exp(0.5 * (tparts["cost"].s2 + float(L[ref] @ L[ref])))
713
- others = [i for i in range(len(loads)) if i != ref]
714
- if not others:
715
- return scale * np.divide(1.0, A[ref], out=np.zeros_like(A[ref]), where=A[ref] > 0)
716
- D = L[others] - L[ref]
717
- w, V = np.linalg.eigh(D @ D.T)
718
- keep = w > 1e-12 * max(float(w.max()), 1e-300)
719
- pts, wts = _rule(int(keep.sum()))
720
- axes = (V[:, keep] * np.sqrt(w[keep]))[:, ::-1] # the largest axis first
721
- mu = -(D @ L[ref])
722
- out = np.zeros(A.shape[1])
723
- for i in range(0, len(wts), _CHUNK):
724
- S = A[ref][None, :] + np.exp(mu[None, :] + pts[i:i + _CHUNK] @ axes.T) @ A[others]
725
- out += wts[i:i + _CHUNK] @ np.divide(1.0, S, out=np.zeros_like(S), where=S > 0)
726
- return scale * out
754
+ return _ratio_mean(np.stack([groups[g] for g in loads]), np.stack([tokens[g] for g in loads]), L)
727
755
 
728
756
  def _predict(self, task: Task, plan: dict, tparts: dict, rule: AcceptanceRule | None,
729
757
  rescue_usd: float | None) -> tuple[Prediction, dict]:
@@ -731,11 +759,11 @@ class FitState:
731
759
  cfg: Configuration = plan["config"]
732
760
  heads = self.heads
733
761
  cost_eta = {k: self._eta(heads["cost"], tparts["cost"], r)[1] for k, r in plan["cost"].items()}
734
- tok_eta = {k: self._eta(heads["tokens"], tparts["tokens"], r)[1] for k, r in plan["cost"].items()}
762
+ rate_eta = {k: self._eta(heads["rate"], tparts["rate"], r)[1] for k, r in plan["cost"].items()}
735
763
  gate_eta = {k: self._eta(heads["gate"], tparts["gate"], r)[1] for k, r in plan["gate"].items()}
736
764
  rng = np.random.default_rng(self.sim_seed)
737
- rd: RunDraws = compose(st, cost_eta, tok_eta, gate_eta, sigma_usd=heads["cost"].sigma,
738
- sigma_tokens=heads["tokens"].sigma, rng=rng)
765
+ rd: RunDraws = compose(st, cost_eta, rate_eta, gate_eta, sigma_usd=heads["cost"].sigma,
766
+ sigma_rate=heads["rate"].sigma, floors=plan["floors"], rng=rng)
739
767
  _, s_eta = self._eta(heads["success"], tparts["success"], plan["run"])
740
768
  g = expit(s_eta)
741
769
  s_known, s_s2 = self._known(heads["success"], tparts["success"], plan["run"])
@@ -758,11 +786,10 @@ class FitState:
758
786
 
759
787
  m_usd, m_tok = per_draw(mr.exp_usd), per_draw(mr.exp_tokens)
760
788
  # The ell.tokens mean is the expectation of what its draws hold, the rescue priced at
761
- # the draw's own tokens per dollar. Given the gates, the cost and tokens heads' unseen
762
- # effects are independent, so E[T / C] = E[T] E[1 / C]: `mr` holds E[T], and `_inv_cost`
763
- # integrates 1 / C over every unseen cost effect. The gates' unseen task effect stays under
764
- # Gauss-Hermite. Without a rescue the ratio does not enter.
765
- m_per_usd = per_draw(mr.exp_tokens * self._inv_cost(plan, tparts, mr)) if rescue > 0 else 0.0
789
+ # the draw's own tokens per dollar, E[T / C]: `_tokens_per_usd` integrates it over every
790
+ # unseen cost and rate effect. The gates' unseen task effect stays under Gauss-Hermite.
791
+ # Without a rescue the ratio does not enter.
792
+ m_per_usd = per_draw(self._tokens_per_usd(plan, tparts, mr)) if rescue > 0 else 0.0
766
793
  iv = Intervals()
767
794
  handles = {}
768
795
  for piece, pd in rd.pieces.items():
@@ -869,15 +896,22 @@ class FitState:
869
896
  return out
870
897
 
871
898
  def node_summary(self, level: str | None = None, head: str | None = None) -> list[NodeSummary]:
872
- """Every fitted node's effect: its own deviation plus its ancestors' (the node's belief)."""
899
+ """Every fitted node's effect: its own deviation plus its ancestors' (the node's belief).
900
+
901
+ From 0.2.8 the tokens entries are derived, in the rate head's place: a node's tokens multiplier is its
902
+ cost multiplier over its rate multiplier on the same draws (log T = log U - log R), with the rate head's
903
+ support. It is the effect before the floor: a prediction holds each draw's rate at or above the model's
904
+ cache-read rate, so where that binds, the predicted tokens move less than the multiplier says."""
873
905
  parents = {n["id"]: n.get("parent") for n in self.design.get("nodes") or []}
874
906
  support = self.design.get("support") or {}
875
907
  wanted = set(LEVEL_ALIASES.get(level, (level,))) if level else None
876
908
  out: list[NodeSummary] = []
877
909
  for name, hs in self.heads.items():
878
- if (head and name != head) or not hs.fitted or not hs.nodes:
910
+ shown = "tokens" if name == "rate" else name
911
+ if (head and shown != head) or not hs.fitted or not hs.nodes:
879
912
  continue
880
913
  draws = hs.draws
914
+ cost = self.heads["cost"] if name == "rate" else None
881
915
  hsup = support.get(name) or {}
882
916
  for j, node in enumerate(hs.nodes):
883
917
  lvl = display_level(node)
@@ -894,13 +928,27 @@ class FitState:
894
928
  if k is not None:
895
929
  chain.append(k)
896
930
  cur = parents.get(cur)
897
- effect, display = self._effect(hs, draws[chain].sum(axis=0))
931
+ eff = draws[chain].sum(axis=0)
932
+ if cost is not None: # tokens: the cost chain's draws less the rate chain's
933
+ eff = self._chain(cost, node, parents) - eff
934
+ effect, display = self._effect(hs, eff)
898
935
  n_runs, mix = hsup.get(node) or [0, {}]
899
- out.append(NodeSummary(level=lvl, key=node_key(node), head=name, effect=effect, display=display,
936
+ out.append(NodeSummary(level=lvl, key=node_key(node), head=shown, effect=effect, display=display,
900
937
  support=int(n_runs), parent=parents.get(node),
901
938
  source_mix={str(k): int(v) for k, v in (mix or {}).items()}))
902
939
  return out
903
940
 
941
+ @staticmethod
942
+ def _chain(hs: HeadState, node: str, parents: dict) -> np.ndarray:
943
+ """The draws of a node's effect in one head: its own and its ancestors' that the head has."""
944
+ idx, cur = [], node
945
+ while cur:
946
+ k = hs.index.get(cur)
947
+ if k is not None:
948
+ idx.append(k)
949
+ cur = parents.get(cur)
950
+ return hs.draws[idx].sum(axis=0) if idx else np.zeros(N_DRAWS)
951
+
904
952
  def _effect(self, hs: HeadState, eff: np.ndarray) -> tuple[Interval, Interval]:
905
953
  """cost: multiplier (x1.4); success and gate: log-odds shift, shown in pp at the baseline;
906
954
  score: shift on the modelled scale, shown in raw units (log: ratio; fraction: pp)."""
loopmath/ingest/base.py CHANGED
@@ -58,6 +58,7 @@ MODEL_ALIASES = {
58
58
  "gpt-5.4": "gpt-5.4",
59
59
  "gpt-6-astra": "gpt-6-astra",
60
60
  "gpt-6-sol": "gpt-6-sol",
61
+ "gpt-6.1-sol": "gpt-6.1-sol",
61
62
  "gpt-6-luna": "gpt-6-luna",
62
63
  }
63
64
 
loopmath/prices.toml CHANGED
@@ -142,7 +142,10 @@ output = 0.00
142
142
  source = "OpenCode 1.17.11 model catalog, opencode free route"
143
143
 
144
144
  # ---------------------------------------------------------------------------
145
- # Rows checked on the provider pages on 2026-09-23.
145
+ # Rows checked on the provider pages on 2026-09-23; gpt-6.1-sol added and
146
+ # gpt-6-luna checked again on 2026-10-12 (no newer luna is listed). as_of
147
+ # stays the copied table's date: a row with no "checked" date takes it as
148
+ # the date of its tariff, and those rows were not read again.
146
149
  # Each source names its page and the date it was read; the tariff record
147
150
  # written with every priced attempt takes its date from "checked YYYY-MM-DD".
148
151
  #
@@ -179,12 +182,19 @@ cache_write = 2.50
179
182
  output = 10.00
180
183
  source = "official list, https://developers.openai.com/api/docs/models/gpt-6-sol, checked 2026-09-23: standard tier, $2 input, $0.20 cached input, $2.50 cache write, $10 output; prompts over 272K input tokens bill more and are priced at this rate (a floor)"
181
184
 
185
+ ["gpt-6.1-sol"]
186
+ input = 2.00
187
+ cache_read = 0.10
188
+ cache_write = 2.50
189
+ output = 10.00
190
+ source = "official list, https://developers.openai.com/api/docs/models/gpt-6.1-sol, checked 2026-10-12: standard tier, $2 input, $0.10 cached input, $2.50 cache write, $10 output; prompts over 272K input tokens bill more and are priced at this rate (a floor)"
191
+
182
192
  ["gpt-6-luna"]
183
193
  input = 0.10
184
194
  cache_read = 0.01
185
195
  cache_write = 0.125
186
196
  output = 0.50
187
- source = "official list, https://developers.openai.com/api/docs/models/gpt-6-luna, checked 2026-09-23: standard tier, $0.10 input, $0.01 cached input, $0.125 cache write, $0.50 output; prompts over 272K input tokens bill more and are priced at this rate (a floor)"
197
+ source = "official list, https://developers.openai.com/api/docs/models/gpt-6-luna, checked 2026-10-12: standard tier, $0.10 input, $0.01 cached input, $0.125 cache write, $0.50 output; prompts over 272K input tokens bill more and are priced at this rate (a floor)"
188
198
 
189
199
  ["opus-4.8"]
190
200
  input = 5.00
@@ -11,7 +11,7 @@ results on it (0.2.3; spec 04 section 5).
11
11
 
12
12
  This module loads the file and checks it: every value is a published number
13
13
  with its own source line, success values are fractions, the reference
14
- model has a result, and only the 12 current models appear. A model with no
14
+ model has a result, and only the 13 current models appear. A model with no
15
15
  result anywhere is listed by `missing_models`, never filled in.
16
16
  """
17
17
 
@@ -28,7 +28,7 @@ BENCHMARKS_TOML = Path(__file__).resolve().parent / "benchmarks.toml"
28
28
  # The current models in the packaged price table.
29
29
  MODELS = (
30
30
  "claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "haiku-4.5",
31
- "gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
31
+ "gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
32
32
  )
33
33
  KINDS = ("success", "cost", "tokens")
34
34
  _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@@ -67,7 +67,7 @@ def check_benchmarks(data: dict) -> list[str]:
67
67
  problems.append(f"{where}: unknown benchmark")
68
68
  continue
69
69
  if r.get("model") not in MODELS:
70
- problems.append(f"{where}: model is not one of the 12 current models (D50)")
70
+ problems.append(f"{where}: model is not one of the 13 current models (D50)")
71
71
  if r.get("effort") is not None and r["effort"] not in EFFORTS:
72
72
  problems.append(f"{where}: effort {r['effort']!r} is not one of {sorted(EFFORTS)}")
73
73
  value = r.get("value")
@@ -98,7 +98,7 @@ def check_benchmarks(data: dict) -> list[str]:
98
98
 
99
99
 
100
100
  def coverage(data: dict) -> dict[str, list[str]]:
101
- """Model to the benchmark ids that have a result for it, for the 12 current models."""
101
+ """Model to the benchmark ids that have a result for it, for the 13 current models."""
102
102
  out: dict[str, list[str]] = {m: [] for m in MODELS}
103
103
  for r in data.get("result") or []:
104
104
  ids = out.get(r.get("model"))
@@ -40,8 +40,9 @@ FALLBACK_REVIEWER = ("codex", "gpt-6-astra", "xhigh")
40
40
  # The models offered when neither `--models` nor `models.allowed` is set (0.2.2): the current ones only. Any
41
41
  # other model is retired: its runs stay data for the fit and its workflow can be the reference line, but it is
42
42
  # never a pick, a choice or a candidate. First the fallback implementer, then the fallback reviewer, as
43
- # `models.allowed` order is read, so a store with no habits keeps the default usual it had.
44
- DEFAULT_MODELS = ("claude-opus-5-5", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "claude-sonnet-5", "claude-fable-5-1")
43
+ # `models.allowed` order is read, so a store with no habits keeps the default usual it had. gpt-6.1-sol is the
44
+ # current sol (0.2.8); gpt-6-sol stays in the catalog, and `--models` can still offer it.
45
+ DEFAULT_MODELS = ("claude-opus-5-5", "gpt-6-astra", "gpt-6.1-sol", "gpt-6-luna", "claude-sonnet-5", "claude-fable-5-1")
45
46
  REVIEW_ROLES = ("reviewer", "referee", "review", "select")
46
47
  # `--json --brief`: what an agent needs to show the choices and start one. The stored
47
48
  # recommendation and the full JSON keep everything; a key added to recommend/2 stays out unless listed.
@@ -601,9 +602,10 @@ def typical(rec: Recommendation | None, p) -> str:
601
602
 
602
603
  def line_numbers(p, *, first: bool = False, marks: tuple[str, ...] = (), rec: Recommendation | None = None,
603
604
  lead_typical: bool = False) -> str:
604
- """Chance, run cost with its median, expected rescue and `ell`, which the first line spells out; a
605
- mean above its interval's upper end gets the tail note in its parentheses, after any `marks` such as
606
- "uncertain". With `lead_typical` the run cost is the typical run with its range; the caller prints the mean."""
605
+ """Chance, run cost with its tokens, then its median, expected rescue and `ell`, which the first line spells
606
+ out; a mean above its interval's upper end gets the tail note in its parentheses, after any `marks` such as
607
+ "uncertain". The tokens are the mean run's, so they follow the mean cost; after the median they read as the
608
+ median run's. With `lead_typical` the run cost is the typical run with its range; the caller prints the mean."""
607
609
  ell = fmt_usd(p.ell.usd.mean)
608
610
  note = TAIL if heavy(p.ell.usd) else ""
609
611
  per = (f"expected cost per accepted result {ell}{paren('lower is better', *marks, note)}" if first
@@ -616,7 +618,7 @@ def line_numbers(p, *, first: bool = False, marks: tuple[str, ...] = (), rec: Re
616
618
  if lead_typical:
617
619
  return f"{chance}, typical run {typical_cost(p.cost)}, {rescue}{per}"
618
620
  return (f"{chance}, {fmt_usd(p.cost.usd.mean)} a run "
619
- f"({(med + '; ') if med else ''}{fmt_tokens(p.cost.tokens.mean)} tokens"
621
+ f"({fmt_tokens(p.cost.tokens.mean)} tokens{('; ' + med) if med else ''}"
620
622
  f"{tail(p.cost.usd, p.cost.tokens)}), {rescue}{per}")
621
623
 
622
624
 
@@ -58,9 +58,9 @@ Then ask exactly one question: "Which one should I start? (1 to N, or describe a
58
58
  > 1. Recommended: plan_implement: opus-5-5/high, sonnet-5/high: 78% in one run (61 to 90%), 90% within 3 attempts, $1.20 a run (median $0.95), $1.73 per accepted result
59
59
  > 2. Pair: 1 plus plan_implement_review: sonnet-5/high x3: extra $0.80 now, judged blind; pays back after 4 similar tasks
60
60
  > 3. Your usual (reference): 71% in one run (52 to 86%), 85% within 3 attempts, $1.45 a run (median $1.10), $2.36 per accepted result
61
- > 4. Most likely to reach the target: best_of_n: gpt-6-sol/xhigh x3: 88% in one run (70 to 97%), 96% within 3 attempts, $4.10 a run (median $3.80), $4.90 per accepted result
61
+ > 4. Most likely to reach the target: best_of_n: gpt-6.1-sol/xhigh x3: 88% in one run (70 to 97%), 96% within 3 attempts, $4.10 a run (median $3.80), $4.90 per accepted result
62
62
  > 5. Cheapest run: solo: sonnet-5/medium: 52% in one run (30 to 73%), 70% within 3 attempts, $0.35 a run, $2.40 per accepted result
63
- > Run cost is what the agents cost for one run; cost per accepted result adds the expected cost of fixing a miss by retrying with best_of_n: gpt-6-sol/xhigh x3, each extra attempt having half the chance of the one before, up to 3 attempts.
63
+ > Run cost is what the agents cost for one run; cost per accepted result adds the expected cost of fixing a miss by retrying with best_of_n: gpt-6.1-sol/xhigh x3, each extra attempt having half the chance of the one before, up to 3 attempts.
64
64
  > Planning page: /Users/me/.loopmath/views/recommend-20260924-181502.html
65
65
  > Which one should I start? (1 to 5, or describe another workflow)
66
66
 
@@ -69,7 +69,7 @@
69
69
  var arrow = m.better === 'lower' ? ' Lower is better (↓).' : m.better === 'higher' ? ' Higher is better (↑).' : '';
70
70
  if (head.indexOf('score:') === 0 && k === 'multiplier') return 'How much a node multiplies the score ' + head.slice(6) + ' on one run: x1.00 is no change.' + arrow;
71
71
  if (head.indexOf('score:') === 0 && k === 'pp') return 'How a node shifts the score ' + head.slice(6) + ' on one run, in percentage points.' + arrow;
72
- if (head === 'tokens') return 'How much a node multiplies tokens per round, relative to its parent: x1.00 is no change, below x1 is fewer.';
72
+ if (head === 'tokens') return 'How much a node multiplies tokens per round, relative to its parent: x1.00 is no change, below x1 is fewer. It is the cost multiplier over the price-per-token multiplier, before the floor: a prediction never prices a token below its model\'s cache-read rate, so where that floor binds, the predicted tokens move less than this.';
73
73
  if (k === 'multiplier') return 'How much a node multiplies dollars per round, relative to its parent: x1.00 is no change, below x1 is cheaper.';
74
74
  if (head === 'gate') return 'How a node shifts the chance that a gate passes on a round, in percentage points at the group\'s base rate.';
75
75
  if (k === 'pp') return 'How a node shifts the chance of an accepted result, in percentage points at the group\'s base rate.';
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopmath
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Plan agent workflows, record how they went, and learn from your own Claude Code and Codex logs
5
5
  Author: QKV Labs
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- loopmath/__init__.py,sha256=snnq8mT6QJRclNwqXkzZUY8Xpy8fs4W2KWZiKzmLdsM,244
1
+ loopmath/__init__.py,sha256=qoxVgTJW5APTeuqbAUGn9fc40vF7vi_cOBSq0yI2vAY,244
2
2
  loopmath/__main__.py,sha256=X6nV3FjHmxDbIjccOYrBts_0KyJwOmDEPI5URzMO_-8,126
3
3
  loopmath/adapter_host.py,sha256=KBLHsP-LzrRDzOaDQMJRDq-NfCJeT5nY_CQAPLqyKQ4,10284
4
4
  loopmath/cli.py,sha256=6oJ8m3KkAv7-yTh-p7WmqCNjbs1moy1jBExVzk-Et54,23459
@@ -21,7 +21,7 @@ loopmath/output.py,sha256=8dm7eh37D0cKAUFSIlcLtbJ8Td1gxxj6vjy99pOGHqs,4584
21
21
  loopmath/pool.py,sha256=s3DfC2G2S6rK47PEyzSpIM1wYNQKnScpCtIallHhego,1953
22
22
  loopmath/price.py,sha256=Mr3yINrwwH9dZ2aKrv7BTsyQcukkU9fU3I4M1l1GR2A,24880
23
23
  loopmath/price_validation.py,sha256=7VY-vli5JFBh1FV2dRC241Akr6gLbJ7qos6fhuBr8_c,13246
24
- loopmath/prices.toml,sha256=dzFTWB0YRu0O7Hvgcaw-tn9tZIywoYYGT2Puwvj-jRI,10802
24
+ loopmath/prices.toml,sha256=5bYkgpWzK0D6numXZM6y-Ok3EuKevDH-iWBnsElIu1c,11376
25
25
  loopmath/receipts.py,sha256=gW_c1ZSyrL0czo1ilFdpeyonkqFuzKYbeaUknbrFDvE,7034
26
26
  loopmath/research_defaults.py,sha256=tKbCtQGdj5E37qaIZeQ72MaX0eZOVSX31wNtOTxtr6E,248
27
27
  loopmath/research_paths.py,sha256=dTXGaD0X0AqvMAZ90FuZZV5Qdxwjlb25ZplDhRTc8T8,2342
@@ -62,18 +62,18 @@ loopmath/belief/__init__.py,sha256=Ny_4xJWWvvAyI8gkrQtBBO-9uKXGvNWFQZcyAkQ06BA,1
62
62
  loopmath/belief/block.py,sha256=GoZ269Vql8L6rB5-klT7cGlx42Rc4m9KsRO9ccU7E8g,7037
63
63
  loopmath/belief/check_pymc.py,sha256=9BLVla7HqRMjF_G4biLyxWY223XVAklJdy1PuCbUOIU,6007
64
64
  loopmath/belief/commands.py,sha256=AxRn2XLiNk_RTbqQpVl_CovpRyv5c00vH5QCbLfiOGc,10298
65
- loopmath/belief/compose.py,sha256=ABqkq8wXc-UUv9RSKlyKJKA2Fhg3USyxSG9xxxcME34,9730
66
- loopmath/belief/design.py,sha256=CIFpqhljD6sEoMzEIO7D_kLZTQJkuobEXaTPAHXgv-c,42452
67
- loopmath/belief/features.py,sha256=cVLrDeSs-s4PBbQ0lm2wkdZxUd5DVoh-yG2Lj1Pa1iA,11454
68
- loopmath/belief/fit.py,sha256=7RTW9UQc2e4CKXsXpptL6wr3qgTkrJjm4Fn79fY1eeQ,37283
65
+ loopmath/belief/compose.py,sha256=MjwvQHh2YpnTKvlQNWguiPm-lcnqIiHaClOH1aK77M4,11841
66
+ loopmath/belief/design.py,sha256=e5mcCqeDNA1wtDmXnYrZ9Bfc7vVM5WJc_NKXRYMjjwY,42727
67
+ loopmath/belief/features.py,sha256=4nStB7E1a3cgAXdh7xXCCH4Ddf9T9vNi0AFnx5DP_LQ,11615
68
+ loopmath/belief/fit.py,sha256=W7XxO9LVoxaS2eP0VInqEY1yt2gEEzDnq-9WY5McaXE,38957
69
69
  loopmath/belief/forest.py,sha256=efHhbNdSl1_KIpPK317AlrzOvE6Jx80UiOZfjABC0EQ,8658
70
70
  loopmath/belief/gaussian.py,sha256=YgN-SUOzSKj-dXndjPaaDMOw2oCx_NVj3hoGtn2B_IM,22090
71
- loopmath/belief/lookahead.py,sha256=NBGEIoqV5OPqs97mCji7wBT2gWi842EL1nzfCu8skGM,19297
71
+ loopmath/belief/lookahead.py,sha256=u-Z9xZ-nI0I1qumxfdONhMwct1ub0n4MwrNJG_P10dI,19295
72
72
  loopmath/belief/outcome.py,sha256=c2bnM0LEFBL3IjiibDq6DnyIbBuSPzK1MY48T4X8z9Y,6744
73
- loopmath/belief/pricing.py,sha256=KXMi1cQ3FWVSxb_2sTGlPKTtv_Mp7Dal_Q8-263Jjeg,6936
73
+ loopmath/belief/pricing.py,sha256=vTKf7iheX9zW9TjpbNOk9BhLhxN0BZNLoSZXD0eKo6k,7914
74
74
  loopmath/belief/priors.py,sha256=Fi1dImT2rNQJzJrFU4XmAKnhsEK8ay__VFRuMkEvO-E,8053
75
75
  loopmath/belief/rules.py,sha256=nEZFBiDIxwDdqcxkeMiiKNrJ4SGRtBirTkCvTViv8As,6352
76
- loopmath/belief/state.py,sha256=pO5gEFP6XtpHZiL7VUCy4fCUxS2ACyvifgrs-XlUZwQ,48992
76
+ loopmath/belief/state.py,sha256=NjycTv40skGlcvBKnypCiQX3eDsBW_8gZWS6QrbFHgU,52020
77
77
  loopmath/builder/__init__.py,sha256=E9_JLObtnM_r2to6bqH13xV3LHN00zVIRH1nzJTCv7s,105
78
78
  loopmath/builder/cli.py,sha256=NPMkFE0vIOAsEMCtGVkvzdzmszrNd7uZ3CNX_E-zV9M,2429
79
79
  loopmath/builder/context.py,sha256=1NCKZSx9Fn64pSixnLJqV80fPDtoPusttnj5ZnjhDrU,18387
@@ -145,7 +145,7 @@ loopmath/graph/scan.py,sha256=-7dU9xVKWjHLdnK7LFjSIk2X0-jVUowdnFZhCc8sAnI,10413
145
145
  loopmath/graph/schema.py,sha256=qWb2FZnaGU5q0CcfNpStrKwAO0Hb_p3HnMCj8fLlO7Y,6601
146
146
  loopmath/graph/token_completeness.py,sha256=PWFJHbo5EZhC3vCwkLLRF-yi-hpGjP3Zw3ofoC-rvcw,916
147
147
  loopmath/ingest/__init__.py,sha256=4wLZg89gTwI9h8yXPfcrtgO_Z_AqDFt74_7Zxbrhjo0,19889
148
- loopmath/ingest/base.py,sha256=Q9hTuVbFFLwGT6kPSpi4tgZWs_aJD3-ubNe0O2y7NGQ,20029
148
+ loopmath/ingest/base.py,sha256=TEj7OLysgbGvMHLxTLxDlF6i_C34_3PsTE8pK4JEl_o,20063
149
149
  loopmath/ingest/claude_code.py,sha256=IMdjzzuzTCNO6iL6fLl2zwSvzWQGwzgyqMjaisM7Mjo,24846
150
150
  loopmath/ingest/claude_code_support.py,sha256=BU3aSxTOvhpUlyPOSmBOcwnTfMP8cnnGlAwmuNFX8OU,2423
151
151
  loopmath/ingest/codex.py,sha256=oKJKol69ps83Y_0ZqjkbMP6qCQPD6zwbXW_D0nn6c-g,27262
@@ -183,7 +183,7 @@ loopmath/onboard/history.py,sha256=qQqidw7pg-XOSQir8bcImTfP3fdFcGt8VsnrpuRMX4A,2
183
183
  loopmath/onboard/label.py,sha256=nKlveJNggqxKcK3I60QQF8L0y0iEcm2d-LIP0Ca3n50,19571
184
184
  loopmath/onboard/usual.py,sha256=rEyg6WOCzEN8Y-UsHLh2br218qdca3IzRp1FVaA_qgw,3444
185
185
  loopmath/priors/__init__.py,sha256=vruMFu0UAW_IDoRquKw9skmaqfrOXXYGhtfW_wTxc90,5749
186
- loopmath/priors/benchmarks.py,sha256=el_D9DP3M0N4HCeBFK74EplyMsx2DC6M_ezDOO2-b9g,5745
186
+ loopmath/priors/benchmarks.py,sha256=hqxOvDFS72A2tfTUpexFPogj7Y30b4CJvXNCQ3F9-rY,5760
187
187
  loopmath/priors/benchmarks.toml,sha256=geoPVCpSnhrgey2ynO7Kkn6picV0suBjhcOWUoCp_QE,54412
188
188
  loopmath/priors/build.py,sha256=bkT1JPcr-198jC9FZZiUWYY7M9uc-YG7S3VtJvA3PpU,16664
189
189
  loopmath/priors/commands.py,sha256=01HoZNVoH_6BVkhfusa2JEz4JuwPPOLqL_g2VGu0M20,5691
@@ -205,7 +205,7 @@ loopmath/priors/bundle/rq1.jsonl.gz,sha256=Ejk6cKab9aFYpjT9kdbek46T3m_lzjCUbtJGU
205
205
  loopmath/priors/bundle/sweep.jsonl.gz,sha256=0f6kxs-DdWt51FUec4zeYk5EAhD7OQjZ5gpQPIOG4Xg,135748
206
206
  loopmath/recommend/__init__.py,sha256=KsxeqH9dZFG2y9vV8JfKfcXtb-neWDshjQwdkRgIeB0,138
207
207
  loopmath/recommend/backlog.py,sha256=Y_GsWF5S4bR6V66HckotQtNr3IV6bpwbhzbQ0XjV1rQ,31613
208
- loopmath/recommend/commands.py,sha256=2LvieWZhM18BQhw5KaPryHVRXDElzuyv7O93qtoH5Og,38099
208
+ loopmath/recommend/commands.py,sha256=CGbK7OgI6fggJEm0LPAxrMNHFYdCMZHo4GRbeWAe2_o,38340
209
209
  loopmath/recommend/curve.py,sha256=cxe2pR9-Ye5XciEUlKFlJpYKeo-FNokYaqJBuChTpsU,9831
210
210
  loopmath/recommend/engine.py,sha256=YjW-D8jVYOvKdL6DtvfOgmdEb6-Xkqacmw36Q1kJNqs,44058
211
211
  loopmath/recommend/gain.py,sha256=PPd6D3XKyPPwcE13X0Q3T82LoNEQF45KVBuabhAvKPw,7446
@@ -233,7 +233,7 @@ loopmath/skill/skills/reference.md,sha256=feqZIhyKCRDb7Cl46g73IqD_0D99aXy6frfg4V
233
233
  loopmath/skill/skills/loopmath/SKILL.md,sha256=MUb_5Nu_0HwuD_pKHiQgoopxG6YLV2gj99oXT6umuAA,2473
234
234
  loopmath/skill/skills/loopmath-import-runs/SKILL.md,sha256=A-mSL5-a31-umQCtpHgnclHD3V1_eSKGMV1SmrxTHdE,3131
235
235
  loopmath/skill/skills/loopmath-onboard/SKILL.md,sha256=hQDyFW5FBgw03p6pHl3CxJIiHrQ3edbZmSwcmZMSJ0s,4814
236
- loopmath/skill/skills/loopmath-plan-task/SKILL.md,sha256=uQSUfVJxDu7j8qmt58I3Spx_zUbj-Or9HW1A7nKXfJI,10687
236
+ loopmath/skill/skills/loopmath-plan-task/SKILL.md,sha256=TE6UcVtRUGy3gkCKZLl05i472IGKeT14-gVb6gA_dzc,10691
237
237
  loopmath/skill/skills/loopmath-record-run/SKILL.md,sha256=rp6rI0fkeE5Fcng1dGKdae6q0K6R_kK8aLbHpDMWazk,6723
238
238
  loopmath/skill/skills/loopmath-update-fit/SKILL.md,sha256=JEO8sEm2GsBZ7IoJNOkSdAshTtBofFa0Rfy34P-0aN4,2648
239
239
  loopmath/store/__init__.py,sha256=vCGuqOSRKmKoT4eLbjEl8kxJRwdHWqxouGTp6gqPLFE,708
@@ -264,7 +264,7 @@ loopmath/views/assets/builder.html,sha256=I9tWu6V_iN2iT9rrDcZvy_LdTHHifPIdvmfD-9
264
264
  loopmath/views/assets/builder.js,sha256=YFY9t9ZxodZxV4srXIv4xUWFNE7rYdBGoWomRbFZUdc,100685
265
265
  loopmath/views/assets/common.js,sha256=DUQsTagXBdsMd17iAhyDAMAUw1Qu8xzNmqqLhJPxczA,15314
266
266
  loopmath/views/assets/estimates.css,sha256=6svx7lENnkXf-38emFJUxONGmXXbU77lnWupJFWLuF0,4321
267
- loopmath/views/assets/estimates.js,sha256=X_DQcISewPLmwc2GHOr73DHOBm4N9qumvUyVjILEOgQ,51889
267
+ loopmath/views/assets/estimates.js,sha256=ZgR_gqQOg9GPsGBtS42XXqnirUF14YDfYNMfPYBjQQU,52109
268
268
  loopmath/views/assets/graph.css,sha256=6-jiJ1T5aiiMVeEse7Oz-w72r-zqb2tX6UeApmdmp4c,1799
269
269
  loopmath/views/assets/graph.js,sha256=mv-yCXwoMYcRhDFzlKFZPaiTKjbv6uTNvt2UyU0bzYk,36947
270
270
  loopmath/views/assets/plans.css,sha256=mRjg4FEiB2a_cGcMZwyFEqvwNHNR24UJ26nkPbVp0nk,2986
@@ -291,12 +291,12 @@ loopmath/workflows/catalog/plan_implement.toml,sha256=qxAs6G3JGOUuW6TnhiaUXzaFfy
291
291
  loopmath/workflows/catalog/plan_implement_review.toml,sha256=rRtiactglcJAynFWbrjnI-Ftbl3qe971ov9HJ3Vr7uU,862
292
292
  loopmath/workflows/catalog/solo.toml,sha256=aJWBEEfS4DNCLqznDI3yp-7s1ZXx_rdqsEIPX7n0-nM,373
293
293
  loopmath/workflows/catalog/swarm.toml,sha256=1hbQR41nl7VS_XN1z2fmwLCNw6kn2syowpwwauFUL3o,850
294
- loopmath-0.2.7.data/data/spec/ocp-v0.2.schema.json,sha256=T4P5gg6H2dW-28MRbg9fSNct_ejXezI69-SaKR9WogU,31855
295
- loopmath-0.2.7.data/data/spec/ocp-v0.schema.json,sha256=tOGqmvAjh0Ap09zV3QLQp3PovQB8inpVNgxSnrPS354,15279
296
- loopmath-0.2.7.data/data/spec/ocp_conformance.py,sha256=VzRGKvC6f-64U72xrDOSJEeYa92Bpj3tEf5x0uJXqhk,893
297
- loopmath-0.2.7.dist-info/licenses/LICENSE,sha256=QMvrQqRQRR-OjtyK0CiOxBV8NgWwr_QacIeFUWXeI5o,1065
298
- loopmath-0.2.7.dist-info/METADATA,sha256=4QjbQ7jzkm6jOwPBvyl7WJ2Bp0oUIPUmGkHcQ5JJim0,21861
299
- loopmath-0.2.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
300
- loopmath-0.2.7.dist-info/entry_points.txt,sha256=WQwYqfGAwvoQ_vu6_lwQpq3V8xbK3i9SyNKPDdQ1VmQ,72
301
- loopmath-0.2.7.dist-info/top_level.txt,sha256=SH9Y0Z7thA0zx1PAgHAwc1vOJaS6o3yS5HazS-LzXOg,9
302
- loopmath-0.2.7.dist-info/RECORD,,
294
+ loopmath-0.2.8.data/data/spec/ocp-v0.2.schema.json,sha256=T4P5gg6H2dW-28MRbg9fSNct_ejXezI69-SaKR9WogU,31855
295
+ loopmath-0.2.8.data/data/spec/ocp-v0.schema.json,sha256=tOGqmvAjh0Ap09zV3QLQp3PovQB8inpVNgxSnrPS354,15279
296
+ loopmath-0.2.8.data/data/spec/ocp_conformance.py,sha256=VzRGKvC6f-64U72xrDOSJEeYa92Bpj3tEf5x0uJXqhk,893
297
+ loopmath-0.2.8.dist-info/licenses/LICENSE,sha256=QMvrQqRQRR-OjtyK0CiOxBV8NgWwr_QacIeFUWXeI5o,1065
298
+ loopmath-0.2.8.dist-info/METADATA,sha256=UfU5gNcFnHHgA6EeaV1A-xRdQ_b6j3HcI-i7F4_C8G8,21861
299
+ loopmath-0.2.8.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
300
+ loopmath-0.2.8.dist-info/entry_points.txt,sha256=WQwYqfGAwvoQ_vu6_lwQpq3V8xbK3i9SyNKPDdQ1VmQ,72
301
+ loopmath-0.2.8.dist-info/top_level.txt,sha256=SH9Y0Z7thA0zx1PAgHAwc1vOJaS6o3yS5HazS-LzXOg,9
302
+ loopmath-0.2.8.dist-info/RECORD,,