soar-lite 0.1.0__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.
soar_lite/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """soar_lite: first-order n-path descent with stall restarts.
2
+
3
+ The public face is deliberate and small. The two plugin contracts
4
+ (`LocalSolver`, `GlobalModel`) and the data types they exchange are re-exported
5
+ from the engine so callers can write their own methods and restart-point models
6
+ against them; the concrete implementations (`DiagonalPreconditioner`,
7
+ `SampleGlobalModel`, `BLRGlobalModel`) are re-exported for convenience.
8
+ `n_path_schedule` is the whole algorithm entry point.
9
+
10
+ Whether a stalled path is re-drawn is the `restart_stalled` knob on
11
+ `n_path_schedule`, not a plugin: *when* a path has stopped is the local solver's
12
+ own stopping test, and *where* it restarts is the global model's, so the engine
13
+ needs no third plugin seam to join them.
14
+
15
+ Internal helpers (the box/argmin utilities, the run's private state holders) are
16
+ not re-exported here on purpose; they are `_`-private in their own modules. A
17
+ test or a contributor may still import them, but nothing outside the package is
18
+ meant to.
19
+ """
20
+
21
+ from soar_lite.domain import Bounds, Domain, DrawFn
22
+ from soar_lite.engine import (
23
+ EvalF,
24
+ EvalFG,
25
+ GlobalModel,
26
+ LocalSolver,
27
+ NPathResult,
28
+ NPathStatus,
29
+ PathPoint,
30
+ Point,
31
+ ServeTrials,
32
+ ValuePoint,
33
+ Work,
34
+ n_path_schedule,
35
+ pointwise,
36
+ )
37
+ from soar_lite.global_model import (
38
+ AcquisitionFn,
39
+ BLRGlobalModel,
40
+ IdentityProjection,
41
+ MinimizeFn,
42
+ Projection,
43
+ ProjectionFactory,
44
+ SampleGlobalModel,
45
+ SphereProjection,
46
+ descent_minimize,
47
+ )
48
+ from soar_lite.local_solver import DiagonalPreconditioner
49
+
50
+ __all__ = [
51
+ "AcquisitionFn",
52
+ "BLRGlobalModel",
53
+ "Bounds",
54
+ "DiagonalPreconditioner",
55
+ "Domain",
56
+ "DrawFn",
57
+ "EvalF",
58
+ "EvalFG",
59
+ "GlobalModel",
60
+ "IdentityProjection",
61
+ "LocalSolver",
62
+ "MinimizeFn",
63
+ "NPathResult",
64
+ "NPathStatus",
65
+ "PathPoint",
66
+ "Point",
67
+ "Projection",
68
+ "ProjectionFactory",
69
+ "SampleGlobalModel",
70
+ "ServeTrials",
71
+ "SphereProjection",
72
+ "ValuePoint",
73
+ "Work",
74
+ "descent_minimize",
75
+ "n_path_schedule",
76
+ "pointwise",
77
+ ]
soar_lite/domain.py ADDED
@@ -0,0 +1,368 @@
1
+ """The search domain: a declared box, its per-dimension scales and its draws.
2
+
3
+ This module owns the coordinate space a restart model reads and the distribution
4
+ it proposes restart points from - the box (any side possibly unbounded), each
5
+ dimension's prior width in coordinate units, and the per-dimension draw, either
6
+ the default for that dimension or one the caller injected. It is the object a
7
+ `Projection` is built on: the geometry adds a `features` map, this owns
8
+ everything about *where* the model may look and *how* a start is proposed, so a
9
+ model-free restart sampler needs nothing but this and a key.
10
+
11
+ The box's own spelling lives here too - `Bounds`, and the validation and
12
+ normalization behind it. This module imports nothing from the package, so it is
13
+ the leaf both `n_path_schedule` and the model modules read a box from; the
14
+ check that holds every point to it stays the engine's.
15
+
16
+ A side may be unbounded, and there are three cases, not two: a bounded dimension
17
+ draws uniformly, a free one (both sides infinite) draws ``N(0, scale_i^2)``, and
18
+ a half-bounded one (exactly one finite side) draws that same normal *truncated*
19
+ to its declared side - the default never steps over the side the caller declared,
20
+ because a start outside it is not a start in the declared domain at all.
21
+ """
22
+
23
+ import math
24
+ from collections.abc import Callable, Sequence
25
+ from typing import TypeAlias
26
+
27
+ import jax
28
+ import jax.numpy as jnp
29
+ from jax.scipy.special import ndtr, ndtri
30
+
31
+ Bounds: TypeAlias = Sequence[tuple[float | None, float | None]] | jnp.ndarray
32
+ """The search box in scipy's spelling, which `Domain` normalizes.
33
+
34
+ One ``(lb, ub)`` pair per dimension, where either side of the pair - the lower
35
+ or the upper bound - may be ``None`` or ``+-inf``, meaning unbounded there. A
36
+ ``(d, 2)`` array is the same data row-wise, for callers that already hold one.
37
+ """
38
+
39
+ DrawFn: TypeAlias = Callable[[jnp.ndarray, int], jnp.ndarray]
40
+ """One dimension's draw, injected per dimension.
41
+
42
+ ``sampler(key, n)`` returns ``n`` coordinates for that one dimension, shape
43
+ ``(n,)``. Injected per dimension so a restart model can sample where a bounded
44
+ box does not reach; ``None`` leaves the domain's own default in place - uniform
45
+ inside a bounded side, ``N(0, scale_i**2)`` where the domain is free, and that
46
+ normal restricted to the declared side where it is half-bounded.
47
+ """
48
+
49
+
50
+ class Domain:
51
+ """The coordinates a restart model may read, and how a start is drawn.
52
+
53
+ A domain carries the box, the per-dimension scales and the per-dimension
54
+ draws, and validates all three once at construction: a NaN side, a lower side
55
+ above its upper, a scale that is not a ``(d,)`` vector of finite positive
56
+ values, or a sampler count that is not ``d`` are refused here rather than
57
+ deep inside a clip or a draw. It is concrete - a caller sampling with no
58
+ surrogate needs nothing more - and `Projection` wraps one to add the feature
59
+ map a fitted model reads.
60
+
61
+ A side may be unbounded. The box then declares only the dimension and which
62
+ sides are unbounded; `scale` is what carries a length there, because no width
63
+ exists to read one off. Not even a bounded side's width is a natural unit:
64
+ `scale` is in coordinate units, so a caller changing a variable from meters
65
+ to millimeters changes `scale`, never the box.
66
+
67
+ `draw` is the one contract: ``(key, n) -> (n, d)`` starts drawn one dimension
68
+ at a time.
69
+
70
+ Example:
71
+ Sample starts with no engine and no surrogate, the case a domain alone
72
+ covers:
73
+
74
+ >>> import jax
75
+ >>> from soar_lite import Domain
76
+
77
+ >>> domain = Domain([(0.0, 1.0), (None, None)], scale=[1.0, 2.0])
78
+ >>> domain.draw(jax.random.PRNGKey(0), 3).shape
79
+ (3, 2)
80
+
81
+ The first column is uniform inside its bounded side, the second draws
82
+ ``N(0, 2^2)`` because nothing there is declared.
83
+
84
+ """
85
+
86
+ d: int
87
+ """The dimension: the number of pairs `bounds` declares.
88
+
89
+ Every shape in the engine is read off it - ``x0`` is ``(n_paths, d)``, a
90
+ proposal is ``(d,)`` - so a caller never states the dimension twice.
91
+ """
92
+
93
+ lo: jnp.ndarray
94
+ """The lower side per dimension, ``(d,)``; ``-inf`` where that side is free."""
95
+
96
+ hi: jnp.ndarray
97
+ """The upper side per dimension, ``(d,)``; ``+inf`` where that side is free."""
98
+
99
+ bounds: Bounds
100
+ """The box as the caller declared it, unbounded sides included."""
101
+
102
+ scale: jnp.ndarray
103
+ """The prior width per dimension, ``(d,)``, in coordinate units.
104
+
105
+ It is the length the default draw is measured in on an unbounded side, and
106
+ what `SphereProjection` normalizes by; a dimension whose spread is far from
107
+ a unit scale needs its own entry here rather than a different box.
108
+ """
109
+
110
+ center: jnp.ndarray
111
+ """The box's midpoint per dimension, ``(d,)``; zero outside `bounded_dims`."""
112
+
113
+ width: jnp.ndarray
114
+ """The box's width per dimension, ``(d,)``; zero outside `bounded_dims`."""
115
+
116
+ bounded_dims: jnp.ndarray
117
+ """The dimensions with both sides finite, ``(d,)``.
118
+
119
+ `width` and `center` read off it, so a dimension outside it is zero in
120
+ both.
121
+ """
122
+
123
+ free_dims: jnp.ndarray
124
+ """The dimensions with neither side finite, ``(d,)``.
125
+
126
+ A dimension in neither mask is the half-bounded one, whose default draw
127
+ alone has a boundary to respect.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ bounds: Bounds,
133
+ *,
134
+ scale: jnp.ndarray | Sequence[float] | None = None,
135
+ samplers: Sequence[DrawFn | None] | None = None,
136
+ ) -> None:
137
+ """Validate and carry the declared box, the scales and the samplers.
138
+
139
+ Args:
140
+ bounds: the box, scipy-style pairs or a ``(d, 2)`` array. A side may
141
+ be ``None`` or ``+-inf`` for an unbounded domain; a NaN side, or
142
+ a lower side above its upper bound, is refused. There is no box
143
+ for a domain to default to: the dimension and the unbounded
144
+ sides are what the box declares.
145
+ scale: per-dimension prior width in coordinate units, a ``(d,)``
146
+ vector or ``None`` for every dimension at one. A projection that
147
+ normalizes compares the entries to each other. The common
148
+ magnitude is not free either: `scale` is the length the default
149
+ draw is measured in, so a dimension whose coordinates span far
150
+ more or far less than a unit scale should carry its own entry.
151
+ samplers: one optional draw per dimension, ``d`` entries; a ``None``
152
+ entry keeps the default for that dimension - uniform inside a
153
+ bounded dimension, ``N(0, scale_i^2)`` inside a free one, and
154
+ that same normal restricted to the domain inside a half-bounded
155
+ one (exactly one finite side), where drawing the unrestricted
156
+ normal would step over the side the caller did declare.
157
+ Dimensions are drawn independently, so a prior that couples them
158
+ needs its own `GlobalModel`, not a domain.
159
+
160
+ Raises:
161
+ ValueError: If the box is malformed, or `scale` is not a ``(d,)``
162
+ vector of finite positive values, or `samplers` is not ``d``
163
+ entries long.
164
+
165
+ """
166
+ self.lo, self.hi = _check_bounds(bounds)
167
+ # The dimension is what `bounds` declares: read it off the argument, not
168
+ # off `lo`/`hi`, which `_check_bounds` just derived from it.
169
+ self.d = len(bounds)
170
+ self.bounds = bounds
171
+ self.scale = _check_scale(scale, self.d)
172
+ self._samplers = _check_samplers(samplers, self.d)
173
+ # The domain's shape per dimension. An unbounded side has no width, so
174
+ # `width` is zero there and the center falls back to the origin - the
175
+ # only center an unbounded dimension has, and `scale` the only length
176
+ # a caller can state about it.
177
+ self.bounded_dims = jnp.isfinite(self.lo) & jnp.isfinite(self.hi)
178
+ # Neither side finite. The half-bounded dimension - one side finite,
179
+ # one not - is the remaining case, and the only one whose default draw
180
+ # has a boundary to respect.
181
+ self.free_dims = ~jnp.isfinite(self.lo) & ~jnp.isfinite(self.hi)
182
+ self.width = jnp.where(self.bounded_dims, self.hi - self.lo, 0.0)
183
+ self.center = jnp.where(self.bounded_dims, self.lo + self.width / 2.0, 0.0)
184
+
185
+ def draw(self, key: jnp.ndarray, n_points: int) -> jnp.ndarray:
186
+ """Return ``(n_points, d)`` restart starts, one dimension at a time.
187
+
188
+ A dimension with an injected `DrawFn` takes its column from that, else
189
+ from that dimension's default: uniform inside a bounded side,
190
+ ``N(0, scale_i^2)`` inside a free one, and the same normal *truncated* to
191
+ the domain inside a half-bounded one - the default never steps over the
192
+ side the caller declared. An injected sampler is the caller's own
193
+ statement about the dimension's spread, so nothing checks the drawn
194
+ values against `scale`: an arbitrary distribution publishes no spread to
195
+ check them against.
196
+
197
+ Raises:
198
+ ValueError: If `n_points` is below one, or a column does not come
199
+ back ``(n_points,)`` long.
200
+
201
+ """
202
+ if n_points <= 0:
203
+ msg = f"a draw needs at least one point, got {n_points}"
204
+ raise ValueError(msg)
205
+ columns: list[jnp.ndarray] = []
206
+ for j, sub in enumerate(jax.random.split(key, self.d)):
207
+ sampler = self._samplers[j]
208
+ if sampler is not None:
209
+ column = sampler(sub, n_points)
210
+ elif bool(self.bounded_dims[j]):
211
+ column = jax.random.uniform(
212
+ sub,
213
+ (n_points,),
214
+ minval=self.lo[j],
215
+ maxval=self.hi[j],
216
+ )
217
+ elif bool(self.free_dims[j]):
218
+ column = self.scale[j] * jax.random.normal(sub, (n_points,))
219
+ else:
220
+ column = _truncated_normal(
221
+ sub,
222
+ n_points,
223
+ lower=float(self.lo[j]),
224
+ upper=float(self.hi[j]),
225
+ scale=float(self.scale[j]),
226
+ )
227
+ if jnp.shape(column) != (n_points,):
228
+ msg = (
229
+ f"each drawn column must be ({n_points},) long, got "
230
+ f"{jnp.shape(column)} for dimension {j}"
231
+ )
232
+ raise ValueError(msg)
233
+ columns.append(column)
234
+ return jnp.stack(columns, axis=-1)
235
+
236
+
237
+ def _check_scale(scale: jnp.ndarray | Sequence[float] | None, d: int) -> jnp.ndarray:
238
+ """Normalize the per-dimension prior width to ``(d,)``, refusing a bad one.
239
+
240
+ ``None`` is every dimension at one, which puts the coordinate unit itself in
241
+ charge: the caller has either made each variable's spread O(1) or is about to
242
+ say what it is. Anything but a ``(d,)`` vector is refused rather than
243
+ broadcast: a scalar or a length-one vector would silently become a uniform
244
+ vector, which is a real scale but never what a caller reaching for those
245
+ shapes means.
246
+ """
247
+ if scale is None:
248
+ return jnp.ones(d)
249
+ given = jnp.asarray(scale)
250
+ if given.ndim != 1 or given.shape[0] != d:
251
+ msg = f"scale must be a ({d},) vector or None, got shape {given.shape}"
252
+ raise ValueError(msg)
253
+ if not bool(jnp.all(jnp.isfinite(given))) or not bool(jnp.all(given > 0.0)):
254
+ msg = f"scale must hold finite positive values, got {scale!r}"
255
+ raise ValueError(msg)
256
+ return given
257
+
258
+
259
+ def _check_samplers(
260
+ samplers: Sequence[DrawFn | None] | None,
261
+ d: int,
262
+ ) -> tuple[DrawFn | None, ...]:
263
+ """Normalize the injected draws to one entry per dimension.
264
+
265
+ ``None`` is every dimension on its default draw. The length is checked
266
+ because zipping a short sequence would leave the tail dimensions silently on
267
+ the default - a wiring error that would look like a working run.
268
+ """
269
+ if samplers is None:
270
+ return (None,) * d
271
+ given = tuple(samplers)
272
+ if len(given) != d:
273
+ msg = f"samplers needs {d} entries, got {len(given)}"
274
+ raise ValueError(msg)
275
+ return given
276
+
277
+
278
+ def _check_bounds(bounds: Bounds) -> tuple[jnp.ndarray, jnp.ndarray]:
279
+ """Reject a malformed box and normalize it to ``(lo, hi)``, in one pass.
280
+
281
+ Both spellings are read the same way: one `(lb, ub)` pair per dim, or a
282
+ `(d, 2)` array. A `None` side becomes ∓inf before anything is looked at, so
283
+ the check and the box it hands out cannot disagree about what a side means.
284
+ A NaN side is rejected - it would poison every clip silently instead of
285
+ failing; +-inf sides are legitimate (unbounded dim).
286
+ """
287
+ pairs: list[tuple[float | None, float | None]]
288
+ if isinstance(bounds, jnp.ndarray):
289
+ if bounds.ndim != 2 or bounds.shape[1] != 2:
290
+ msg = f"bounds as an array needs shape (d, 2), got {bounds.shape}"
291
+ raise ValueError(msg)
292
+ pairs = list(zip(bounds[:, 0].tolist(), bounds[:, 1].tolist(), strict=True))
293
+ else:
294
+ pairs = []
295
+ for j, pair in enumerate(bounds):
296
+ if len(pair) != 2:
297
+ msg = f"bounds[{j}] must be a (lb, ub) pair, got {pair!r}"
298
+ raise ValueError(msg)
299
+ pairs.append((pair[0], pair[1]))
300
+ lo: list[float] = []
301
+ hi: list[float] = []
302
+ for j, (low, high) in enumerate(pairs):
303
+ lo_f = -math.inf if low is None else low
304
+ hi_f = math.inf if high is None else high
305
+ if math.isnan(lo_f) or math.isnan(hi_f):
306
+ msg = f"bounds[{j}] has a NaN side: ({low!r}, {high!r})"
307
+ raise ValueError(msg)
308
+ if lo_f > hi_f:
309
+ msg = f"bounds[{j}] has lo={lo_f} > hi={hi_f}"
310
+ raise ValueError(msg)
311
+ lo.append(lo_f)
312
+ hi.append(hi_f)
313
+ return jnp.asarray(lo), jnp.asarray(hi)
314
+
315
+
316
+ _TRUNCATED_TAIL = 9.0
317
+ """Clip the standard-normal draw to this many scales either way.
318
+
319
+ Only the domain's own endpoints can push the CDF inverse past it: `ndtri` is
320
+ undefined at 0 and 1, and a truncation far into the tail rounds both bounds of
321
+ the uniform interval onto the same endpoint. The clip keeps such a draw finite
322
+ and costs nothing else - beyond nine scales the normal's mass is negligible, so
323
+ no reachable draw is touched, while a domain truncated that far out collapses
324
+ onto its own boundary, which is the right limit anyway.
325
+ """
326
+
327
+
328
+ def _truncated_normal(
329
+ key: jnp.ndarray,
330
+ n_points: int,
331
+ *,
332
+ lower: float,
333
+ upper: float,
334
+ scale: float,
335
+ ) -> jnp.ndarray:
336
+ """Draw ``N(0, scale^2)`` restricted to ``[lower, upper]``.
337
+
338
+ Both bounds take an infinity, so this one routine serves either orientation
339
+ of a half-bounded domain. Inverse-CDF because the alternative - draw and
340
+ reject - costs a variable number of rounds that JAX cannot express without
341
+ a fixed cap, which would bias whatever the cap cuts off.
342
+
343
+ Args:
344
+ key: the PRNG key to draw from.
345
+ n_points: how many values to draw.
346
+ lower: the domain's lower bound, possibly ``-inf``.
347
+ upper: the domain's upper bound, possibly ``+inf``.
348
+ scale: the prior's standard deviation, in coordinate units.
349
+
350
+ Returns:
351
+ ``(n_points,)`` finite values, all inside ``[lower, upper]``.
352
+
353
+ """
354
+ v = jax.random.uniform(key, (n_points,))
355
+ # Sample the *tail* probability, not the CDF value. A half-line is one tail,
356
+ # and a truncation far out makes that tail small enough that `1 - Phi(a)` and
357
+ # `Phi(a)` agree to the last float32 - the CDF value loses its precision
358
+ # exactly where the truncated mass still lives. Multiplying the small tail by
359
+ # `v` keeps the relative precision, so the draw tracks the true truncated
360
+ # normal instead of piling its far tail onto one clipped value.
361
+ if lower == -math.inf:
362
+ p = ndtr(jnp.asarray(upper) / scale)
363
+ z = ndtri(p * v)
364
+ else:
365
+ p = ndtr(-jnp.asarray(lower) / scale)
366
+ z = -ndtri(p * (1.0 - v))
367
+ z = jnp.clip(z, -_TRUNCATED_TAIL, _TRUNCATED_TAIL)
368
+ return jnp.clip(scale * z, lower, upper)
@@ -0,0 +1,64 @@
1
+ """The engine package: the contracts a plugin is written against, and the machine.
2
+
3
+ This package is two modules behind one facade.
4
+ `soar_lite.engine.contracts` is what a plugin author reads: the two plugin
5
+ contracts (`LocalSolver`, `GlobalModel`), the data they exchange (`Point`,
6
+ `ValuePoint`, `PathPoint`, `Work`, `NPathResult`, `NPathStatus`), the
7
+ evaluation-seam aliases, and `pointwise`. `soar_lite.engine.schedule` is the
8
+ machine: the `n_path_schedule` loop and the four private holders a run's state
9
+ lives in.
10
+ This module re-exports the public half, so `from soar_lite.engine import
11
+ LocalSolver` names the contract without a caller knowing which file holds it.
12
+
13
+ The dependency direction is one-way - plugins import these types, never the
14
+ reverse:
15
+
16
+ engine/contracts.py <- LocalSolver, GlobalModel, Point, ValuePoint, PathPoint
17
+ engine/schedule.py <- n_path_schedule and the four holders
18
+ domain.py <- Domain, Bounds (the box; a leaf)
19
+ local_solver.py <- DiagonalPreconditioner (implements LocalSolver)
20
+ global_model/ <- SampleGlobalModel, BLRGlobalModel and its projections
21
+
22
+ The two concrete defaults are bound lazily inside `n_path_schedule`, which keeps
23
+ that graph acyclic. The engine is first-order (value and gradient),
24
+ coordinate-free, and owns the flock, the domain and its box test, the shared horizon
25
+ and the restart cadence; the local solver owns direction, step size, acceptance
26
+ and its step's enumeration, and the global model only where a restart lands.
27
+
28
+ Restarting is two plugin seams and one flag, not a third plugin: `_Paths` marks a
29
+ path stalled when its method gave up or its point was not usable,
30
+ `restart_stalled` says whether such a path is re-drawn, and `GlobalModel.propose`
31
+ says where.
32
+ """
33
+
34
+ from soar_lite.engine.contracts import (
35
+ EvalF,
36
+ EvalFG,
37
+ GlobalModel,
38
+ LocalSolver,
39
+ NPathResult,
40
+ NPathStatus,
41
+ PathPoint,
42
+ Point,
43
+ ServeTrials,
44
+ ValuePoint,
45
+ Work,
46
+ pointwise,
47
+ )
48
+ from soar_lite.engine.schedule import n_path_schedule
49
+
50
+ __all__ = [
51
+ "EvalF",
52
+ "EvalFG",
53
+ "GlobalModel",
54
+ "LocalSolver",
55
+ "NPathResult",
56
+ "NPathStatus",
57
+ "PathPoint",
58
+ "Point",
59
+ "ServeTrials",
60
+ "ValuePoint",
61
+ "Work",
62
+ "n_path_schedule",
63
+ "pointwise",
64
+ ]