22IX-HapticSight — Optical-Haptic Interaction Protocol (OHIP)
33Engagement 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
3713from __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