Skip to content

Commit 079f8d1

Browse files
committed
more template updates
1 parent 47c1698 commit 079f8d1

8 files changed

Lines changed: 730 additions & 68 deletions

File tree

templates/_shared/constraints.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import math
5+
from collections.abc import Callable
56
from typing import Any, cast
67

78
JSONDict = dict[str, Any]
@@ -451,3 +452,94 @@ def accumulate_reject_counts(counts: JSONDict, evaluation: JSONDict) -> JSONDict
451452
for rule_id, count in cast(JSONDict, evaluation.get("reject_counts", {})).items():
452453
updated[str(rule_id)] = int(updated.get(str(rule_id), 0)) + int(count)
453454
return updated
455+
456+
457+
def apply_bound_tightening(params: list[JSONDict], constraints: JSONDict | None) -> list[JSONDict]:
458+
if not constraints:
459+
return params
460+
461+
tightened_bounds: dict[str, tuple[int | float | None, int | float | None]] = {}
462+
for rule in cast(list[JSONDict], constraints.get("bound_tightening", [])):
463+
param_name = str(rule["param"])
464+
current_min, current_max = tightened_bounds.get(param_name, (None, None))
465+
if "min" in rule:
466+
next_min = cast(int | float, rule["min"])
467+
current_min = next_min if current_min is None else max(current_min, next_min)
468+
if "max" in rule:
469+
next_max = cast(int | float, rule["max"])
470+
current_max = next_max if current_max is None else min(current_max, next_max)
471+
tightened_bounds[param_name] = (current_min, current_max)
472+
473+
tightened_params: list[JSONDict] = []
474+
for param in params:
475+
param_name = str(param["name"])
476+
if param_name not in tightened_bounds or str(param["type"]) not in _NUMERIC_PARAMETER_TYPES:
477+
tightened_params.append(param)
478+
continue
479+
480+
bound_min, bound_max = tightened_bounds[param_name]
481+
raw_lo, raw_hi = cast(list[int | float], param["bounds"])
482+
lo = raw_lo if bound_min is None else bound_min
483+
hi = raw_hi if bound_max is None else bound_max
484+
485+
if str(param["type"]) == "int":
486+
narrowed_bounds: list[int | float] = [int(lo), int(hi)]
487+
else:
488+
narrowed_bounds = [float(lo), float(hi)]
489+
490+
if narrowed_bounds == list(param["bounds"]):
491+
tightened_params.append(param)
492+
continue
493+
494+
tightened = dict(param)
495+
tightened["bounds"] = narrowed_bounds
496+
tightened_params.append(tightened)
497+
return tightened_params
498+
499+
500+
def sample_feasible_candidates(
501+
sample_candidate: Callable[[], JSONDict],
502+
constraints: JSONDict | None,
503+
*,
504+
target_count: int,
505+
max_attempts: int,
506+
) -> JSONDict:
507+
if target_count <= 0:
508+
raise ValueError("target_count must be >= 1")
509+
if max_attempts <= 0:
510+
raise ValueError("max_attempts must be >= 1")
511+
512+
feasible: list[JSONDict] = []
513+
attempts = 0
514+
reject_counts: JSONDict = {}
515+
516+
while len(feasible) < target_count and attempts < max_attempts:
517+
attempts += 1
518+
candidate = sample_candidate()
519+
if not constraints:
520+
feasible.append(candidate)
521+
continue
522+
523+
evaluation = evaluate_constraints(candidate, constraints)
524+
if bool(evaluation["feasible"]):
525+
feasible.append(candidate)
526+
continue
527+
reject_counts = accumulate_reject_counts(reject_counts, evaluation)
528+
529+
return {
530+
"candidates": feasible,
531+
"attempts": attempts,
532+
"infeasible_attempts": attempts - len(feasible),
533+
"reject_counts": reject_counts,
534+
}
535+
536+
537+
def format_reject_summary(reject_counts: JSONDict, *, limit: int = 3) -> str:
538+
if not reject_counts:
539+
return "none"
540+
541+
ranked = sorted(
542+
((str(rule_id), int(count)) for rule_id, count in reject_counts.items()),
543+
key=lambda item: (-item[1], item[0]),
544+
)
545+
return ", ".join(f"{rule_id}={count}" for rule_id, count in ranked[: max(1, limit)])

templates/bo_client/run_bo.py

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ def _load_shared_module(module_name: str, filename: str) -> ModuleType:
5656
validate_against_schema = _CONTRACT.validate_against_schema
5757

5858
normalize_constraints = _CONSTRAINTS.normalize_constraints
59+
apply_bound_tightening = _CONSTRAINTS.apply_bound_tightening
60+
format_reject_summary = _CONSTRAINTS.format_reject_summary
61+
sample_feasible_candidates = _CONSTRAINTS.sample_feasible_candidates
5962

6063
append_jsonl = _RUNTIME.append_jsonl
6164
atomic_write_json = _RUNTIME.atomic_write_json
@@ -130,25 +133,83 @@ def _is_usable_observation(row: JSONDict, objective_name: str) -> bool:
130133
return math.isfinite(float(value))
131134

132135

136+
def _load_constraints(root: Path, cfg: JSONDict, params: list[JSONDict]) -> JSONDict | None:
137+
constraints_cfg, constraints_path = load_optional_contract_document(root, "constraints")
138+
if constraints_path is None:
139+
return None
140+
if not isinstance(constraints_cfg, dict):
141+
raise ValueError("constraints must be an object")
142+
constraints_schema, _ = load_schema_from_paths(
143+
root,
144+
cfg.get("paths", {}),
145+
key="constraints_schema_file",
146+
default_rel="../_shared/schemas/constraints.schema.json",
147+
)
148+
validate_against_schema(constraints_cfg, constraints_schema, source_path=constraints_path)
149+
return cast(JSONDict, normalize_constraints(constraints_cfg, params))
150+
151+
152+
def _sampling_attempt_budget(cfg: JSONDict, constraints: JSONDict | None, target_count: int) -> int:
153+
if not constraints:
154+
return target_count
155+
candidate_pool_size = max(1, int(cfg["candidate_pool_size"]))
156+
return max(target_count, candidate_pool_size, target_count * 8)
157+
158+
159+
def _sample_random_candidates(
160+
rng: random.Random,
161+
cfg: JSONDict,
162+
params: list[JSONDict],
163+
constraints: JSONDict | None,
164+
*,
165+
target_count: int,
166+
) -> JSONDict:
167+
sampling_params = apply_bound_tightening(params, constraints)
168+
return cast(
169+
JSONDict,
170+
sample_feasible_candidates(
171+
lambda: cast(JSONDict, sample_random_point(rng, sampling_params)),
172+
constraints,
173+
target_count=target_count,
174+
max_attempts=_sampling_attempt_budget(cfg, constraints, target_count),
175+
),
176+
)
177+
178+
179+
def _require_feasible_candidates(sampled: JSONDict, *, phase: str) -> list[JSONDict]:
180+
candidates = cast(list[JSONDict], sampled["candidates"])
181+
if candidates:
182+
return candidates
183+
attempts = int(sampled["attempts"])
184+
reject_summary = format_reject_summary(cast(JSONDict, sampled["reject_counts"]))
185+
raise ValueError(
186+
f"constraints eliminated all {attempts} {phase} attempts "
187+
f"(dominant rejects: {reject_summary})"
188+
)
189+
190+
133191
def propose(
134192
rng: random.Random,
135193
state: JSONDict,
136194
cfg: JSONDict,
137195
params: list[JSONDict],
138196
obj_cfg: JSONDict,
139197
seed: int,
198+
constraints: JSONDict | None = None,
140199
) -> tuple[JSONDict, JSONDict]:
141200
obs = state["observations"]
142201
objective = obj_cfg["primary_objective"]
143202
objective_name = str(objective["name"])
144203
if len(obs) < int(cfg["initial_random_trials"]):
145-
return sample_random_point(rng, params), {
204+
sampled = _sample_random_candidates(rng, cfg, params, constraints, target_count=1)
205+
return _require_feasible_candidates(sampled, phase="initial-random")[0], {
146206
"strategy": "initial_random",
147207
"surrogate_backend": None,
148208
}
149209
usable_obs = [row for row in obs if _is_usable_observation(row, objective_name)]
150210
if not usable_obs:
151-
return sample_random_point(rng, params), {
211+
sampled = _sample_random_candidates(rng, cfg, params, constraints, target_count=1)
212+
return _require_feasible_candidates(sampled, phase="fallback-random")[0], {
152213
"strategy": "initial_random",
153214
"surrogate_backend": None,
154215
"fallback_reason": "no_usable_observations",
@@ -158,7 +219,14 @@ def propose(
158219
acq_cfg = cfg["acquisition"]
159220
backend = str(surrogate_cfg.get("type", "rbf_proxy")).lower()
160221
best = state["best"]["objective_value"] if state["best"] else None
161-
candidates = [sample_random_point(rng, params) for _ in range(int(cfg["candidate_pool_size"]))]
222+
sampled = _sample_random_candidates(
223+
rng,
224+
cfg,
225+
params,
226+
constraints,
227+
target_count=int(cfg["candidate_pool_size"]),
228+
)
229+
candidates = _require_feasible_candidates(sampled, phase="candidate-pool")
162230

163231
if backend == "rbf_proxy":
164232
return propose_with_proxy(
@@ -167,7 +235,8 @@ def propose(
167235
if backend == "gp":
168236
gp_min_fit_observations = max(2, int(surrogate_cfg.get("gp_min_fit_observations", 2)))
169237
if len(usable_obs) < gp_min_fit_observations:
170-
return sample_random_point(rng, params), {
238+
sampled = _sample_random_candidates(rng, cfg, params, constraints, target_count=1)
239+
return _require_feasible_candidates(sampled, phase="fallback-random")[0], {
171240
"strategy": "initial_random",
172241
"surrogate_backend": None,
173242
"fallback_reason": (
@@ -1025,6 +1094,7 @@ def cmd_suggest(args: argparse.Namespace) -> None:
10251094
)
10261095
validate_against_schema(space_cfg, search_space_schema, source_path=space_path)
10271096
params = normalize_search_space(space_cfg)
1097+
constraints = _load_constraints(root, cfg, params)
10281098

10291099
obj_cfg, _ = load_contract_document(root, "objective_schema")
10301100
if not isinstance(obj_cfg, dict):
@@ -1090,7 +1160,7 @@ def cmd_suggest(args: argparse.Namespace) -> None:
10901160

10911161
seed = int(state["meta"]["seed"]) + int(state["next_trial_id"])
10921162
rng = random.Random(seed)
1093-
cand, decision = propose(rng, state, cfg, params, obj_cfg, seed)
1163+
cand, decision = propose(rng, state, cfg, params, obj_cfg, seed, constraints)
10941164
trial_id = int(state["next_trial_id"])
10951165
suggestion = {
10961166
"schema_version": state_schema_version(state),
@@ -1709,22 +1779,7 @@ def cmd_validate(args: argparse.Namespace) -> None:
17091779

17101780
if params is not None:
17111781
try:
1712-
constraints_cfg, constraints_path = load_optional_contract_document(root, "constraints")
1713-
if constraints_path is not None:
1714-
if not isinstance(constraints_cfg, dict):
1715-
raise ValueError("constraints must be an object")
1716-
constraints_schema, _ = load_schema_from_paths(
1717-
root,
1718-
cfg.get("paths", {}),
1719-
key="constraints_schema_file",
1720-
default_rel="../_shared/schemas/constraints.schema.json",
1721-
)
1722-
validate_against_schema(
1723-
constraints_cfg,
1724-
constraints_schema,
1725-
source_path=constraints_path,
1726-
)
1727-
normalize_constraints(constraints_cfg, params)
1782+
_load_constraints(root, cfg, params)
17281783
except Exception as exc: # pragma: no cover
17291784
hard_errors.append(f"constraints validation failure: {exc}")
17301785

0 commit comments

Comments
 (0)