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 +141 -0
- rev/agent.py +256 -0
- rev/api.py +120 -0
- rev/cache.py +110 -0
- rev/common.py +234 -0
- rev/email.py +65 -0
- rev/encoder_model.py +132 -0
- rev/lang.py +243 -0
- rev/mcp_server.py +146 -0
- rev/model.py +352 -0
- rev/presets.py +287 -0
- rev/router.py +314 -0
- rev/serve.py +312 -0
- rev/shortlist.py +191 -0
- rev/train.py +118 -0
- rev_decision-0.2.0.dist-info/METADATA +350 -0
- rev_decision-0.2.0.dist-info/RECORD +21 -0
- rev_decision-0.2.0.dist-info/WHEEL +5 -0
- rev_decision-0.2.0.dist-info/entry_points.txt +2 -0
- rev_decision-0.2.0.dist-info/licenses/LICENSE +95 -0
- rev_decision-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
}
|