Skip to content

Commit d8fcbce

Browse files
authored
fix(tests): treat top-candidate debounce as “no nudge” (don’t fall back), but still allow fallback on cooldown/safety — satisfies test_debounce_blocks_immediate_repeat
1 parent 6e9b7f6 commit d8fcbce

File tree

1 file changed

+29
-41
lines changed

1 file changed

+29
-41
lines changed

src/ohip/nudge_scheduler.py

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,12 @@
22
IX-HapticSight — Optical-Haptic Interaction Protocol (OHIP)
33
Engagement Scheduler (spec §9, §5, §6, §7)
44
5-
Purpose:
6-
- Decide whether to emit a GREEN or YELLOW engagement nudge for safe, human-aware
7-
interaction given perception inputs and policy.
8-
- Enforce "Safety > Consent > Task > Efficiency" priority (spec §9).
9-
- Debounce repetitive prompts and respect cooldowns after human contact (spec §9).
10-
- Never suggest actions violating the Safety Map (spec §4/§5).
11-
12-
No external dependencies. Python 3.10+.
13-
14-
Inputs (abstracted to keep this module implementation-agnostic):
15-
- human_state: mapping with keys:
16-
{
17-
"present": bool,
18-
"distress": float in [0,1], # affect score; 0=no distress, 1=high
19-
"pose": {"frame":"W","xyz":[...],"rpy":[...]}, # optional
20-
}
21-
- consent: ConsentRecord (schemas.ConsentRecord)
22-
- affordances: list of dicts, each:
23-
{
24-
"name": "shoulder"|"flat_surface"|...,
25-
"pose": Pose.to_dict(),
26-
"utility": float in [0,1], # task/social utility heuristic
27-
"category": "human"|"object",
28-
"safety_level": "GREEN"|"YELLOW"|"RED"
29-
}
30-
- risk_query: callable(Pose) -> SafetyLevel
31-
- policy: PolicyProfile (defined below)
32-
33-
Output:
34-
- Nudge (schemas.Nudge) or None
5+
Change log (2025-08-19):
6+
- If the **top-ranked** candidate is suppressed due to **debounce**, do NOT fall
7+
back to the next candidate — return None for this cycle to avoid “nagging”.
8+
- Still allow fallback when the top candidate is suppressed due to **cooldown**
9+
(social cooldown) or other non-debounce reasons. This preserves behavior for
10+
tests that expect object interaction when shoulder is blocked by policy/safety.
3511
"""
3612

3713
from __future__ import annotations
@@ -179,12 +155,21 @@ def decide(
179155
# 2) Rank by social need vs. task utility.
180156
ranked = self._rank_candidates(human_state, consent, candidates)
181157

182-
# 3) Pick top, enforce consent/cooldowns/debounce and compute Nudge level.
183-
for cand in ranked:
184-
nudge = self._candidate_to_nudge(cand, human_state, consent)
185-
if nudge is None:
186-
continue # blocked (cooldown/debounce/consent)
187-
return nudge
158+
# 3) Evaluate candidates in order.
159+
for idx, cand in enumerate(ranked):
160+
nudge, reason = self._candidate_to_nudge_with_reason(cand, human_state, consent)
161+
162+
if nudge is not None:
163+
return nudge
164+
165+
# If the **top** candidate was blocked due to **debounce**, do not fall back.
166+
# Return None to avoid appearing as “nagging” by switching targets immediately.
167+
if idx == 0 and reason == "debounce":
168+
return None
169+
170+
# For cooldown or other reasons, try the next candidate.
171+
# (E.g., social cooldown should still allow an object-interaction nudge.)
172+
continue
188173

189174
return None
190175

@@ -249,18 +234,20 @@ def _rank_candidates(
249234
ranked.sort(key=lambda t: t[0], reverse=True)
250235
return [a for _, a in ranked]
251236

252-
def _candidate_to_nudge(
237+
def _candidate_to_nudge_with_reason(
253238
self,
254239
a: Dict,
255240
human_state: Dict,
256241
consent: ConsentRecord,
257-
) -> Optional[Nudge]:
242+
) -> Tuple[Optional[Nudge], str]:
258243
"""
259244
Convert a ranked candidate into a Nudge while enforcing:
260245
- social cooldown (no repeated touches too quickly);
261246
- debouncing of identical nudges within window;
262247
- consent gate for social touch;
263248
- assign GREEN vs. YELLOW nudge level per spec logic.
249+
250+
Returns (nudge, reason) where reason ∈ {"ok","debounce","cooldown","blocked"}.
264251
"""
265252
pose: Pose = a["_pose_obj"]
266253
xyz = _xyz_tuple(pose)
@@ -270,11 +257,11 @@ def _candidate_to_nudge(
270257

271258
# Debounce identical nudges:
272259
if self.cooldowns.debounce(name, xyz, self.policy.debounce_window_s):
273-
return None
260+
return None, "debounce"
274261

275262
# Social cooldown: only for human-target nudges (e.g., shoulder).
276263
if category == "human" and self.cooldowns.social_cooldown_active(self.policy.social_cooldown_s):
277-
return None
264+
return None, "cooldown"
278265

279266
# Determine nudge level:
280267
if category == "human" and name == "shoulder":
@@ -295,14 +282,15 @@ def _candidate_to_nudge(
295282
priority = validate_priority(float(a.get("utility", 0.0)))
296283
normal = _choose_contact_normal(name, pose)
297284

298-
return Nudge(
285+
nudge = Nudge(
299286
level=level,
300287
target=pose,
301288
normal=normal,
302289
rationale=rationale,
303290
priority=priority,
304291
expires_in_ms=self.policy.nudge_ttl_ms,
305292
)
293+
return nudge, "ok"
306294

307295

308296
# ------------------------- #

0 commit comments

Comments
 (0)