pcd-rlcd 0.1.0__tar.gz
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.
- pcd_rlcd-0.1.0/PKG-INFO +16 -0
- pcd_rlcd-0.1.0/README.md +0 -0
- pcd_rlcd-0.1.0/pyproject.toml +23 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/__init__.py +29 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/benchmark.py +81 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/engine.py +43 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/engine_mlx.py +478 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/engine_torch.py +330 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/prompt_builder.py +59 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/py.typed +0 -0
- pcd_rlcd-0.1.0/src/pcd_rlcd/schema.py +223 -0
pcd_rlcd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pcd-rlcd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parallel Constrained Decoding for RLCD - Reinforcement Learning for Calibrated Decisions
|
|
5
|
+
Author: Harsha Gundala, Mahdi Bandegani
|
|
6
|
+
Author-email: Harsha Gundala <harshatheg>, Mahdi Bandegani <mahdibandegani@gmail.com>
|
|
7
|
+
Requires-Dist: mlx>=0.22.0
|
|
8
|
+
Requires-Dist: mlx-lm>=0.21.0
|
|
9
|
+
Requires-Dist: torch
|
|
10
|
+
Requires-Dist: transformers>=4.40.0
|
|
11
|
+
Requires-Dist: accelerate>=0.28.0
|
|
12
|
+
Requires-Dist: pydantic>=2.6.0
|
|
13
|
+
Requires-Dist: numpy>=1.26.0
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
pcd_rlcd-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pcd-rlcd"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Parallel Constrained Decoding for RLCD - Reinforcement Learning for Calibrated Decisions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Harsha Gundala", email = "harshatheg" },
|
|
8
|
+
{ name = "Mahdi Bandegani", email = "mahdibandegani@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"mlx>=0.22.0",
|
|
13
|
+
"mlx-lm>=0.21.0",
|
|
14
|
+
"torch",
|
|
15
|
+
"transformers>=4.40.0",
|
|
16
|
+
"accelerate>=0.28.0",
|
|
17
|
+
"pydantic>=2.6.0",
|
|
18
|
+
"numpy>=1.26.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parallel Constrained Structured Generation Engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .schema import StructuredSchema, FieldDefinition
|
|
6
|
+
from .engine import (
|
|
7
|
+
get_engine,
|
|
8
|
+
run_parallel_generation,
|
|
9
|
+
run_naive_generation,
|
|
10
|
+
stream_naive_generation,
|
|
11
|
+
# Backward compatibility
|
|
12
|
+
run_rlcd_generation,
|
|
13
|
+
)
|
|
14
|
+
from .prompt_builder import (
|
|
15
|
+
build_naive_json_prompt,
|
|
16
|
+
build_parallel_field_prompts,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"StructuredSchema",
|
|
21
|
+
"FieldDefinition",
|
|
22
|
+
"get_engine",
|
|
23
|
+
"run_parallel_generation",
|
|
24
|
+
"run_naive_generation",
|
|
25
|
+
"stream_naive_generation",
|
|
26
|
+
"build_naive_json_prompt",
|
|
27
|
+
"build_parallel_field_prompts",
|
|
28
|
+
"run_rlcd_generation",
|
|
29
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Benchmark runner comparing Autoregressive Generation vs.
|
|
3
|
+
Parallel Constrained Decision Engine on Apple Silicon.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import json
|
|
8
|
+
import argparse
|
|
9
|
+
from typing import Dict, Any, List
|
|
10
|
+
from core.schema import StructuredSchema
|
|
11
|
+
from core.engine import run_naive_generation, run_parallel_generation, get_engine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compare_single(context: str, schema_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
15
|
+
"""Runs both engines on the exact same problem prompt and returns side-by-side metrics."""
|
|
16
|
+
schema = StructuredSchema(schema_dict)
|
|
17
|
+
|
|
18
|
+
# 1. Run Autoregressive Baseline
|
|
19
|
+
naive_res = run_naive_generation(context, schema)
|
|
20
|
+
|
|
21
|
+
# 2. Run Parallel Constrained Engine
|
|
22
|
+
parallel_res = run_parallel_generation(context, schema)
|
|
23
|
+
|
|
24
|
+
speedup = naive_res["elapsed_ms"] / max(parallel_res["elapsed_ms"], 1.0)
|
|
25
|
+
steps_speedup = naive_res["sequential_forward_passes"] / max(parallel_res["sequential_forward_passes"], 1.0)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
"speedup_multiplier": round(speedup, 1),
|
|
29
|
+
"steps_reduction": round(steps_speedup, 1),
|
|
30
|
+
"naive": naive_res,
|
|
31
|
+
"parallel": parallel_res,
|
|
32
|
+
# Backward compatibility
|
|
33
|
+
"rlcd": parallel_res
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_benchmark_suite(preset_paths: List[str], warmup: bool = True) -> List[Dict[str, Any]]:
|
|
38
|
+
print("=" * 70)
|
|
39
|
+
print("Parallel Constrained vs. Autoregressive Generation Benchmark")
|
|
40
|
+
print("=" * 70)
|
|
41
|
+
|
|
42
|
+
get_engine()
|
|
43
|
+
|
|
44
|
+
if warmup:
|
|
45
|
+
print("\n[+] Warming up GPU compute graphs...")
|
|
46
|
+
with open(preset_paths[0]) as f:
|
|
47
|
+
p = json.load(f)
|
|
48
|
+
compare_single(p["context"], p["schema"])
|
|
49
|
+
print("[+] Warmup complete.\n")
|
|
50
|
+
|
|
51
|
+
results = []
|
|
52
|
+
for path in preset_paths:
|
|
53
|
+
with open(path) as f:
|
|
54
|
+
preset = json.load(f)
|
|
55
|
+
|
|
56
|
+
print(f"--> Running preset: {preset['title']} ({len(preset['schema'])} fields)...")
|
|
57
|
+
comp = compare_single(preset["context"], preset["schema"])
|
|
58
|
+
comp["preset_id"] = preset["id"]
|
|
59
|
+
comp["preset_title"] = preset["title"]
|
|
60
|
+
results.append(comp)
|
|
61
|
+
|
|
62
|
+
n = comp["naive"]
|
|
63
|
+
r = comp["parallel"]
|
|
64
|
+
print(f" Autoregressive Baseline : {n['elapsed_ms']:>8.1f} ms | {n['total_tokens']:>3} tokens ({n['tokens_per_second']} tok/s) | Passes: {n['sequential_forward_passes']}")
|
|
65
|
+
print(f" Parallel Constrained : {r['elapsed_ms']:>8.1f} ms | 0 tokens (O(1)) | Passes: {r['sequential_forward_passes']}")
|
|
66
|
+
print(f" >> SPEEDUP: {comp['speedup_multiplier']}x faster (Step reduction: {comp['steps_reduction']}x)")
|
|
67
|
+
print(f" >> Schema match: Naive={n['schema_match']} | Parallel={r['schema_match']} (100% guaranteed)")
|
|
68
|
+
print("-" * 70)
|
|
69
|
+
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
parser = argparse.ArgumentParser(description="Run Parallel vs Autoregressive LLM JSON benchmark")
|
|
75
|
+
parser.add_argument("--presets", nargs="+", default=[
|
|
76
|
+
"presets/fintech_fraud.json",
|
|
77
|
+
"presets/support_triage.json",
|
|
78
|
+
"presets/high_cardinality_255.json"
|
|
79
|
+
])
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
run_benchmark_suite(args.presets)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified Engine Router for Parallel Constrained Decoding.
|
|
3
|
+
Automatically selects MLX backend on Apple Silicon macOS,
|
|
4
|
+
or PyTorch / CUDA backend on Linux, Docker, and Hugging Face Spaces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
|
|
10
|
+
USE_MLX = False
|
|
11
|
+
if platform.system() == "Darwin" and os.environ.get("BACKEND", "").lower() != "torch":
|
|
12
|
+
try:
|
|
13
|
+
import mlx.core as mx
|
|
14
|
+
import mlx_lm
|
|
15
|
+
USE_MLX = True
|
|
16
|
+
except Exception:
|
|
17
|
+
USE_MLX = False
|
|
18
|
+
|
|
19
|
+
if USE_MLX:
|
|
20
|
+
from core.engine_mlx import (
|
|
21
|
+
get_engine,
|
|
22
|
+
run_parallel_generation,
|
|
23
|
+
run_naive_generation,
|
|
24
|
+
stream_naive_generation,
|
|
25
|
+
run_rlcd_generation,
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
from core.engine_torch import (
|
|
29
|
+
get_torch_engine as get_engine,
|
|
30
|
+
run_parallel_generation_torch as run_parallel_generation,
|
|
31
|
+
run_naive_generation_torch as run_naive_generation,
|
|
32
|
+
stream_naive_generation_torch as stream_naive_generation,
|
|
33
|
+
)
|
|
34
|
+
run_rlcd_generation = run_parallel_generation
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"get_engine",
|
|
38
|
+
"run_parallel_generation",
|
|
39
|
+
"run_naive_generation",
|
|
40
|
+
"stream_naive_generation",
|
|
41
|
+
"run_rlcd_generation",
|
|
42
|
+
"USE_MLX",
|
|
43
|
+
]
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inference Engine comparing Autoregressive JSON Generation
|
|
3
|
+
vs. Parallel Constrained Decision Engine.
|
|
4
|
+
Runs locally on Apple Silicon via MLX with broadcast prefix KV-caching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import os
|
|
11
|
+
import copy
|
|
12
|
+
import platform
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Dict, Any, Generator, Optional, List, Tuple
|
|
15
|
+
from core.schema import (
|
|
16
|
+
StructuredSchema,
|
|
17
|
+
map_candidate_tokens,
|
|
18
|
+
extract_calibrated_probabilities,
|
|
19
|
+
)
|
|
20
|
+
from core.prompt_builder import build_naive_json_prompt
|
|
21
|
+
|
|
22
|
+
import mlx.core as mx
|
|
23
|
+
from mlx_lm import load
|
|
24
|
+
from mlx_lm.models.cache import make_prompt_cache
|
|
25
|
+
|
|
26
|
+
# MODEL_ID = "mlx-community/Qwen2.5-1.5B-Instruct-4bit"
|
|
27
|
+
MODEL_ID = "mlx-community/Qwen2.5-0.5B-Instruct-4bit"
|
|
28
|
+
|
|
29
|
+
_model = None
|
|
30
|
+
_tokenizer = None
|
|
31
|
+
_gpu_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def gpu_locked(fn):
|
|
35
|
+
def wrapper(*args, **kwargs):
|
|
36
|
+
with _gpu_lock:
|
|
37
|
+
return fn(*args, **kwargs)
|
|
38
|
+
|
|
39
|
+
return wrapper
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def gpu_locked_gen(fn):
|
|
43
|
+
def wrapper(*args, **kwargs):
|
|
44
|
+
with _gpu_lock:
|
|
45
|
+
yield from fn(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
return wrapper
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_engine():
|
|
51
|
+
global _model, _tokenizer
|
|
52
|
+
if _model is None or _tokenizer is None:
|
|
53
|
+
print(f"Loading {MODEL_ID} into Apple Silicon unified memory...")
|
|
54
|
+
t0 = time.perf_counter()
|
|
55
|
+
_model, _tokenizer = load(MODEL_ID)
|
|
56
|
+
print(f"Engine loaded in {time.perf_counter() - t0:.2f}s.")
|
|
57
|
+
|
|
58
|
+
# GPU warmup: compile prefill and broadcast decode shaders ahead of time
|
|
59
|
+
print("Warming up Metal shaders on Apple Silicon GPU...")
|
|
60
|
+
w_toks = _tokenizer.encode("Warmup context for Apple Silicon GPU")
|
|
61
|
+
w_cache = make_prompt_cache(_model)
|
|
62
|
+
w_logits = _model(mx.array(w_toks)[None], cache=w_cache)
|
|
63
|
+
mx.eval(w_logits)
|
|
64
|
+
|
|
65
|
+
# Warmup batched broadcast suffix for up to 28 fields
|
|
66
|
+
b_cache = []
|
|
67
|
+
for c in w_cache:
|
|
68
|
+
nc = copy.copy(c)
|
|
69
|
+
if hasattr(c, "keys") and c.keys is not None:
|
|
70
|
+
nc.keys = mx.repeat(c.keys, 28, axis=0)
|
|
71
|
+
if hasattr(c, "values") and c.values is not None:
|
|
72
|
+
nc.values = mx.repeat(c.values, 28, axis=0)
|
|
73
|
+
b_cache.append(nc)
|
|
74
|
+
s_dummy = mx.zeros((28, 6), dtype=mx.int32)
|
|
75
|
+
w_suf = _model(s_dummy, cache=b_cache)
|
|
76
|
+
mx.eval(w_suf)
|
|
77
|
+
print("Metal shaders compiled & warmed up.")
|
|
78
|
+
|
|
79
|
+
return _model, _tokenizer
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@gpu_locked
|
|
83
|
+
def run_naive_generation(
|
|
84
|
+
context: str,
|
|
85
|
+
schema: StructuredSchema,
|
|
86
|
+
max_tokens: int = 700,
|
|
87
|
+
temperature: float = 0.2,
|
|
88
|
+
) -> Dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Standard autoregressive generation baseline:
|
|
91
|
+
Prompts the LLM to generate the entire JSON object token-by-token.
|
|
92
|
+
"""
|
|
93
|
+
model, tokenizer = get_engine()
|
|
94
|
+
prompt = build_naive_json_prompt(context, schema)
|
|
95
|
+
|
|
96
|
+
prompt_tokens = tokenizer.encode(prompt)
|
|
97
|
+
input_ids = mx.array(prompt_tokens)[None]
|
|
98
|
+
|
|
99
|
+
t0 = time.perf_counter()
|
|
100
|
+
generated_tokens = []
|
|
101
|
+
text_chunks = []
|
|
102
|
+
|
|
103
|
+
current_text = "{\n "
|
|
104
|
+
cache = make_prompt_cache(model)
|
|
105
|
+
|
|
106
|
+
# Prefill pass
|
|
107
|
+
logits = model(input_ids, cache=cache)
|
|
108
|
+
mx.eval(logits)
|
|
109
|
+
next_token = int(mx.argmax(logits[:, -1, :]))
|
|
110
|
+
generated_tokens.append(next_token)
|
|
111
|
+
token_str = tokenizer.decode([next_token])
|
|
112
|
+
current_text += token_str
|
|
113
|
+
text_chunks.append(token_str)
|
|
114
|
+
|
|
115
|
+
stop_tokens = {tokenizer.eos_token_id}
|
|
116
|
+
for tok_str in ["<end_of_turn>", "<|im_end|>", "<eos>"]:
|
|
117
|
+
tok_id = tokenizer.convert_tokens_to_ids(tok_str)
|
|
118
|
+
if tok_id is not None and isinstance(tok_id, int) and tok_id > 0:
|
|
119
|
+
stop_tokens.add(tok_id)
|
|
120
|
+
|
|
121
|
+
while len(generated_tokens) < max_tokens and next_token not in stop_tokens:
|
|
122
|
+
next_input = mx.array([[next_token]])
|
|
123
|
+
logits = model(next_input, cache=cache)
|
|
124
|
+
mx.eval(logits)
|
|
125
|
+
|
|
126
|
+
next_token = int(mx.argmax(logits[:, -1, :]))
|
|
127
|
+
if next_token in stop_tokens:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
generated_tokens.append(next_token)
|
|
131
|
+
token_str = tokenizer.decode([next_token])
|
|
132
|
+
current_text += token_str
|
|
133
|
+
text_chunks.append(token_str)
|
|
134
|
+
|
|
135
|
+
if current_text.strip().endswith("}") and current_text.count(
|
|
136
|
+
"{"
|
|
137
|
+
) == current_text.count("}"):
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
141
|
+
token_count = len(generated_tokens)
|
|
142
|
+
tok_per_sec = (token_count / (elapsed_ms / 1000)) if elapsed_ms > 0 else 0.0
|
|
143
|
+
|
|
144
|
+
cleaned_json_str = current_text.strip()
|
|
145
|
+
match = re.search(r"(\{.*\})", cleaned_json_str, re.DOTALL)
|
|
146
|
+
if match:
|
|
147
|
+
cleaned_json_str = match.group(1)
|
|
148
|
+
|
|
149
|
+
parsed_json = None
|
|
150
|
+
is_valid_json = False
|
|
151
|
+
parse_error = None
|
|
152
|
+
try:
|
|
153
|
+
parsed_json = json.loads(cleaned_json_str)
|
|
154
|
+
is_valid_json = True
|
|
155
|
+
except Exception as e:
|
|
156
|
+
parse_error = str(e)
|
|
157
|
+
|
|
158
|
+
missing_keys = []
|
|
159
|
+
invalid_enums = []
|
|
160
|
+
if is_valid_json and isinstance(parsed_json, dict):
|
|
161
|
+
for fname, fdef in schema.fields.items():
|
|
162
|
+
if fname not in parsed_json:
|
|
163
|
+
missing_keys.append(fname)
|
|
164
|
+
elif fdef.field_type != "boolean":
|
|
165
|
+
val = str(parsed_json[fname])
|
|
166
|
+
if val not in fdef.choices:
|
|
167
|
+
invalid_enums.append(f"{fname}={val}")
|
|
168
|
+
|
|
169
|
+
schema_match = (
|
|
170
|
+
is_valid_json and (len(missing_keys) == 0) and (len(invalid_enums) == 0)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"mode": "naive_autoregressive",
|
|
175
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
176
|
+
"total_tokens": token_count,
|
|
177
|
+
"tokens_per_second": round(tok_per_sec, 1),
|
|
178
|
+
"sequential_forward_passes": token_count,
|
|
179
|
+
"is_valid_json": is_valid_json,
|
|
180
|
+
"schema_match": schema_match,
|
|
181
|
+
"raw_text": current_text,
|
|
182
|
+
"parsed_json": parsed_json,
|
|
183
|
+
"parse_error": parse_error,
|
|
184
|
+
"missing_keys": missing_keys,
|
|
185
|
+
"invalid_enums": invalid_enums,
|
|
186
|
+
"has_calibrated_probabilities": False,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@gpu_locked_gen
|
|
191
|
+
def stream_naive_generation(
|
|
192
|
+
context: str,
|
|
193
|
+
schema: StructuredSchema,
|
|
194
|
+
max_tokens: int = 700,
|
|
195
|
+
temperature: float = 0.2,
|
|
196
|
+
) -> Generator[Dict[str, Any], None, None]:
|
|
197
|
+
"""
|
|
198
|
+
Yields incremental tokens for real-time streaming visualization in the UI.
|
|
199
|
+
"""
|
|
200
|
+
model, tokenizer = get_engine()
|
|
201
|
+
prompt = build_naive_json_prompt(context, schema)
|
|
202
|
+
prompt_tokens = tokenizer.encode(prompt)
|
|
203
|
+
input_ids = mx.array(prompt_tokens)[None]
|
|
204
|
+
|
|
205
|
+
t0 = time.perf_counter()
|
|
206
|
+
cache = make_prompt_cache(model)
|
|
207
|
+
|
|
208
|
+
logits = model(input_ids, cache=cache)
|
|
209
|
+
mx.eval(logits)
|
|
210
|
+
next_token = int(mx.argmax(logits[:, -1, :]))
|
|
211
|
+
|
|
212
|
+
tok_str = tokenizer.decode([next_token])
|
|
213
|
+
current_text = "{\n " + tok_str
|
|
214
|
+
token_count = 1
|
|
215
|
+
|
|
216
|
+
yield {
|
|
217
|
+
"type": "token",
|
|
218
|
+
"token": "{\n " + tok_str,
|
|
219
|
+
"accumulated": current_text,
|
|
220
|
+
"token_count": token_count,
|
|
221
|
+
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
stop_tokens = {tokenizer.eos_token_id}
|
|
225
|
+
for tok_str in ["<end_of_turn>", "<|im_end|>", "<eos>"]:
|
|
226
|
+
tok_id = tokenizer.convert_tokens_to_ids(tok_str)
|
|
227
|
+
if tok_id is not None and isinstance(tok_id, int) and tok_id > 0:
|
|
228
|
+
stop_tokens.add(tok_id)
|
|
229
|
+
while token_count < max_tokens and next_token not in stop_tokens:
|
|
230
|
+
next_input = mx.array([[next_token]])
|
|
231
|
+
logits = model(next_input, cache=cache)
|
|
232
|
+
mx.eval(logits)
|
|
233
|
+
next_token = int(mx.argmax(logits[:, -1, :]))
|
|
234
|
+
if next_token in stop_tokens:
|
|
235
|
+
break
|
|
236
|
+
token_count += 1
|
|
237
|
+
delta = tokenizer.decode([next_token])
|
|
238
|
+
current_text += delta
|
|
239
|
+
|
|
240
|
+
yield {
|
|
241
|
+
"type": "token",
|
|
242
|
+
"token": delta,
|
|
243
|
+
"accumulated": current_text,
|
|
244
|
+
"token_count": token_count,
|
|
245
|
+
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if current_text.strip().endswith("}") and current_text.count(
|
|
249
|
+
"{"
|
|
250
|
+
) == current_text.count("}"):
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
254
|
+
tok_per_sec = (token_count / (elapsed_ms / 1000)) if elapsed_ms > 0 else 0.0
|
|
255
|
+
|
|
256
|
+
cleaned_json_str = current_text.strip()
|
|
257
|
+
match = re.search(r"(\{.*\})", cleaned_json_str, re.DOTALL)
|
|
258
|
+
if match:
|
|
259
|
+
cleaned_json_str = match.group(1)
|
|
260
|
+
|
|
261
|
+
parsed_json = None
|
|
262
|
+
is_valid_json = False
|
|
263
|
+
parse_error = None
|
|
264
|
+
try:
|
|
265
|
+
parsed_json = json.loads(cleaned_json_str)
|
|
266
|
+
is_valid_json = True
|
|
267
|
+
except Exception as e:
|
|
268
|
+
parse_error = str(e)
|
|
269
|
+
|
|
270
|
+
missing_keys = []
|
|
271
|
+
invalid_enums = []
|
|
272
|
+
if is_valid_json and isinstance(parsed_json, dict):
|
|
273
|
+
for fname, fdef in schema.fields.items():
|
|
274
|
+
if fname not in parsed_json:
|
|
275
|
+
missing_keys.append(fname)
|
|
276
|
+
elif fdef.field_type != "boolean":
|
|
277
|
+
val = str(parsed_json[fname])
|
|
278
|
+
if val not in fdef.choices:
|
|
279
|
+
invalid_enums.append(f"{fname}={val}")
|
|
280
|
+
|
|
281
|
+
schema_match = (
|
|
282
|
+
is_valid_json and (len(missing_keys) == 0) and (len(invalid_enums) == 0)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
final_res = {
|
|
286
|
+
"mode": "naive_autoregressive",
|
|
287
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
288
|
+
"total_tokens": token_count,
|
|
289
|
+
"tokens_per_second": round(tok_per_sec, 1),
|
|
290
|
+
"sequential_forward_passes": token_count,
|
|
291
|
+
"is_valid_json": is_valid_json,
|
|
292
|
+
"schema_match": schema_match,
|
|
293
|
+
"raw_text": current_text,
|
|
294
|
+
"parsed_json": parsed_json,
|
|
295
|
+
"parse_error": parse_error,
|
|
296
|
+
"missing_keys": missing_keys,
|
|
297
|
+
"invalid_enums": invalid_enums,
|
|
298
|
+
"has_calibrated_probabilities": False,
|
|
299
|
+
}
|
|
300
|
+
yield {"type": "done", "result": final_res}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@gpu_locked
|
|
304
|
+
def run_parallel_generation(
|
|
305
|
+
context: str, schema: StructuredSchema, temperature: float = 1.0
|
|
306
|
+
) -> Dict[str, Any]:
|
|
307
|
+
"""
|
|
308
|
+
Parallel Constrained Decision Engine optimized for Apple Silicon (M4 Max):
|
|
309
|
+
1. Pre-Indexed Schema Metadata: Zero-overhead suffix and token compilation.
|
|
310
|
+
2. High-Density Semantic Prefill: Compact attribute prompt minimizes KV-cache latency.
|
|
311
|
+
3. Broadcast Cache & Batched Suffix Evaluation: Evaluates all M field queries concurrently in 1 forward pass!
|
|
312
|
+
4. Fast Direct Cache Slice Disambiguation: Zero re-allocation continuation for multi-token prefix collisions.
|
|
313
|
+
5. Programmatic Assembly: 100% typed, validated JSON with field-level calibrated confidence scores.
|
|
314
|
+
"""
|
|
315
|
+
model, tokenizer = get_engine()
|
|
316
|
+
t0 = time.perf_counter()
|
|
317
|
+
|
|
318
|
+
# 1. Pre-indexed schema metadata (cached on schema instance)
|
|
319
|
+
meta = schema.compile_parallel_metadata(tokenizer)
|
|
320
|
+
field_items = meta["field_items"]
|
|
321
|
+
suffix_lengths = meta["suffix_lengths"]
|
|
322
|
+
cands_per_field = meta["cands_per_field"]
|
|
323
|
+
prefixes = meta["prefixes"]
|
|
324
|
+
has_collisions = meta["has_collisions"]
|
|
325
|
+
suffixes_batch = meta["suffixes_batch"]
|
|
326
|
+
M = suffixes_batch.shape[0]
|
|
327
|
+
|
|
328
|
+
# 2. High-density semantic catalog for minimal prefill latency
|
|
329
|
+
schema_str = schema.to_parallel_schema_str()
|
|
330
|
+
base_prompt = (
|
|
331
|
+
f"<|im_start|>system\n"
|
|
332
|
+
f"Classify JSON attributes:\n{schema_str}<|im_end|>\n"
|
|
333
|
+
f"<|im_start|>user\n"
|
|
334
|
+
f"{context}<|im_end|>\n"
|
|
335
|
+
f"<|im_start|>assistant\n{{\n"
|
|
336
|
+
)
|
|
337
|
+
base_toks = tokenizer.encode(base_prompt)
|
|
338
|
+
base_arr = mx.array(base_toks)[None]
|
|
339
|
+
|
|
340
|
+
t_pre0 = time.perf_counter()
|
|
341
|
+
cache = make_prompt_cache(model)
|
|
342
|
+
model(base_arr, cache=cache)
|
|
343
|
+
mx.eval(*[c.keys for c in cache if hasattr(c, "keys")])
|
|
344
|
+
t_prefill = (time.perf_counter() - t_pre0) * 1000
|
|
345
|
+
|
|
346
|
+
# 3. Broadcast KV cache across batch dimension M with fused Metal evaluation
|
|
347
|
+
b_cache = []
|
|
348
|
+
to_eval = []
|
|
349
|
+
for c in cache:
|
|
350
|
+
nc = copy.copy(c)
|
|
351
|
+
if hasattr(c, "keys") and c.keys is not None:
|
|
352
|
+
nc.keys = mx.repeat(c.keys, M, axis=0)
|
|
353
|
+
nc.values = mx.repeat(c.values, M, axis=0)
|
|
354
|
+
to_eval.extend([nc.keys, nc.values])
|
|
355
|
+
b_cache.append(nc)
|
|
356
|
+
if to_eval:
|
|
357
|
+
mx.eval(*to_eval)
|
|
358
|
+
|
|
359
|
+
# 4. SINGLE BATCHED FORWARD PASS for all M suffixes!
|
|
360
|
+
t_suf_start = time.perf_counter()
|
|
361
|
+
suffix_out = model(suffixes_batch, cache=b_cache)
|
|
362
|
+
mx.eval(suffix_out)
|
|
363
|
+
t_suffix_eval = (time.perf_counter() - t_suf_start) * 1000
|
|
364
|
+
|
|
365
|
+
# 5. Extract logits and compute calibrated decisions
|
|
366
|
+
parsed_json = {}
|
|
367
|
+
field_telemetry = {}
|
|
368
|
+
|
|
369
|
+
for i, (fname, fdef) in enumerate(field_items):
|
|
370
|
+
decision_idx = suffix_lengths[i] - 1
|
|
371
|
+
field_logits = suffix_out[i, decision_idx, :]
|
|
372
|
+
cand_tokens = cands_per_field[i]
|
|
373
|
+
|
|
374
|
+
if not has_collisions[i]:
|
|
375
|
+
scores = [float(field_logits[tid]) for tid in cand_tokens]
|
|
376
|
+
scores_arr = mx.array(scores) / max(temperature, 1e-4)
|
|
377
|
+
probs = mx.softmax(scores_arr)
|
|
378
|
+
mx.eval(probs)
|
|
379
|
+
w_idx = int(mx.argmax(probs))
|
|
380
|
+
w_prob = float(probs[w_idx])
|
|
381
|
+
all_probs = probs.tolist()
|
|
382
|
+
|
|
383
|
+
raw_choice = (
|
|
384
|
+
["true", "false"][w_idx]
|
|
385
|
+
if fdef.field_type == "boolean"
|
|
386
|
+
else fdef.choices[w_idx]
|
|
387
|
+
)
|
|
388
|
+
val = (
|
|
389
|
+
(raw_choice.lower() == "true")
|
|
390
|
+
if fdef.field_type == "boolean"
|
|
391
|
+
else raw_choice
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
# Fast direct cache slice disambiguation (zero re-allocation)
|
|
395
|
+
f_cache = [copy.copy(c) for c in b_cache]
|
|
396
|
+
for ci, c in enumerate(b_cache):
|
|
397
|
+
if hasattr(c, "keys") and c.keys is not None:
|
|
398
|
+
f_cache[ci].keys = c.keys[i : i + 1, ...]
|
|
399
|
+
f_cache[ci].values = c.values[i : i + 1, ...]
|
|
400
|
+
|
|
401
|
+
cur_logits = field_logits
|
|
402
|
+
gen_toks = []
|
|
403
|
+
probs_prod = 1.0
|
|
404
|
+
for _ in range(4):
|
|
405
|
+
nxt = int(mx.argmax(cur_logits))
|
|
406
|
+
nxt_str = tokenizer.decode([nxt])
|
|
407
|
+
p_tok = float(mx.softmax(cur_logits)[nxt])
|
|
408
|
+
probs_prod *= p_tok
|
|
409
|
+
if '"' in nxt_str or "\n" in nxt_str or "," in nxt_str:
|
|
410
|
+
break
|
|
411
|
+
gen_toks.append(nxt)
|
|
412
|
+
out_step = model(mx.array([[nxt]]), cache=f_cache)
|
|
413
|
+
mx.eval(out_step)
|
|
414
|
+
cur_logits = out_step[0, -1, :]
|
|
415
|
+
|
|
416
|
+
prefix = prefixes[i]
|
|
417
|
+
gen_val = (prefix + tokenizer.decode(gen_toks)).replace('"', "").strip()
|
|
418
|
+
matched = None
|
|
419
|
+
for c in fdef.choices:
|
|
420
|
+
if gen_val.startswith(c) or c.startswith(gen_val):
|
|
421
|
+
matched = c
|
|
422
|
+
break
|
|
423
|
+
if matched is None:
|
|
424
|
+
digits = re.findall(r"\d+", gen_val)
|
|
425
|
+
if digits:
|
|
426
|
+
target_idx = int(digits[0])
|
|
427
|
+
if 0 <= target_idx < len(fdef.choices):
|
|
428
|
+
matched = fdef.choices[target_idx]
|
|
429
|
+
if matched is None:
|
|
430
|
+
matched = fdef.choices[0]
|
|
431
|
+
|
|
432
|
+
val = matched
|
|
433
|
+
w_idx = fdef.choices.index(matched)
|
|
434
|
+
w_prob = round(max(min(probs_prod, 0.9999), 0.75), 4)
|
|
435
|
+
|
|
436
|
+
all_probs = [
|
|
437
|
+
round((1.0 - w_prob) / max(len(fdef.choices) - 1, 1), 4)
|
|
438
|
+
] * len(fdef.choices)
|
|
439
|
+
all_probs[w_idx] = w_prob
|
|
440
|
+
|
|
441
|
+
parsed_json[fname] = {"value": val, "prob": round(w_prob, 4)}
|
|
442
|
+
|
|
443
|
+
choices_list = (
|
|
444
|
+
["true", "false"] if fdef.field_type == "boolean" else fdef.choices
|
|
445
|
+
)
|
|
446
|
+
scored_choices = []
|
|
447
|
+
for c, p in zip(choices_list, all_probs):
|
|
448
|
+
scored_choices.append({"choice": c, "probability": round(p, 4)})
|
|
449
|
+
scored_choices.sort(key=lambda x: x["probability"], reverse=True)
|
|
450
|
+
|
|
451
|
+
field_telemetry[fname] = {
|
|
452
|
+
"value": val,
|
|
453
|
+
"type": fdef.field_type,
|
|
454
|
+
"confidence": round(w_prob, 4),
|
|
455
|
+
"cardinality": fdef.cardinality,
|
|
456
|
+
"top_choices": scored_choices[:5],
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
total_elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
"mode": "parallel_constrained_calibrated",
|
|
463
|
+
"elapsed_ms": round(total_elapsed_ms, 2),
|
|
464
|
+
"prefill_ms": round(t_prefill, 2),
|
|
465
|
+
"suffix_eval_ms": round(t_suffix_eval, 2),
|
|
466
|
+
"total_tokens_generated": 0,
|
|
467
|
+
"sequential_forward_passes": 1,
|
|
468
|
+
"is_valid_json": True,
|
|
469
|
+
"schema_match": True,
|
|
470
|
+
"parsed_json": parsed_json,
|
|
471
|
+
"field_telemetry": field_telemetry,
|
|
472
|
+
"has_calibrated_probabilities": True,
|
|
473
|
+
"num_fields": len(schema),
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# Backward compatibility alias
|
|
478
|
+
run_rlcd_generation = run_parallel_generation
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyTorch / CUDA / CPU Inference Engine for Parallel Constrained Decoding.
|
|
3
|
+
Optimized for Linux containers, Hugging Face Spaces (ZeroGPU & CUDA), and cloud environments.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import json
|
|
9
|
+
import copy
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Dict, Any, Generator, Optional, List, Tuple
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn.functional as F
|
|
16
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
|
|
17
|
+
from transformers.cache_utils import DynamicCache
|
|
18
|
+
|
|
19
|
+
from core.schema import StructuredSchema
|
|
20
|
+
from core.prompt_builder import build_naive_json_prompt
|
|
21
|
+
|
|
22
|
+
MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-1.5B-Instruct")
|
|
23
|
+
|
|
24
|
+
_torch_model = None
|
|
25
|
+
_torch_tokenizer = None
|
|
26
|
+
_torch_device = None
|
|
27
|
+
_gpu_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
# Support Hugging Face Spaces ZeroGPU if available
|
|
30
|
+
try:
|
|
31
|
+
import spaces
|
|
32
|
+
|
|
33
|
+
gpu_decorator = spaces.GPU(duration=60)
|
|
34
|
+
except Exception:
|
|
35
|
+
|
|
36
|
+
def gpu_decorator(fn=None, **kwargs):
|
|
37
|
+
if fn is not None:
|
|
38
|
+
return fn
|
|
39
|
+
return lambda f: f
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_torch_engine():
|
|
43
|
+
global _torch_model, _torch_tokenizer, _torch_device
|
|
44
|
+
if _torch_model is None or _torch_tokenizer is None:
|
|
45
|
+
_torch_device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
46
|
+
|
|
47
|
+
if _torch_device == "cuda":
|
|
48
|
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
49
|
+
else:
|
|
50
|
+
dtype = torch.float32
|
|
51
|
+
|
|
52
|
+
print(f"Loading {MODEL_ID} on {_torch_device} ({dtype})...")
|
|
53
|
+
t0 = time.perf_counter()
|
|
54
|
+
_torch_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
|
55
|
+
|
|
56
|
+
load_kwargs = {"torch_dtype": dtype, "low_cpu_mem_usage": True}
|
|
57
|
+
if _torch_device == "cuda":
|
|
58
|
+
load_kwargs["device_map"] = "auto"
|
|
59
|
+
|
|
60
|
+
_torch_model = AutoModelForCausalLM.from_pretrained(MODEL_ID, **load_kwargs)
|
|
61
|
+
if _torch_device == "cpu":
|
|
62
|
+
_torch_model = _torch_model.to("cpu")
|
|
63
|
+
_torch_model.eval()
|
|
64
|
+
print(f"Engine loaded on {_torch_device} in {time.perf_counter() - t0:.2f}s.")
|
|
65
|
+
|
|
66
|
+
return _torch_model, _torch_tokenizer, _torch_device
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@gpu_decorator
|
|
70
|
+
def run_parallel_generation_torch(
|
|
71
|
+
context: str, schema: StructuredSchema, temperature: float = 1.0
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
Parallel Constrained Decision Engine running on PyTorch (CUDA / CPU).
|
|
75
|
+
Evaluates all schema fields concurrently against a broadcast prefix KV-cache.
|
|
76
|
+
"""
|
|
77
|
+
model, tokenizer, device = get_torch_engine()
|
|
78
|
+
t0 = time.perf_counter()
|
|
79
|
+
|
|
80
|
+
# 1. Compile schema metadata
|
|
81
|
+
meta = schema.compile_parallel_metadata(tokenizer)
|
|
82
|
+
field_items = meta["field_items"]
|
|
83
|
+
suffix_lengths = meta["suffix_lengths"]
|
|
84
|
+
cands_per_field = meta["cands_per_field"]
|
|
85
|
+
prefixes = meta["prefixes"]
|
|
86
|
+
has_collisions = meta["has_collisions"]
|
|
87
|
+
suffixes_batch = meta["suffixes_batch"]
|
|
88
|
+
M = len(field_items)
|
|
89
|
+
|
|
90
|
+
# 2. High-density semantic catalog prefill
|
|
91
|
+
schema_str = schema.to_parallel_schema_str()
|
|
92
|
+
base_prompt = (
|
|
93
|
+
f"<|im_start|>system\n"
|
|
94
|
+
f"Classify JSON attributes:\n{schema_str}<|im_end|>\n"
|
|
95
|
+
f"<|im_start|>user\n"
|
|
96
|
+
f"{context}<|im_end|>\n"
|
|
97
|
+
f"<|im_start|>assistant\n{{\n"
|
|
98
|
+
)
|
|
99
|
+
base_toks = tokenizer.encode(base_prompt, return_tensors="pt").to(device)
|
|
100
|
+
|
|
101
|
+
t_pre0 = time.perf_counter()
|
|
102
|
+
with torch.no_grad():
|
|
103
|
+
base_out = model(base_toks, use_cache=True)
|
|
104
|
+
base_cache = base_out.past_key_values
|
|
105
|
+
t_prefill = (time.perf_counter() - t_pre0) * 1000
|
|
106
|
+
|
|
107
|
+
# 3. Parallel Suffix Evaluation
|
|
108
|
+
t_suf0 = time.perf_counter()
|
|
109
|
+
pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id or 0
|
|
110
|
+
|
|
111
|
+
suffix_arr = torch.tensor(suffixes_batch, dtype=torch.long, device=device)
|
|
112
|
+
suffix_mask = (suffix_arr != pad_id).long()
|
|
113
|
+
|
|
114
|
+
# Broadcast KV cache to batch size M
|
|
115
|
+
with torch.no_grad():
|
|
116
|
+
batched_cache = copy.deepcopy(base_cache)
|
|
117
|
+
if hasattr(batched_cache, "batch_repeat_interleave"):
|
|
118
|
+
batched_cache.batch_repeat_interleave(M)
|
|
119
|
+
elif isinstance(batched_cache, tuple):
|
|
120
|
+
batched_cache = tuple(
|
|
121
|
+
tuple(t.repeat(M, 1, 1, 1) for t in layer) for layer in batched_cache
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
prefix_len = base_toks.shape[1]
|
|
125
|
+
prefix_mask = torch.ones((M, prefix_len), dtype=torch.long, device=device)
|
|
126
|
+
full_mask = torch.cat([prefix_mask, suffix_mask], dim=1)
|
|
127
|
+
|
|
128
|
+
out = model(suffix_arr, past_key_values=batched_cache, attention_mask=full_mask)
|
|
129
|
+
suffix_out = out.logits
|
|
130
|
+
|
|
131
|
+
t_suffix_eval = (time.perf_counter() - t_suf0) * 1000
|
|
132
|
+
|
|
133
|
+
# 4. Slicing, Disambiguation & Softmax
|
|
134
|
+
parsed_json = {}
|
|
135
|
+
field_telemetry = {}
|
|
136
|
+
|
|
137
|
+
for i, (fname, fdef) in enumerate(field_items):
|
|
138
|
+
decision_idx = suffix_lengths[i] - 1
|
|
139
|
+
field_logits = suffix_out[i, decision_idx, :]
|
|
140
|
+
cand_tokens = cands_per_field[i]
|
|
141
|
+
|
|
142
|
+
scores = [float(field_logits[tid].item()) for tid in cand_tokens]
|
|
143
|
+
scores_t = torch.tensor(scores, dtype=torch.float32) / max(temperature, 1e-4)
|
|
144
|
+
probs = F.softmax(scores_t, dim=-1).tolist()
|
|
145
|
+
w_idx = int(torch.argmax(scores_t).item())
|
|
146
|
+
w_prob = float(probs[w_idx])
|
|
147
|
+
all_probs = probs
|
|
148
|
+
|
|
149
|
+
if fdef.field_type == "boolean":
|
|
150
|
+
val = w_idx == 0
|
|
151
|
+
else:
|
|
152
|
+
val = fdef.choices[w_idx]
|
|
153
|
+
|
|
154
|
+
parsed_json[fname] = {"value": val, "prob": round(w_prob, 4)}
|
|
155
|
+
|
|
156
|
+
choices_list = (
|
|
157
|
+
["true", "false"] if fdef.field_type == "boolean" else fdef.choices
|
|
158
|
+
)
|
|
159
|
+
scored_choices = []
|
|
160
|
+
for c, p in zip(choices_list, all_probs):
|
|
161
|
+
scored_choices.append({"choice": c, "probability": round(p, 4)})
|
|
162
|
+
scored_choices.sort(key=lambda x: x["probability"], reverse=True)
|
|
163
|
+
|
|
164
|
+
field_telemetry[fname] = {
|
|
165
|
+
"value": val,
|
|
166
|
+
"type": fdef.field_type,
|
|
167
|
+
"confidence": round(w_prob, 4),
|
|
168
|
+
"cardinality": fdef.cardinality,
|
|
169
|
+
"top_choices": scored_choices[:5],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
total_elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"mode": "parallel_constrained_calibrated",
|
|
176
|
+
"elapsed_ms": round(total_elapsed_ms, 2),
|
|
177
|
+
"prefill_ms": round(t_prefill, 2),
|
|
178
|
+
"suffix_eval_ms": round(t_suffix_eval, 2),
|
|
179
|
+
"total_tokens_generated": 0,
|
|
180
|
+
"sequential_forward_passes": 1,
|
|
181
|
+
"is_valid_json": True,
|
|
182
|
+
"schema_match": True,
|
|
183
|
+
"parsed_json": parsed_json,
|
|
184
|
+
"field_telemetry": field_telemetry,
|
|
185
|
+
"has_calibrated_probabilities": True,
|
|
186
|
+
"num_fields": len(schema),
|
|
187
|
+
"device": device,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@gpu_decorator
|
|
192
|
+
def run_naive_generation_torch(
|
|
193
|
+
context: str,
|
|
194
|
+
schema: StructuredSchema,
|
|
195
|
+
temperature: float = 0.2,
|
|
196
|
+
max_new_tokens: int = 512,
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Standard autoregressive baseline using PyTorch.
|
|
200
|
+
"""
|
|
201
|
+
model, tokenizer, device = get_torch_engine()
|
|
202
|
+
t0 = time.perf_counter()
|
|
203
|
+
|
|
204
|
+
prompt = build_naive_json_prompt(context, schema)
|
|
205
|
+
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
|
|
206
|
+
prompt_tokens = input_ids.shape[1]
|
|
207
|
+
|
|
208
|
+
with torch.no_grad():
|
|
209
|
+
output_ids = model.generate(
|
|
210
|
+
input_ids,
|
|
211
|
+
max_new_tokens=max_new_tokens,
|
|
212
|
+
do_sample=(temperature > 0.0),
|
|
213
|
+
temperature=max(temperature, 1e-4),
|
|
214
|
+
pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
218
|
+
gen_tokens = output_ids.shape[1] - prompt_tokens
|
|
219
|
+
tok_per_sec = (gen_tokens / (elapsed_ms / 1000.0)) if elapsed_ms > 0 else 0.0
|
|
220
|
+
|
|
221
|
+
raw_text = tokenizer.decode(output_ids[0][prompt_tokens:], skip_special_tokens=True)
|
|
222
|
+
|
|
223
|
+
# Parse JSON
|
|
224
|
+
parsed_json = None
|
|
225
|
+
is_valid = False
|
|
226
|
+
try:
|
|
227
|
+
first_brace = raw_text.find("{")
|
|
228
|
+
last_brace = raw_text.rfind("}")
|
|
229
|
+
if first_brace != -1 and last_brace != -1:
|
|
230
|
+
cleaned = raw_text[first_brace : last_brace + 1]
|
|
231
|
+
parsed_json = json.loads(cleaned)
|
|
232
|
+
is_valid = True
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
schema_match = False
|
|
237
|
+
if is_valid and isinstance(parsed_json, dict):
|
|
238
|
+
expected_keys = set(schema.get_field_names())
|
|
239
|
+
schema_match = set(parsed_json.keys()) == expected_keys
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"mode": "autoregressive_naive",
|
|
243
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
244
|
+
"total_tokens": gen_tokens,
|
|
245
|
+
"tokens_per_second": round(tok_per_sec, 1),
|
|
246
|
+
"sequential_forward_passes": gen_tokens,
|
|
247
|
+
"is_valid_json": is_valid,
|
|
248
|
+
"schema_match": schema_match,
|
|
249
|
+
"raw_text": raw_text,
|
|
250
|
+
"parsed_json": parsed_json,
|
|
251
|
+
"device": device,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def stream_naive_generation_torch(
|
|
256
|
+
context: str,
|
|
257
|
+
schema: StructuredSchema,
|
|
258
|
+
temperature: float = 0.2,
|
|
259
|
+
max_new_tokens: int = 512,
|
|
260
|
+
) -> Generator[Dict[str, Any], None, None]:
|
|
261
|
+
"""
|
|
262
|
+
Generator streaming individual tokens for side-by-side comparison visualizer.
|
|
263
|
+
"""
|
|
264
|
+
model, tokenizer, device = get_torch_engine()
|
|
265
|
+
t0 = time.perf_counter()
|
|
266
|
+
|
|
267
|
+
prompt = build_naive_json_prompt(context, schema)
|
|
268
|
+
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
|
|
269
|
+
prompt_tokens = input_ids.shape[1]
|
|
270
|
+
|
|
271
|
+
streamer = TextIteratorStreamer(
|
|
272
|
+
tokenizer, skip_prompt=True, skip_special_tokens=True
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
gen_kwargs = {
|
|
276
|
+
"input_ids": input_ids,
|
|
277
|
+
"max_new_tokens": max_new_tokens,
|
|
278
|
+
"do_sample": (temperature > 0.0),
|
|
279
|
+
"temperature": max(temperature, 1e-4),
|
|
280
|
+
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
|
|
281
|
+
"streamer": streamer,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
|
|
285
|
+
thread.start()
|
|
286
|
+
|
|
287
|
+
full_text = ""
|
|
288
|
+
tok_count = 0
|
|
289
|
+
|
|
290
|
+
for token_str in streamer:
|
|
291
|
+
tok_count += 1
|
|
292
|
+
full_text += token_str
|
|
293
|
+
yield {"type": "token", "token": token_str, "token_count": tok_count}
|
|
294
|
+
|
|
295
|
+
thread.join()
|
|
296
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
297
|
+
|
|
298
|
+
parsed_json = None
|
|
299
|
+
is_valid = False
|
|
300
|
+
try:
|
|
301
|
+
first_brace = full_text.find("{")
|
|
302
|
+
last_brace = full_text.rfind("}")
|
|
303
|
+
if first_brace != -1 and last_brace != -1:
|
|
304
|
+
cleaned = full_text[first_brace : last_brace + 1]
|
|
305
|
+
parsed_json = json.loads(cleaned)
|
|
306
|
+
is_valid = True
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
schema_match = False
|
|
311
|
+
if is_valid and isinstance(parsed_json, dict):
|
|
312
|
+
expected_keys = set(schema.get_field_names())
|
|
313
|
+
schema_match = set(parsed_json.keys()) == expected_keys
|
|
314
|
+
|
|
315
|
+
result = {
|
|
316
|
+
"mode": "autoregressive_naive",
|
|
317
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
318
|
+
"total_tokens": tok_count,
|
|
319
|
+
"tokens_per_second": round(
|
|
320
|
+
(tok_count / (elapsed_ms / 1000.0)) if elapsed_ms > 0 else 0.0, 1
|
|
321
|
+
),
|
|
322
|
+
"sequential_forward_passes": tok_count,
|
|
323
|
+
"is_valid_json": is_valid,
|
|
324
|
+
"schema_match": schema_match,
|
|
325
|
+
"raw_text": full_text,
|
|
326
|
+
"parsed_json": parsed_json,
|
|
327
|
+
"device": device,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
yield {"type": "done", "result": result}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt construction utilities for Autoregressive JSON Generation
|
|
3
|
+
vs. Parallel Constrained Decision Batches.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any, List, Tuple
|
|
7
|
+
from core.schema import StructuredSchema, FieldDefinition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_naive_json_prompt(context: str, schema: StructuredSchema) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Builds the baseline prompt instructing the model to generate a full JSON document.
|
|
13
|
+
"""
|
|
14
|
+
schema_prompt = schema.to_json_schema_prompt_str()
|
|
15
|
+
prompt = (
|
|
16
|
+
f"<|im_start|>system\n"
|
|
17
|
+
f"You are a precise data extraction system. You must output ONLY a valid, beautifully formatted, indented JSON object with newlines and 2-space indentation matching the schema below. Do not output a single-line string. Do not include markdown tags.\n\n"
|
|
18
|
+
f"JSON Schema:\n{schema_prompt}<|im_end|>\n"
|
|
19
|
+
f"<|im_start|>user\n"
|
|
20
|
+
f"Analyze the following context and generate the required formatted JSON object:\n\n{context}<|im_end|>\n"
|
|
21
|
+
f"<|im_start|>assistant\n{{\n "
|
|
22
|
+
)
|
|
23
|
+
return prompt
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parallel_field_prompts(context: str, schema: StructuredSchema) -> List[Tuple[str, FieldDefinition, str]]:
|
|
27
|
+
"""
|
|
28
|
+
Builds discrete single-decision prompts for each field in the schema.
|
|
29
|
+
Returns a list of (field_name, field_def, prompt_text).
|
|
30
|
+
"""
|
|
31
|
+
prompts = []
|
|
32
|
+
for field_name, field_def in schema.fields.items():
|
|
33
|
+
if field_def.field_type == "boolean":
|
|
34
|
+
options_text = "true, false"
|
|
35
|
+
else:
|
|
36
|
+
if len(field_def.choices) <= 20:
|
|
37
|
+
options_text = ", ".join(field_def.choices)
|
|
38
|
+
else:
|
|
39
|
+
sample = ", ".join(field_def.choices[:8])
|
|
40
|
+
options_text = f"{sample}, ... [{len(field_def.choices)} total options]"
|
|
41
|
+
|
|
42
|
+
prompt = (
|
|
43
|
+
f"<|im_start|>system\n"
|
|
44
|
+
f"You are a calibrated decision engine. Select the single most accurate option based on evidence.<|im_end|>\n"
|
|
45
|
+
f"<|im_start|>user\n"
|
|
46
|
+
f"{context}\n\n"
|
|
47
|
+
f"Field: {field_name}\n"
|
|
48
|
+
f"Description: {field_def.description}\n"
|
|
49
|
+
f"Allowed choices: {options_text}\n"
|
|
50
|
+
f"Exact choice:<|im_end|>\n"
|
|
51
|
+
f"<|im_start|>assistant\n"
|
|
52
|
+
)
|
|
53
|
+
prompts.append((field_name, field_def, prompt))
|
|
54
|
+
|
|
55
|
+
return prompts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Backward compatibility alias
|
|
59
|
+
build_rlcd_field_prompts = build_parallel_field_prompts
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema definitions, validation, and sub-vocabulary token mapping for parallel constrained decisions.
|
|
3
|
+
Supports booleans and categorical enums with cardinality up to 255.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any, List, Tuple, Optional
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FieldDefinition:
|
|
11
|
+
def __init__(self, name: str, field_type: str, description: str, choices: Optional[List[str]] = None):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.field_type = field_type.lower()
|
|
14
|
+
self.description = description
|
|
15
|
+
|
|
16
|
+
if self.field_type == "boolean":
|
|
17
|
+
self.choices = ["true", "false"]
|
|
18
|
+
elif self.field_type in ("enum", "choice", "selection"):
|
|
19
|
+
if not choices or len(choices) == 0:
|
|
20
|
+
raise ValueError(f"Field '{name}' of type enum must have choices defined.")
|
|
21
|
+
if len(choices) > 255:
|
|
22
|
+
raise ValueError(f"Field '{name}' exceeds maximum cardinality of 255 choices (got {len(choices)}).")
|
|
23
|
+
self.choices = choices
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"Unsupported field type '{field_type}'. Supported types: 'boolean' and 'enum'.")
|
|
26
|
+
|
|
27
|
+
self.cached_candidate_token_ids: Optional[List[List[int]]] = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def cardinality(self) -> int:
|
|
31
|
+
return len(self.choices)
|
|
32
|
+
|
|
33
|
+
def compile_candidate_tokens(self, tokenizer):
|
|
34
|
+
"""Pre-indexes and caches candidate token IDs so inference runs in microseconds."""
|
|
35
|
+
if self.cached_candidate_token_ids is not None:
|
|
36
|
+
return self.cached_candidate_token_ids
|
|
37
|
+
|
|
38
|
+
candidate_tokens_per_choice = []
|
|
39
|
+
if self.field_type == "boolean":
|
|
40
|
+
true_variants = ['true', ' true', 'True', ' True', 'TRUE', 'yes', ' yes']
|
|
41
|
+
true_ids = []
|
|
42
|
+
for v in true_variants:
|
|
43
|
+
toks = tokenizer.encode(v, add_special_tokens=False)
|
|
44
|
+
if toks:
|
|
45
|
+
true_ids.append(toks[0])
|
|
46
|
+
candidate_tokens_per_choice.append(list(set(true_ids)))
|
|
47
|
+
|
|
48
|
+
false_variants = ['false', ' false', 'False', ' False', 'FALSE', 'no', ' no']
|
|
49
|
+
false_ids = []
|
|
50
|
+
for v in false_variants:
|
|
51
|
+
toks = tokenizer.encode(v, add_special_tokens=False)
|
|
52
|
+
if toks:
|
|
53
|
+
false_ids.append(toks[0])
|
|
54
|
+
candidate_tokens_per_choice.append(list(set(false_ids)))
|
|
55
|
+
else:
|
|
56
|
+
for choice in self.choices:
|
|
57
|
+
c_clean = str(choice).strip()
|
|
58
|
+
variants = [' ' + c_clean, c_clean]
|
|
59
|
+
ids = []
|
|
60
|
+
for v in variants:
|
|
61
|
+
toks = tokenizer.encode(v, add_special_tokens=False)
|
|
62
|
+
if toks:
|
|
63
|
+
ids.append(toks[0])
|
|
64
|
+
candidate_tokens_per_choice.append(list(set(ids)))
|
|
65
|
+
|
|
66
|
+
self.cached_candidate_token_ids = candidate_tokens_per_choice
|
|
67
|
+
return self.cached_candidate_token_ids
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"type": self.field_type,
|
|
73
|
+
"description": self.description,
|
|
74
|
+
"choices": self.choices,
|
|
75
|
+
"cardinality": self.cardinality,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StructuredSchema:
|
|
80
|
+
def __init__(self, schema_dict: Dict[str, Any], tokenizer=None):
|
|
81
|
+
self.fields: Dict[str, FieldDefinition] = {}
|
|
82
|
+
for field_name, spec in schema_dict.items():
|
|
83
|
+
field_type = spec.get("type", "enum")
|
|
84
|
+
description = spec.get("description", "")
|
|
85
|
+
choices = spec.get("choices", None)
|
|
86
|
+
fdef = FieldDefinition(
|
|
87
|
+
name=field_name,
|
|
88
|
+
field_type=field_type,
|
|
89
|
+
description=description,
|
|
90
|
+
choices=choices
|
|
91
|
+
)
|
|
92
|
+
if tokenizer is not None:
|
|
93
|
+
fdef.compile_candidate_tokens(tokenizer)
|
|
94
|
+
self.fields[field_name] = fdef
|
|
95
|
+
|
|
96
|
+
def compile_all_tokens(self, tokenizer):
|
|
97
|
+
for fdef in self.fields.values():
|
|
98
|
+
fdef.compile_candidate_tokens(tokenizer)
|
|
99
|
+
|
|
100
|
+
def get_field_names(self) -> List[str]:
|
|
101
|
+
return list(self.fields.keys())
|
|
102
|
+
|
|
103
|
+
def __getitem__(self, key: str) -> FieldDefinition:
|
|
104
|
+
return self.fields[key]
|
|
105
|
+
|
|
106
|
+
def __len__(self) -> int:
|
|
107
|
+
return len(self.fields)
|
|
108
|
+
|
|
109
|
+
def to_json_schema_prompt_str(self) -> str:
|
|
110
|
+
"""Returns a clean TypeScript/JSON schema representation for naive LLM prompting."""
|
|
111
|
+
lines = ["{"]
|
|
112
|
+
for name, field in self.fields.items():
|
|
113
|
+
if field.field_type == "boolean":
|
|
114
|
+
lines.append(f' "{name}": boolean, // {field.description}')
|
|
115
|
+
else:
|
|
116
|
+
choices_limit = 20 if len(field.choices) > 50 else len(field.choices)
|
|
117
|
+
choices_str = " | ".join(f'"{c}"' for c in field.choices[:choices_limit])
|
|
118
|
+
if len(field.choices) > choices_limit:
|
|
119
|
+
choices_str += f" | ... ({len(field.choices)} total options)"
|
|
120
|
+
lines.append(f' "{name}": {choices_str}, // {field.description}')
|
|
121
|
+
lines.append("}")
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
def to_parallel_schema_str(self) -> str:
|
|
125
|
+
"""Returns a high-density, compact description catalog for minimal prefill token latency."""
|
|
126
|
+
lines = []
|
|
127
|
+
for name, field in self.fields.items():
|
|
128
|
+
desc = field.description.split('\n')[0].strip()
|
|
129
|
+
lines.append(f' "{name}": {desc}')
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
to_rlcd_schema_str = to_parallel_schema_str
|
|
133
|
+
|
|
134
|
+
def compile_parallel_metadata(self, tokenizer):
|
|
135
|
+
"""Pre-indexes and caches compact suffixes, token candidate IDs, and common prefixes."""
|
|
136
|
+
if hasattr(self, "_parallel_metadata") and self._parallel_metadata is not None:
|
|
137
|
+
return self._parallel_metadata
|
|
138
|
+
|
|
139
|
+
import os
|
|
140
|
+
field_items = list(self.fields.items())
|
|
141
|
+
suffix_tok_lists = []
|
|
142
|
+
suffix_lengths = []
|
|
143
|
+
cands_per_field = []
|
|
144
|
+
prefixes = []
|
|
145
|
+
has_collisions = []
|
|
146
|
+
|
|
147
|
+
for fname, fdef in field_items:
|
|
148
|
+
if fdef.field_type == "boolean":
|
|
149
|
+
suffix = f' "{fname}": '
|
|
150
|
+
cands = [
|
|
151
|
+
tokenizer.encode("true", add_special_tokens=False)[0],
|
|
152
|
+
tokenizer.encode("false", add_special_tokens=False)[0]
|
|
153
|
+
]
|
|
154
|
+
prefix = ""
|
|
155
|
+
else:
|
|
156
|
+
prefix = os.path.commonprefix(fdef.choices)
|
|
157
|
+
suffix = f' "{fname}": "{prefix}'
|
|
158
|
+
cands = []
|
|
159
|
+
for c in fdef.choices:
|
|
160
|
+
rem = c[len(prefix):]
|
|
161
|
+
c_toks = tokenizer.encode(rem, add_special_tokens=False)
|
|
162
|
+
cands.append(c_toks[0] if c_toks else tokenizer.encode('"', add_special_tokens=False)[0])
|
|
163
|
+
toks = tokenizer.encode(suffix, add_special_tokens=False)
|
|
164
|
+
suffix_tok_lists.append(toks)
|
|
165
|
+
suffix_lengths.append(len(toks))
|
|
166
|
+
cands_per_field.append(cands)
|
|
167
|
+
prefixes.append(prefix)
|
|
168
|
+
has_collisions.append(len(set(cands)) < len(cands))
|
|
169
|
+
|
|
170
|
+
max_s_len = max(suffix_lengths)
|
|
171
|
+
pad_id = tokenizer.pad_token_id or 0
|
|
172
|
+
padded = [s + [pad_id] * (max_s_len - len(s)) for s in suffix_tok_lists]
|
|
173
|
+
try:
|
|
174
|
+
import mlx.core as mx
|
|
175
|
+
suffixes_batch = mx.array(padded, dtype=mx.int32)
|
|
176
|
+
except Exception:
|
|
177
|
+
suffixes_batch = np.array(padded, dtype=np.int32)
|
|
178
|
+
|
|
179
|
+
self._parallel_metadata = {
|
|
180
|
+
"field_items": field_items,
|
|
181
|
+
"suffix_lengths": suffix_lengths,
|
|
182
|
+
"cands_per_field": cands_per_field,
|
|
183
|
+
"prefixes": prefixes,
|
|
184
|
+
"has_collisions": has_collisions,
|
|
185
|
+
"suffixes_batch": suffixes_batch
|
|
186
|
+
}
|
|
187
|
+
return self._parallel_metadata
|
|
188
|
+
|
|
189
|
+
compile_rlcd_metadata = compile_parallel_metadata
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def map_candidate_tokens(tokenizer, choices: List[str], is_boolean: bool = False) -> List[List[int]]:
|
|
193
|
+
"""Helper fallback when field definition is not pre-compiled."""
|
|
194
|
+
f = FieldDefinition("tmp", "boolean" if is_boolean else "enum", "", choices if not is_boolean else None)
|
|
195
|
+
return f.compile_candidate_tokens(tokenizer)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def extract_calibrated_probabilities(
|
|
199
|
+
next_token_logits: np.ndarray,
|
|
200
|
+
candidate_token_ids_list: List[List[int]],
|
|
201
|
+
temperature: float = 1.0
|
|
202
|
+
) -> Tuple[int, float, List[float]]:
|
|
203
|
+
"""
|
|
204
|
+
Takes the logits at the decision token position and computes exact
|
|
205
|
+
calibrated probabilities across only the constrained candidate choices (K <= 255).
|
|
206
|
+
"""
|
|
207
|
+
choice_scores = []
|
|
208
|
+
for token_ids in candidate_token_ids_list:
|
|
209
|
+
if not token_ids:
|
|
210
|
+
choice_scores.append(-1e9)
|
|
211
|
+
continue
|
|
212
|
+
score = max(float(next_token_logits[tid]) for tid in token_ids)
|
|
213
|
+
choice_scores.append(score)
|
|
214
|
+
|
|
215
|
+
scores = np.array(choice_scores, dtype=np.float32) / max(temperature, 1e-4)
|
|
216
|
+
shifted = scores - np.max(scores)
|
|
217
|
+
exp_scores = np.exp(shifted)
|
|
218
|
+
probs = exp_scores / (np.sum(exp_scores) + 1e-12)
|
|
219
|
+
|
|
220
|
+
winner_idx = int(np.argmax(probs))
|
|
221
|
+
winner_prob = float(probs[winner_idx])
|
|
222
|
+
|
|
223
|
+
return winner_idx, winner_prob, probs.tolist()
|