mudra-interact 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ {
2
+ "catalog_version": "2.0.0",
3
+ "entries": [
4
+ {
5
+ "gesture_id": "contact_thumb_index",
6
+ "display_name": "Thumb-index contact",
7
+ "contact_pattern": "I",
8
+ "confidence": 0.72,
9
+ "limitations": [
10
+ "A geometric contact cue does not establish full finger posture, handedness or cultural meaning.",
11
+ "Palm orientation and participant context are outside the core rule."
12
+ ],
13
+ "provenance": {
14
+ "status": "specification_source",
15
+ "reference": "MUDRA_INTERACT_CORE_SPEC.md section 4 contact rule table",
16
+ "content_license": "Apache-2.0"
17
+ }
18
+ },
19
+ {
20
+ "gesture_id": "contact_thumb_middle",
21
+ "display_name": "Thumb-middle contact",
22
+ "contact_pattern": "M",
23
+ "confidence": 0.70,
24
+ "limitations": [
25
+ "A geometric contact cue does not establish full finger posture, handedness or cultural meaning.",
26
+ "Participant confirmation is required before any sharing or action."
27
+ ],
28
+ "provenance": {
29
+ "status": "specification_source",
30
+ "reference": "MUDRA_INTERACT_CORE_SPEC.md section 4 contact rule table",
31
+ "content_license": "Apache-2.0"
32
+ }
33
+ },
34
+ {
35
+ "gesture_id": "contact_thumb_ring",
36
+ "display_name": "Thumb-ring contact",
37
+ "contact_pattern": "R",
38
+ "confidence": 0.70,
39
+ "limitations": [
40
+ "A geometric contact cue does not establish full finger posture, handedness or cultural meaning.",
41
+ "Participant confirmation is required before any sharing or action."
42
+ ],
43
+ "provenance": {
44
+ "status": "specification_source",
45
+ "reference": "MUDRA_INTERACT_CORE_SPEC.md section 4 contact rule table",
46
+ "content_license": "Apache-2.0"
47
+ }
48
+ },
49
+ {
50
+ "gesture_id": "contact_thumb_middle_ring",
51
+ "display_name": "Thumb-middle-ring contact",
52
+ "contact_pattern": "M+R",
53
+ "confidence": 0.78,
54
+ "limitations": [
55
+ "A geometric contact cue does not establish full finger posture, handedness or cultural meaning.",
56
+ "The rule is neutral and does not infer a named tradition or efficacy claim."
57
+ ],
58
+ "provenance": {
59
+ "status": "specification_source",
60
+ "reference": "MUDRA_INTERACT_CORE_SPEC.md section 4 contact rule table",
61
+ "content_license": "Apache-2.0"
62
+ }
63
+ }
64
+ ]
65
+ }
@@ -0,0 +1,124 @@
1
+ """Strict neutral v2 catalogue loader with detached lookup results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .errors import MudraValidationError
11
+ from .validation import CATALOG_VERSION, MAX_STRING_SCALARS, parse_json_bytes, require_finite_float
12
+
13
+
14
+ CATALOG_IDS = frozenset(
15
+ {"contact_thumb_index", "contact_thumb_middle", "contact_thumb_ring", "contact_thumb_middle_ring"}
16
+ )
17
+ CONTACT_PATTERNS = frozenset({"I", "M", "R", "M+R"})
18
+ ENTRY_FIELDS = {"gesture_id", "display_name", "contact_pattern", "confidence", "limitations", "provenance"}
19
+ PROVENANCE_FIELDS = {"status", "reference", "content_license"}
20
+ PROVENANCE_STATUSES = frozenset({"specification_source", "unverified_historical_alias"})
21
+
22
+
23
+ def _invalid() -> None:
24
+ raise MudraValidationError("catalog_invalid")
25
+
26
+
27
+ def validate_catalog_payload(payload: Any) -> dict[str, Any]:
28
+ if type(payload) is not dict or set(payload) != {"catalog_version", "entries"}:
29
+ _invalid()
30
+ if payload["catalog_version"] != CATALOG_VERSION or type(payload["entries"]) is not list:
31
+ _invalid()
32
+ entries = payload["entries"]
33
+ if len(entries) != 4:
34
+ _invalid()
35
+ seen: set[str] = set()
36
+ normalized: list[dict[str, Any]] = []
37
+ for entry in entries:
38
+ if type(entry) is not dict or set(entry) != ENTRY_FIELDS:
39
+ _invalid()
40
+ gesture_id = entry["gesture_id"]
41
+ display_name = entry["display_name"]
42
+ pattern = entry["contact_pattern"]
43
+ if type(gesture_id) is not str or gesture_id not in CATALOG_IDS or gesture_id in seen:
44
+ _invalid()
45
+ if type(display_name) is not str or not (1 <= len(display_name) <= 80):
46
+ _invalid()
47
+ if type(pattern) is not str or pattern not in CONTACT_PATTERNS:
48
+ _invalid()
49
+ try:
50
+ confidence = require_finite_float(entry["confidence"], minimum=0.0, maximum=1.0, code="catalog_invalid")
51
+ except MudraValidationError:
52
+ _invalid()
53
+ limitations = entry["limitations"]
54
+ if type(limitations) is not list or not (1 <= len(limitations) <= 8):
55
+ _invalid()
56
+ if any(type(item) is not str or not (1 <= len(item) <= 240) for item in limitations):
57
+ _invalid()
58
+ provenance = entry["provenance"]
59
+ if type(provenance) is not dict or set(provenance) != PROVENANCE_FIELDS:
60
+ _invalid()
61
+ if type(provenance["status"]) is not str or provenance["status"] not in PROVENANCE_STATUSES:
62
+ _invalid()
63
+ if type(provenance["reference"]) is not str or not (1 <= len(provenance["reference"]) <= 240):
64
+ _invalid()
65
+ if type(provenance["content_license"]) is not str or not (1 <= len(provenance["content_license"]) <= 80):
66
+ _invalid()
67
+ seen.add(gesture_id)
68
+ normalized.append(
69
+ {
70
+ "gesture_id": gesture_id,
71
+ "display_name": display_name,
72
+ "contact_pattern": pattern,
73
+ "confidence": confidence,
74
+ "limitations": list(limitations),
75
+ "provenance": dict(provenance),
76
+ }
77
+ )
78
+ if seen != CATALOG_IDS:
79
+ _invalid()
80
+ return {"catalog_version": CATALOG_VERSION, "entries": normalized}
81
+
82
+
83
+ def load_catalog(path: str | Path | None = None) -> dict[str, dict[str, Any]]:
84
+ try:
85
+ if path is None:
86
+ resource = files("mudra_interact_core").joinpath("catalog/mudra_catalog.json")
87
+ data = resource.read_bytes()
88
+ else:
89
+ candidate = Path(path)
90
+ if candidate.is_symlink() or not candidate.is_file():
91
+ _invalid()
92
+ data = candidate.read_bytes()
93
+ payload = parse_json_bytes(data, limit=65_536)
94
+ validated = validate_catalog_payload(payload)
95
+ except MudraValidationError:
96
+ raise
97
+ except (OSError, UnicodeError):
98
+ _invalid()
99
+ return {entry["gesture_id"]: copy.deepcopy(entry) for entry in validated["entries"]}
100
+
101
+
102
+ def lookup(gesture_id: str) -> dict[str, Any] | None:
103
+ if type(gesture_id) is not str or len(gesture_id) > MAX_STRING_SCALARS:
104
+ _invalid()
105
+ entry = load_catalog().get(gesture_id)
106
+ return copy.deepcopy(entry) if entry is not None else None
107
+
108
+
109
+ def validate_label_pack(payload: Any) -> dict[str, Any]:
110
+ """Validate a review record without implying that a reviewer approved it."""
111
+
112
+ if type(payload) is not dict or set(payload) != {"pack_version", "entries"} or type(payload["entries"]) is not list:
113
+ _invalid()
114
+ for entry in payload["entries"]:
115
+ if type(entry) is not dict or set(entry) != {
116
+ "gesture_id", "label", "reviewer", "evidence_reference", "rights", "language_scope", "prohibited_claims", "withdrawal"
117
+ }:
118
+ _invalid()
119
+ if any(type(entry[key]) is not str or not entry[key] for key in entry):
120
+ _invalid()
121
+ return copy.deepcopy(payload)
122
+
123
+
124
+ __all__ = ["CATALOG_IDS", "load_catalog", "lookup", "validate_catalog_payload", "validate_label_pack"]
@@ -0,0 +1,371 @@
1
+ """Fail-closed local command line interface for Mudra Interact Core.
2
+
3
+ The CLI is deliberately a thin boundary around the validated public API. It
4
+ never discovers files, accepts stdin or URLs, writes input data, or treats a
5
+ JSON value as consent. Successful output is built before one write so a
6
+ non-zero exit is always an unsuccessful operation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import stat
15
+ import sys
16
+ from datetime import UTC, datetime
17
+ from importlib.metadata import PackageNotFoundError, version
18
+ from pathlib import Path
19
+ from typing import Any, Sequence
20
+
21
+ from .errors import MudraValidationError
22
+ from .frame import Frame, parse_batch, parse_frame
23
+ from .protocol import InteractionParty, RecognitionState, parse_event
24
+ from .session import RecognitionSession
25
+ from .validation import (
26
+ MAX_BATCH_BYTES,
27
+ MAX_EVENT_BYTES,
28
+ MAX_FRAME_BYTES,
29
+ SCHEMA_VERSION,
30
+ fail,
31
+ parse_json_bytes,
32
+ require_canonical_uuid,
33
+ )
34
+
35
+
36
+ EXIT_OK = 0
37
+ EXIT_USAGE = 2
38
+ EXIT_DENIED = 3
39
+ EXIT_IO = 4
40
+ EXIT_INTERRUPTED = 130
41
+ _OUTPUT_LIMIT = 512
42
+ _VERSION_FALLBACK = "0.2.0"
43
+ _REASON_PRECEDENCE = (
44
+ "unsupported_pattern",
45
+ "ambiguous_contacts",
46
+ "low_confidence",
47
+ "gesture_changed",
48
+ "frame_gap",
49
+ "hold_incomplete",
50
+ "insufficient_frames",
51
+ )
52
+
53
+
54
+ class _UsageError(Exception):
55
+ """An argparse error whose user input must not be echoed."""
56
+
57
+
58
+ class _OutputError(Exception):
59
+ """The selected output stream failed, including a partial write."""
60
+
61
+
62
+ class _ArgumentParser(argparse.ArgumentParser):
63
+ def error(self, _message: str) -> None:
64
+ raise _UsageError
65
+
66
+
67
+ def _package_version() -> str:
68
+ try:
69
+ return version("mudra-interact")
70
+ except PackageNotFoundError:
71
+ return _VERSION_FALLBACK
72
+
73
+
74
+ def _build_parser() -> _ArgumentParser:
75
+ parser = _ArgumentParser(
76
+ prog="mudra-interact",
77
+ description="Run offline v2 landmark recognition and validate image-free events.",
78
+ add_help=False,
79
+ allow_abbrev=False,
80
+ )
81
+ parser.add_argument("command", nargs="?", choices=("recognize", "validate-event"))
82
+ parser.add_argument("--help", action="store_true", help="show this help and exit")
83
+ parser.add_argument("--version", action="store_true", help="show the installed package version and exit")
84
+ parser.add_argument("--input", dest="input_path")
85
+ parser.add_argument("--stabilize", action="store_true", help="process a bounded frame batch through one session")
86
+ parser.add_argument("--emit-event", action="store_true", help="emit one event after explicit consent and confirmation")
87
+ parser.add_argument("--consent", action="store_true", help="explicitly attest sharing consent for this selected batch")
88
+ parser.add_argument("--confirm", action="store_true", help="explicitly attest participant confirmation for this selected batch")
89
+ parser.add_argument("--sender", default=None, help="event sender: human or agent")
90
+ parser.add_argument("--recipient", default=None, help="event recipient: human or agent")
91
+ parser.add_argument("--conversation-id", default=None)
92
+ parser.add_argument("--project-id", default=None)
93
+ return parser
94
+
95
+
96
+ def _write_stream(stream: Any, data: bytes) -> None:
97
+ """Write all bytes exactly once; never retry a failed or partial write."""
98
+
99
+ target = getattr(stream, "buffer", stream)
100
+ try:
101
+ written = target.write(data)
102
+ except TypeError:
103
+ # Test harnesses may provide a text-only stream. Real CLI streams are
104
+ # binary and therefore never take this branch.
105
+ try:
106
+ written = target.write(data.decode("utf-8"))
107
+ except (BrokenPipeError, OSError, UnicodeError) as exc:
108
+ raise _OutputError from exc
109
+ except (BrokenPipeError, OSError, UnicodeError) as exc:
110
+ raise _OutputError from exc
111
+ if written is not None and written != len(data):
112
+ raise _OutputError
113
+ flush = getattr(target, "flush", None)
114
+ if callable(flush):
115
+ try:
116
+ flush()
117
+ except (BrokenPipeError, OSError) as exc:
118
+ raise _OutputError from exc
119
+
120
+
121
+ def _emit_error(code: str) -> None:
122
+ payload = json.dumps({"error": {"code": code}}, separators=(",", ":"), ensure_ascii=True).encode("ascii") + b"\n"
123
+ if len(payload) > _OUTPUT_LIMIT:
124
+ return
125
+ try:
126
+ _write_stream(sys.stderr, payload)
127
+ except _OutputError:
128
+ # A broken diagnostic pipe must not trigger a traceback or a retry.
129
+ return
130
+
131
+
132
+ def _emit_success(payload: bytes) -> int:
133
+ try:
134
+ _write_stream(sys.stdout, payload + b"\n")
135
+ except _OutputError:
136
+ return EXIT_IO
137
+ return EXIT_OK
138
+
139
+
140
+ def _is_explicit_remote_or_special(text: str) -> str | None:
141
+ if not text or text == "-":
142
+ return "input_unavailable"
143
+ lowered = text.lower()
144
+ if "://" in lowered or lowered.startswith(("data:", "stdin:", "\\\\", "//")):
145
+ return "unsupported_platform"
146
+ if "\x00" in text:
147
+ return "input_unavailable"
148
+ return None
149
+
150
+
151
+ def _read_local_file(raw_path: str | None, *, limit: int) -> bytes:
152
+ if type(raw_path) is not str:
153
+ fail("input_unavailable")
154
+ special = _is_explicit_remote_or_special(raw_path)
155
+ if special is not None:
156
+ fail(special)
157
+ path = Path(raw_path)
158
+ try:
159
+ initial = os.lstat(path)
160
+ except (OSError, ValueError):
161
+ fail("input_unavailable")
162
+ if stat.S_ISLNK(initial.st_mode) or not stat.S_ISREG(initial.st_mode):
163
+ fail("input_unavailable")
164
+ # Windows reparse points include symlinks and remote/device forms. A
165
+ # regular-file mode bit alone is not enough to make those safe.
166
+ if getattr(initial, "st_file_attributes", 0) & 0x400:
167
+ fail("unsupported_platform")
168
+
169
+ flags = os.O_RDONLY
170
+ flags |= getattr(os, "O_BINARY", 0)
171
+ flags |= getattr(os, "O_CLOEXEC", 0)
172
+ flags |= getattr(os, "O_NOFOLLOW", 0)
173
+ try:
174
+ descriptor = os.open(path, flags)
175
+ except (OSError, ValueError):
176
+ fail("input_unavailable")
177
+ try:
178
+ opened = os.fstat(descriptor)
179
+ if not stat.S_ISREG(opened.st_mode):
180
+ fail("input_unavailable")
181
+ if getattr(opened, "st_file_attributes", 0) & 0x400:
182
+ fail("unsupported_platform")
183
+ # On POSIX, O_NOFOLLOW plus this identity check closes the ordinary
184
+ # symlink/replacement race. Windows receives the opened-handle type
185
+ # check and retains its documented caller-owned mount policy.
186
+ if hasattr(initial, "st_ino") and hasattr(opened, "st_ino"):
187
+ if initial.st_ino != opened.st_ino or initial.st_dev != opened.st_dev:
188
+ fail("input_unavailable")
189
+ if opened.st_size > limit:
190
+ fail("input_too_large")
191
+ chunks: list[bytes] = []
192
+ total = 0
193
+ while True:
194
+ chunk = os.read(descriptor, min(64 * 1024, limit + 1 - total))
195
+ if not chunk:
196
+ break
197
+ chunks.append(chunk)
198
+ total += len(chunk)
199
+ if total > limit:
200
+ fail("input_too_large")
201
+ return b"".join(chunks)
202
+ except MudraValidationError:
203
+ raise
204
+ except (OSError, ValueError):
205
+ fail("input_unavailable")
206
+ finally:
207
+ try:
208
+ os.close(descriptor)
209
+ except OSError:
210
+ pass
211
+
212
+
213
+ def _parse_frames(data: bytes, *, stabilize: bool) -> tuple[Frame, ...]:
214
+ if not stabilize:
215
+ return (parse_frame(data),)
216
+ # A one-frame --stabilize input proves that one observation cannot become
217
+ # stable. A batch is fully parsed before any session state advances, so a
218
+ # malformed late frame has no partial-success path.
219
+ document = parse_json_bytes(data, limit=MAX_BATCH_BYTES)
220
+ if isinstance(document, dict) and "frames" not in document:
221
+ if len(data) > MAX_FRAME_BYTES:
222
+ fail("input_too_large")
223
+ return (parse_frame(document),)
224
+ return parse_batch(document).frames
225
+
226
+
227
+ def _party(value: str | None, default: InteractionParty) -> InteractionParty:
228
+ selected = default.value if value is None else value
229
+ if type(selected) is not str:
230
+ fail("invalid_shape")
231
+ try:
232
+ return InteractionParty(selected)
233
+ except (ValueError, TypeError):
234
+ fail("invalid_shape")
235
+
236
+
237
+ def _reason_code(state: RecognitionState, uncertainties: Sequence[str]) -> str | None:
238
+ if state is RecognitionState.STABLE:
239
+ return None
240
+ present = set(uncertainties)
241
+ for code in _REASON_PRECEDENCE:
242
+ if code in present:
243
+ return code
244
+ if state is RecognitionState.CANDIDATE:
245
+ return "insufficient_frames"
246
+ return "unsupported_pattern"
247
+
248
+
249
+ def _recognition_report(session: RecognitionSession) -> bytes:
250
+ recognition = session.current
251
+ result = {
252
+ "schema_version": SCHEMA_VERSION,
253
+ "recognition": recognition.payload(),
254
+ "reason_code": _reason_code(recognition.state, recognition.uncertainties),
255
+ }
256
+ return json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
257
+
258
+
259
+ def _run_recognize(args: argparse.Namespace) -> bytes:
260
+ if args.emit_event and not args.stabilize:
261
+ raise _UsageError
262
+ if not args.emit_event and (args.consent or args.confirm):
263
+ raise _UsageError
264
+ data = _read_local_file(args.input_path, limit=MAX_BATCH_BYTES if args.stabilize else MAX_FRAME_BYTES)
265
+ frames = _parse_frames(data, stabilize=args.stabilize)
266
+
267
+ # Parse and validate all event-only options before checking either consent
268
+ # flag, matching the contract's precedence rule.
269
+ sender = _party(args.sender, InteractionParty.HUMAN)
270
+ recipient = _party(args.recipient, InteractionParty.AGENT)
271
+ conversation_id = require_canonical_uuid(args.conversation_id, nullable=True)
272
+ project_id = require_canonical_uuid(args.project_id, nullable=True)
273
+ session = RecognitionSession(
274
+ frames[0].stream_id,
275
+ conversation_id=conversation_id,
276
+ project_id=project_id,
277
+ )
278
+ for frame in frames:
279
+ session.observe(frame)
280
+ final_time = frames[-1].monotonic_ms
281
+
282
+ if args.emit_event:
283
+ if args.consent is not True:
284
+ fail("consent_required")
285
+ if args.confirm is not True:
286
+ fail("confirmation_required")
287
+ session.confirm(True, True, final_time)
288
+ event = session.emit_event(
289
+ sender=sender,
290
+ recipient=recipient,
291
+ now_ms=final_time,
292
+ occurred_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
293
+ )
294
+ return event.to_bytes()
295
+ return _recognition_report(session)
296
+
297
+
298
+ def _run_validate_event(args: argparse.Namespace) -> bytes:
299
+ data = _read_local_file(args.input_path, limit=MAX_EVENT_BYTES)
300
+ parse_event(data)
301
+ return b'{"schema_version":"2.0.0","valid":true}'
302
+
303
+
304
+ def _validate_namespace(args: argparse.Namespace) -> None:
305
+ if args.command is None:
306
+ args.command = "recognize"
307
+ if args.command == "validate-event":
308
+ if args.input_path is None or args.stabilize or args.emit_event or args.consent or args.confirm:
309
+ raise _UsageError
310
+ if any(value is not None for value in (args.sender, args.recipient, args.conversation_id, args.project_id)):
311
+ raise _UsageError
312
+ elif args.input_path is None:
313
+ raise _UsageError
314
+
315
+
316
+ def _help_bytes(parser: argparse.ArgumentParser) -> bytes:
317
+ return parser.format_help().encode("utf-8")
318
+
319
+
320
+ def main(argv: Sequence[str] | None = None) -> int:
321
+ parser = _build_parser()
322
+ try:
323
+ args = parser.parse_args(list(argv) if argv is not None else None)
324
+ if args.help:
325
+ return _emit_success(_help_bytes(parser).rstrip(b"\n"))
326
+ if args.version:
327
+ meaningful = [
328
+ args.command,
329
+ args.input_path,
330
+ args.stabilize,
331
+ args.emit_event,
332
+ args.consent,
333
+ args.confirm,
334
+ args.sender,
335
+ args.recipient,
336
+ args.conversation_id,
337
+ args.project_id,
338
+ ]
339
+ if any(value not in (None, False) for value in meaningful):
340
+ raise _UsageError
341
+ return _emit_success(_package_version().encode("ascii"))
342
+ _validate_namespace(args)
343
+ output = _run_validate_event(args) if args.command == "validate-event" else _run_recognize(args)
344
+ return _emit_success(output)
345
+ except _UsageError:
346
+ _emit_error("invalid_shape")
347
+ return EXIT_USAGE
348
+ except MudraValidationError as error:
349
+ _emit_error(error.code)
350
+ if error.code in {"consent_required", "confirmation_required", "stale_confirmation"}:
351
+ return EXIT_DENIED
352
+ if error.code in {"input_unavailable", "unsupported_platform", "internal_error"}:
353
+ return EXIT_IO
354
+ return EXIT_USAGE
355
+ except KeyboardInterrupt:
356
+ _emit_error("internal_error")
357
+ return EXIT_INTERRUPTED
358
+ except _OutputError:
359
+ return EXIT_IO
360
+ except Exception:
361
+ # No implementation detail, path, raw input or traceback may cross the
362
+ # command boundary.
363
+ _emit_error("internal_error")
364
+ return EXIT_IO
365
+
366
+
367
+ __all__ = ["main"]
368
+
369
+
370
+ if __name__ == "__main__":
371
+ raise SystemExit(main())
@@ -0,0 +1,43 @@
1
+ """Declared coordinate-space conversion for the geometric recognizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from .errors import MudraValidationError
8
+ from .frame import COORDINATE_SPACES, Frame, Landmark
9
+ from .validation import MAX_IMAGE_DIMENSION, fail, require_coordinate
10
+
11
+
12
+ def convert_landmarks(
13
+ landmarks: Sequence[Landmark],
14
+ *,
15
+ coordinate_space: str,
16
+ image_width: int | None = None,
17
+ image_height: int | None = None,
18
+ ) -> tuple[Landmark, ...]:
19
+ """Convert declared image-normalized y units into the Cartesian rule space."""
20
+
21
+ if type(landmarks) not in (list, tuple) or len(landmarks) != 21:
22
+ fail("invalid_shape")
23
+ if type(coordinate_space) is not str or coordinate_space not in COORDINATE_SPACES:
24
+ fail("invalid_configuration")
25
+ if coordinate_space == "cartesian_relative_v1":
26
+ if image_width is not None or image_height is not None:
27
+ fail("invalid_configuration")
28
+ return tuple(Landmark(point.x, point.y, point.z) for point in landmarks)
29
+ if type(image_width) is not int or type(image_height) is not int:
30
+ fail("invalid_configuration")
31
+ if not (1 <= image_width <= MAX_IMAGE_DIMENSION and 1 <= image_height <= MAX_IMAGE_DIMENSION):
32
+ fail("invalid_configuration")
33
+ factor = image_height / image_width
34
+ return tuple(Landmark(point.x, point.y * factor, point.z) for point in landmarks)
35
+
36
+
37
+ def to_cartesian(frame: Frame) -> tuple[Landmark, ...]:
38
+ if not isinstance(frame, Frame):
39
+ fail("invalid_shape")
40
+ return frame.converted_landmarks()
41
+
42
+
43
+ __all__ = ["convert_landmarks", "to_cartesian"]
@@ -0,0 +1,49 @@
1
+ """Safe public errors for Mudra Interact Core.
2
+
3
+ The error code is the complete public message. Input values, paths and
4
+ exception text are deliberately never included.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ SAFE_ERROR_CODES = frozenset(
11
+ {
12
+ "invalid_json",
13
+ "input_too_large",
14
+ "invalid_shape",
15
+ "invalid_number",
16
+ "invalid_geometry",
17
+ "unsupported_version",
18
+ "invalid_configuration",
19
+ "invalid_sequence",
20
+ "invalid_state",
21
+ "consent_required",
22
+ "confirmation_required",
23
+ "stale_confirmation",
24
+ "scope_mismatch",
25
+ "catalog_invalid",
26
+ "unsupported_platform",
27
+ "input_unavailable",
28
+ "internal_error",
29
+ }
30
+ )
31
+
32
+
33
+ class MudraValidationError(ValueError):
34
+ """A deterministic, non-sensitive validation failure."""
35
+
36
+ __slots__ = ("code",)
37
+
38
+ def __init__(self, code: str) -> None:
39
+ if code not in SAFE_ERROR_CODES:
40
+ code = "internal_error"
41
+ self.code = code
42
+ super().__init__(code)
43
+
44
+ def __str__(self) -> str:
45
+ return self.code
46
+
47
+ def __repr__(self) -> str:
48
+ return f"MudraValidationError({self.code!r})"
49
+