urim 0.0.1a1__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.
urim/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """urim (Python port). Same canonical JSON, hashes and envelope as the TypeScript port."""
2
+
3
+ from .camps import decode_reply, encode_request, request_id_of
4
+ from .canonical import canonicalize, sha256
5
+ from .classify import classify_reply, classify_vendor_reply
6
+ from .client import Client, create_client
7
+ from .cuts import resolve_cuts
8
+ from .define import choice, define_decision, noul, score, threshold, tier
9
+ from .descriptors import DESCRIPTORS, GENERIC_ERRORS, descriptor_for, model_spec_for
10
+ from .emit import emit
11
+ from .envelope import build_envelope
12
+ from .envelope_read import read_envelope
13
+ from .errors import FAILOVER_TYPES, Issue, UrimError
14
+ from .fit import estimate_fit_cost, parse_cases, run_fit
15
+ from .lock import parse_lock, serialize_lock
16
+ from .plan import estimate_input_tokens, explain, next_step, plan_attempts
17
+ from .schema import json_schema_adapter, pydantic_schema
18
+ from .types import CONFIDENCE_RULE, Objective
19
+
20
+ __all__ = [
21
+ "CONFIDENCE_RULE",
22
+ "DESCRIPTORS",
23
+ "FAILOVER_TYPES",
24
+ "GENERIC_ERRORS",
25
+ "Client",
26
+ "Issue",
27
+ "Objective",
28
+ "UrimError",
29
+ "build_envelope",
30
+ "canonicalize",
31
+ "choice",
32
+ "classify_reply",
33
+ "classify_vendor_reply",
34
+ "create_client",
35
+ "decode_reply",
36
+ "define_decision",
37
+ "descriptor_for",
38
+ "emit",
39
+ "encode_request",
40
+ "estimate_fit_cost",
41
+ "estimate_input_tokens",
42
+ "explain",
43
+ "json_schema_adapter",
44
+ "model_spec_for",
45
+ "next_step",
46
+ "noul",
47
+ "parse_cases",
48
+ "parse_lock",
49
+ "plan_attempts",
50
+ "pydantic_schema",
51
+ "read_envelope",
52
+ "request_id_of",
53
+ "resolve_cuts",
54
+ "run_fit",
55
+ "score",
56
+ "serialize_lock",
57
+ "sha256",
58
+ "threshold",
59
+ "tier",
60
+ ]
urim/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m urim`` runs the ``urim`` command."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
urim/adapters.py ADDED
@@ -0,0 +1,80 @@
1
+ """A: real HTTP adapters over httpx (opt-in: ``from urim.adapters import typesafe_jev, openai_decisions``).
2
+
3
+ Nothing here runs unless you import it AND pass it to ``create_client``. The credential arrives from the client,
4
+ which reads the env var named by ``credential_env`` at send time. It is put in one request header and never
5
+ logged, stored, or placed in any error message. Pass ``create_client(http=httpx.AsyncClient(...))`` to share a
6
+ connection pool (tests use ``httpx.MockTransport``); otherwise one client is opened per send.
7
+ Requires the optional extra: ``pip install urim[httpx]``. Mirrors urim-ts src/adapters/index.ts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from .descriptors import DESCRIPTORS, AuthSpec, Descriptor
18
+ from .errors import UrimError
19
+ from .jsonfmt import stringify
20
+ from .testing import SendIO
21
+ from .types import WireReply, WireRequest
22
+
23
+
24
+ def _reject_constant(name: str) -> Any:
25
+ raise ValueError(name) # NaN/Infinity are not JSON (JSON.parse rejects them too)
26
+
27
+
28
+ def _read_body(text: str) -> Any:
29
+ try:
30
+ return json.loads(text, parse_constant=_reject_constant)
31
+ except ValueError:
32
+ return text # non-JSON -> the decoder reports nonconformant_response
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HttpAdapter:
37
+ provider: str
38
+ credential_env: str
39
+ _url: str
40
+ _auth: AuthSpec
41
+
42
+ async def send(self, req: WireRequest, io: SendIO) -> WireReply:
43
+ if io.credential == "":
44
+ raise UrimError("auth", f"missing credential: set {self.credential_env}", tier=req.tier_id)
45
+ value = io.credential if self._auth.scheme is None else f"{self._auth.scheme} {io.credential}"
46
+ headers = {"content-type": "application/json", self._auth.header: value}
47
+ content = stringify(req.body).encode("utf-8")
48
+ started = time.monotonic()
49
+ if io.http is not None:
50
+ res = await io.http.post(req.url, content=content, headers=headers)
51
+ else:
52
+ import httpx # optional extra, imported only when a real send happens
53
+
54
+ async with httpx.AsyncClient() as http:
55
+ res = await http.post(req.url, content=content, headers=headers)
56
+ return WireReply(
57
+ res.status_code,
58
+ {k.lower(): v for k, v in res.headers.items()},
59
+ _read_body(res.text),
60
+ (time.monotonic() - started) * 1000,
61
+ )
62
+
63
+
64
+ def http_adapter(d: Descriptor) -> HttpAdapter:
65
+ """One adapter per descriptor whose URL needs no caller-supplied placeholders."""
66
+ if not d.auth or "{" in d.url:
67
+ raise UrimError(
68
+ "invalid_definition", f"{d.provider}: descriptor needs URL/auth options this slice does not support"
69
+ )
70
+ return HttpAdapter(d.provider, d.auth[0].env, d.url, d.auth[0])
71
+
72
+
73
+ def typesafe_jev() -> HttpAdapter:
74
+ """TypeSafe Jev, systemone camp. Bearer TYPESAFE_API_KEY -> the descriptor URL."""
75
+ return http_adapter(DESCRIPTORS["typesafe"])
76
+
77
+
78
+ def openai_decisions() -> HttpAdapter:
79
+ """OpenAI, decisions camp. Bearer OPENAI_API_KEY -> the descriptor URL."""
80
+ return http_adapter(DESCRIPTORS["openai"])
urim/boundary.py ADDED
@@ -0,0 +1,100 @@
1
+ """C: the ONE place Python snake_case meets the camelCase JSON urim-ts writes (contract, lock, envelope, settled).
2
+
3
+ Rules:
4
+ - Only dataclass attribute names are mapped. Mapping keys (question names, tier ids, threshold names, vendor
5
+ ``native`` data, raw bodies) are user/vendor data and are never re-cased.
6
+ - ``json_name``: snake -> lowerCamel for every member (no exceptions since slice 7), plus keyword escapes (``from_``).
7
+ - ``ABSENT`` attributes are omitted; ``interpret_failed`` is written only when true (as in urim-ts).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import types as _pytypes
14
+ import typing
15
+ from collections.abc import Mapping
16
+ from functools import cache
17
+ from types import MappingProxyType
18
+ from typing import Any, Literal, TypeVar, Union
19
+
20
+ from .freeze import deep_freeze
21
+ from .types import ABSENT, PriceTable, _Absent
22
+
23
+ _T = TypeVar("_T")
24
+ _ESCAPES = {"from_": "from"}
25
+ _OMIT_IF_FALSE = frozenset({"interpret_failed"})
26
+
27
+
28
+ @cache
29
+ def json_name(attr: str) -> str:
30
+ """The JSON member name for dataclass attribute ``attr``."""
31
+ if attr in _ESCAPES:
32
+ return _ESCAPES[attr]
33
+ if "_" not in attr:
34
+ return attr
35
+ head, *rest = attr.split("_")
36
+ return head + "".join(p[:1].upper() + p[1:] for p in rest)
37
+
38
+
39
+ def _omitted(name: str, value: Any) -> bool:
40
+ return value is ABSENT or (name in _OMIT_IF_FALSE and value is False)
41
+
42
+
43
+ def to_json(value: Any) -> Any:
44
+ """Python data -> plain JSON-ready data with urim-ts member names."""
45
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
46
+ return {
47
+ json_name(f.name): to_json(v)
48
+ for f in dataclasses.fields(value)
49
+ if not _omitted(f.name, v := getattr(value, f.name))
50
+ }
51
+ if isinstance(value, Mapping):
52
+ return {k: to_json(v) for k, v in value.items()}
53
+ if isinstance(value, (list, tuple)):
54
+ return [to_json(v) for v in value]
55
+ return value
56
+
57
+
58
+ @cache
59
+ def _hints(cls: type) -> dict[str, Any]:
60
+ return typing.get_type_hints(cls)
61
+
62
+
63
+ def _convert(tp: Any, v: Any) -> Any:
64
+ origin = typing.get_origin(tp)
65
+ if origin in (Union, _pytypes.UnionType):
66
+ args = [a for a in typing.get_args(tp) if a is not _Absent]
67
+ if v is None and type(None) in args:
68
+ return None
69
+ concrete = [a for a in args if a is not type(None)]
70
+ return _convert(concrete[0], v) if len(concrete) == 1 else deep_freeze(v)
71
+ if isinstance(tp, type) and dataclasses.is_dataclass(tp):
72
+ return from_json(tp, v)
73
+ if origin is tuple:
74
+ (item, *_rest) = typing.get_args(tp)
75
+ return tuple(_convert(item, x) for x in v)
76
+ if origin in (Mapping, dict) or tp is Mapping:
77
+ kv = typing.get_args(tp)
78
+ return MappingProxyType({k: _convert(kv[1] if kv else Any, x) for k, x in v.items()})
79
+ if origin is Literal or tp in (str, int, float, bool):
80
+ return v
81
+ return deep_freeze(v)
82
+
83
+
84
+ def from_json(cls: type[_T], data: Mapping[str, Any]) -> _T:
85
+ """Plain JSON (already validated by its reader) -> frozen snake_case dataclass. Unknown members ignored."""
86
+ dc: Any = cls # a dataclass type; the checker cannot see that through a TypeVar
87
+ hints = _hints(dc)
88
+ kwargs = {}
89
+ for f in dataclasses.fields(dc):
90
+ key = json_name(f.name)
91
+ if key in data:
92
+ kwargs[f.name] = _convert(hints[f.name], data[key])
93
+ elif f.default is dataclasses.MISSING:
94
+ raise KeyError(f"{cls.__name__}: missing member {key!r}")
95
+ return cls(**kwargs)
96
+
97
+
98
+ def parse_price_table(data: Mapping[str, Any]) -> PriceTable:
99
+ """The shared PriceTable JSON (camelCase, as urim-ts) -> PriceTable."""
100
+ return from_json(PriceTable, data)
urim/camps/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """C: camp dispatch. URL, wire model, error map, aliases and request-id header all come from descriptor data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from ..classify import classify_vendor_reply
8
+ from ..descriptors import descriptor_for, explicit_model_spec, header, model_spec_for
9
+ from ..errors import UrimError
10
+ from ..types import Definition, Tier, WireReply, WireRequest
11
+ from . import decisions, systemone
12
+ from .shared import Decoded, DecodedFail, DecodedOk, TokenUsage
13
+
14
+ __all__ = ["Decoded", "DecodedFail", "DecodedOk", "TokenUsage", "decode_reply", "encode_request", "request_id_of"]
15
+
16
+
17
+ class _Codec(Protocol):
18
+ PATH: str
19
+
20
+ def encode(self, d: Definition, wire: str, input: Any) -> Any: ...
21
+ def decode(self, d: Definition, tier: Tier, body: Any, *, unavailable_sentinel: bool = False) -> Decoded: ...
22
+
23
+
24
+ def _codec(tier: Tier) -> _Codec:
25
+ return systemone if tier.camp == "systemone" else decisions
26
+
27
+
28
+ def encode_request(d: Definition, tier: Tier, input: Any) -> WireRequest:
29
+ desc = descriptor_for(tier)
30
+ wire = model_spec_for(desc, tier).wire
31
+ codec = _codec(tier)
32
+ return WireRequest(tier.id, tier.camp, codec.PATH, desc.url.replace("{model}", wire), codec.encode(d, wire, input))
33
+
34
+
35
+ def request_id_of(tier: Tier, reply: WireReply) -> str | None:
36
+ name = descriptor_for(tier).request_id_header
37
+ return None if name is None else header(reply.headers, name)
38
+
39
+
40
+ def _with_request_id(e: UrimError, rid: str | None) -> UrimError:
41
+ if rid is None or e.request_id is not None:
42
+ return e
43
+ return UrimError(
44
+ e.type,
45
+ e.message,
46
+ tier=e.tier,
47
+ status=e.status,
48
+ request_id=rid,
49
+ retry_after_ms=e.retry_after_ms,
50
+ issues=e.issues,
51
+ cause=e.__cause__,
52
+ )
53
+
54
+
55
+ def decode_reply(d: Definition, tier: Tier, reply: WireReply) -> Decoded:
56
+ desc = descriptor_for(tier)
57
+ rid = request_id_of(tier, reply)
58
+ failure = classify_vendor_reply(desc, reply)
59
+ if failure.type is not None:
60
+ return DecodedFail(
61
+ UrimError(
62
+ failure.type,
63
+ f"HTTP {reply.status} from {tier.id}",
64
+ tier=tier.id,
65
+ status=reply.status,
66
+ request_id=rid,
67
+ retry_after_ms=failure.retry_after_ms,
68
+ )
69
+ )
70
+ decoded = _codec(tier).decode(d, tier, reply.body, unavailable_sentinel=desc.confidence_unavailable_sentinel)
71
+ if isinstance(decoded, DecodedFail):
72
+ return DecodedFail(_with_request_id(decoded.error, rid))
73
+ # Only tiers with an explicit descriptor model row are checked (accepted 2026-10-28).
74
+ spec = explicit_model_spec(desc, tier)
75
+ if spec is not None and decoded.model is not None and decoded.model not in spec.answered_as:
76
+ return DecodedFail(
77
+ UrimError(
78
+ "lock_violation", f'{tier.id} was answered by model "{decoded.model}"', tier=tier.id, request_id=rid
79
+ )
80
+ )
81
+ return decoded
@@ -0,0 +1,89 @@
1
+ """C: the OpenAI decisions body. Sources as in urim-ts src/camps/decisions.ts (checked 2026-10-28 EDT).
2
+ TODO(vendor-fact): OpenAI's confidence formula is undocumented; native choice/score confidence passes through."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..errors import UrimError
9
+ from ..types import Definition, Question, Tier
10
+ from .shared import (
11
+ Decoded,
12
+ DecodedFail,
13
+ RawAnswer,
14
+ argmax,
15
+ binary_confidence,
16
+ binary_label,
17
+ nonconformant,
18
+ num,
19
+ to_answers,
20
+ vendor_confidence,
21
+ )
22
+
23
+ PATH = "/v1/decisions"
24
+ _NATIVE_TYPE = {"noul": "predicate", "choice": "choice", "score": "score"}
25
+
26
+
27
+ def _encode_question(name: str, q: Question) -> dict[str, Any]:
28
+ base: dict[str, Any] = {"type": _NATIVE_TYPE[q.type], "name": name, "instructions": q.prompt}
29
+ if q.type == "choice":
30
+ return {**base, "choices": [{"value": v, "description": t} for v, t in q.options.items()]}
31
+ if q.type == "score":
32
+ return {**base, "levels": [{"label": v, "description": t} for v, t in q.options.items()]}
33
+ return base # predicates carry no yes/no descriptions (O2; accepted 2026-10-28)
34
+
35
+
36
+ def encode(d: Definition, wire_model: str, input: Any) -> Any:
37
+ return {
38
+ "model": wire_model,
39
+ # TODO(vendor-fact): exact message item wrapper is medium confidence.
40
+ "input": [{"role": "user", "content": [{"type": "input_json", "data": input}]}],
41
+ "questions": [_encode_question(n, q) for n, q in d.questions.items()],
42
+ }
43
+
44
+
45
+ def _decode_one(name: str, a: dict[str, Any], sentinel: bool) -> RawAnswer | None:
46
+ t = a.get("type")
47
+ if t == "predicate":
48
+ p = num(a.get("probability"))
49
+ # OpenAI returns no predicate confidence: Urim derives |2p - 1| and says so (urim_derived).
50
+ return None if p is None else RawAnswer(name, binary_label(p), binary_confidence(p), p, None, "urim_derived")
51
+ if t == "choice": # a boolean choice never equals a string label -> nonconformant later
52
+ return RawAnswer(
53
+ name, a.get("choice"), vendor_confidence(a, sentinel), a.get("choice"), a.get("confidence"), "native"
54
+ )
55
+ if t == "score":
56
+ probs = (
57
+ [p for p in a.get("probabilities", []) if isinstance(p, dict)]
58
+ if isinstance(a.get("probabilities"), list)
59
+ else []
60
+ )
61
+ top = probs[argmax([-1.0 if (x := num(p.get("probability"))) is None else x for p in probs])] if probs else None
62
+ return RawAnswer(
63
+ name,
64
+ None if top is None else top.get("label"),
65
+ vendor_confidence(a, sentinel),
66
+ a.get("score"),
67
+ a.get("confidence"),
68
+ "native",
69
+ )
70
+ return None
71
+
72
+
73
+ def decode(d: Definition, tier: Tier, body: Any, *, unavailable_sentinel: bool = False) -> Decoded:
74
+ if not isinstance(body, dict) or not isinstance(body.get("answers"), list):
75
+ return nonconformant(tier, "response has no answers array")
76
+ names = list(d.questions.keys())
77
+ raws: dict[str, RawAnswer] = {}
78
+ for i, a in enumerate(body["answers"]):
79
+ if not isinstance(a, dict):
80
+ continue
81
+ name = a["name"] if isinstance(a.get("name"), str) else (names[i] if i < len(names) else None)
82
+ if name is None or name not in d.questions:
83
+ continue
84
+ if a.get("type") == "refusal":
85
+ return DecodedFail(UrimError("refused", f'{tier.id} refused question "{name}"', tier=tier.id))
86
+ raw = _decode_one(name, a, unavailable_sentinel)
87
+ if raw is not None:
88
+ raws[name] = raw
89
+ return to_answers(d, tier, raws, body)
urim/camps/shared.py ADDED
@@ -0,0 +1,124 @@
1
+ """C: helpers shared by the camp codecs: label-space checks, the binary confidence rule, usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any, Literal
9
+
10
+ from ..errors import UrimError
11
+ from ..types import Answer, ConfidenceSource, Definition, Question, Tier
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RawAnswer:
16
+ """A decoded native answer, already reduced to a candidate label."""
17
+
18
+ name: str
19
+ label: Any
20
+ confidence: float | None
21
+ native_value: Any
22
+ native_confidence: Any
23
+ confidence_source: ConfidenceSource
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TokenUsage:
28
+ input_tokens: int | float | None
29
+ output_tokens: int | float | None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DecodedOk:
34
+ answers: Mapping[str, Answer]
35
+ usage: TokenUsage | None
36
+ model: str | None
37
+ ok: Literal[True] = True
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DecodedFail:
42
+ error: UrimError
43
+ ok: Literal[False] = False
44
+
45
+
46
+ Decoded = DecodedOk | DecodedFail
47
+
48
+
49
+ def num(v: Any) -> float | int | None:
50
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
51
+ return None
52
+ return v if math.isfinite(v) else None
53
+
54
+
55
+ def vendor_confidence(a: Mapping[str, Any], unavailable_sentinel: bool) -> float | int | None:
56
+ """A vendor confidence. With the sentinel flag, 0 plus present-but-empty probabilities is "unavailable" -> None."""
57
+ c = num(a.get("confidence"))
58
+ if not unavailable_sentinel or c != 0:
59
+ return c
60
+ p = a.get("probabilities")
61
+ empty = isinstance(p, (list, dict)) and len(p) == 0
62
+ return None if empty else c
63
+
64
+
65
+ def _unit(v: Any) -> float | int | None:
66
+ n = num(v)
67
+ return n if n is not None and 0 <= n <= 1 else None
68
+
69
+
70
+ def nonconformant(tier: Tier, message: str) -> DecodedFail:
71
+ return DecodedFail(UrimError("nonconformant_response", message, tier=tier.id))
72
+
73
+
74
+ def binary_label(p: float) -> str:
75
+ """P(yes) -> label. A tie (0.5) reads "yes"; its confidence is 0, so no threshold can pass on it."""
76
+ return "yes" if p >= 0.5 else "no"
77
+
78
+
79
+ def binary_confidence(p: float) -> float:
80
+ """The normalized confidence (p_max - 1/n)/(1 - 1/n), n = 2, i.e. |2p - 1|, computed client-side.
81
+ Rounded to 12 dp exactly as urim-ts does (Math.round(x * 1e12) / 1e12; half rounds up)."""
82
+ x = ((max(p, 1 - p) - 0.5) / 0.5) * 1e12
83
+ return math.floor(x + 0.5) / 1e12
84
+
85
+
86
+ def argmax(ps: list[float]) -> int:
87
+ """Index of the largest value; first wins on a tie."""
88
+ best = 0
89
+ for i, p in enumerate(ps):
90
+ if p > ps[best]:
91
+ best = i
92
+ return best
93
+
94
+
95
+ def usage_of(body: Mapping[str, Any]) -> TokenUsage | None:
96
+ u = body.get("usage")
97
+ return TokenUsage(num(u.get("input_tokens")), num(u.get("output_tokens"))) if isinstance(u, dict) else None
98
+
99
+
100
+ def to_answers(d: Definition, tier: Tier, raws: Mapping[str, RawAnswer], body: Mapping[str, Any]) -> Decoded:
101
+ """Checks every question is answered inside its label space; never alters native values."""
102
+ answers: dict[str, Answer] = {}
103
+ for name, q in d.questions.items():
104
+ raw = raws.get(name)
105
+ if raw is None:
106
+ return nonconformant(tier, f'question "{name}" not answered')
107
+ if not isinstance(raw.label, str) or raw.label not in q.options:
108
+ return nonconformant(tier, f'question "{name}" answered outside its labels')
109
+ conf = _unit(raw.confidence)
110
+ answers[name] = Answer(
111
+ raw.label,
112
+ conf,
113
+ None if conf is None else raw.confidence_source,
114
+ confidence_formula=None
115
+ if conf is None
116
+ else ("systemone_normalized" if raw.confidence_source == "urim_derived" else "vendor_unspecified"),
117
+ native={"name": raw.name, "value": raw.native_value, "confidence": raw.native_confidence},
118
+ )
119
+ model = body.get("model")
120
+ return DecodedOk(answers, usage_of(body), model if isinstance(model, str) else None)
121
+
122
+
123
+ def score_labels(q: Question) -> list[str]:
124
+ return list(q.options.keys())
@@ -0,0 +1,71 @@
1
+ """C: the systemone body (spoken by every descriptor whose camp is systemone).
2
+ Body shape per the systemone descriptors' vendor sources (checked 2026-10-28 EDT)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..types import Definition, Question, Tier
9
+ from .shared import (
10
+ Decoded,
11
+ RawAnswer,
12
+ argmax,
13
+ binary_confidence,
14
+ binary_label,
15
+ nonconformant,
16
+ num,
17
+ score_labels,
18
+ to_answers,
19
+ vendor_confidence,
20
+ )
21
+
22
+ PATH = "/v1/systemone"
23
+
24
+
25
+ def _criteria(q: Question) -> Any:
26
+ if q.type == "choice":
27
+ return dict(q.options)
28
+ if q.type == "score":
29
+ return list(q.options.values()) # ordered; index = level from 0
30
+ return {"true": q.options["yes"], "false": q.options["no"]}
31
+
32
+
33
+ def encode(d: Definition, wire_model: str, state: Any) -> Any:
34
+ return {
35
+ "model": wire_model,
36
+ "state": state,
37
+ "questions": {
38
+ name: {"type": q.type, "instructions": q.prompt, "criteria": _criteria(q)}
39
+ for name, q in d.questions.items()
40
+ },
41
+ }
42
+
43
+
44
+ def _decode_one(name: str, q: Question, a: Any, sentinel: bool) -> RawAnswer | None:
45
+ if not isinstance(a, dict):
46
+ return None
47
+ if q.type == "noul":
48
+ p = num(a.get("noul"))
49
+ return None if p is None else RawAnswer(name, binary_label(p), binary_confidence(p), p, None, "urim_derived")
50
+ if q.type == "choice":
51
+ return RawAnswer(
52
+ name, a.get("choice"), vendor_confidence(a, sentinel), a.get("choice"), a.get("confidence"), "native"
53
+ )
54
+ labels = score_labels(q)
55
+ given = a.get("probabilities")
56
+ probs: dict[str, Any] = given if isinstance(given, dict) else {}
57
+ ps = [num(probs.get(str(i))) for i in range(len(labels))]
58
+ vals = [-1.0 if p is None else p for p in ps]
59
+ label = None if all(v < 0 for v in vals) else labels[argmax(vals)]
60
+ return RawAnswer(name, label, vendor_confidence(a, sentinel), a.get("score"), a.get("confidence"), "native")
61
+
62
+
63
+ def decode(d: Definition, tier: Tier, body: Any, *, unavailable_sentinel: bool = False) -> Decoded:
64
+ if not isinstance(body, dict) or not isinstance(body.get("answers"), dict):
65
+ return nonconformant(tier, "response has no answers map")
66
+ raws = {}
67
+ for name, q in d.questions.items():
68
+ raw = _decode_one(name, q, body["answers"].get(name), unavailable_sentinel)
69
+ if raw is not None:
70
+ raws[name] = raw
71
+ return to_answers(d, tier, raws, body)
urim/canonical.py ADDED
@@ -0,0 +1,18 @@
1
+ """C: canonical JSON (sorted keys, no whitespace) + sha256. Byte-identical to urim-ts src/canonical.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ from .jsonfmt import canonical, js_units
9
+
10
+
11
+ def canonicalize(value: Any) -> str:
12
+ return canonical(value)
13
+
14
+
15
+ def sha256(text: str) -> str:
16
+ # Node's update(text, "utf8") writes a lone surrogate as U+FFFD; canonical output escapes them anyway.
17
+ clean = "".join("\ufffd" if 0xD800 <= ord(c) <= 0xDFFF else c for c in js_units(text))
18
+ return "sha256:" + hashlib.sha256(clean.encode("utf-8")).hexdigest()