@@ -56,6 +56,9 @@ def _load_shared_module(module_name: str, filename: str) -> ModuleType:
5656validate_against_schema = _CONTRACT .validate_against_schema
5757
5858normalize_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
6063append_jsonl = _RUNTIME .append_jsonl
6164atomic_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+
133191def 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