soar-lite 0.1.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 (55) hide show
  1. soar_lite-0.1.0/.gitignore +37 -0
  2. soar_lite-0.1.0/CHANGELOG.md +30 -0
  3. soar_lite-0.1.0/CITATION.cff +25 -0
  4. soar_lite-0.1.0/CONTEXT.md +325 -0
  5. soar_lite-0.1.0/CONTRIBUTING.md +110 -0
  6. soar_lite-0.1.0/DEPRECATED.md +636 -0
  7. soar_lite-0.1.0/LICENSE +21 -0
  8. soar_lite-0.1.0/PKG-INFO +269 -0
  9. soar_lite-0.1.0/README.md +241 -0
  10. soar_lite-0.1.0/docs/README.md +14 -0
  11. soar_lite-0.1.0/docs/figures/README.md +92 -0
  12. soar_lite-0.1.0/docs/figures/headline-batches.png +0 -0
  13. soar_lite-0.1.0/docs/figures/headline-points.png +0 -0
  14. soar_lite-0.1.0/docs/figures/invariance-units.png +0 -0
  15. soar_lite-0.1.0/docs/figures/lr-sweep.png +0 -0
  16. soar_lite-0.1.0/docs/figures/multimodal-batches.png +0 -0
  17. soar_lite-0.1.0/docs/figures/multimodal-points.png +0 -0
  18. soar_lite-0.1.0/docs/figures/smooth-batches.png +0 -0
  19. soar_lite-0.1.0/docs/figures/smooth-points.png +0 -0
  20. soar_lite-0.1.0/docs/site/explanation/how-it-works.md +880 -0
  21. soar_lite-0.1.0/docs/site/hooks.py +23 -0
  22. soar_lite-0.1.0/docs/site/how-to/speed.md +85 -0
  23. soar_lite-0.1.0/docs/site/how-to/surrogate-restarts.md +170 -0
  24. soar_lite-0.1.0/docs/site/index.md +138 -0
  25. soar_lite-0.1.0/docs/site/reference/api.md +164 -0
  26. soar_lite-0.1.0/docs/site/tutorial/getting-started.md +370 -0
  27. soar_lite-0.1.0/pyproject.toml +111 -0
  28. soar_lite-0.1.0/soar_lite/__init__.py +77 -0
  29. soar_lite-0.1.0/soar_lite/domain.py +368 -0
  30. soar_lite-0.1.0/soar_lite/engine/__init__.py +64 -0
  31. soar_lite-0.1.0/soar_lite/engine/contracts.py +761 -0
  32. soar_lite-0.1.0/soar_lite/engine/schedule.py +1296 -0
  33. soar_lite-0.1.0/soar_lite/global_model/__init__.py +39 -0
  34. soar_lite-0.1.0/soar_lite/global_model/blr.py +507 -0
  35. soar_lite-0.1.0/soar_lite/global_model/descent.py +98 -0
  36. soar_lite-0.1.0/soar_lite/global_model/projection.py +266 -0
  37. soar_lite-0.1.0/soar_lite/global_model/sample.py +56 -0
  38. soar_lite-0.1.0/soar_lite/local_solver.py +366 -0
  39. soar_lite-0.1.0/soar_lite/py.typed +0 -0
  40. soar_lite-0.1.0/tests/__init__.py +1 -0
  41. soar_lite-0.1.0/tests/flock.py +59 -0
  42. soar_lite-0.1.0/tests/test_arm_roster.py +31 -0
  43. soar_lite-0.1.0/tests/test_docstring_references.py +88 -0
  44. soar_lite-0.1.0/tests/test_domain.py +160 -0
  45. soar_lite-0.1.0/tests/test_front_doors_agree.py +134 -0
  46. soar_lite-0.1.0/tests/test_global_model.py +57 -0
  47. soar_lite-0.1.0/tests/test_inner_minimize.py +72 -0
  48. soar_lite-0.1.0/tests/test_local_solver.py +664 -0
  49. soar_lite-0.1.0/tests/test_nonfinite_matrix.py +219 -0
  50. soar_lite-0.1.0/tests/test_paths.py +535 -0
  51. soar_lite-0.1.0/tests/test_plugin_seam.py +840 -0
  52. soar_lite-0.1.0/tests/test_pointwise.py +206 -0
  53. soar_lite-0.1.0/tests/test_scheduler.py +1448 -0
  54. soar_lite-0.1.0/tests/test_spherical_linear.py +589 -0
  55. soar_lite-0.1.0/tests/test_trace_budget_guard.py +134 -0
@@ -0,0 +1,37 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ /site/
8
+
9
+ # Local scratch / working notes (never committed)
10
+ .scratch/
11
+
12
+ # Virtual environments. `.venv*/` also covers a side env built on another
13
+ # backend (e.g. `.venv-cuda/`). uv writes its own `*` .gitignore inside a venv,
14
+ # but that file is disposable, so the top-level rule is what we rely on.
15
+ .venv*/
16
+ venv/
17
+
18
+ # Tooling caches
19
+ .ruff_cache/
20
+ .pytest_cache/
21
+ .mypy_cache/
22
+ .pyright/
23
+ .mypy/
24
+
25
+ # Editors / OS
26
+ .DS_Store
27
+ *.swp
28
+
29
+ # Byte-compiled / native
30
+ *.so
31
+
32
+ # Jupyter Notebooks. The notebook is source; the checkpoints beside it are not.
33
+ .ipynb_checkpoints/
34
+
35
+ # Experiment run artifacts (regenerated, not source)
36
+ experiments/results/
37
+ experiments/figures/
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ User-visible changes, newest first. This file starts at the first public
4
+ release: what happened before it is in `git log`, and the internal record
5
+ (a rename sweep, a default flipped three times before anyone could have
6
+ depended on it) is not a thing a user upgrading between versions needs to read.
7
+
8
+ ## 0.1.0
9
+
10
+ First public release. `README.md` and the site describe what this version is, so
11
+ this entry is the inventory rather than a diff against something earlier.
12
+
13
+ - `n_path_schedule` — the whole algorithm behind one call: `n_paths` descents
14
+ advance in lockstep under one budget, each re-drawn from the global model once
15
+ its own stopping test stalls it (`restart_stalled`, on by default), and the
16
+ report names the best point evaluated as `x`/`f` beside the flock the run
17
+ ended on.
18
+ - `DiagonalPreconditioner` — a per-coordinate inverse-curvature scale estimated
19
+ from successive gradients, plus a momentum `mu` (0 by default). No matrix and
20
+ no learning rate to tune, and once the first secant pair exists the step is
21
+ invariant to rescaling of the objective.
22
+ - Two plugin seams, `LocalSolver` and `GlobalModel` — `SampleGlobalModel` draws
23
+ restarts from the domain; `BLRGlobalModel` accumulates the run's evidence into
24
+ a Bayesian surrogate whose acquisition descent is injected.
25
+ - `pointwise` and a batched `eval_f_g` — value and gradient in one call, with an
26
+ optional feasibility mask, so one engine serves a scalar objective, a batched
27
+ one, or a penalized transform of a constrained problem.
28
+
29
+ Below `1.0.0` a breaking change bumps the minor version: `0.2.0` may not run code
30
+ written for `0.1.0`, and only `1.0.0` starts the compatibility promise.
@@ -0,0 +1,25 @@
1
+ cff-version: 1.2.0
2
+ message: If soar-lite does useful work in something you publish, cite it with this entry.
3
+ title: soar-lite
4
+ abstract: >-
5
+ A JAX optimizer for objectives that are differentiable but expensive to
6
+ evaluate. A flock of n paths descends in parallel with a unit-invariant
7
+ diagonal secant preconditioner; a stalled path restarts from a Bayesian
8
+ linear surrogate fit on projected coordinates; and the report keeps every
9
+ served point, so an outside budget is spent against counted work.
10
+ authors:
11
+ - family-names: Tree
12
+ given-names: Watson
13
+ email: watson.tree@outlook.com
14
+ version: 0.1.0
15
+ license: MIT
16
+ type: software
17
+ repository-code: https://github.com/watson-tree/soar_lite
18
+ url: https://watson-tree.github.io/soar_lite/
19
+ keywords:
20
+ - optimization
21
+ - first-order
22
+ - quasi-newton
23
+ - multi-start
24
+ - nonconvex
25
+ - jax
@@ -0,0 +1,325 @@
1
+ # soar-lite
2
+
3
+ A first-order multi-path optimizer: value and gradient come back together from
4
+ the caller; `n_paths` independent descent paths advance in lockstep under one shared
5
+ budget, each re-drawn from a global model once its own stopping test stalls it
6
+ (the `restart_stalled` knob, on by default).
7
+
8
+ ## Glossary
9
+
10
+ ### Engine
11
+
12
+ **path**:
13
+ One independent descent: a current point plus that descent's own solver
14
+ state. The engine runs `n_paths` of them in lockstep. A restart replaces its
15
+ current point with a fresh draw, so the points one path holds across a run are
16
+ continuous in identity, not in space — which is why `best`, the reported answer,
17
+ need not be a point any path still holds.
18
+ _Avoid_: chain, trajectory, thread, particle
19
+
20
+ **n_paths**:
21
+ The engine's width — the number of paths the engine drives together.
22
+ _Avoid_: population size, swarm size
23
+
24
+ **flock**:
25
+ The paths' current points as one `(n_paths, d)` array: exactly one point per path,
26
+ so a flock is the run's state at one moment, not the set of paths. Every path
27
+ keeps its row whether or not it holds a usable point (`flock_f` is `nan` there),
28
+ so a flock always has `n_paths` of them. A *batch* is a set of points too and is
29
+ deliberately a different one: it is the work a single pass serves — a step's
30
+ trials, or the settle set of redraw starts plus winners — so it holds no point
31
+ for an idle path and is not the state. Iteration 0 places the flock from `x0`
32
+ where one was handed in, and otherwise from the global model; the engine's copy
33
+ is the flock as the run ended.
34
+ _Avoid_: the set of paths, population, ensemble, all the paths
35
+
36
+ **iteration**:
37
+ One pass of the engine's main loop, counted in `n_iters`. Per iteration the engine
38
+ splits the paths into a redraw set (no point yet, or stalled with
39
+ `restart_stalled` on) and a cohort that steps: a path either redraws or steps
40
+ this iteration, never both. The parallel wall-clock unit; an iteration serves
41
+ one or more batches (the steppers' trials as the method reads them, and the one
42
+ settle batch that buys whatever the iteration kept still owes). A flock in
43
+ which every path is parked — nothing left to do, since restarts are off — ends
44
+ the run on `all_stalled` instead of entering a batchless iteration.
45
+ Iteration 0 is the flock's placement: an empty `x` redraws unconditionally,
46
+ so starting the flock is the same operation as any later redraw. `n_iters`
47
+ counts finished iterations only — one cut short by a refused batch is not
48
+ counted, so a refused placement reports `n_iters = 0` and a refused first
49
+ step `n_iters = 1`.
50
+ _Avoid_: round (retired), epoch, iterations (as a name for the count)
51
+
52
+ **redraw set**:
53
+ The paths an iteration gives a fresh start: every path holding no point — always,
54
+ whatever `restart_stalled` says, which is how iteration 0 places the flock and
55
+ how a refused start eventually gets replaced — plus every stalled path when the
56
+ knob is on. Its size is what the engine asks the model for, one start per path,
57
+ so iteration 0 is the whole flock and a later one is only the paths that
58
+ stalled. Iteration 0 with an `x0` is the one redraw set whose starts the caller
59
+ supplied: `x0` is consumed there and no model is asked.
60
+ _Avoid_: restart list, restart batch
61
+
62
+ **cohort**:
63
+ The paths that step this iteration: neither in the redraw set nor stalled. One
64
+ `local_solver.propose` call is handed exactly this set, so a method never sees a
65
+ path the engine has stopped — not even a step that would answer `None`. It is
66
+ not the flock, and not just the redraw set's complement: a path parked by
67
+ `restart_stalled=False` belongs to neither, and sits out while the rest moves.
68
+ _Avoid_: the flock, active paths
69
+
70
+ **best**:
71
+ The run's running best, a single `f`/`x`: the best *feasible* point — the
72
+ run's reported answer, and what `NPathResult` calls `f`/`x`. The engine is the
73
+ sole owner. It starts at `nan` and stays there while no finite feasible value
74
+ has arrived, so `nan` means "no answer" and `inf` is never a sentinel - it is
75
+ left to mean a value an objective actually took. A *feasible* `-inf` is not
76
+ weighed but taken: the objective has no floor there, so it becomes `f`/`x` and
77
+ ends the run with `status` `unbounded`; a `nan`, a `+inf` or an infeasible
78
+ `-inf` changes nothing.
79
+ _Avoid_: incumbent (retired), best-so-far, current optimum
80
+
81
+ **stall / stalled**:
82
+ A path that moved nowhere this iteration — it neither redrew nor committed a
83
+ step: its local method answered no winner (`None` — its own stopping test
84
+ fired), or the point it was given was refused as not usable, whether the winner
85
+ the method named or the start the model drew: a non-finite value or gradient in
86
+ either.
87
+ Stalled is not dead: unless the run turned `restart_stalled` off, the path is
88
+ re-drawn by the global model on the next iteration; with the knob off it stays
89
+ parked — still counted, still readable for `best` — but never moved again. A
90
+ turn of the loop with every path parked that way is `all_stalled` — reachable
91
+ only when `restart_stalled` is off, since with it on a stalled path is re-drawn
92
+ rather than parked: no path can move and none is owed a start, so the run stops.
93
+ A path with no point at all is a redraw that ignores `restart_stalled`: the
94
+ machine's own initial state is not a stall. A start refused while the path held
95
+ nothing sets the bit anyway, but the no-point rule is asked first, so the flag
96
+ never sees that case.
97
+ _Avoid_: paused, idle, dead
98
+
99
+ **domain** (type `Domain`):
100
+ The object that carries everything the engine knows about the search space: the
101
+ per-dimension `(lb, ub)` sides (either may be unbounded), the per-dimension
102
+ `scale` the draws use, and the injected per-dimension samplers. It is the sole
103
+ authority on the dimension `d`, and the box every point the run serves must lie
104
+ inside: the engine checks each point a seam hands over and refuses one that
105
+ misses, instead of moving it onto a wall. Bounds are data, never a unit-cube
106
+ assumption.
107
+ _Avoid_: box (the box is only the bounded-sides part of the domain)
108
+
109
+ **sampler**:
110
+ The injected per-dimension callable that draws starts; the default is uniform on
111
+ a bounded side, `N(0, scale²)` on a free one, and that same normal truncated to
112
+ the declared side on a half-bounded one. The *domain* remains the sole
113
+ authority on `d`.
114
+ _Avoid_: initializer, prior (as a name for the *callable*)
115
+
116
+ ### Evaluation
117
+
118
+ **batch**:
119
+ The `(M, d)` points served by one pass — fused (`eval_f_g`) or value-only
120
+ (`eval_f`). The engine composes each batch from either one trial round — one
121
+ point per path the round names — or the settle set: the redraw starts and
122
+ the winners still owing a half.
123
+ _Avoid_: group, lot, chunk
124
+
125
+ **point**:
126
+ One candidate — one row of a batch. The four counters (below) count the billed
127
+ ones. Once settled it is a `PathPoint`: the `x`/`f`/`g` triple a path commits,
128
+ assembled only once both halves exist (see *settle*), so a point that arrived
129
+ fused travels on as one object rather than three arrays.
130
+ _Avoid_: query, sample, evaluation (retired)
131
+
132
+ **settle**:
133
+ The iteration's one fused batch that buys whatever a kept point still owes, and
134
+ the only place a produced `Point` becomes a settled `PathPoint`. A redraw start
135
+ and a winner the method never read owe both halves and take f and g from this
136
+ one pass; a winner the method read through `serve_trials` owes only its gradient
137
+ when the `eval_f` seam served it value-only — with no such seam the same call
138
+ served the fused pair, so the winner already carries both and passes through as
139
+ it stands, never served twice. Everything is bought before anything is placed,
140
+ so nothing commits before it is settled.
141
+ _Avoid_: commit batch, gradient round
142
+
143
+ **four counters** (`n_f_g_points` / `n_f_g_batches` / `n_f_points` / `n_f_batches`, all four in one `Work`):
144
+ The run's reported work, on two axes and two seams. `points` counts billed
145
+ points (a batch bills its actual size), `batches` counts billed batches (a batch
146
+ bills 1); `f_g` is the fused value+gradient pair, `f` the value-only pair. All
147
+ four are counted — "billed" means "actually served out" — and none of them
148
+ decides the stop. Points and batches are different numbers on different axes
149
+ and must never share a chart, and the `f` pair must never be read as the `f_g`
150
+ pair. They replace scipy's single `nfev`. The four live in one frozen `Work`,
151
+ reached as `result.work` and read flat on the result as well; that object is
152
+ what the budget gate is handed — as it stands at the loop head, and as a
153
+ pending batch would leave it at a serve site — so a gate reads counts on their
154
+ way into the report and cannot write them.
155
+ _Avoid_: nfev (retired)
156
+
157
+ **objective**:
158
+ The scalar function being minimized; its per-point values are the f values the
159
+ engine records. Distinct from the *cost model* (the four counters), which
160
+ counts work.
161
+ _Avoid_: loss, cost, fitness
162
+
163
+ **eval_f_g**:
164
+ The caller's per-batch callable (type `EvalFG`): points in,
165
+ `(f, gradient, feas?)` out. The seam to the objective's values and
166
+ gradients; the engine is blind to it otherwise.
167
+ _Avoid_: oracle, evaluator, black box
168
+
169
+ **eval_f**:
170
+ The optional value-only sibling (type `EvalF`): points in, `(f, feas?)` out,
171
+ no gradient.
172
+ It exists so a step's trials are read by value alone and only the winner's
173
+ gradient is bought once at *settle*, instead of once per trial. Without it
174
+ every trial is itself a fused batch.
175
+ _Avoid_: value oracle, cheap evaluator
176
+
177
+ **feasibility mask**:
178
+ The optional per-point boolean saying which served points may count toward the
179
+ reported best (`f`/`x`). It is
180
+ read as 0/1 and nothing else: only `0` (or `False`) marks a point infeasible, so
181
+ a mask holding anything else — a signed constraint margin, whose violated points
182
+ are all negative, or a `nan` — is refused with a `ValueError` instead of being
183
+ read as truthiness, where every one of those flags would have counted as
184
+ feasible and quietly promoted a point into the answer.
185
+ _Avoid_: validity mask
186
+
187
+ ### Local method
188
+
189
+ **step**:
190
+ One move along a single path: propose → (trial)× → a winner, or none (`None`).
191
+ Each path takes at most one move per iteration — a step, or a redraw start; a
192
+ step consumes zero or more trials. The method lands it through `step(points)`: the
193
+ engine hands back the settled `PathPoint` `(x, f, g)` the path now stands at, so
194
+ the method moves the matching state forward. The **method** named the winner in
195
+ `propose`; the engine bought its owed halves in *settle* and commits here, so
196
+ `step` is the only commit and the only place a method may advance its model — a
197
+ secant pair is a move that was taken, and only this callback is told one was. It
198
+ is never called for a winner the engine refused (not usable) or for a path whose
199
+ stopping test held.
200
+ _Avoid_: update, move; accept, commit (as names for this handoff)
201
+
202
+ **round**:
203
+ One `serve_trials` call: one point per path it names, served and reported as
204
+ one batch. It is the unit of both price and memory — a method that enumerates
205
+ reads its candidates as successive rounds, so however far it looks it holds one
206
+ point per path at a time and pays one batch per look. The method sets how many
207
+ rounds a step reads; the engine sets the width of each.
208
+ _Avoid_: trial (that is one point inside a round)
209
+
210
+ **trial**:
211
+ One served proposal within a step — this path's point on one round the method
212
+ reads through `serve_trials`. What a trial carries is the seam's business: with
213
+ `eval_f` connected it is the coords and value alone — the winner's gradient is
214
+ bought later (see *settle*), so a method that needs a gradient to rule needs the
215
+ fused seam — and without `eval_f` the same round serves the fused pair, gradient
216
+ included. There is no engine-side cap: the method's own stopping test (or its
217
+ own trial budget) ends the enumeration, one batch per round. A step that reads no
218
+ trial — a single full proposal, like the shipped method's — serves none at all.
219
+ _Avoid_: line-search round (retired)
220
+
221
+ **secant pair**:
222
+ The `(Δx, Δg)` between two consecutive points of one path — the only curvature
223
+ evidence the local method reads.
224
+ _Avoid_: gradient difference, step pair
225
+
226
+ **diagonal preconditioner**:
227
+ The local method itself: a per-coordinate inverse-curvature estimate read off
228
+ the secant pair, applied as `-precon ⊙ m` where `m` is the momentum (a blend of
229
+ the gradient and its history; the bare gradient `g` at `mu = 0`). No matrix, no
230
+ linear solve, no scalar learning rate.
231
+ _Avoid_: diagonal Newton (it is a biased `1/H` estimate, not Newton)
232
+
233
+ **floor (λ)**:
234
+ The guard that keeps a coordinate's denominator positive when the secant
235
+ vanishes (a flat but unconverged coordinate).
236
+ _Avoid_: epsilon, addend, regularizer
237
+
238
+ **warmup**:
239
+ A path's first step, before any secant pair exists: one full proposal along
240
+ `-g₀` at `alpha0`, committed as proposed. It carries no value guard of its own —
241
+ the phase enters only the direction; like the steady step, it is read by no
242
+ value and compared against no start.
243
+ _Avoid_: initialization step
244
+
245
+ ### Restart
246
+
247
+ **global model** (type `GlobalModel`):
248
+ The engine's second seam, owner of *where* a redrawn path starts. It observes
249
+ every served batch as a mapping from path to the point served there
250
+ (`observe(served)`, one entry per path, so a model that only wants stacked rows
251
+ stacks them itself) and answers `propose(paths, domain)`
252
+ with one fresh start per path it was asked about, keyed by that path. `None` for
253
+ a path is the model declining it — its own stopping rule, as `None` from a
254
+ method's `propose` is the method's — and that path starts nowhere this
255
+ iteration. The default `SampleGlobalModel` fits nothing: it draws starts
256
+ from the `domain`. An `x0` the caller hands the entry states the first flock
257
+ directly and the model is not asked; with `x0=None` it draws that flock too, as
258
+ iteration 0's one ask. `BLRGlobalModel`
259
+ fits a Bayesian linear posterior and proposes the local minimum of `-log EI` —
260
+ EI there is an acquisition function deciding *where is worth trying*, not a
261
+ restart decision.
262
+ _Avoid_: restart rule, sampler (the sampler is a per-dimension draw inside the
263
+ domain)
264
+
265
+ **inner minimizer** (`minimize`, type `MinimizeFn`):
266
+ The global model's own seam, one level further in, owning *how* to descend the
267
+ acquisition the model builds (`-log EI` for `BLRGlobalModel`). The model holds
268
+ the acquisition and never searches it itself: `propose` hands the acquisition,
269
+ the ask's `domain`, the paths and a fresh subkey to `minimize`, and carries its
270
+ answer out as one start per path, `None` for a path it could not place. The
271
+ shipped default is `descent_minimize`, the engine run restart-free at the
272
+ cohort's width, so the acquisition is descended by the method it will later
273
+ drive; a caller that needs an inner knob the default does not expose — e.g. a
274
+ `time_limit`, another local solver, `budget_check`, restarts inside — passes its
275
+ own `MinimizeFn` instead.
276
+ _Avoid_: inner loop, acquisition optimizer (the model does not optimize the
277
+ acquisition, it asks for one)
278
+
279
+ **restart_stalled**:
280
+ The run's one restart bit, deciding *whether* a stall restarts: on (the
281
+ default), the stalled path is re-drawn by the global model next iteration; off,
282
+ it stays parked, and a flock that is all parked is a deadlock — the run ends on
283
+ `all_stalled` rather than idling to its horizon. It is orthogonal to *when* (the
284
+ local method's own stopping test) and *where* (the global model). A run that
285
+ never restarts needs no model of its own:
286
+ `restart_stalled=False` with the default `SampleGlobalModel` is pure descent
287
+ from the initial flock — the model runs once, drawing that flock, when `x0` is
288
+ left `None`, and never again.
289
+ _Avoid_: restart check, reset decision
290
+
291
+ ### Cost model
292
+
293
+ **horizon (`max_iters`)**:
294
+ The engine's own stop, counted in iterations: every completed iteration costs
295
+ one, and iteration 0 places the flock. A deterministic bound, not a cost
296
+ proxy — one iteration serves however many trial rounds the method's own search
297
+ reads, plus the one settle batch that buys what it kept still owing, while a
298
+ flock with nothing left to do stops the run
299
+ (`all_stalled`) instead of billing a batchless iteration — so the four counters
300
+ report what a run actually billed. At least one of `max_iters`/`time_limit` must
301
+ be given, validated once at entry: `stop` asks the caller's gate like the other
302
+ two, but a gate is the caller's own predicate and the engine cannot see it
303
+ advance, so it cannot tell a gate that will close from one that never does — a
304
+ run may not rest on that alone.
305
+ _Avoid_: eval budget, cost estimate, time limit
306
+
307
+ **deadline (`time_limit`)**:
308
+ The same stop in seconds, for callers whose per-iteration cost is too uneven to
309
+ count iterations. Checked before every batch (fused or value-only) and at the
310
+ loop head, like the horizon, so the over-run is under one batch plus the one
311
+ call in flight.
312
+ _Avoid_: budget, timeout (that is a per-call concern)
313
+
314
+ **budget gate** (`budget_check`):
315
+ The caller's predicate, asked as one of the run's three stops at every loop head
316
+ and again before each batch — serve the whole batch or nothing, never a partial
317
+ prefix. It is handed the run's `Work`, the same four counts the result reports,
318
+ so it gates on any axis it likes (points, batches, both seams together) without
319
+ keeping a tally of its own. Before a batch it is handed the ledger that batch
320
+ would leave, and a batch it refuses is never charged: a ceiling on points is one
321
+ the run cannot cross, met from below by up to one batch width since a batch is
322
+ served whole or not at all. At the loop head it is handed the ledger as it
323
+ stands. It exists for a budget the engine cannot see (points, wall-clock,
324
+ dollars). It is an extra gate, never a horizon.
325
+ _Avoid_: stopping budget
@@ -0,0 +1,110 @@
1
+ # Contributing
2
+
3
+ Welcome. Whether you have a question, a bug report or a change in mind, feel
4
+ free to open an issue or a pull request — the sections below say what a change
5
+ has to clear.
6
+
7
+ ## Proposing a change
8
+
9
+ Open a pull request; GitHub runs the checks below on it. An issue is the right
10
+ place for a question, a bug report, or an idea that has no implementation yet.
11
+
12
+ ## The checks a change has to pass
13
+
14
+ ```bash
15
+ uv sync --all-groups # jax, the dev tools, and the docs build
16
+ uv run python -m pytest -q # all tests in one go
17
+ uv run --no-sync python -m tests.test_local_solver # or one module at a time,
18
+ uv run --no-sync python -m tests.test_global_model # each module also runs
19
+ uv run --no-sync python -m tests.test_scheduler # as a plain script
20
+ uv run --no-sync python -m tests.test_plugin_seam
21
+ uv run ruff check && uv run ruff format --check && uv run pyright
22
+ uv run mkdocs build --strict # the docs site; a warning fails the build
23
+ ```
24
+
25
+ The tests are assert-style self-checks: plain `assert` in `test_*` functions,
26
+ pytest-collectable, no recorded golden numbers that would encode this
27
+ repository's history. The four `python -m` modules above double as plain
28
+ scripts; the rest, like everything under `experiments/tests`, are pytest-only
29
+ and some use fixtures.
30
+
31
+ ## Commits and releases
32
+
33
+ A commit subject names the kind of change before it describes it —
34
+ `type(scope): what changed` — so `engine: settle only the points that still owe
35
+ a gradient` becomes `fix(engine): settle only the points that still owe a
36
+ gradient`. The types are `feat`, `fix`, `docs`, `test`, `refactor`, `perf`,
37
+ `build`, `ci` and `chore`; the scope is optional — the module or the area the
38
+ change lands in, added as `fix(engine): …` when the type alone does not say
39
+ where. The rule starts with this release: earlier subjects are a mix of untyped
40
+ `area: …` and occasional typed ones, and they stay as written, so read them as
41
+ prose and type every subject you author from here on. The type earns its place
42
+ because a user is the intended reader of `CHANGELOG.md`: `docs`, `test`, `ci`,
43
+ `build` and `chore` never reach them, and that is the set a changelog generated
44
+ from the log skips.
45
+
46
+ Versions are SemVer read under its `0.x` rule: below `1.0.0`, any change a caller
47
+ can notice — including one that breaks their code — bumps the minor version, and
48
+ a patch is only for a fix that leaves the API as it was. `1.0.0` is where the
49
+ compatibility promise starts.
50
+
51
+ A release is four steps, in this order:
52
+
53
+ 1. Put the version in every place it is written: `version` in `pyproject.toml`,
54
+ `version` in `CITATION.cff`, the `## ` heading of a new top entry in
55
+ `CHANGELOG.md`, and a `uv lock` so `uv.lock` re-syncs it. The heading
56
+ carries no date; the tag is the date.
57
+ 2. Land it behind the checks above.
58
+ 3. Tag that commit on `main` `v<version>` and push the tag. Publishing is a
59
+ tag's business: nothing ships from a branch or a pull request, and the tag
60
+ must start with `v` — a tag like `0.1.0` matches no workflow and ships
61
+ nothing.
62
+ 4. Watch `publish.yml` run the tests, build and upload.
63
+
64
+ PyPI never lets a version number be used twice — a published version can be
65
+ yanked but not replaced, so an upload is the irreversible step. That is what the
66
+ release-tag check in `publish.yml` stands in front of: a tag that does not name
67
+ the version in `pyproject.toml` fails the build before anything is uploaded, and
68
+ the tag can be deleted and pushed again.
69
+
70
+ ## Front doors
71
+
72
+ `docs/site/index.md` is the canonical home page and the root `README.md` mirrors it
73
+ for the sections both carry. A fact in the opening, or in any section either one
74
+ carries from the other — `Install`, `Minimal run`, `What's inside`, `Citing` — is
75
+ corrected in `docs/site/index.md` and copied across;
76
+ `tests/test_front_doors_agree.py` fails if the two drift apart, or if a link in
77
+ either one stops resolving. Link destinations are the sole exception — the site
78
+ addresses its neighbours from inside `docs/`, the README from the root.
79
+
80
+ ## Names
81
+
82
+ Names have fixed meanings: `CONTEXT.md` is the glossary, including the terms to
83
+ avoid. Consult it before introducing one.
84
+
85
+ ## Records are append-only
86
+
87
+ `DEPRECATED.md`, `experiments/DEPRECATED.md`, `experiments/RESULTS.md` and
88
+ `CHANGELOG.md` are records, not living documents: nothing in them is deleted or
89
+ rewritten to say something else, and a measured number is never re-edited — a
90
+ new measurement or a superseded direction is a new entry, and the old one stays
91
+ as written. `experiments/DESIGN.md` is the opposite: a current-state document,
92
+ so a passage that is no longer true there is rewritten in place.
93
+
94
+ ## The benchmark suite
95
+
96
+ `experiments/` holds the comparison harness behind the figures in the README. It
97
+ is a sub-library, not a dependency: it imports `soar_lite`, nothing in the
98
+ package or the docs imports it, and neither the wheel nor the sdist ships it. Run
99
+ instructions are in `experiments/README.md`, what it measures and the rules it
100
+ follows in `experiments/DESIGN.md`, and the numbers in `experiments/RESULTS.md`.
101
+
102
+ ## Open directions
103
+
104
+ The two plugin seams (`LocalSolver`, `GlobalModel`) are the natural extension
105
+ points. Writing your own is currently documented only as the contracts in
106
+ `docs/site/reference/api.md` and the `Protocol`s in `soar_lite/engine/contracts.py`,
107
+ not yet as a how-to guide. `BLRGlobalModel`'s acquisition has a known upgrade
108
+ (see the `shortcut:` note in `soar_lite/global_model/blr.py`), and more global
109
+ models or local solvers (e.g. line search) are welcome — `experiments/RESULTS.md`
110
+ records where the tuned baselines stand.