rev-decision 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.
rev/__init__.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ rev: Unified System 1 Decision Engine with TypeSafe API and Sub-Microsecond Multi-Backbone Routing.
3
+
4
+ Unifies Pretrained Encoders:
5
+ - ModernBERT-large (421M): English backbone with long-context, deep reasoning.
6
+ - mmBERT-base (322M): Multilingual backbone natively supporting 100+ languages.
7
+ - Typed Decisions (421M): Fine-tuned on specialized enterprise decision schemas.
8
+ - Causal LM Backbones: Document Prefix KV-Caching (<5ms repeated queries).
9
+
10
+ Features:
11
+ - Sub-microsecond Unicode script routing (rev.lang).
12
+ - RLCD strictly proper scoring rules (log score + spherical + RPS) for calibrated probabilities.
13
+ - High-cardinality candidate shortlisting (rev.shortlist).
14
+ - Production presets for triage, email, guardrails, moderation, invoice, security, traces.
15
+ """
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ # Language and Script Detection
20
+ from . import lang
21
+
22
+ # Common Utilities & Scoring Rules
23
+ from .common import (
24
+ QTYPES,
25
+ QTYPE_NAMES,
26
+ serialize_state,
27
+ render_criterion,
28
+ render_options,
29
+ build_sequence,
30
+ collate_items,
31
+ proper_reward,
32
+ confidence_from_probs,
33
+ clamp_temperature,
34
+ ece_score,
35
+ amp_dtype,
36
+ )
37
+
38
+ # Encoder Model Architecture
39
+ from .encoder_model import (
40
+ EncoderDecisionModel,
41
+ build_encoder_model,
42
+ )
43
+
44
+ # Inference Runtime
45
+ from .agent import Agent
46
+
47
+ # Unified 'One Model' Router
48
+ from .router import (
49
+ UnifiedModel,
50
+ Router,
51
+ RouteDecision,
52
+ predict,
53
+ get_default_model,
54
+ )
55
+
56
+ # Primary 'Model' alias pointing to the unified engine
57
+ Model = UnifiedModel
58
+
59
+ # Candidate Shortlisting
60
+ from .shortlist import (
61
+ shortlist_choice,
62
+ predict_shortlist,
63
+ embed_fn_from_agent,
64
+ )
65
+
66
+ # Workflow Presets
67
+ from . import presets
68
+
69
+ # Email Utilities
70
+ from . import email
71
+ from .email import email_state, clean_email_body
72
+
73
+ # TypeSafe API Models (for backward compatibility and server endpoints)
74
+ from .api import (
75
+ Noul,
76
+ Choice,
77
+ Score,
78
+ SystemOneRequest,
79
+ to_record,
80
+ to_answers,
81
+ render,
82
+ choice_confidence,
83
+ score_confidence,
84
+ )
85
+
86
+ # Optional Causal LM Backbone and Pointer Head
87
+ try:
88
+ from .model import (
89
+ DecisionModel as CausalDecisionModel,
90
+ PointerHead,
91
+ encode,
92
+ user_tokens,
93
+ branch_mask_batch,
94
+ encode_state,
95
+ encode_question_branches,
96
+ )
97
+ except ImportError:
98
+ CausalDecisionModel = None
99
+ PointerHead = None
100
+
101
+ __all__ = [
102
+ # Unified Model
103
+ "Model",
104
+ "UnifiedModel",
105
+ "Router",
106
+ "Agent",
107
+ "EncoderDecisionModel",
108
+ "predict",
109
+ "get_default_model",
110
+ "RouteDecision",
111
+ # Presets & Modules
112
+ "presets",
113
+ "lang",
114
+ "email",
115
+ "email_state",
116
+ "clean_email_body",
117
+ # Shortlisting
118
+ "shortlist_choice",
119
+ "predict_shortlist",
120
+ "embed_fn_from_agent",
121
+ # Scoring & Calibration
122
+ "proper_reward",
123
+ "confidence_from_probs",
124
+ "clamp_temperature",
125
+ "ece_score",
126
+ "render_options",
127
+ "build_sequence",
128
+ # TypeSafe API
129
+ "Noul",
130
+ "Choice",
131
+ "Score",
132
+ "SystemOneRequest",
133
+ "to_record",
134
+ "to_answers",
135
+ "render",
136
+ "choice_confidence",
137
+ "score_confidence",
138
+ # Causal
139
+ "CausalDecisionModel",
140
+ "PointerHead",
141
+ ]
rev/agent.py ADDED
@@ -0,0 +1,256 @@
1
+ """
2
+ High-level inference runtime for rev encoder decision models.
3
+ Provides calibrated, non-autoregressive, sub-10ms decision evaluations.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import warnings
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from .common import (
15
+ QTYPES,
16
+ TEMP_MAX,
17
+ TEMP_MIN,
18
+ amp_dtype,
19
+ build_sequence,
20
+ clamp_temperature,
21
+ collate_items,
22
+ confidence_from_probs,
23
+ render_options,
24
+ temp_bucket,
25
+ )
26
+ from .encoder_model import EncoderDecisionModel, build_encoder_model
27
+
28
+
29
+ def _fix_tokenizer_config(path: str):
30
+ """Ensure tokenizer_config.json loads cleanly across all transformers versions."""
31
+ cfg_file = os.path.join(path, "tokenizer", "tokenizer_config.json")
32
+ if not os.path.exists(cfg_file):
33
+ cfg_file = os.path.join(path, "tokenizer_config.json")
34
+ if not os.path.exists(cfg_file):
35
+ return
36
+ try:
37
+ with open(cfg_file) as f:
38
+ tcfg = json.load(f)
39
+ changed = False
40
+ if tcfg.get("tokenizer_class") in (None, "TokenizersBackend"):
41
+ tcfg["tokenizer_class"] = "PreTrainedTokenizerFast"
42
+ tcfg.pop("backend", None)
43
+ tcfg.pop("is_local", None)
44
+ changed = True
45
+ extra = tcfg.get("extra_special_tokens")
46
+ if isinstance(extra, list):
47
+ tcfg["extra_special_tokens"] = {"extra_%d" % i: t for i, t in enumerate(extra)}
48
+ changed = True
49
+ if changed:
50
+ with open(cfg_file, "w") as f:
51
+ json.dump(tcfg, f, indent=2)
52
+ except Exception:
53
+ pass
54
+
55
+
56
+ class Agent:
57
+ """System 1 encoder decision runtime: fast, non-autoregressive, calibrated decisions."""
58
+
59
+ def __init__(
60
+ self,
61
+ model_id_or_path: str = "convaiinnovations/laya",
62
+ device: Optional[str] = None,
63
+ token: Optional[str] = None,
64
+ subfolder: Optional[str] = None,
65
+ ):
66
+ """
67
+ Load an encoder checkpoint.
68
+ `subfolder` can select specific checkpoints (e.g. 'multilingual' or 'typed-decisions').
69
+ """
70
+ from safetensors.torch import load_file
71
+ from transformers import AutoTokenizer
72
+
73
+ model_dir = model_id_or_path
74
+ if not os.path.exists(model_dir):
75
+ if model_id_or_path.startswith(("/", "./", "../")) or os.path.isabs(model_id_or_path):
76
+ raise FileNotFoundError(f"Local model path not found: {model_id_or_path!r}")
77
+ from huggingface_hub import snapshot_download
78
+
79
+ prefix = f"{subfolder}/" if subfolder else ""
80
+ kw = {
81
+ "token": token or os.environ.get("HF_TOKEN"),
82
+ "allow_patterns": [
83
+ prefix + name
84
+ for name in (
85
+ "rl_agent_config.json",
86
+ "model.safetensors",
87
+ "tokenizer/*",
88
+ "tokenizer_config.json",
89
+ "vocab.json",
90
+ "merges.txt",
91
+ "encoder/*",
92
+ )
93
+ ],
94
+ }
95
+ model_dir = snapshot_download(model_id_or_path, **kw)
96
+
97
+ if subfolder:
98
+ model_dir = os.path.join(model_dir, subfolder)
99
+ if not os.path.isdir(model_dir):
100
+ raise FileNotFoundError(f"Subfolder {subfolder!r} not found in {model_id_or_path!r}.")
101
+
102
+ _fix_tokenizer_config(model_dir)
103
+
104
+ cfg_path = os.path.join(model_dir, "rl_agent_config.json")
105
+ if os.path.exists(cfg_path):
106
+ with open(cfg_path) as f:
107
+ self.cfg = json.load(f)
108
+ else:
109
+ self.cfg = {
110
+ "encoder": "answerdotai/ModernBERT-large",
111
+ "head_layers": 2,
112
+ "max_len": 512,
113
+ "head_max_len": 192,
114
+ }
115
+
116
+ weights_path = os.path.join(model_dir, "model.safetensors")
117
+ weights = load_file(weights_path) if os.path.exists(weights_path) else None
118
+
119
+ tok_dir = os.path.join(model_dir, "tokenizer")
120
+ if not os.path.exists(tok_dir):
121
+ tok_dir = model_dir
122
+ self.tok = AutoTokenizer.from_pretrained(tok_dir, fix_markdown=False)
123
+
124
+ enc_dir = os.path.join(model_dir, "encoder")
125
+ self.model = build_encoder_model(self.cfg, enc_dir if os.path.exists(enc_dir) else None)
126
+
127
+ if weights is not None:
128
+ self.model.load_state_dict(weights, strict=False)
129
+
130
+ self.model.eval()
131
+
132
+ if device is None:
133
+ device = "cuda" if torch.cuda.is_available() else "cpu"
134
+ self.device = torch.device(device)
135
+ self.model.to(self.device)
136
+
137
+ self.max_len = int(self.cfg.get("max_len", 512))
138
+ self.head_max_len = int(self.cfg.get("head_max_len", 192))
139
+ self.temperatures = self.cfg.get("temperatures", {})
140
+
141
+ @torch.no_grad()
142
+ def system_one(
143
+ self,
144
+ state: Union[str, dict, list],
145
+ questions: Dict[str, Any],
146
+ temperatures: Optional[Dict[str, float]] = None,
147
+ ) -> Dict[str, Any]:
148
+ """
149
+ Evaluate all questions for state in a single non-autoregressive forward pass.
150
+ Returns dict containing answers, probabilities, and confidence scores.
151
+ """
152
+ if not questions:
153
+ return {"answers": {}, "actions": {}}
154
+
155
+ items = []
156
+ q_order = []
157
+ for qid, q in questions.items():
158
+ q_norm = {
159
+ "t": q.get("type") or q.get("t", "choice"),
160
+ "ins": q.get("instructions") or q.get("ins", ""),
161
+ "crit": q.get("criteria") or q.get("crit", {}),
162
+ }
163
+ qtype_id = QTYPES[q_norm["t"]]
164
+ ids, markers = build_sequence(
165
+ self.tok,
166
+ state,
167
+ q_norm,
168
+ max_len=self.max_len,
169
+ head_max_len=self.head_max_len,
170
+ )
171
+ items.append({
172
+ "ids": ids,
173
+ "markers": markers,
174
+ "qtype": qtype_id,
175
+ "qid": qid,
176
+ "q": q_norm,
177
+ })
178
+ q_order.append((qid, q_norm))
179
+
180
+ pad_id = getattr(self.tok, "pad_token_id", None) or 0
181
+ batch = collate_items(items, pad_id=pad_id)
182
+
183
+ input_ids = batch["input_ids"].to(self.device)
184
+ attention_mask = batch["attention_mask"].to(self.device)
185
+ marker_pos = batch["marker_pos"].to(self.device)
186
+ marker_mask = batch["marker_mask"].to(self.device)
187
+ qtype = batch["qtype"].to(self.device)
188
+
189
+ logits, act_logits = self.model(
190
+ input_ids=input_ids,
191
+ attention_mask=attention_mask,
192
+ marker_pos=marker_pos,
193
+ marker_mask=marker_mask,
194
+ qtype=qtype,
195
+ )
196
+
197
+ answers = {}
198
+ for i, (qid, q_norm) in enumerate(q_order):
199
+ k = int(marker_mask[i].sum().item())
200
+ row_logits = logits[i, :k].cpu().numpy()
201
+
202
+ # Temperature calibration
203
+ bucket = temp_bucket(QTYPES[q_norm["t"]], k)
204
+ t_val = 1.0
205
+ if temperatures and bucket in temperatures:
206
+ t_val = clamp_temperature(temperatures[bucket])
207
+ elif bucket in self.temperatures:
208
+ t_val = clamp_temperature(self.temperatures[bucket])
209
+
210
+ calibrated_logits = row_logits / t_val
211
+ exp_l = np.exp(calibrated_logits - np.max(calibrated_logits))
212
+ probs = exp_l / np.sum(exp_l)
213
+
214
+ conf = confidence_from_probs(probs, k)
215
+ t = q_norm["t"]
216
+
217
+ if t == "choice":
218
+ opts = render_options(q_norm)
219
+ crit = q_norm["crit"]
220
+ keys = list(crit.keys()) if isinstance(crit, dict) else [str(j) for j in range(len(opts))]
221
+ best_idx = int(np.argmax(probs))
222
+ answers[qid] = {
223
+ "type": "choice",
224
+ "choice": keys[best_idx] if best_idx < len(keys) else str(best_idx),
225
+ "confidence": round(conf, 4),
226
+ "probabilities": {keys[j]: round(float(probs[j]), 4) for j in range(min(len(keys), k))},
227
+ }
228
+ elif t == "score":
229
+ expected_score = float(np.sum(np.arange(k) * probs))
230
+ answers[qid] = {
231
+ "type": "score",
232
+ "score": round(expected_score, 3),
233
+ "confidence": round(conf, 4),
234
+ "probabilities": {str(j): round(float(probs[j]), 4) for j in range(k)},
235
+ }
236
+ else: # noul
237
+ # probs[0] is false, probs[1] is true
238
+ p_true = float(probs[1]) if k >= 2 else 0.0
239
+ answers[qid] = {
240
+ "type": "noul",
241
+ "noul": round(p_true, 4),
242
+ "confidence": round(conf, 4),
243
+ }
244
+
245
+ # Action head / deferral
246
+ act_probs = torch.softmax(act_logits, dim=-1).cpu().numpy()
247
+ actions = {}
248
+ for i, (qid, _) in enumerate(q_order):
249
+ actions[qid] = {
250
+ "action": int(np.argmax(act_probs[i])),
251
+ "probabilities": [round(float(p), 4) for p in act_probs[i]],
252
+ }
253
+
254
+ return {"answers": answers, "actions": actions}
255
+
256
+ predict = system_one
rev/api.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ TypeSafe-compatible request/response shapes (POST /v1/systemone) mapped onto the single pointer primitive.
3
+
4
+ Noul -> 2 options [false, true]; answer = p(true)
5
+ Choice -> options 'name' or 'name: desc'; answer = argmax, probabilities by name, confidence
6
+ Score -> options = ordered level descriptions; answer = expected level, legend, probabilities by index
7
+ """
8
+
9
+ from typing import Any, Literal, Union, Dict, List
10
+ from pydantic import BaseModel, Field, model_validator
11
+
12
+ JSONContent = Union[str, dict, list, int, float, bool, None]
13
+ MAX_OPTIONS = 255
14
+
15
+
16
+ class Noul(BaseModel):
17
+ type: Literal["noul"] = "noul"
18
+ instructions: JSONContent
19
+ criteria: dict[str, JSONContent] | None = None
20
+
21
+
22
+ class Choice(BaseModel):
23
+ type: Literal["choice"] = "choice"
24
+ instructions: JSONContent
25
+ criteria: dict[str, JSONContent]
26
+
27
+ @model_validator(mode="after")
28
+ def _check(self):
29
+ if not 1 <= len(self.criteria) <= MAX_OPTIONS:
30
+ raise ValueError(f"criteria must have 1..{MAX_OPTIONS} options")
31
+ return self
32
+
33
+
34
+ class Score(BaseModel):
35
+ type: Literal["score"] = "score"
36
+ instructions: JSONContent
37
+ criteria: list[JSONContent] = Field(min_length=2, max_length=MAX_OPTIONS)
38
+
39
+
40
+ Question = Union[Noul, Choice, Score]
41
+
42
+
43
+ class SystemOneRequest(BaseModel):
44
+ state: JSONContent
45
+ model: str = "rev-latest"
46
+ questions: dict[str, Question] = Field(min_length=1)
47
+
48
+
49
+ def render(v: JSONContent, indent: int = 0) -> str:
50
+ """Flatten str | object | array into text the model sees."""
51
+ pad = " " * indent
52
+ if v is None:
53
+ return ""
54
+ if isinstance(v, (str, int, float, bool)):
55
+ return str(v)
56
+ if isinstance(v, list):
57
+ return "\n".join(f"{pad}- {render(x, indent + 1).lstrip()}" for x in v)
58
+ return "\n".join(
59
+ f"{pad}{k}:\n{render(x, indent + 1)}" if isinstance(x, (dict, list)) else f"{pad}{k}: {render(x)}"
60
+ for k, x in v.items()
61
+ )
62
+
63
+
64
+ def option_text(name: str, desc: JSONContent) -> str:
65
+ return name if desc is None or desc == "" else f"{name}: {render(desc)}"
66
+
67
+
68
+ def to_record(req: SystemOneRequest):
69
+ """-> internal record for encode(), plus per-question metadata to map probabilities back."""
70
+ qs, meta = [], []
71
+ for qid, q in req.questions.items():
72
+ instr = render(q.instructions)
73
+ if q.type == "noul":
74
+ c = q.criteria or {}
75
+ opts = [option_text("no", c.get("false")), option_text("yes", c.get("true"))]
76
+ meta.append({"id": qid, "type": "noul"})
77
+ elif q.type == "choice":
78
+ opts = [option_text(k, v) for k, v in q.criteria.items()]
79
+ meta.append({"id": qid, "type": "choice", "keys": list(q.criteria.keys())})
80
+ else:
81
+ opts = [render(x) for x in q.criteria]
82
+ meta.append({"id": qid, "type": "score", "legend": {str(i): render(x) for i, x in enumerate(q.criteria)}})
83
+ qs.append({"instr": instr, "options": opts, "label": 0})
84
+ return {"state": render(req.state), "questions": qs}, meta
85
+
86
+
87
+ def choice_confidence(p: list[float]) -> float:
88
+ K = len(p)
89
+ return 1.0 if K == 1 else (max(p) - 1.0 / K) / (1.0 - 1.0 / K)
90
+
91
+
92
+ def score_confidence(p: list[float]) -> float:
93
+ L = len(p)
94
+ mode = max(range(L), key=lambda i: p[i])
95
+ return 1.0 - sum(pi * abs(i - mode) for i, pi in enumerate(p)) / (L - 1)
96
+
97
+
98
+ def to_answers(probs: list[list[float]], meta: list[dict]) -> dict[str, Any]:
99
+ out = {}
100
+ for p, m in zip(probs, meta):
101
+ if m["type"] == "noul":
102
+ out[m["id"]] = {"type": "noul", "noul": round(p[1], 3)}
103
+ elif m["type"] == "choice":
104
+ best_idx = max(range(len(p)), key=lambda i: p[i])
105
+ out[m["id"]] = {
106
+ "type": "choice",
107
+ "choice": m["keys"][best_idx],
108
+ "confidence": round(choice_confidence(p), 3),
109
+ "probabilities": {k: round(v, 4) for k, v in zip(m["keys"], p)},
110
+ }
111
+ else:
112
+ score = sum(i * pi for i, pi in enumerate(p))
113
+ out[m["id"]] = {
114
+ "type": "score",
115
+ "score": round(score, 2),
116
+ "confidence": round(score_confidence(p), 3),
117
+ "legend": m["legend"],
118
+ "probabilities": {str(i): round(v, 4) for i, v in enumerate(p)},
119
+ }
120
+ return out
rev/cache.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Thread-safe LRU state prefix KV-cache manager for rev.
3
+ Caches precomputed key/value tensors of document prefixes to enable sub-5ms decision queries.
4
+ """
5
+
6
+ import collections
7
+ import hashlib
8
+ import threading
9
+ import time
10
+ from typing import Any, Optional
11
+
12
+
13
+ class CachedStateEntry:
14
+ def __init__(self, key: str, past_key_values: Any, state_len: int, token_ids: list[int] = None):
15
+ self.key = key
16
+ self.past_key_values = past_key_values
17
+ self.state_len = state_len
18
+ self.token_ids = token_ids or []
19
+ self.created_at = time.time()
20
+ self.last_accessed = time.time()
21
+ self.hits = 0
22
+
23
+ def touch(self):
24
+ self.last_accessed = time.time()
25
+ self.hits += 1
26
+
27
+
28
+ class StateKVCacheManager:
29
+ """
30
+ LRU cache for prefix key-value states.
31
+ Thread-safe and eviction-ready.
32
+ """
33
+ def __init__(self, max_entries: int = 64):
34
+ self.max_entries = max_entries
35
+ self._cache: collections.OrderedDict[str, CachedStateEntry] = collections.OrderedDict()
36
+ self._lock = threading.Lock()
37
+ self._total_hits = 0
38
+ self._total_misses = 0
39
+
40
+ @staticmethod
41
+ def hash_state(state_text: str) -> str:
42
+ return hashlib.sha256(state_text.strip().encode("utf-8")).hexdigest()
43
+
44
+ def get(self, state_text: str) -> Optional[CachedStateEntry]:
45
+ key = self.hash_state(state_text)
46
+ return self.get_by_hash(key)
47
+
48
+ def get_by_hash(self, key: str) -> Optional[CachedStateEntry]:
49
+ with self._lock:
50
+ if key in self._cache:
51
+ entry = self._cache[key]
52
+ entry.touch()
53
+ self._cache.move_to_end(key)
54
+ self._total_hits += 1
55
+ return entry
56
+ else:
57
+ self._total_misses += 1
58
+ return None
59
+
60
+ def put(self, state_text: str, past_key_values: Any, state_len: int, token_ids: list[int] = None) -> str:
61
+ key = self.hash_state(state_text)
62
+ with self._lock:
63
+ if key in self._cache:
64
+ entry = self._cache[key]
65
+ entry.past_key_values = past_key_values
66
+ entry.state_len = state_len
67
+ entry.token_ids = token_ids or []
68
+ entry.touch()
69
+ self._cache.move_to_end(key)
70
+ else:
71
+ if len(self._cache) >= self.max_entries:
72
+ self._cache.popitem(last=False)
73
+ entry = CachedStateEntry(key, past_key_values, state_len, token_ids or [])
74
+ self._cache[key] = entry
75
+ return key
76
+
77
+ def evict(self, key: str) -> bool:
78
+ with self._lock:
79
+ if key in self._cache:
80
+ del self._cache[key]
81
+ return True
82
+ return False
83
+
84
+ def clear(self):
85
+ with self._lock:
86
+ self._cache.clear()
87
+ self._total_hits = 0
88
+ self._total_misses = 0
89
+
90
+ def stats(self) -> dict:
91
+ with self._lock:
92
+ total = self._total_hits + self._total_misses
93
+ hit_rate = round(self._total_hits / total, 4) if total > 0 else 0.0
94
+ entries_info = [
95
+ {
96
+ "hash": k,
97
+ "state_len": entry.state_len,
98
+ "hits": entry.hits,
99
+ "age_seconds": round(time.time() - entry.created_at, 1),
100
+ }
101
+ for k, entry in self._cache.items()
102
+ ]
103
+ return {
104
+ "total_entries": len(self._cache),
105
+ "max_entries": self.max_entries,
106
+ "hits": self._total_hits,
107
+ "misses": self._total_misses,
108
+ "hit_rate": hit_rate,
109
+ "entries": entries_info,
110
+ }