@@ -45,6 +45,50 @@ def _apply_modifiers(score: float, phase: str, rule_name: str, goap_hint: str) -
4545 return score
4646
4747
48+ def _is_rule_blocked (
49+ brain : Brain ,
50+ r : RuleDef ,
51+ now : float ,
52+ rule_eval : dict ,
53+ diag_results : list ,
54+ rule_times : dict ,
55+ ) -> bool :
56+ """Check cooldown and circuit breaker. Returns True if rule should be skipped.
57+
58+ Shared by Phases 2/3/4 to avoid duplicating eligibility logic.
59+ """
60+ if r .name in brain ._cooldowns and now < brain ._cooldowns [r .name ]:
61+ remaining = brain ._cooldowns [r .name ] - now
62+ rule_eval [r .name ] = f"cooldown({ remaining :.0f} s)"
63+ diag_results .append (f"{ r .name } =CD" )
64+ rule_times [r .name ] = 0.0
65+ return True
66+ breaker = brain ._breakers .get (r .name )
67+ if breaker and not breaker .allow ():
68+ rule_eval [r .name ] = "OPEN"
69+ diag_results .append (f"{ r .name } =OPEN" )
70+ rule_times [r .name ] = 0.0
71+ return True
72+ return False
73+
74+
75+ def _safe_score (fn : object , * args : object ) -> float :
76+ """Call a scoring function, returning 0.0 on any exception.
77+
78+ Logs at WARNING so failures are visible but don't crash the tick.
79+ """
80+ try :
81+ result : float = fn (* args ) # type: ignore[operator]
82+ return result
83+ except Exception :
84+ log .warning (
85+ "[DECISION] Score function %s raised, defaulting to 0.0" ,
86+ getattr (fn , "__name__" , fn ),
87+ exc_info = True ,
88+ )
89+ return 0.0
90+
91+
4892def compute_divergence (brain : Brain , state : GameState , now : float , binary_winner : str ) -> None :
4993 """Phase 1: compute scores for all rules, log when score-based
5094 selection would differ from binary selection."""
@@ -56,10 +100,11 @@ def compute_divergence(brain: Brain, state: GameState, now: float, binary_winner
56100 if r .name in brain ._cooldowns and now < brain ._cooldowns [r .name ]:
57101 scores [r .name ] = - 1.0 # on cooldown
58102 continue
59- try :
60- s = r .score_fn (state )
61- except Exception :
62- s = 0.0
103+ breaker = brain ._breakers .get (r .name )
104+ if breaker and not breaker .allow ():
105+ scores [r .name ] = - 2.0 # circuit-broken
106+ continue
107+ s = _safe_score (r .score_fn , state )
63108 scores [r .name ] = s
64109 if s > best_score :
65110 best_score = s
@@ -89,11 +134,7 @@ def select_by_tier(
89134 tier_groups : dict [int , list [RuleDef ]] = defaultdict (list )
90135
91136 for r in brain ._rules :
92- if r .name in brain ._cooldowns and now < brain ._cooldowns [r .name ]:
93- remaining = brain ._cooldowns [r .name ] - now
94- rule_eval [r .name ] = f"cooldown({ remaining :.0f} s)"
95- diag_results .append (f"{ r .name } =CD" )
96- rule_times [r .name ] = 0.0
137+ if _is_rule_blocked (brain , r , now , rule_eval , diag_results , rule_times ):
97138 continue
98139 tier_groups [r .tier ].append (r )
99140
@@ -103,7 +144,7 @@ def select_by_tier(
103144 scored : list [tuple [float , RuleDef ]] = []
104145 for r in tier_groups [tier ]:
105146 t0 = time .perf_counter ()
106- s = _apply_modifiers (r .score_fn ( state ), phase , r .name , goap_hint )
147+ s = _apply_modifiers (_safe_score ( r .score_fn , state ), phase , r .name , goap_hint )
107148 rule_times [r .name ] = (time .perf_counter () - t0 ) * 1000
108149 rule_eval [r .name ] = f"{ s :.2f} " if s > 0 else "0"
109150 diag_results .append (f"{ r .name } ={ s :.2f} " )
@@ -128,14 +169,10 @@ def select_weighted(
128169 phase , goap_hint = _resolve_phase_context (brain )
129170
130171 for r in brain ._rules :
131- if r .name in brain ._cooldowns and now < brain ._cooldowns [r .name ]:
132- remaining = brain ._cooldowns [r .name ] - now
133- rule_eval [r .name ] = f"cooldown({ remaining :.0f} s)"
134- diag_results .append (f"{ r .name } =CD" )
135- rule_times [r .name ] = 0.0
172+ if _is_rule_blocked (brain , r , now , rule_eval , diag_results , rule_times ):
136173 continue
137174 t0 = time .perf_counter ()
138- s = _apply_modifiers (r .score_fn ( state ), phase , r .name , goap_hint )
175+ s = _apply_modifiers (_safe_score ( r .score_fn , state ), phase , r .name , goap_hint )
139176 rule_times [r .name ] = (time .perf_counter () - t0 ) * 1000
140177 weighted = r .weight * s
141178 rule_eval [r .name ] = f"{ weighted :.1f} " if s > 0 else "0"
@@ -173,18 +210,14 @@ def select_with_considerations(
173210 phase , goap_hint = _resolve_phase_context (brain )
174211
175212 for r in brain ._rules :
176- if r .name in brain ._cooldowns and now < brain ._cooldowns [r .name ]:
177- remaining = brain ._cooldowns [r .name ] - now
178- rule_eval [r .name ] = f"cooldown({ remaining :.0f} s)"
179- diag_results .append (f"{ r .name } =CD" )
180- rule_times [r .name ] = 0.0
213+ if _is_rule_blocked (brain , r , now , rule_eval , diag_results , rule_times ):
181214 continue
182215 t0 = time .perf_counter ()
183216 # Phase 4: prefer considerations over score_fn when defined
184217 if r .considerations and brain ._ctx :
185- raw = score_from_considerations ( r .considerations , state , brain ._ctx )
218+ raw = _safe_score ( score_from_considerations , r .considerations , state , brain ._ctx )
186219 else :
187- raw = r .score_fn ( state )
220+ raw = _safe_score ( r .score_fn , state )
188221 s = _apply_modifiers (raw , phase , r .name , goap_hint )
189222 rule_times [r .name ] = (time .perf_counter () - t0 ) * 1000
190223 weighted = r .weight * s
0 commit comments