abbot 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.
abbot/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """abbot — an active-learning-for-labs layer on top of BoTorch.
2
+
3
+ From A to B: a Bayesian guide for lab experiments. abbot is NOT a competing
4
+ optimization framework; it is the composite-SBI + lab-experiment-orchestration
5
+ layer on top of botorch (which already provides the surrogate-agnostic
6
+ Model/Posterior layer, the BO/US loop primitives, async/batch, and
7
+ serialization). If it drifts into "framework", stop.
8
+ """
9
+
10
+ from abbot.composite import (
11
+ ObjectiveReducer,
12
+ Rescore,
13
+ composite_evaluate,
14
+ )
15
+ from abbot.cost import (
16
+ CallableCost,
17
+ CostModel,
18
+ FittedCost,
19
+ FixedCost,
20
+ PowerLawCost,
21
+ SourceCost,
22
+ )
23
+ from abbot.experiment import (
24
+ BatchExperiment,
25
+ Experiment,
26
+ ManualExperiment,
27
+ MockAnalyticExperiment,
28
+ MockDatasetExperiment,
29
+ TensorExperiment,
30
+ Verdict,
31
+ )
32
+ from abbot.goal import (
33
+ BandIntersection,
34
+ FunctionGoal,
35
+ Goal,
36
+ LevelSet,
37
+ PercentileSet,
38
+ TopK,
39
+ Wishlist,
40
+ goals_from_spec,
41
+ )
42
+ from abbot.history import DataStore, History, Record, ResumeMismatchError
43
+ from abbot.interop.sbi import PosteriorSampler, TrainedPosterior, sbi_evaluate
44
+ from abbot.loop import drive, run_campaign
45
+ from abbot.model import (
46
+ EnsembleConstructor,
47
+ ModelConstructor,
48
+ ModelListConstructor,
49
+ MultiFidelityGPConstructor,
50
+ MultiSourceGPConstructor,
51
+ MultiTaskGPConstructor,
52
+ SingleTaskGPConstructor,
53
+ default_constructor,
54
+ )
55
+ from abbot.policy import (
56
+ BAXPolicy,
57
+ ParetoBO,
58
+ Policy,
59
+ SimpleBO,
60
+ UncertaintySampling,
61
+ default_policy,
62
+ )
63
+ from abbot.problem import (
64
+ Box,
65
+ ContinuousDomain,
66
+ EnumeratedDomain,
67
+ InequalitySpec,
68
+ IntegerDomain,
69
+ LinearConstraint,
70
+ ObjectiveSpec,
71
+ OutputSpec,
72
+ ProblemSpec,
73
+ Source,
74
+ StructuredDomain,
75
+ )
76
+ from abbot.session import (
77
+ Campaign,
78
+ CampaignResult,
79
+ CampaignState,
80
+ Candidate,
81
+ PersistentFaultError,
82
+ Targets,
83
+ apply_targets,
84
+ default_initial_designs,
85
+ )
86
+ from abbot.supervision import Supervisor
87
+
88
+ __version__ = "0.1.0"
89
+
90
+ __all__ = [
91
+ "BAXPolicy",
92
+ "BandIntersection",
93
+ "BatchExperiment",
94
+ "Box",
95
+ "CallableCost",
96
+ "Campaign",
97
+ "CampaignResult",
98
+ "CampaignState",
99
+ "Candidate",
100
+ "ContinuousDomain",
101
+ "CostModel",
102
+ "DataStore",
103
+ "EnsembleConstructor",
104
+ "EnumeratedDomain",
105
+ "Experiment",
106
+ "FittedCost",
107
+ "FixedCost",
108
+ "FunctionGoal",
109
+ "Goal",
110
+ "History",
111
+ "InequalitySpec",
112
+ "IntegerDomain",
113
+ "LevelSet",
114
+ "LinearConstraint",
115
+ "ManualExperiment",
116
+ "MockAnalyticExperiment",
117
+ "MockDatasetExperiment",
118
+ "ModelConstructor",
119
+ "ModelListConstructor",
120
+ "MultiFidelityGPConstructor",
121
+ "MultiSourceGPConstructor",
122
+ "MultiTaskGPConstructor",
123
+ "ObjectiveReducer",
124
+ "ObjectiveSpec",
125
+ "OutputSpec",
126
+ "ParetoBO",
127
+ "PercentileSet",
128
+ "PersistentFaultError",
129
+ "Policy",
130
+ "PosteriorSampler",
131
+ "PowerLawCost",
132
+ "ProblemSpec",
133
+ "Record",
134
+ "Rescore",
135
+ "ResumeMismatchError",
136
+ "SimpleBO",
137
+ "SingleTaskGPConstructor",
138
+ "Source",
139
+ "SourceCost",
140
+ "StructuredDomain",
141
+ "Supervisor",
142
+ "Targets",
143
+ "TensorExperiment",
144
+ "TopK",
145
+ "TrainedPosterior",
146
+ "UncertaintySampling",
147
+ "Verdict",
148
+ "Wishlist",
149
+ "__version__",
150
+ "apply_targets",
151
+ "composite_evaluate",
152
+ "default_constructor",
153
+ "default_initial_designs",
154
+ "default_policy",
155
+ "drive",
156
+ "goals_from_spec",
157
+ "run_campaign",
158
+ "sbi_evaluate",
159
+ ]
abbot/acq_opt.py ADDED
@@ -0,0 +1,206 @@
1
+ """The CandidateOptimizer seam: candidate proposal decoupled from the surrogate.
2
+
3
+ ``Continuous`` covers any space with a continuous or
4
+ integer axis (``optimize_acqf``; integer axes relaxed then rounded; enumerated axes
5
+ alongside them enumerated by ``optimize_acqf_mixed``). ``DiscreteArgmax`` covers
6
+ all-enumerated spaces (``optimize_acqf_discrete``). True combinatorial
7
+ (non-enumerable) search is an extension point, not solved speculatively.
8
+
9
+ ``fixed_features={position: value}`` pins inputs, which is how a policy that doesn't
10
+ choose sources proposes at their target (``spec.target_features()``).
11
+
12
+ Decoupling proposal from the surrogate is what lets a discrete materials problem
13
+ and a continuous accelerator problem share one policy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ import torch
21
+ from botorch.acquisition import AcquisitionFunction
22
+ from botorch.optim import optimize_acqf, optimize_acqf_discrete, optimize_acqf_mixed
23
+
24
+ from abbot.problem import ContinuousDomain, EnumeratedDomain, IntegerDomain, ProblemSpec
25
+
26
+
27
+ @runtime_checkable
28
+ class CandidateOptimizer(Protocol):
29
+ """Maximize an acquisition function over the inputs, return (n, d).
30
+
31
+ ``fixed_features`` pins inputs ``{position: value}``; optimizers that predate it
32
+ may not accept it, and policies only pass it when the spec has sources.
33
+ """
34
+
35
+ def optimize(
36
+ self,
37
+ acq: AcquisitionFunction,
38
+ spec: ProblemSpec,
39
+ n: int,
40
+ *,
41
+ fixed_features: dict[int, float] | None = None,
42
+ ) -> torch.Tensor: ...
43
+
44
+
45
+ # Above this many combinations of enumerated values, a mixed space is too big to
46
+ # enumerate per proposal; pass the combinations you care about to DiscreteArgmax.
47
+ MAX_MIXED_COMBINATIONS = 256
48
+
49
+
50
+ class Continuous:
51
+ """Proposal via botorch ``optimize_acqf`` for any space with a continuous or
52
+ integer input. Integer inputs are optimized as continuous and rounded;
53
+ enumerated inputs alongside them are enumerated (``optimize_acqf_mixed``, one
54
+ sub-problem per combination of their values, up to
55
+ ``MAX_MIXED_COMBINATIONS``). With ``log=True`` inputs (and no enumerated ones
56
+ to enumerate), the restarts start from the best of ``raw_samples`` log-uniform
57
+ designs, so the search covers every decade rather than mostly the top one."""
58
+
59
+ def __init__(self, num_restarts: int = 10, raw_samples: int = 256) -> None:
60
+ self.num_restarts = num_restarts
61
+ self.raw_samples = raw_samples
62
+
63
+ def optimize(
64
+ self,
65
+ acq: AcquisitionFunction,
66
+ spec: ProblemSpec,
67
+ n: int,
68
+ *,
69
+ fixed_features: dict[int, float] | None = None,
70
+ ) -> torch.Tensor:
71
+ from itertools import product
72
+
73
+ inputs = spec.inputs
74
+ unsupported = [
75
+ d.name for d in inputs
76
+ if not isinstance(d, (ContinuousDomain, IntegerDomain, EnumeratedDomain))
77
+ ]
78
+ if unsupported:
79
+ raise NotImplementedError(
80
+ f"Continuous optimizer can't propose structured inputs {unsupported}."
81
+ )
82
+ fixed = dict(fixed_features or {})
83
+ integer = [j for j, d in enumerate(inputs) if isinstance(d, IntegerDomain)]
84
+
85
+ def round_integers(X: torch.Tensor) -> torch.Tensor:
86
+ if integer:
87
+ X = X.clone()
88
+ X[..., integer] = X[..., integer].round()
89
+ return X
90
+
91
+ enum = [
92
+ j for j, d in enumerate(inputs)
93
+ if isinstance(d, EnumeratedDomain) and j not in fixed
94
+ ]
95
+ kwargs = {
96
+ "acq_function": acq,
97
+ "bounds": spec.bounds_tensor(),
98
+ "q": n,
99
+ "num_restarts": self.num_restarts,
100
+ "raw_samples": self.raw_samples,
101
+ "inequality_constraints": spec.inequality_constraints(),
102
+ "post_processing_func": round_integers,
103
+ }
104
+ if not enum:
105
+ if set(spec.log_indices()) - set(fixed):
106
+ kwargs["batch_initial_conditions"] = self._log_uniform_starts(acq, spec, n, fixed)
107
+ kwargs.pop("raw_samples")
108
+ candidates, _ = optimize_acqf(fixed_features=fixed or None, **kwargs)
109
+ return candidates.detach()
110
+ combos = list(product(*(inputs[j].codes for j in enum)))
111
+ if len(combos) > MAX_MIXED_COMBINATIONS:
112
+ raise ValueError(
113
+ f"{len(combos)} combinations of enumerated values "
114
+ f"({[inputs[j].name for j in enum]}) is too many to enumerate per "
115
+ f"proposal (limit {MAX_MIXED_COMBINATIONS}); make some of them "
116
+ "continuous or integer, or use DiscreteArgmax(choices=...)."
117
+ )
118
+ fixed_list = [{**fixed, **dict(zip(enum, map(float, c)))} for c in combos]
119
+ candidates, _ = optimize_acqf_mixed(fixed_features_list=fixed_list, **kwargs)
120
+ return candidates.detach()
121
+
122
+
123
+ def _log_uniform_starts(self, acq, spec: ProblemSpec, n: int, fixed: dict[int, float]):
124
+ """``(num_restarts, n, d)`` starting batches: the best of ``raw_samples``
125
+ batches drawn by ``spec.sample_designs`` (log-uniform on log axes)."""
126
+ X = spec._sample(self.raw_samples * n, None, fixed).reshape(self.raw_samples, n, -1)
127
+ with torch.no_grad():
128
+ value = acq(X).reshape(-1)
129
+ best = value.nan_to_num(float("-inf")).topk(min(self.num_restarts, len(value))).indices
130
+ return X[best]
131
+
132
+
133
+ class DiscreteArgmax:
134
+ """Enumerated proposal via botorch ``optimize_acqf_discrete``.
135
+
136
+ ``choices`` is the (N, d) tensor of enumerable candidates (every input). When
137
+ omitted it is built as the Cartesian product of each enumerated or integer
138
+ input's values (small spaces only — large products should pass ``choices``
139
+ explicitly). ``fixed_features`` keeps only the choices that match.
140
+ """
141
+
142
+ def __init__(self, choices: torch.Tensor | None = None) -> None:
143
+ self.choices = choices
144
+
145
+ def _enumerate(self, spec: ProblemSpec) -> torch.Tensor:
146
+ from itertools import product
147
+
148
+ axes = []
149
+ for d in spec.inputs:
150
+ if isinstance(d, EnumeratedDomain):
151
+ axes.append(d.codes)
152
+ elif isinstance(d, IntegerDomain):
153
+ axes.append(list(range(d.lower, d.upper + 1)))
154
+ else:
155
+ raise TypeError(
156
+ "DiscreteArgmax without explicit `choices` requires every "
157
+ f"input to be enumerated or integer; {d.name!r} is not."
158
+ )
159
+ return torch.tensor(list(product(*axes)), dtype=torch.double)
160
+
161
+ def optimize(
162
+ self,
163
+ acq: AcquisitionFunction,
164
+ spec: ProblemSpec,
165
+ n: int,
166
+ *,
167
+ fixed_features: dict[int, float] | None = None,
168
+ ) -> torch.Tensor:
169
+ if self.choices is None:
170
+ choices = self._enumerate(spec)
171
+ else: # only those the spec allows (with travel limits, the ones in reach)
172
+ choices = self.choices.double()
173
+ bounds = spec.bounds_tensor()
174
+ choices = choices[((choices >= bounds[0] - 1e-9) & (choices <= bounds[1] + 1e-9)).all(-1)]
175
+ if not len(choices):
176
+ raise ValueError(
177
+ "no choices lie within the bounds (with travel limits: within "
178
+ "max_step of the last design)"
179
+ )
180
+ choices = choices.double()
181
+ for j, value in (fixed_features or {}).items():
182
+ choices = choices[torch.isclose(choices[:, j], torch.tensor(value).double())]
183
+ if not len(choices):
184
+ raise ValueError(f"no choices match the fixed inputs {fixed_features}")
185
+ candidates, _ = optimize_acqf_discrete(acq, q=n, choices=choices)
186
+ return candidates.detach()
187
+
188
+
189
+ def optimize(optimizer, acq, spec: ProblemSpec, n: int, fixed_features=None) -> torch.Tensor:
190
+ """``optimizer.optimize``, passing ``fixed_features`` only when there are some,
191
+ so custom optimizers written before it existed keep working without sources."""
192
+ if fixed_features:
193
+ return optimizer.optimize(acq, spec, n, fixed_features=fixed_features)
194
+ return optimizer.optimize(acq, spec, n)
195
+
196
+
197
+ def default_optimizer(spec: ProblemSpec) -> CandidateOptimizer:
198
+ """Pick the CandidateOptimizer the spec supports.
199
+
200
+ - all-enumerated inputs -> ``DiscreteArgmax`` (``optimize_acqf_discrete``)
201
+ - otherwise -> ``Continuous`` (``optimize_acqf``; integers rounded,
202
+ enumerated inputs alongside continuous ones enumerated)
203
+ """
204
+ if all(isinstance(d, EnumeratedDomain) for d in spec.inputs):
205
+ return DiscreteArgmax()
206
+ return Continuous()
abbot/analysis.py ADDED
@@ -0,0 +1,256 @@
1
+ """Live-analysis built-ins, computed from a campaign's history + fitted model.
2
+
3
+ These are pure functions of ``(History, spec[, model])`` so they are unit-testable
4
+ without a UI and reusable by a notebook or an agent. The UI registers them through
5
+ ``Campaign``'s ``on_step`` hook and renders the results; callers can register their
6
+ own analyses the same way. Kept deliberately small — the general-purpose "any
7
+ analysis" surface is the hook itself; these are just the common basics.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ import torch
15
+
16
+ from abbot.history import History
17
+ from abbot.problem import ProblemSpec
18
+ from abbot.session import canonical_Y, observed_front
19
+
20
+ if TYPE_CHECKING:
21
+ from abbot.policy import BAXPolicy
22
+
23
+
24
+ def _counts(r, spec: ProblemSpec) -> bool:
25
+ """A record counts toward the best so far: clean, measured, and satisfying
26
+ every output constraint."""
27
+ if not r.usable or r.outputs is None:
28
+ return False
29
+ if not spec.has_output_constraints:
30
+ return True
31
+ from abbot.feasibility import feasible
32
+
33
+ return bool(feasible(spec, r.outputs.double().reshape(1, -1))[0])
34
+
35
+
36
+ def convergence(history: History, spec: ProblemSpec) -> list[float]:
37
+ """Running best objective value vs. step, in RAW units (single-objective only).
38
+
39
+ Element ``i`` is the best clean observed objective among the first ``i+1``
40
+ records, respecting direction (max of raw for MAXIMIZE, min for MINIMIZE).
41
+ Faulted records carry forward the previous best, and so do records that break
42
+ an output constraint (NaN until one satisfies them).
43
+ """
44
+ if spec.n_objectives != 1:
45
+ raise ValueError("convergence is single-objective; use hypervolume for MO.")
46
+ col = spec.objective_indices()[0] # objective may not be column 0 (OBSERVE outputs)
47
+ maximize = spec.objectives[0].direction == "MAXIMIZE"
48
+ best = None
49
+ out: list[float] = []
50
+ for r in history.records:
51
+ if _counts(r, spec):
52
+ v = float(r.outputs.reshape(-1)[col])
53
+ if best is None:
54
+ best = v
55
+ else:
56
+ best = max(best, v) if maximize else min(best, v)
57
+ out.append(best if best is not None else float("nan"))
58
+ return out
59
+
60
+
61
+ def hypervolume(history: History, spec: ProblemSpec) -> list[float]:
62
+ """Dominated hypervolume of the observed canonical outputs vs. step (MO).
63
+
64
+ Uses botorch's exact ``Hypervolume`` against an inferred reference point (from
65
+ the final canonical set, so the series is comparable across steps). Returns a
66
+ per-step list; steps before any clean point are 0.0. With output
67
+ constraints, only records that satisfy them count.
68
+ """
69
+ if spec.n_objectives < 2:
70
+ raise ValueError("hypervolume needs >=2 objectives; use convergence for 1.")
71
+ from botorch.utils.multi_objective.hypervolume import (
72
+ Hypervolume,
73
+ infer_reference_point,
74
+ )
75
+ from botorch.utils.multi_objective.pareto import is_non_dominated
76
+
77
+ clean = [r for r in history.records if _counts(r, spec)]
78
+ if not clean:
79
+ return [0.0] * len(history.records)
80
+ # Objective columns in canonical space, one row per clean record (same rule
81
+ # everywhere via spec.objective_view).
82
+ all_Y = spec.objective_view(torch.stack([r.outputs.reshape(-1) for r in clean]))
83
+ ref = infer_reference_point(all_Y[is_non_dominated(all_Y)])
84
+ hv = Hypervolume(ref_point=ref)
85
+
86
+ out: list[float] = []
87
+ acc: list[torch.Tensor] = []
88
+ for r in history.records:
89
+ if _counts(r, spec):
90
+ acc.append(spec.objective_view(r.outputs.reshape(1, -1))[0])
91
+ if acc:
92
+ Y = torch.stack(acc)
93
+ front = Y[is_non_dominated(Y)]
94
+ try:
95
+ out.append(float(hv.compute(front)))
96
+ except Exception: # noqa: BLE001 - a degenerate front -> HV 0, keep the series going
97
+ out.append(0.0)
98
+ else:
99
+ out.append(0.0)
100
+ return out
101
+
102
+
103
+ def recommended_set(history: History, spec: ProblemSpec, model=None) -> list[int]:
104
+ """Current recommended handover indices.
105
+
106
+ If a fitted ``model`` is given, uses the denoised posterior-mean recommendation
107
+ (via the policy path is the caller's job); here we expose the model-free
108
+ observed non-dominated set, which is also the loop's fallback.
109
+ """
110
+ return observed_front(history, spec)
111
+
112
+
113
+ # --- model visualization ------------------------------------------------------
114
+ # The surrogate is fit in maximize-canonical space (see session.canonical_Y), so
115
+ # these un-apply the objective signs to report posterior values in RAW units.
116
+
117
+
118
+ def _posterior_raw(model, X: torch.Tensor, spec: ProblemSpec):
119
+ """Posterior (mean, std) at X over ALL outputs, converted back to RAW units.
120
+
121
+ Returns tensors shaped (n, n_outputs). The sign flip maps canonical -> raw for
122
+ the mean (OBSERVE columns pass through); std is sign-invariant.
123
+ """
124
+ with torch.no_grad():
125
+ post = model.posterior(X.double())
126
+ mean = spec.to_raw(post.mean.reshape(X.shape[0], -1)) # canonical -> raw
127
+ std = post.variance.clamp_min(1e-12).sqrt().reshape(X.shape[0], -1)
128
+ return mean, std
129
+
130
+
131
+ def parity(history: History, spec: ProblemSpec, model) -> dict:
132
+ """Predicted (posterior mean ± std) vs. observed, per OUTPUT — dimension-agnostic.
133
+
134
+ Returns ``{output_name: {"observed": [...], "predicted": [...], "std": [...]}}``
135
+ for every modeled output (incl. OBSERVE). The go-to "is the model any good?"
136
+ view when the design space is too high-D to plot.
137
+ """
138
+ clean = history.clean
139
+ if not len(clean):
140
+ return {}
141
+ X = clean.designs.double()
142
+ Y = clean.outputs.double() # raw observed, full width
143
+ mean, std = _posterior_raw(model, X, spec)
144
+ out = {}
145
+ for j, o in enumerate(spec.outputs):
146
+ out[o.name] = {
147
+ "observed": Y[:, j].tolist(),
148
+ "predicted": mean[:, j].tolist(),
149
+ "std": std[:, j].tolist(),
150
+ }
151
+ return out
152
+
153
+
154
+ def _anchor_point(history: History, spec: ProblemSpec) -> torch.Tensor:
155
+ """The design to hold the non-varied dimensions at: the recommended/best point."""
156
+ rec = observed_front(history, spec)
157
+ idx = rec[0] if rec else 0
158
+ return history.records[idx].candidate.reshape(-1).double()
159
+
160
+
161
+ def slice_1d(
162
+ history: History,
163
+ spec: ProblemSpec,
164
+ model,
165
+ var: int = 0,
166
+ output: int = 0,
167
+ n: int = 80,
168
+ ) -> dict:
169
+ """1-D posterior slice: vary one variable over its range, others fixed at the
170
+ recommended point. ``output`` indexes ``spec.outputs``. Returns grid ``x``, raw
171
+ ``mean``, and ``std`` for a band.
172
+ """
173
+ bounds = spec.bounds_tensor()
174
+ lo, hi = float(bounds[0, var]), float(bounds[1, var])
175
+ anchor = _anchor_point(history, spec)
176
+ xs = torch.linspace(lo, hi, n, dtype=torch.double)
177
+ grid = anchor.repeat(n, 1)
178
+ grid[:, var] = xs
179
+ mean, std = _posterior_raw(model, grid, spec)
180
+ return {
181
+ "x": xs.tolist(),
182
+ "mean": mean[:, output].tolist(),
183
+ "std": std[:, output].tolist(),
184
+ "var_name": spec.variable_names[var],
185
+ "objective_name": spec.outputs[output].name,
186
+ }
187
+
188
+
189
+ def slice_2d(
190
+ history: History,
191
+ spec: ProblemSpec,
192
+ model,
193
+ var_x: int = 0,
194
+ var_y: int = 1,
195
+ output: int = 0,
196
+ n: int = 40,
197
+ ) -> dict:
198
+ """2-D posterior-mean slice: vary two variables over a grid, others fixed at the
199
+ recommended point. ``output`` indexes ``spec.outputs``. Returns ``x``, ``y``
200
+ axes and a ``z`` matrix (raw mean).
201
+ """
202
+ bounds = spec.bounds_tensor()
203
+ xs = torch.linspace(float(bounds[0, var_x]), float(bounds[1, var_x]), n, dtype=torch.double)
204
+ ys = torch.linspace(float(bounds[0, var_y]), float(bounds[1, var_y]), n, dtype=torch.double)
205
+ anchor = _anchor_point(history, spec)
206
+ gx, gy = torch.meshgrid(xs, ys, indexing="xy")
207
+ grid = anchor.repeat(n * n, 1)
208
+ grid[:, var_x] = gx.reshape(-1)
209
+ grid[:, var_y] = gy.reshape(-1)
210
+ mean, _ = _posterior_raw(model, grid, spec)
211
+ z = mean[:, output].reshape(n, n)
212
+ return {
213
+ "x": xs.tolist(),
214
+ "y": ys.tolist(),
215
+ "z": z.tolist(),
216
+ "var_x_name": spec.variable_names[var_x],
217
+ "var_y_name": spec.variable_names[var_y],
218
+ "objective_name": spec.outputs[output].name,
219
+ }
220
+
221
+
222
+ def bax_target_region(
223
+ policy: BAXPolicy, model, spec: ProblemSpec
224
+ ) -> dict[str, int]:
225
+ """Snapshot of the BAX discovered-target region on the current posterior mean.
226
+
227
+ Returns ``{"identified": int, "grid_size": int}``: how many candidate-grid
228
+ points the policy's goal selects from the posterior-MEAN prediction (the size
229
+ of the region found so far — should grow then stabilize), and the grid size.
230
+ When the policy applies output constraints, ``identified`` counts what the goal
231
+ selects among points predicted to satisfy them, and ``"before_constraints"``
232
+ what it selects from every point, so the narrowing is visible.
233
+ Pure function of ``(policy, model, spec)``; encapsulates grid + raw-posterior +
234
+ ``goal.identify_subspace`` so no BAX internals leak to the caller.
235
+ """
236
+ grid = policy.candidate_grid(spec)
237
+ mean, _ = _posterior_raw(model, grid, spec)
238
+ out = {
239
+ "identified": len(policy._identify(spec, mean, grid)),
240
+ "grid_size": int(grid.shape[0]),
241
+ }
242
+ if policy._constrains(spec):
243
+ out["before_constraints"] = len(policy._goal_for(spec).identify_subspace(mean, grid))
244
+ return out
245
+
246
+
247
+ __all__ = [
248
+ "bax_target_region",
249
+ "canonical_Y",
250
+ "convergence",
251
+ "hypervolume",
252
+ "parity",
253
+ "recommended_set",
254
+ "slice_1d",
255
+ "slice_2d",
256
+ ]