gemmarouter 0.1.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.
gemmarouter/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """Command-line interface for gemmarouter (`gemmarouter route` and `gemmarouter playground`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Sequence
9
+
10
+ from .router import DEFAULT_REPO_ID, load
11
+
12
+
13
+ def _parse_option_flags(raw_options: list[str]) -> dict[str, str]:
14
+ """Parse `-o 'key:description'` CLI flags into an options dictionary."""
15
+ parsed: dict[str, str] = {}
16
+ for item in raw_options:
17
+ if ":" in item:
18
+ k, v = item.split(":", 1)
19
+ parsed[k.strip()] = v.strip()
20
+ else:
21
+ parsed[item.strip()] = item.strip()
22
+ return parsed
23
+
24
+
25
+ def _run_route_cmd(args: argparse.Namespace) -> int:
26
+ """Execute the `gemmarouter route` CLI subcommand."""
27
+ options = _parse_option_flags(args.option)
28
+ router = load(model_id=args.model, device=args.device, threshold=args.threshold)
29
+ res = router.route(args.query, options=options, threshold=args.threshold)
30
+ if args.json:
31
+ print(json.dumps(res.to_dict(), indent=2))
32
+ return 0
33
+
34
+ status = "ABSTAIN (None of the options fit)" if res.abstain else f"ROUTED -> {res.decision}"
35
+ print("=" * 68)
36
+ print(f"Query : {args.query}")
37
+ print(f"Decision : {status}")
38
+ print(f"Top Candidate : {res.top_option} ({res.confidence * 100:.2f}%)")
39
+ print(f"P(null) : {res.null_probability:.4f} (threshold={res.threshold:.2f})")
40
+ print(f"Latency : {res.latency_ms:.2f} ms (Head: {res.head_latency_ms:.3f} ms)")
41
+ print("=" * 68)
42
+ return 0
43
+
44
+
45
+ def _run_playground_cmd(args: argparse.Namespace) -> int:
46
+ """Execute the `gemmarouter playground` CLI subcommand."""
47
+ from .playground import launch_playground
48
+
49
+ launch_playground(
50
+ host=args.host,
51
+ port=args.port,
52
+ our_repo=args.model,
53
+ open_browser=not args.no_browser,
54
+ preload=not args.no_preload,
55
+ )
56
+ return 0
57
+
58
+
59
+ def build_parser() -> argparse.ArgumentParser:
60
+ """Build the top-level `gemmarouter` CLI argument parser."""
61
+ parser = argparse.ArgumentParser(
62
+ prog="gemmarouter",
63
+ description="Set-Equivariant Decision & Tool Router on EmbeddingGemma 2",
64
+ )
65
+ sub = parser.add_subparsers(dest="command", required=True)
66
+
67
+ p_route = sub.add_parser("route", help="Route a query across candidate options from the terminal")
68
+ p_route.add_argument("query", help="Incoming user query or agent prompt")
69
+ p_route.add_argument(
70
+ "-o",
71
+ "--option",
72
+ action="append",
73
+ required=True,
74
+ help="Candidate option in 'key:description' format (pass at least two -o flags)",
75
+ )
76
+ p_route.add_argument("--threshold", type=float, default=0.58, help="Null Gate abstention threshold (default: 0.58)")
77
+ p_route.add_argument("--model", default=DEFAULT_REPO_ID, help="Hugging Face model ID or local path")
78
+ p_route.add_argument("--device", default=None, help="PyTorch device ('cpu' or 'cuda')")
79
+ p_route.add_argument("--json", action="store_true", help="Output raw JSON")
80
+
81
+ p_play = sub.add_parser("playground", help="Launch the interactive 3-column Live Decision Playground web UI")
82
+ p_play.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
83
+ p_play.add_argument("--port", type=int, default=7860, help="Bind port (default: 7860)")
84
+ p_play.add_argument("--model", default=DEFAULT_REPO_ID, help="Hugging Face model ID or local path")
85
+ p_play.add_argument("--no-browser", action="store_true", help="Do not auto-open browser tab")
86
+ p_play.add_argument("--no-preload", action="store_true", help="Lazy-load models on first request")
87
+ return parser
88
+
89
+
90
+ def main(argv: Sequence[str] | None = None) -> int:
91
+ """Entrypoint for the `gemmarouter` console script."""
92
+ parser = build_parser()
93
+ args = parser.parse_args(argv)
94
+ if args.command == "route":
95
+ return _run_route_cmd(args)
96
+ if args.command == "playground":
97
+ return _run_playground_cmd(args)
98
+ parser.print_help()
99
+ return 1
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,50 @@
1
+ """Configuration for GemmaDecisionRouterModel: Set-Equivariant Decision Head + Null Gate on EmbeddingGemma 2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from transformers import AutoConfig, PretrainedConfig
8
+ from transformers.models.auto.configuration_auto import CONFIG_MAPPING
9
+
10
+ QUESTION_TYPES = ("choice", "score", "noul")
11
+
12
+
13
+ class GemmaDecisionRouterConfig(PretrainedConfig):
14
+ """Configuration class for GemmaDecisionRouterModel."""
15
+
16
+ model_type = "gemma_decision_router"
17
+ sub_configs = {"encoder_config": AutoConfig}
18
+
19
+ def __init__(
20
+ self,
21
+ encoder_config: dict[str, Any] | PretrainedConfig | None = None,
22
+ embed_dim: int = 768,
23
+ n_heads: int = 8,
24
+ n_layers: int = 2,
25
+ decision_temperature: float = 0.05,
26
+ null_threshold: float = 0.58,
27
+ max_seq_length: int = 2048,
28
+ prompt: str = "task: classification | query: ",
29
+ option_prompt: str = "title: none | text: ",
30
+ question_types: list[str] | None = None,
31
+ **kwargs: Any,
32
+ ) -> None:
33
+ if isinstance(encoder_config, dict):
34
+ model_type = encoder_config.get("model_type")
35
+ if model_type is not None and model_type in CONFIG_MAPPING:
36
+ encoder_config = CONFIG_MAPPING[model_type].from_dict(encoder_config)
37
+ self.encoder_config = encoder_config
38
+ self.embed_dim = embed_dim
39
+ self.n_heads = n_heads
40
+ self.n_layers = n_layers
41
+ self.decision_temperature = decision_temperature
42
+ self.null_threshold = null_threshold
43
+ self.max_seq_length = max_seq_length
44
+ self.prompt = prompt
45
+ self.option_prompt = option_prompt
46
+ self.question_types = list(question_types or QUESTION_TYPES)
47
+ super().__init__(**kwargs)
48
+
49
+
50
+ __all__ = ["GemmaDecisionRouterConfig", "QUESTION_TYPES"]
gemmarouter/head.py ADDED
@@ -0,0 +1,90 @@
1
+ """Set-equivariant decision head with cross-option competition and decoupled coverage gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+ from torch import Tensor
9
+
10
+
11
+ class SetEquivariantDecisionHead(nn.Module):
12
+ """Permutation-equivariant 2-layer Transformer decision head with zero option positional encodings."""
13
+
14
+ def __init__(self, d_model: int = 768, n_heads: int = 8, n_layers: int = 2) -> None:
15
+ super().__init__()
16
+ self.query_role = nn.Parameter(torch.randn(1, 1, d_model) * 0.02)
17
+ self.option_role = nn.Parameter(torch.randn(1, 1, d_model) * 0.02)
18
+ self.q_proj = nn.Linear(d_model, d_model)
19
+ self.o_proj = nn.Linear(d_model, d_model)
20
+ self.pair_proj = nn.Linear(d_model + 1, d_model)
21
+
22
+ encoder_layer = nn.TransformerEncoderLayer(
23
+ d_model=d_model,
24
+ nhead=n_heads,
25
+ dim_feedforward=d_model * 2,
26
+ dropout=0.1,
27
+ activation="gelu",
28
+ batch_first=True,
29
+ norm_first=True,
30
+ )
31
+ self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
32
+ self.option_scorer = nn.Sequential(
33
+ nn.Linear(d_model * 2, 256),
34
+ nn.GELU(),
35
+ nn.Dropout(0.1),
36
+ nn.Linear(256, 1),
37
+ )
38
+ nn.init.normal_(self.option_scorer[-1].weight, std=0.01)
39
+ nn.init.zeros_(self.option_scorer[-1].bias)
40
+
41
+ self.log_tau = nn.Parameter(torch.tensor(-3.5))
42
+ self.a_null = nn.Parameter(torch.tensor(-100.0))
43
+ self.b_null = nn.Parameter(torch.tensor(74.0))
44
+ self.null_mlp = nn.Sequential(nn.Linear(6, 64), nn.GELU(), nn.Linear(64, 1))
45
+ nn.init.normal_(self.null_mlp[-1].weight, std=0.02)
46
+ nn.init.zeros_(self.null_mlp[-1].bias)
47
+
48
+ def _compute_null_logits(
49
+ self, raw_sims: Tensor, adapt_sims: Tensor, choice_logits: Tensor
50
+ ) -> Tensor:
51
+ """Compute 6-feature permutation-invariant set statistics and return null logits [B]."""
52
+ k_top = min(2, raw_sims.size(1))
53
+ top2_raw, _ = torch.topk(raw_sims, k=k_top, dim=-1)
54
+ top2_adp, _ = torch.topk(adapt_sims, k=k_top, dim=-1)
55
+ max_sim = top2_raw[:, 0:1]
56
+ raw_margin = top2_raw[:, 0:1] - (top2_raw[:, 1:2] if k_top > 1 else top2_raw[:, 0:1])
57
+ adp_max = top2_adp[:, 0:1]
58
+ adp_margin = top2_adp[:, 0:1] - (top2_adp[:, 1:2] if k_top > 1 else top2_adp[:, 0:1])
59
+ mean_sim = raw_sims.mean(dim=-1, keepdim=True)
60
+
61
+ probs = F.softmax(choice_logits, dim=-1)
62
+ entropy = -(probs * torch.log(probs.clamp(min=1e-9))).sum(dim=-1, keepdim=True)
63
+ stats = torch.cat(
64
+ [max_sim * 10.0, raw_margin * 10.0, adp_max * 10.0, adp_margin * 10.0, mean_sim * 10.0, entropy],
65
+ dim=-1,
66
+ )
67
+ base_null = self.a_null * max_sim.squeeze(-1) + self.b_null
68
+ return base_null + self.null_mlp(stats).squeeze(-1)
69
+
70
+ def forward(self, query_emb: Tensor, option_embs: Tensor) -> tuple[Tensor, Tensor]:
71
+ """Compute per-option choice logits [B, K] and set-level null logits [B]."""
72
+ raw_sims = torch.bmm(option_embs, query_emb.unsqueeze(-1)).squeeze(-1)
73
+ q_adapt = F.normalize(query_emb + 0.2 * self.q_proj(query_emb), p=2, dim=-1)
74
+ o_adapt = F.normalize(option_embs + 0.2 * self.o_proj(option_embs), p=2, dim=-1)
75
+ adapt_sims = torch.bmm(o_adapt, q_adapt.unsqueeze(-1)).squeeze(-1)
76
+
77
+ q_exp = q_adapt.unsqueeze(1).expand_as(o_adapt)
78
+ pair_feat = torch.cat([q_exp * o_adapt, adapt_sims.unsqueeze(-1)], dim=-1)
79
+ q_tok = q_adapt.unsqueeze(1) + self.query_role
80
+ o_toks = o_adapt + self.option_role + 0.2 * self.pair_proj(pair_feat)
81
+
82
+ hidden = self.transformer(torch.cat([q_tok, o_toks], dim=1))
83
+ q_context = hidden[:, 0:1, :].expand_as(option_embs)
84
+ o_context = hidden[:, 1:, :]
85
+
86
+ tau = torch.exp(self.log_tau).clamp(min=1e-3, max=1.0)
87
+ delta_scores = self.option_scorer(torch.cat([o_context, q_context * o_context], dim=-1)).squeeze(-1)
88
+ choice_logits = (0.5 * raw_sims + 0.5 * adapt_sims) / tau + delta_scores
89
+ null_logits = self._compute_null_logits(raw_sims, adapt_sims, choice_logits)
90
+ return choice_logits, null_logits
@@ -0,0 +1,335 @@
1
+ """GemmaDecisionRouter: Set-Equivariant Typed Decision & Router Model on top of EmbeddingGemma 2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ import json
7
+ from dataclasses import asdict, dataclass
8
+ from typing import Any, Literal
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ import torch.nn.functional as F
13
+ from torch import Tensor
14
+ from transformers import AutoModel, AutoTokenizer, PreTrainedModel
15
+ from transformers.utils import ModelOutput
16
+
17
+ from .configuration import QUESTION_TYPES, GemmaDecisionRouterConfig
18
+ from .head import SetEquivariantDecisionHead
19
+
20
+ Kind = Literal["choice", "score", "noul"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class QuestionSpec:
25
+ """Normalized specification for a single routing or decision question."""
26
+
27
+ qid: str
28
+ kind: Kind
29
+ instructions: str
30
+ options: tuple[str, ...]
31
+ descriptions: tuple[str, ...]
32
+
33
+ @property
34
+ def kind_id(self) -> int:
35
+ return QUESTION_TYPES.index(self.kind)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TypedAnswer:
40
+ """Structured answer returned by GemmaDecisionRouterModel.decide()."""
41
+
42
+ kind: Kind
43
+ label: str
44
+ value: str | int | bool
45
+ confidence: float
46
+ probabilities: dict[str, float]
47
+ expected_level: float | None = None
48
+ null_probability: float | None = None
49
+ abstain: bool = False
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return asdict(self)
53
+
54
+
55
+ @dataclass
56
+ class GemmaDecisionOutput(ModelOutput):
57
+ """Output container for GemmaDecisionRouterModel.forward()."""
58
+
59
+ loss: Tensor | None = None
60
+ logits: Tensor | None = None
61
+ null_logits: Tensor | None = None
62
+ probabilities: Tensor | None = None
63
+ text_embeds: Tensor | None = None
64
+
65
+
66
+ def _json(x: Any) -> Any:
67
+ return json.loads(x) if isinstance(x, str) else x
68
+
69
+
70
+ def _is_int(x: Any) -> bool:
71
+ return str(x).lstrip("-").isdigit()
72
+
73
+
74
+ def _parse_score_criteria(crit: Any, probs: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
75
+ """Extract option keys and descriptions for a `score` question."""
76
+ if isinstance(crit, list):
77
+ return tuple(str(i) for i in range(len(crit))), tuple(str(c) for c in crit)
78
+ if isinstance(crit, dict) and all(_is_int(k) for k in crit):
79
+ keys = sorted(crit, key=int)
80
+ return tuple(str(k) for k in keys), tuple(str(crit[k]) for k in keys)
81
+ if isinstance(crit, dict):
82
+ return tuple(str(i) for i in range(len(crit))), tuple(f"{k}: {v}" for k, v in crit.items())
83
+ opts = tuple(str(k) for k in sorted(probs, key=int))
84
+ return opts, opts
85
+
86
+
87
+ def parse_question(qid: str, question: dict[str, Any], gold: dict[str, Any] | None = None) -> QuestionSpec:
88
+ """Parse a raw question dictionary into a strongly typed QuestionSpec."""
89
+ raw_kind = question.get("type") or (gold or {}).get("type") or "choice"
90
+ kind: Kind = raw_kind if raw_kind in QUESTION_TYPES else "choice"
91
+ crit = question.get("criteria")
92
+ probs = (gold or {}).get("probabilities") or {}
93
+
94
+ if kind == "noul":
95
+ if isinstance(crit, dict) and "false" in crit and "true" in crit:
96
+ opts, desc = ("false", "true"), (str(crit["false"]), str(crit["true"]))
97
+ else:
98
+ opts, desc = ("false", "true"), ("False / No: does not hold", "True / Yes: holds")
99
+ elif kind == "score":
100
+ opts, desc = _parse_score_criteria(crit, probs)
101
+ else:
102
+ if isinstance(crit, dict):
103
+ opts, desc = tuple(map(str, crit)), tuple(str(v) for v in crit.values())
104
+ elif isinstance(crit, list):
105
+ opts = desc = tuple(map(str, crit))
106
+ else:
107
+ opts = desc = tuple(map(str, probs))
108
+
109
+ if not opts:
110
+ raise ValueError(f"question {qid!r} has no options")
111
+ return QuestionSpec(qid, kind, str(question.get("instructions") or qid), opts, desc)
112
+
113
+
114
+ def render_state(workflow: str, state: Any) -> str:
115
+ """Flatten a string or nested dictionary state into a canonical text string."""
116
+ state = _json(state) if isinstance(state, str) and state.strip()[:1] in "{[" else state
117
+ if not isinstance(state, dict):
118
+ if state == "":
119
+ return f"workflow: {workflow}" if workflow else ""
120
+ return f"workflow: {workflow} | state: {state}" if workflow else str(state)
121
+ lines: list[str] = []
122
+
123
+ def walk(prefix: str, value: Any) -> None:
124
+ if isinstance(value, dict):
125
+ for k, v in value.items():
126
+ walk(f"{prefix} · {k}" if prefix else k, v)
127
+ else:
128
+ lines.append(f"{prefix.replace('_', ' ')}: {value}")
129
+
130
+ walk("", state)
131
+ return (f"workflow: {workflow}\n" if workflow else "") + "\n".join(lines)
132
+
133
+
134
+ def _format_option_texts(sp: QuestionSpec) -> list[str]:
135
+ """Format candidate option strings according to the question kind."""
136
+ opt_texts: list[str] = []
137
+ for key, desc in zip(sp.options, sp.descriptions):
138
+ if sp.kind == "noul":
139
+ opt_texts.append(f"Question: {sp.instructions} | Answer: {key} ({desc})")
140
+ elif sp.kind == "score":
141
+ opt_texts.append(f"Question: {sp.instructions} | Score level {key} of {len(sp.options)-1}: {desc}")
142
+ else:
143
+ opt_texts.append(f"{key}: {desc}" if desc != key else str(key))
144
+ return opt_texts
145
+
146
+
147
+ class GemmaDecisionRouterPreTrainedModel(PreTrainedModel):
148
+ """Base PreTrainedModel wrapper for GemmaDecisionRouter."""
149
+
150
+ config_class = GemmaDecisionRouterConfig
151
+ base_model_prefix = "gemma_decision_router"
152
+ _supports_sdpa = True
153
+
154
+ def _init_weights(self, module: nn.Module) -> None:
155
+ pass
156
+
157
+
158
+ class GemmaDecisionRouterModel(GemmaDecisionRouterPreTrainedModel):
159
+ """EmbeddingGemma 2 text encoder + Set-Equivariant Decision Head + Calibrated Null Gate."""
160
+
161
+ def __init__(self, config: GemmaDecisionRouterConfig) -> None:
162
+ super().__init__(config)
163
+ self.encoder = AutoModel.from_config(config.encoder_config)
164
+ self.head = SetEquivariantDecisionHead(
165
+ d_model=config.embed_dim,
166
+ n_heads=config.n_heads,
167
+ n_layers=config.n_layers,
168
+ )
169
+ self._option_emb_cache: OrderedDict[tuple[str, ...], Tensor] = OrderedDict()
170
+ self._max_option_cache_size: int = 128
171
+ self.post_init()
172
+
173
+ def clear_option_cache(self) -> None:
174
+ """Clear the in-memory option embedding cache."""
175
+ self._option_emb_cache.clear()
176
+
177
+ def get_tokenizer(self, tokenizer: Any = None) -> Any:
178
+ """Return or lazy-load the tokenizer associated with this model."""
179
+ if tokenizer is not None:
180
+ return tokenizer
181
+ if getattr(self, "_tokenizer", None) is None:
182
+ source = (
183
+ getattr(self.config, "_name_or_path", None)
184
+ or getattr(self, "name_or_path", None)
185
+ or "Ajeet-001/embeddinggemma2-decision-router"
186
+ )
187
+ self._tokenizer = AutoTokenizer.from_pretrained(source, trust_remote_code=False)
188
+ return self._tokenizer
189
+
190
+ def embed(self, input_ids: Tensor, attention_mask: Tensor) -> Tensor:
191
+ """Mean-pool and L2-normalize token representations from the encoder."""
192
+ out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
193
+ tokens = out.last_hidden_state
194
+ mask = attention_mask.unsqueeze(-1).to(tokens.dtype)
195
+ pooled = (tokens * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1e-9)
196
+ return F.normalize(pooled[:, : self.config.embed_dim].float(), p=2, dim=-1)
197
+
198
+ @torch.no_grad()
199
+ def encode(
200
+ self, texts: list[str], prefix: str | None = None, batch_size: int = 64, tokenizer: Any = None
201
+ ) -> Tensor:
202
+ """Encode a list of strings with the given prompt prefix into L2-normalized vectors."""
203
+ tok = self.get_tokenizer(tokenizer)
204
+ pref = self.config.prompt if prefix is None else prefix
205
+ prompted = [f"{pref}{t}" for t in texts]
206
+ all_vecs = []
207
+ for i in range(0, len(prompted), batch_size):
208
+ batch = prompted[i : i + batch_size]
209
+ enc = tok(
210
+ batch,
211
+ padding=True,
212
+ truncation=True,
213
+ max_length=self.config.max_seq_length,
214
+ return_tensors="pt",
215
+ ).to(self.device)
216
+ all_vecs.append(self.embed(enc["input_ids"], enc["attention_mask"]))
217
+ return torch.cat(all_vecs, dim=0)
218
+
219
+ @torch.no_grad()
220
+ def encode_options_cached(
221
+ self, opt_texts: list[str], tokenizer: Any = None
222
+ ) -> tuple[Tensor, bool]:
223
+ """Encode option strings once and reuse bounded LRU-cached L2-normalized vectors."""
224
+ cache_key = (str(self.config.option_prompt), *opt_texts)
225
+ cached = self._option_emb_cache.get(cache_key)
226
+ if cached is not None and cached.device == self.device:
227
+ self._option_emb_cache.move_to_end(cache_key)
228
+ return cached, True
229
+ encoded = self.encode(opt_texts, prefix=self.config.option_prompt, tokenizer=tokenizer)
230
+ if len(self._option_emb_cache) >= self._max_option_cache_size:
231
+ self._option_emb_cache.popitem(last=False)
232
+ self._option_emb_cache[cache_key] = encoded
233
+ return encoded, False
234
+
235
+ def forward(
236
+ self,
237
+ query_emb: Tensor,
238
+ option_embs: Tensor,
239
+ return_dict: bool = True,
240
+ ) -> GemmaDecisionOutput | tuple[Tensor, Tensor, Tensor]:
241
+ """Run the SetEquivariantDecisionHead on pre-encoded query and option embeddings."""
242
+ head_dtype = next(self.head.parameters()).dtype
243
+ q_in = query_emb.to(head_dtype)
244
+ o_in = option_embs.to(head_dtype)
245
+ sims = torch.bmm(o_in.float(), q_in.float().unsqueeze(-1)).squeeze(-1)
246
+ head_logits, null_logits = self.head(q_in, o_in)
247
+ logits = (sims / self.config.decision_temperature) + 0.15 * head_logits.float()
248
+ probs = F.softmax(logits, dim=-1)
249
+ if not return_dict:
250
+ return (logits, null_logits, probs)
251
+ return GemmaDecisionOutput(logits=logits, null_logits=null_logits.float(), probabilities=probs)
252
+
253
+ def _score_single_question(
254
+ self,
255
+ q_vec: Tensor,
256
+ sp: QuestionSpec,
257
+ effective_thresh: float,
258
+ tokenizer: Any = None,
259
+ ) -> TypedAnswer:
260
+ """Score a single QuestionSpec using cached option embeddings and the decision head."""
261
+ opt_texts = _format_option_texts(sp)
262
+ o_vecs, _ = self.encode_options_cached(opt_texts, tokenizer=tokenizer)
263
+ out = self.forward(q_vec, o_vecs.unsqueeze(0))
264
+ if sp.kind == "choice":
265
+ head_dtype = next(self.head.parameters()).dtype
266
+ head_logits, _ = self.head(q_vec.to(head_dtype), o_vecs.unsqueeze(0).to(head_dtype))
267
+ p = F.softmax(head_logits.float(), dim=-1)[0].cpu()
268
+ else:
269
+ p = out.probabilities[0].cpu()
270
+ j = int(p.argmax())
271
+ label = sp.options[j]
272
+ value: str | int | bool = (
273
+ (label == "true") if sp.kind == "noul" else int(label) if sp.kind == "score" else label
274
+ )
275
+ expected = (
276
+ float(sum(int(o) * float(pi) for o, pi in zip(sp.options, p))) if sp.kind == "score" else None
277
+ )
278
+ null_prob = float(torch.sigmoid(out.null_logits[0]).cpu()) if sp.kind == "choice" else None
279
+ abstain = (null_prob >= effective_thresh) if null_prob is not None else False
280
+ return TypedAnswer(
281
+ kind=sp.kind,
282
+ label=label,
283
+ value=value,
284
+ confidence=float(p[j]),
285
+ probabilities={k: round(float(pi), 4) for k, pi in zip(sp.options, p.tolist())},
286
+ expected_level=round(expected, 4) if expected is not None else None,
287
+ null_probability=round(null_prob, 4) if null_prob is not None else None,
288
+ abstain=abstain,
289
+ )
290
+
291
+ @torch.no_grad()
292
+ def decide(
293
+ self,
294
+ state: Any,
295
+ questions: dict[str, dict[str, Any]],
296
+ workflow: str = "",
297
+ tokenizer: Any = None,
298
+ abstain_threshold: float | None = None,
299
+ include_question: bool | None = None,
300
+ ) -> dict[str, TypedAnswer]:
301
+ """Answer every question in a schema (`choice`, `score`, `noul`) with automatic option caching."""
302
+ was_training = self.training
303
+ self.eval()
304
+ state_str = render_state(workflow, state)
305
+ q_dict = _json(questions)
306
+ specs = [parse_question(qid, q) for qid, q in q_dict.items()]
307
+ plain_text = isinstance(state, str) and state.strip() != "" and state.strip()[0] not in "{["
308
+
309
+ q_texts: list[str] = []
310
+ for sp in specs:
311
+ drop_q = include_question is False or (include_question is None and plain_text and sp.kind == "choice")
312
+ if drop_q and state_str:
313
+ q_texts.append(f"State: {state.strip() if plain_text else state_str}")
314
+ else:
315
+ q_texts.append(f"State: {state_str}\nQuestion: {sp.instructions}" if state_str else f"Question: {sp.instructions}")
316
+
317
+ q_vecs = self.encode(q_texts, prefix=self.config.prompt, tokenizer=tokenizer)
318
+ effective_thresh = float(
319
+ getattr(self.config, "null_threshold", 0.58) if abstain_threshold is None else abstain_threshold
320
+ )
321
+ answers = {
322
+ sp.qid: self._score_single_question(q_vecs[i : i + 1], sp, effective_thresh, tokenizer=tokenizer)
323
+ for i, sp in enumerate(specs)
324
+ }
325
+ self.train(was_training)
326
+ return answers
327
+
328
+
329
+ __all__ = [
330
+ "GemmaDecisionOutput",
331
+ "GemmaDecisionRouterModel",
332
+ "GemmaDecisionRouterPreTrainedModel",
333
+ "QuestionSpec",
334
+ "TypedAnswer",
335
+ ]
@@ -0,0 +1,32 @@
1
+ """Self-contained launcher module for the Live Head-to-Head Decision Playground."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import webbrowser
6
+
7
+ from . import _playground_server
8
+
9
+
10
+ def launch_playground(
11
+ host: str = "127.0.0.1",
12
+ port: int = 7860,
13
+ our_repo: str = "Ajeet-001/embeddinggemma2-decision-router",
14
+ open_browser: bool = True,
15
+ preload: bool = True,
16
+ ) -> None:
17
+ """Start the interactive 3-column Live Head-to-Head Decision Playground server."""
18
+ server = _playground_server.create_server(host=host, port=port, our_repo=our_repo, preload=preload)
19
+ actual_port = server.server_address[1]
20
+ url = f"http://{host}:{actual_port}"
21
+ print("=" * 78)
22
+ print("GEMMAROUTER LIVE HEAD-TO-HEAD DECISION PLAYGROUND READY")
23
+ print(f"URL: {url}")
24
+ print("=" * 78)
25
+ if open_browser:
26
+ webbrowser.open(url)
27
+ try:
28
+ server.serve_forever()
29
+ except KeyboardInterrupt:
30
+ pass
31
+ finally:
32
+ server.server_close()