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/__init__.py +35 -0
- gemmarouter/_playground_server.py +1618 -0
- gemmarouter/cli.py +103 -0
- gemmarouter/configuration.py +50 -0
- gemmarouter/head.py +90 -0
- gemmarouter/modeling.py +335 -0
- gemmarouter/playground.py +32 -0
- gemmarouter/router.py +247 -0
- gemmarouter-0.1.0.dist-info/METADATA +159 -0
- gemmarouter-0.1.0.dist-info/RECORD +14 -0
- gemmarouter-0.1.0.dist-info/WHEEL +5 -0
- gemmarouter-0.1.0.dist-info/entry_points.txt +2 -0
- gemmarouter-0.1.0.dist-info/licenses/LICENSE +14 -0
- gemmarouter-0.1.0.dist-info/top_level.txt +1 -0
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
|
gemmarouter/modeling.py
ADDED
|
@@ -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()
|