Skip to content

Commit 9ef9008

Browse files
committed
recognition: add coercion-safe routing mode for object-cue unlock
1 parent 33a7265 commit 9ef9008

8 files changed

Lines changed: 200 additions & 12 deletions

File tree

docs/CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ All `PHASMID_*` reads are centralized in `src/phasmid/config.py`.
3737
| `PHASMID_DUMMY_OCCUPANCY_WARN` | float (>=0) | `0.10` | Doctor plausibility advisory | Warn when disclosure-face size ratio falls below threshold | `config.dummy_occupancy_warn()` |
3838
| `PHASMID_DUMMY_PROFILE_DIR` | path | `.state/dummy_profile` | Doctor plausibility advisory | Local path scanned for disclosure-face plausibility baseline | `config.dummy_profile_dir()` |
3939
| `PHASMID_DUMMY_CONTAINER_PATH` | path | `vault.bin` | Doctor plausibility advisory | Local container path used for occupancy ratio baseline | `config.dummy_container_path()` |
40+
| `PHASMID_RECOGNITION_MODE` | enum (`strict`, `coercion_safe`, `demo`) | `strict` | Object cue routing | Selects ambiguity/failure handling policy for object-cue unlock routing | `config.recognition_mode()` |
41+
| `PHASMID_TRUE_UNLOCK_THRESHOLD` | float (`0.0`-`1.0`) | `0.85` | Object cue routing | Confidence threshold for direct unlock path | `config.true_unlock_threshold()` |
42+
| `PHASMID_DUMMY_FALLBACK_THRESHOLD` | float (`0.0`-`1.0`) | `0.40` | Object cue routing | Confidence floor used by demo fallback routing policy | `config.dummy_fallback_threshold()` |
4043
| `PHASMID_ENABLE_DISPLAY` | bool | `false` | Bridge UI simulator | Enables OpenCV preview window for display simulator | `config.display_enabled()` |
4144
| `PHASMID_DARK` | bool | `false` | TUI theming | Optional dark theme selection flag | `config.tui_dark_enabled()` |
4245
| `PHASMID_LIGHT` | bool | `false` | TUI theming | Optional light theme selection flag | `config.tui_light_enabled()` |

src/phasmid/ai_gate.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
from .config import (
1111
STATE_BLOB_NAME,
1212
STATE_KEY_NAME,
13+
debug_enabled,
14+
dummy_fallback_threshold,
1315
experimental_object_model_enabled,
16+
recognition_mode,
1417
state_dir,
18+
true_unlock_threshold,
1519
)
1620
from .local_state_crypto import LocalStateCipher
1721
from .object_cue_matcher import ObjectCueMatcher
@@ -39,6 +43,9 @@ class AIGate:
3943
MATCH_HISTORY_REQUIRED = 3
4044
REFERENCE_CAPTURE_SAMPLES = 3
4145
TARGET_FPS = 5
46+
MODE_STRICT = "strict"
47+
MODE_COERCION_SAFE = "coercion_safe"
48+
MODE_DEMO = "demo"
4249

4350
def __init__(self, reference_dir=None):
4451
self._stop_event = threading.Event()
@@ -260,10 +267,36 @@ def sequence_for_mode(self, mode, length=1):
260267
return [self.AUTH_TOKENS[mode]] * length
261268

262269
def get_auth_sequence(self, length=1):
263-
if self.last_match_mode in self.AUTH_TOKENS:
270+
current_mode = recognition_mode()
271+
confidence = self._recognition_confidence()
272+
true_threshold = true_unlock_threshold()
273+
fallback_threshold = dummy_fallback_threshold()
274+
275+
if self.last_match_mode in self.AUTH_TOKENS and confidence >= true_threshold:
264276
return self.sequence_for_mode(self.last_match_mode, length=length)
277+
278+
if current_mode == self.MODE_COERCION_SAFE:
279+
return self.sequence_for_mode(self.MODES[0], length=length)
280+
281+
if current_mode == self.MODE_DEMO and confidence >= fallback_threshold:
282+
return self.sequence_for_mode(self.MODES[0], length=length)
283+
265284
return [self.MATCH_NONE] * length
266285

286+
def _recognition_confidence(self):
287+
if self.last_match_mode in self.AUTH_TOKENS:
288+
if self.experimental_object_model_enabled:
289+
result = self.latest_gate_results.get(self.last_match_mode)
290+
if result is not None and result.quality_score is not None:
291+
score = float(result.quality_score)
292+
if score < 0.0:
293+
return 0.0
294+
if score > 1.0:
295+
return 1.0
296+
return score
297+
return 1.0
298+
return 0.0
299+
267300
def capture_reference(self, mode):
268301
self._validate_mode(mode)
269302
if self.latest_frame is None:
@@ -329,6 +362,13 @@ def get_status(self):
329362
}
330363
for mode, result in self.latest_gate_results.items()
331364
}
365+
if recognition_mode() == self.MODE_DEMO and debug_enabled():
366+
status["recognition_debug"] = {
367+
"mode": recognition_mode(),
368+
"confidence": self._recognition_confidence(),
369+
"true_unlock_threshold": true_unlock_threshold(),
370+
"dummy_fallback_threshold": dummy_fallback_threshold(),
371+
}
332372
return status
333373

334374
def clear_references(self):

src/phasmid/cli.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
RestrictedActionRejected,
3131
evaluate_restricted_action,
3232
)
33+
from .services.access_cue_service import access_cue_service
3334
from .vault_core import PhasmidVault
3435
from .volatile_state import require_volatile_state
3536

@@ -144,10 +145,13 @@ def _collect_auth_sequence():
144145
console.print(
145146
f" [bold green]✓[/bold green] [green]{text.CLI_OBJECT_MATCHED.removeprefix(_prefix)}[/green]"
146147
)
147-
elif gate.last_match_mode == gate.MATCH_AMBIGUOUS:
148-
warn(text.CLI_AMBIGUOUS_MATCH.removeprefix("[LOCAL] "))
149148
else:
150-
warn(text.CLI_NO_MATCH_TIMEOUT.removeprefix("[LOCAL] "))
149+
if access_cue_service.recognition_mode() == "coercion_safe":
150+
warn(text.CLI_NO_MATCH_TIMEOUT.removeprefix("[LOCAL] "))
151+
elif gate.last_match_mode == gate.MATCH_AMBIGUOUS:
152+
warn(text.CLI_AMBIGUOUS_MATCH.removeprefix("[LOCAL] "))
153+
else:
154+
warn(text.CLI_NO_MATCH_TIMEOUT.removeprefix("[LOCAL] "))
151155

152156
return get_gesture_sequence(length=1)
153157

@@ -559,11 +563,6 @@ def _run_legacy_command(args) -> None:
559563
console.print(Rule("Object Verification", style="dim cyan"))
560564
user_gesture_seq = _collect_auth_sequence()
561565

562-
if gate.last_match_mode == gate.MATCH_AMBIGUOUS:
563-
ui.show_alert("ACCESS ERROR\nAMBIGUOUS OBJECT")
564-
attempt_limiter.record_failure(attempt_scope)
565-
error("Access rejected: ambiguous object match.")
566-
return
567566
if not user_gesture_seq or user_gesture_seq[0] == gate.MATCH_NONE:
568567
ui.show_alert("ACCESS ERROR\nOBJECT NOT FOUND")
569568
attempt_limiter.record_failure(attempt_scope)

src/phasmid/config.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,39 @@ def dummy_container_path() -> str:
175175
return env_text("PHASMID_DUMMY_CONTAINER_PATH", "vault.bin")
176176

177177

178+
def recognition_mode() -> str:
179+
mode = env_text("PHASMID_RECOGNITION_MODE", "strict").strip().lower()
180+
if mode in {"strict", "coercion_safe", "demo"}:
181+
return mode
182+
return "strict"
183+
184+
185+
def true_unlock_threshold() -> float:
186+
raw = env_text("PHASMID_TRUE_UNLOCK_THRESHOLD", "0.85")
187+
try:
188+
value = float(raw)
189+
except ValueError:
190+
value = 0.85
191+
if value < 0.0:
192+
return 0.0
193+
if value > 1.0:
194+
return 1.0
195+
return value
196+
197+
198+
def dummy_fallback_threshold() -> float:
199+
raw = env_text("PHASMID_DUMMY_FALLBACK_THRESHOLD", "0.40")
200+
try:
201+
value = float(raw)
202+
except ValueError:
203+
value = 0.40
204+
if value < 0.0:
205+
return 0.0
206+
if value > 1.0:
207+
return 1.0
208+
return value
209+
210+
178211
def display_enabled() -> bool:
179212
return env_flag("PHASMID_ENABLE_DISPLAY", default=False)
180213

src/phasmid/services/access_cue_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from ..ai_gate import gate
4+
from ..config import recognition_mode
45

56

67
class AccessCueService:
@@ -38,6 +39,9 @@ def status(self):
3839
def auth_sequence(self, length=1):
3940
return self.gate.get_auth_sequence(length=length)
4041

42+
def recognition_mode(self):
43+
return recognition_mode()
44+
4145
def sequence_for_mode(self, mode, length=1):
4246
return self.gate.sequence_for_mode(mode, length=length)
4347

src/phasmid/web_server.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -716,9 +716,6 @@ async def retrieve(request: Request, password: str = Form(...)):
716716
if not _access_attempts.check(attempt_scope).allowed:
717717
return {"error": text.ACCESS_TEMPORARILY_UNAVAILABLE}
718718
auth_sequence = access_cue_service.auth_sequence(length=1)
719-
if access_cue_service.current_match_mode() == access_cue_service.match_ambiguous():
720-
_access_attempts.record_failure(attempt_scope)
721-
return {"error": text.AMBIGUOUS_OBJECT_MATCH}
722719
if auth_sequence[0] == access_cue_service.match_none():
723720
_access_attempts.record_failure(attempt_scope)
724721
return {"error": text.NO_VALID_ENTRY_FOUND}

tests/test_ai_gate.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,82 @@
1919

2020

2121
class AIGateTemplateTests(unittest.TestCase):
22+
def test_strict_mode_returns_no_match_when_unmatched(self):
23+
with tempfile.TemporaryDirectory() as tmp:
24+
gate = AIGate(reference_dir=tmp)
25+
gate.last_match_mode = gate.MATCH_NONE
26+
with mock.patch.dict(
27+
os.environ,
28+
{"PHASMID_RECOGNITION_MODE": "strict"},
29+
clear=False,
30+
):
31+
seq = gate.get_auth_sequence(length=2)
32+
self.assertEqual(seq, [gate.MATCH_NONE, gate.MATCH_NONE])
33+
34+
def test_coercion_safe_routes_unmatched_to_fallback_entry(self):
35+
with tempfile.TemporaryDirectory() as tmp:
36+
gate = AIGate(reference_dir=tmp)
37+
gate.last_match_mode = gate.MATCH_NONE
38+
with mock.patch.dict(
39+
os.environ,
40+
{"PHASMID_RECOGNITION_MODE": "coercion_safe"},
41+
clear=False,
42+
):
43+
seq = gate.get_auth_sequence(length=2)
44+
self.assertEqual(seq, ["reference_dummy_matched", "reference_dummy_matched"])
45+
46+
def test_strict_mode_rejects_low_confidence_match(self):
47+
with tempfile.TemporaryDirectory() as tmp:
48+
gate = AIGate(reference_dir=tmp)
49+
gate.experimental_object_model_enabled = True
50+
gate.last_match_mode = "secret"
51+
gate.latest_gate_results["secret"] = ObjectGateResult(
52+
state="accepted",
53+
orb_score=30.0,
54+
model_score=0.9,
55+
quality_score=0.2,
56+
stable_frames=3,
57+
attempted_frames=3,
58+
elapsed_ms=1,
59+
reason_code="combined_match",
60+
)
61+
with mock.patch.dict(
62+
os.environ,
63+
{
64+
"PHASMID_RECOGNITION_MODE": "strict",
65+
"PHASMID_TRUE_UNLOCK_THRESHOLD": "0.85",
66+
},
67+
clear=False,
68+
):
69+
seq = gate.get_auth_sequence(length=1)
70+
self.assertEqual(seq, [gate.MATCH_NONE])
71+
72+
def test_coercion_safe_routes_low_confidence_match_to_fallback(self):
73+
with tempfile.TemporaryDirectory() as tmp:
74+
gate = AIGate(reference_dir=tmp)
75+
gate.experimental_object_model_enabled = True
76+
gate.last_match_mode = "secret"
77+
gate.latest_gate_results["secret"] = ObjectGateResult(
78+
state="accepted",
79+
orb_score=30.0,
80+
model_score=0.9,
81+
quality_score=0.2,
82+
stable_frames=3,
83+
attempted_frames=3,
84+
elapsed_ms=1,
85+
reason_code="combined_match",
86+
)
87+
with mock.patch.dict(
88+
os.environ,
89+
{
90+
"PHASMID_RECOGNITION_MODE": "coercion_safe",
91+
"PHASMID_TRUE_UNLOCK_THRESHOLD": "0.85",
92+
},
93+
clear=False,
94+
):
95+
seq = gate.get_auth_sequence(length=1)
96+
self.assertEqual(seq, ["reference_dummy_matched"])
97+
2298
def test_feature_flag_off_preserves_legacy_match_update(self):
2399
with tempfile.TemporaryDirectory() as tmp:
24100
gate = AIGate(reference_dir=tmp)

tests/test_web_server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,42 @@ async def run():
179179

180180
asyncio.run(run())
181181

182+
def test_retrieve_does_not_return_ambiguous_error_in_coercion_safe(self):
183+
async def run():
184+
request = SimpleNamespace(
185+
client=SimpleNamespace(host="127.0.0.1"),
186+
url=SimpleNamespace(path="/retrieve"),
187+
)
188+
with (
189+
mock.patch.object(
190+
web_server.access_cue_service,
191+
"current_match_mode",
192+
return_value=web_server.access_cue_service.match_ambiguous(),
193+
),
194+
mock.patch.object(
195+
web_server.access_cue_service,
196+
"auth_sequence",
197+
return_value=["reference_dummy_matched"],
198+
),
199+
mock.patch.object(
200+
web_server.access_cue_service,
201+
"modes",
202+
return_value=("dummy",),
203+
),
204+
mock.patch.object(
205+
web_server.vault,
206+
"retrieve_with_policy",
207+
return_value=(None, None, "open"),
208+
),
209+
):
210+
response = await web_server.retrieve(request, password="pw")
211+
self.assertEqual(response["error"], web_server.text.NO_VALID_ENTRY_FOUND)
212+
self.assertNotEqual(
213+
response.get("error"), web_server.text.AMBIGUOUS_OBJECT_MATCH
214+
)
215+
216+
asyncio.run(run())
217+
182218
def test_video_feed_requires_unlocked_ui(self):
183219
route = next(
184220
route

0 commit comments

Comments
 (0)