|
36 | 36 | from brain.circuit_breaker import CircuitBreaker |
37 | 37 | from brain.completion import tick_active_routine |
38 | 38 | from brain.profiling import tick_profiling |
39 | | -from brain.rule_def import RuleDef |
| 39 | +from brain.rule_def import Consideration, RuleDef |
40 | 40 | from brain.scoring_phases import ( |
| 41 | + build_phase_selector, |
41 | 42 | compute_divergence, |
42 | | - select_by_tier, |
43 | | - select_weighted, |
44 | | - select_with_considerations, |
45 | 43 | ) |
46 | 44 | from brain.transitions import handle_transition |
47 | 45 | from perception.state import GameState |
@@ -90,6 +88,7 @@ def __init__( |
90 | 88 | self._active_start_time: float = 0.0 # when current routine activated |
91 | 89 | self._last_lock_blocked: str = "" # suppress repeated lock messages |
92 | 90 | self.utility_phase: int = utility_phase |
| 91 | + self._phase_selector = build_phase_selector(utility_phase) |
93 | 92 |
|
94 | 93 | # Per-tick profiling (rule eval + routine tick times in ms) |
95 | 94 | self.rule_times: dict[str, float] = {} # rule_name -> eval ms |
@@ -120,7 +119,7 @@ def add_rule( |
120 | 119 | score_fn: Callable[[GameState], float] | None = None, |
121 | 120 | tier: int = 0, |
122 | 121 | weight: float = 1.0, |
123 | | - considerations: list | None = None, |
| 122 | + considerations: list[Consideration] | None = None, |
124 | 123 | breaker_max_failures: int = 5, |
125 | 124 | breaker_window: float = 300.0, |
126 | 125 | breaker_recovery: float = 120.0, |
@@ -170,6 +169,23 @@ def add_rule( |
170 | 169 | recovery_seconds=breaker_recovery, |
171 | 170 | ) |
172 | 171 |
|
| 172 | + # -- Public accessors (used by BrainRunner, simulator) -- |
| 173 | + |
| 174 | + @property |
| 175 | + def active_routine(self) -> RoutineBase | None: |
| 176 | + """The currently running routine, or None.""" |
| 177 | + return self._active |
| 178 | + |
| 179 | + @property |
| 180 | + def active_routine_name(self) -> str: |
| 181 | + """Name of the currently running routine.""" |
| 182 | + return self._active_name |
| 183 | + |
| 184 | + @property |
| 185 | + def last_matched_rule(self) -> str: |
| 186 | + """Name of the rule that matched on the most recent tick.""" |
| 187 | + return self._last_matched_rule |
| 188 | + |
173 | 189 | def tick(self, state: GameState) -> None: |
174 | 190 | """Evaluate rules and run the appropriate routine.""" |
175 | 191 | tick_start = self.perf_clock() |
@@ -199,51 +215,9 @@ def _evaluate_rules( |
199 | 215 | rule_eval: dict[str, str] = {} |
200 | 216 | rule_times: dict[str, float] = {} |
201 | 217 |
|
202 | | - if self.utility_phase >= 4: |
203 | | - selected, selected_name, selected_emergency = select_with_considerations( |
204 | | - self, state, now, rule_eval, diag_results, rule_times |
205 | | - ) |
206 | | - elif self.utility_phase >= 3: |
207 | | - selected, selected_name, selected_emergency = select_weighted( |
208 | | - self, state, now, rule_eval, diag_results, rule_times |
209 | | - ) |
210 | | - elif self.utility_phase >= 2: |
211 | | - selected, selected_name, selected_emergency = select_by_tier( |
212 | | - self, state, now, rule_eval, diag_results, rule_times |
213 | | - ) |
214 | | - else: |
215 | | - # Phase 0/1: binary conditions, insertion-order priority. |
216 | | - # Short-circuit after winner found: skip condition evaluation |
217 | | - # for lower-priority rules. This is safe because detection |
218 | | - # logic (threat scan, add detection) runs pre-rule in the |
219 | | - # tick pipeline, not inside condition functions. |
220 | | - for r in self._rules: |
221 | | - if r.name in self._cooldowns and now < self._cooldowns[r.name]: |
222 | | - remaining = self._cooldowns[r.name] - now |
223 | | - rule_eval[r.name] = f"cooldown({remaining:.0f}s)" |
224 | | - diag_results.append(f"{r.name}=CD") |
225 | | - rule_times[r.name] = 0.0 |
226 | | - continue |
227 | | - breaker = self._breakers.get(r.name) |
228 | | - if breaker and not breaker.allow(): |
229 | | - rule_eval[r.name] = "OPEN" |
230 | | - diag_results.append(f"{r.name}=OPEN") |
231 | | - rule_times[r.name] = 0.0 |
232 | | - continue |
233 | | - if selected is not None: |
234 | | - rule_eval[r.name] = "skip" |
235 | | - diag_results.append(f"{r.name}=skip") |
236 | | - rule_times[r.name] = 0.0 |
237 | | - continue |
238 | | - t0 = self.perf_clock() |
239 | | - matched = r.condition(state) |
240 | | - rule_times[r.name] = (self.perf_clock() - t0) * 1000 |
241 | | - rule_eval[r.name] = "YES" if matched else "no" |
242 | | - diag_results.append(f"{r.name}={'YES' if matched else 'no'}") |
243 | | - if matched: |
244 | | - selected = r.routine |
245 | | - selected_name = r.name |
246 | | - selected_emergency = r.emergency |
| 218 | + selected, selected_name, selected_emergency = self._phase_selector.select( |
| 219 | + self, state, now, rule_eval, diag_results, rule_times |
| 220 | + ) |
247 | 221 |
|
248 | 222 | # Phase 1+: compute scores in parallel for divergence logging |
249 | 223 | if self.utility_phase >= 1: |
|
0 commit comments