ruling 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ruling/modernbert.py ADDED
@@ -0,0 +1,163 @@
1
+ """ModernBERT in MLX: a bidirectional encoder, not a generative model.
2
+
3
+ A port of Hugging Face's ModernBertForMaskedLM that takes explicit position
4
+ ids and a full boolean attention mask, so a caller can pack several
5
+ independent segments into one sequence. Every third layer attends globally;
6
+ the others see tokens within half of `local_attention` positions. Weights load
7
+ from the checkpoint's safetensors; the input embedding is tied to the decoder.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import mlx.core as mx
17
+ import mlx.nn as nn
18
+ from huggingface_hub import hf_hub_download
19
+ from transformers import AutoTokenizer
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Config:
24
+ vocab_size: int
25
+ hidden_size: int
26
+ num_hidden_layers: int
27
+ num_attention_heads: int
28
+ intermediate_size: int
29
+ global_attn_every_n_layers: int
30
+ local_attention: int
31
+ global_rope_theta: float
32
+ local_rope_theta: float
33
+ norm_eps: float
34
+ max_position_embeddings: int
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict) -> "Config":
38
+ return cls(**{name: data[name] for name in cls.__dataclass_fields__})
39
+
40
+ def is_global(self, layer: int) -> bool:
41
+ return layer % self.global_attn_every_n_layers == 0
42
+
43
+
44
+ def _rotate_half(x: mx.array) -> mx.array:
45
+ half = x.shape[-1] // 2
46
+ return mx.concatenate([-x[..., half:], x[..., :half]], axis=-1)
47
+
48
+
49
+ class Attention(nn.Module):
50
+ def __init__(self, config: Config, theta: float):
51
+ super().__init__()
52
+ self.heads = config.num_attention_heads
53
+ self.head_dim = config.hidden_size // config.num_attention_heads
54
+ self.Wqkv = nn.Linear(config.hidden_size, 3 * config.hidden_size, bias=False)
55
+ self.Wo = nn.Linear(config.hidden_size, config.hidden_size, bias=False)
56
+ self._inv_freq = 1.0 / (theta ** (mx.arange(0, self.head_dim, 2, dtype=mx.float32) / self.head_dim))
57
+
58
+ def __call__(self, x: mx.array, positions: mx.array, mask: mx.array) -> mx.array:
59
+ batch, length, _ = x.shape
60
+ qkv = self.Wqkv(x).reshape(batch, length, 3, self.heads, self.head_dim)
61
+ q, k, v = (qkv[:, :, i].transpose(0, 2, 1, 3) for i in range(3))
62
+ freqs = positions[:, :, None].astype(mx.float32) * self._inv_freq
63
+ emb = mx.concatenate([freqs, freqs], axis=-1)[:, None]
64
+ cos, sin = mx.cos(emb).astype(q.dtype), mx.sin(emb).astype(q.dtype)
65
+ q = q * cos + _rotate_half(q) * sin
66
+ k = k * cos + _rotate_half(k) * sin
67
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=self.head_dim ** -0.5, mask=mask[:, None])
68
+ return self.Wo(out.transpose(0, 2, 1, 3).reshape(batch, length, -1))
69
+
70
+
71
+ class MLP(nn.Module):
72
+ def __init__(self, config: Config):
73
+ super().__init__()
74
+ self.Wi = nn.Linear(config.hidden_size, 2 * config.intermediate_size, bias=False)
75
+ self.Wo = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
76
+
77
+ def __call__(self, x: mx.array) -> mx.array:
78
+ value, gate = mx.split(self.Wi(x), 2, axis=-1)
79
+ return self.Wo(nn.gelu(value) * gate)
80
+
81
+
82
+ class Layer(nn.Module):
83
+ def __init__(self, config: Config, index: int):
84
+ super().__init__()
85
+ self.is_global = config.is_global(index)
86
+ self.window = config.local_attention // 2
87
+ self.attn_norm = nn.LayerNorm(config.hidden_size, eps=config.norm_eps, bias=False) if index else None
88
+ self.attn = Attention(config, config.global_rope_theta if self.is_global else config.local_rope_theta)
89
+ self.mlp_norm = nn.LayerNorm(config.hidden_size, eps=config.norm_eps, bias=False)
90
+ self.mlp = MLP(config)
91
+
92
+ def __call__(self, x: mx.array, positions: mx.array, full: mx.array, local: mx.array) -> mx.array:
93
+ normed = self.attn_norm(x) if self.attn_norm is not None else x
94
+ x = x + self.attn(normed, positions, full if self.is_global else local)
95
+ return x + self.mlp(self.mlp_norm(x))
96
+
97
+
98
+ class Encoder(nn.Module):
99
+ def __init__(self, config: Config):
100
+ super().__init__()
101
+ self.config = config
102
+ self.tok_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)
103
+ self.embed_norm = nn.LayerNorm(config.hidden_size, eps=config.norm_eps, bias=False)
104
+ self.layers = [Layer(config, i) for i in range(config.num_hidden_layers)]
105
+ self.final_norm = nn.LayerNorm(config.hidden_size, eps=config.norm_eps, bias=False)
106
+
107
+ def __call__(self, tokens: mx.array, positions: mx.array, mask: mx.array) -> mx.array:
108
+ """Hidden states [batch, length, hidden]. `mask[b, i, j]` is True where token i may attend to token j."""
109
+ distance = mx.abs(positions[:, :, None] - positions[:, None, :])
110
+ local = mask & (distance <= self.config.local_attention // 2)
111
+ x = self.embed_norm(self.tok_embeddings(tokens))
112
+ for layer in self.layers:
113
+ x = layer(x, positions, mask, local)
114
+ return self.final_norm(x)
115
+
116
+
117
+ class MaskedLM(nn.Module):
118
+ def __init__(self, config: Config):
119
+ super().__init__()
120
+ self.encoder = Encoder(config)
121
+ self.head_dense = nn.Linear(config.hidden_size, config.hidden_size, bias=False)
122
+ self.head_norm = nn.LayerNorm(config.hidden_size, eps=config.norm_eps, bias=False)
123
+ self.decoder_bias = mx.zeros((config.vocab_size,))
124
+
125
+ def transform(self, hidden: mx.array) -> mx.array:
126
+ """The pretrained prediction transform applied before the vocabulary projection."""
127
+ return self.head_norm(nn.gelu(self.head_dense(hidden)))
128
+
129
+ def token_logits(self, hidden: mx.array) -> mx.array:
130
+ return self.encoder.tok_embeddings.as_linear(self.transform(hidden)) + self.decoder_bias
131
+
132
+
133
+ RENAMES = {
134
+ "decoder.weight": "encoder.tok_embeddings.weight",
135
+ "decoder.bias": "decoder_bias",
136
+ "head.dense.weight": "head_dense.weight",
137
+ "head.norm.weight": "head_norm.weight",
138
+ "model.embeddings.norm.weight": "encoder.embed_norm.weight",
139
+ "model.final_norm.weight": "encoder.final_norm.weight",
140
+ }
141
+
142
+
143
+ def _rename(key: str) -> str:
144
+ return RENAMES.get(key) or key.replace("model.layers.", "encoder.layers.")
145
+
146
+
147
+ def load(repo: str, revision: str) -> tuple[MaskedLM, AutoTokenizer, Config]:
148
+ config = Config.from_dict(json.loads(Path(hf_hub_download(repo, "config.json", revision=revision)).read_text()))
149
+ model = MaskedLM(config)
150
+ weights = mx.load(hf_hub_download(repo, "model.safetensors", revision=revision))
151
+ model.load_weights([(_rename(k), v) for k, v in weights.items()], strict=True)
152
+ mx.eval(model.parameters())
153
+ tokenizer = AutoTokenizer.from_pretrained(repo, revision=revision)
154
+ return model, tokenizer, config
155
+
156
+
157
+ def sequence_inputs(token_lists: list[list[int]], pad_id: int) -> tuple[mx.array, mx.array, mx.array]:
158
+ """Plain batched inputs: positions from zero and a padding mask, as the reference model uses."""
159
+ width = max(len(t) for t in token_lists)
160
+ tokens = mx.array([t + [pad_id] * (width - len(t)) for t in token_lists])
161
+ positions = mx.broadcast_to(mx.arange(width)[None], (len(token_lists), width))
162
+ valid = mx.array([[i < len(t) for i in range(width)] for t in token_lists])
163
+ return tokens, positions, valid[:, None, :] & valid[:, :, None]
ruling/peft.py ADDED
@@ -0,0 +1,144 @@
1
+ """Convert a ruling adapter to and from PEFT's LoRA layout.
2
+
3
+ mlx-lm stores `lora_a` as `(in, r)` and `lora_b` as `(r, out)` and applies
4
+ `scale * (x @ lora_a) @ lora_b`. PEFT stores `lora_A.weight` as `(r, in)` and
5
+ `lora_B.weight` as `(out, r)` and applies `(lora_alpha / r) * lora_B(lora_A(x))`.
6
+ The conversion is therefore a transpose of each tensor and `lora_alpha = scale * r`.
7
+
8
+ Only plain per-layer linears convert. mlx-lm can also put LoRA on switch
9
+ (MoE) linears and embeddings, and multimodal wrappers nest the stack under
10
+ `language_model`; none of those has a one-to-one PEFT key, so they are refused
11
+ rather than exported under a name PEFT would silently ignore.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import shutil
19
+ from pathlib import Path
20
+
21
+ import mlx.core as mx
22
+ from huggingface_hub import hf_hub_download
23
+
24
+ MLX_KEY = re.compile(r"^model\.layers\.(\d+)\.(.+)\.lora_([ab])$")
25
+ PEFT_KEY = re.compile(r"^base_model\.model\.model\.layers\.(\d+)\.(.+)\.lora_([AB])\.weight$")
26
+ MLX_WEIGHTS = "adapters.safetensors"
27
+ PEFT_WEIGHTS = "adapter_model.safetensors"
28
+ CONFIG = "adapter_config.json"
29
+ TRAINING = "training.json"
30
+
31
+
32
+ def export_adapter(adapter: Path, output: Path, base_model: str) -> None:
33
+ """Write `adapter`, a directory from `ruling train`, as a PEFT adapter for `base_model`."""
34
+ config = json.loads((adapter / CONFIG).read_text())
35
+ if config.get("fine_tune_type", "lora") != "lora":
36
+ raise ValueError(f"only LoRA adapters export to PEFT, not {config['fine_tune_type']}")
37
+ lora = config["lora_parameters"]
38
+ rank = lora["rank"]
39
+ modules = _modules(mx.load(str(adapter / MLX_WEIGHTS)), MLX_KEY, "a", "b", rank, transposed=False)
40
+ layers, keys = _product(modules)
41
+ weights = {}
42
+ for (layer, key), (a, b) in modules.items():
43
+ prefix = f"base_model.model.model.layers.{layer}.{key}"
44
+ weights[f"{prefix}.lora_A.weight"] = a.T.astype(mx.float32)
45
+ weights[f"{prefix}.lora_B.weight"] = b.T.astype(mx.float32)
46
+ alpha = lora["scale"] * rank
47
+ peft_config = {
48
+ "peft_type": "LORA",
49
+ "task_type": "CAUSAL_LM",
50
+ "base_model_name_or_path": base_model,
51
+ "revision": None,
52
+ "inference_mode": False,
53
+ "r": rank,
54
+ "lora_alpha": int(alpha) if alpha == int(alpha) else alpha,
55
+ "lora_dropout": lora["dropout"],
56
+ "target_modules": sorted({key.rsplit(".", 1)[-1] for key in keys}),
57
+ "layers_to_transform": layers,
58
+ "bias": "none",
59
+ }
60
+ _write(output, PEFT_WEIGHTS, weights, peft_config, adapter / TRAINING)
61
+
62
+
63
+ def import_adapter(peft: Path, output: Path) -> None:
64
+ """Write `peft`, a PEFT LoRA adapter directory, in the layout `ruling train` produces and the engine serves."""
65
+ config = json.loads((peft / CONFIG).read_text())
66
+ _check_peft_config(config)
67
+ rank = config["r"]
68
+ modules = _modules(mx.load(str(peft / PEFT_WEIGHTS)), PEFT_KEY, "A", "B", rank, transposed=True)
69
+ layers, keys = _product(modules)
70
+ total = base_layer_count(config["base_model_name_or_path"], config.get("revision"))
71
+ if layers != list(range(total - len(layers), total)):
72
+ raise ValueError(f"LoRA layers {layers} are not the last {len(layers)} of {total}; "
73
+ "mlx-lm can only adapt a suffix of the stack")
74
+ weights = {}
75
+ for (layer, key), (a, b) in modules.items():
76
+ weights[f"model.layers.{layer}.{key}.lora_a"] = a.T
77
+ weights[f"model.layers.{layer}.{key}.lora_b"] = b.T
78
+ mlx_config = {
79
+ "fine_tune_type": "lora",
80
+ "num_layers": len(layers),
81
+ "lora_parameters": {"rank": rank, "scale": config["lora_alpha"] / rank, "dropout": config.get("lora_dropout", 0.0),
82
+ "keys": keys},
83
+ }
84
+ _write(output, MLX_WEIGHTS, weights, mlx_config, peft / TRAINING)
85
+
86
+
87
+ def base_layer_count(base_model: str, revision: str | None) -> int:
88
+ """How many decoder layers the base model has, from its Hugging Face `config.json`."""
89
+ config = json.loads(Path(hf_hub_download(base_model, "config.json", revision=revision)).read_text())
90
+ if "num_hidden_layers" not in config:
91
+ raise ValueError(f"{base_model}'s config.json has no top-level num_hidden_layers, so the LoRA layer "
92
+ "range cannot be checked against the stack")
93
+ return config["num_hidden_layers"]
94
+
95
+
96
+ def _modules(weights: dict[str, mx.array], pattern: re.Pattern, first: str, second: str, rank: int,
97
+ transposed: bool) -> dict[tuple[int, str], tuple[mx.array, mx.array]]:
98
+ """Pair each module's two LoRA factors by (layer, module path), refusing any key outside the pattern."""
99
+ halves: dict[tuple[int, str], dict[str, mx.array]] = {}
100
+ for name, tensor in weights.items():
101
+ match = pattern.match(name)
102
+ if match is None:
103
+ raise ValueError(f"{name!r} is not a LoRA factor on a plain per-layer linear; only "
104
+ "model.layers.N.<module> adapters convert")
105
+ layer, key, factor = int(match.group(1)), match.group(2), match.group(3)
106
+ halves.setdefault((layer, key), {})[factor] = tensor
107
+ modules = {}
108
+ for (layer, key), pair in sorted(halves.items()):
109
+ if set(pair) != {first, second}:
110
+ raise ValueError(f"layer {layer} {key} has only one of its two LoRA factors")
111
+ a, b = pair[first], pair[second]
112
+ rank_axis = (a.shape[0], b.shape[1]) if transposed else (a.shape[1], b.shape[0])
113
+ if rank_axis != (rank, rank):
114
+ raise ValueError(f"layer {layer} {key} has rank {rank_axis}, but the config says {rank}")
115
+ modules[(layer, key)] = (a, b)
116
+ return modules
117
+
118
+
119
+ def _product(modules: dict[tuple[int, str], object]) -> tuple[list[int], list[str]]:
120
+ """The layer indices and module paths, which must form a full product for either layout to express them."""
121
+ layers = sorted({layer for layer, _ in modules})
122
+ keys = sorted({key for _, key in modules})
123
+ missing = [(layer, key) for layer in layers for key in keys if (layer, key) not in modules]
124
+ if missing:
125
+ raise ValueError(f"every adapted layer must adapt the same modules; missing {missing}")
126
+ return layers, keys
127
+
128
+
129
+ def _check_peft_config(config: dict) -> None:
130
+ if config.get("peft_type") != "LORA":
131
+ raise ValueError(f"only LORA adapters import, not {config.get('peft_type')}")
132
+ for field, plain in (("use_dora", False), ("use_rslora", False), ("bias", "none"), ("rank_pattern", {}),
133
+ ("alpha_pattern", {}), ("fan_in_fan_out", False)):
134
+ if config.get(field, plain) not in (plain, None):
135
+ raise ValueError(f"PEFT adapters with {field}={config[field]!r} have no mlx-lm equivalent")
136
+
137
+
138
+ def _write(output: Path, weights_name: str, weights: dict[str, mx.array], config: dict, training: Path) -> None:
139
+ output.mkdir(parents=True, exist_ok=True)
140
+ mx.save_safetensors(str(output / weights_name), weights)
141
+ (output / CONFIG).write_text(json.dumps(config, indent=2) + "\n")
142
+ # Provenance travels with the weights in both directions.
143
+ if training.exists():
144
+ shutil.copyfile(training, output / TRAINING)
ruling/playground.html ADDED
@@ -0,0 +1,217 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ruling playground</title>
7
+ <style>
8
+ :root { --bg: #fafaf7; --fg: #1b1b1b; --muted: #6b6b6b; --line: #dcdcd5; --bar: #2f6f4f; --accent: #b3451f; }
9
+ @media (prefers-color-scheme: dark) { :root { --bg: #161615; --fg: #ecece6; --muted: #9a9a92; --line: #33332f; --bar: #5fae85; --accent: #e0774d; } }
10
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
11
+ main { max-width: 960px; margin: 0 auto; padding: 24px 16px; display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
12
+ h1 { grid-column: 1 / -1; margin: 0; font-size: 18px; }
13
+ h1 small { color: var(--muted); font-weight: normal; margin-left: 8px; }
14
+ label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 4px; }
15
+ textarea { width: 100%; box-sizing: border-box; min-height: 260px; background: transparent; color: inherit; border: 1px solid var(--line); border-radius: 4px; padding: 8px; font: inherit; }
16
+ button { grid-column: 1 / -1; justify-self: start; padding: 8px 14px; border: 1px solid var(--fg); background: var(--fg); color: var(--bg); border-radius: 4px; font: inherit; cursor: pointer; }
17
+ #out { grid-column: 1 / -1; display: grid; gap: 12px; }
18
+ .q { border: 1px solid var(--line); border-radius: 4px; padding: 10px 12px; }
19
+ .q b { display: block; margin-bottom: 6px; }
20
+ .row { display: grid; grid-template-columns: 140px 1fr 60px; gap: 8px; align-items: center; font-size: 13px; }
21
+ .bar { height: 10px; background: var(--line); border-radius: 2px; overflow: hidden; }
22
+ .bar i { display: block; height: 100%; background: var(--bar); }
23
+ .meta { color: var(--muted); font-size: 12px; }
24
+ .err { color: var(--accent); }
25
+ a { color: inherit; }
26
+ #thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
27
+ .thumb { position: relative; width: 220px; max-width: 100%; font-size: 12px; color: var(--muted); }
28
+ /* The whole image, scaled to fit, so a receipt's text or a photo's edge stays readable before deciding. */
29
+ .thumb img { display: block; width: 100%; height: 220px; object-fit: contain; background: var(--line); border: 1px solid var(--line); border-radius: 4px; }
30
+ .thumb button { position: absolute; top: 2px; right: 2px; padding: 0 5px; font-size: 12px; line-height: 16px; }
31
+ input[type=file] { font: inherit; font-size: 12px; margin-top: 8px; max-width: 100%; }
32
+ #key, select { width: 100%; box-sizing: border-box; margin-bottom: 8px; background: transparent; color: inherit; border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; font: inherit; }
33
+ button.small { grid-column: auto; padding: 2px 8px; font-size: 12px; background: transparent; color: inherit; border-color: var(--line); }
34
+ @media (max-width: 700px) { main { grid-template-columns: 1fr; } }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <main>
39
+ <h1>ruling <small>typed decisions, no text generation · <a href="/history">retraining history</a></small></h1>
40
+ <div>
41
+ <label for="state">state (text or JSON)</label>
42
+ <textarea id="state">{"message": "My card was charged twice for one order. Refund the duplicate."}</textarea>
43
+ <label for="images">images, added to the state as <code>images</code>; ask about them as &lt;image 1&gt;, &lt;image 2&gt;, …</label>
44
+ <input type="file" id="images" accept="image/png,image/jpeg,image/webp" multiple>
45
+ <button type="button" id="image-example" class="small">image example</button>
46
+ <div id="images-note" class="meta"></div>
47
+ <div id="thumbs"></div>
48
+ </div>
49
+ <div>
50
+ <label for="questions">questions (JSON)</label>
51
+ <textarea id="questions">{
52
+ "team": {"type": "choice", "instructions": "Which team should handle this?",
53
+ "criteria": {"billing": "Charges and refunds", "technical": "Bugs", "sales": "Pricing"}},
54
+ "urgency": {"type": "score", "instructions": "How urgent is this?",
55
+ "criteria": ["Can wait", "This week", "Today"]},
56
+ "refund": {"type": "noul", "instructions": "The customer asks for a refund."}
57
+ }</textarea>
58
+ </div>
59
+ <div>
60
+ <label for="model">model</label>
61
+ <select id="model"></select>
62
+ <label for="key">API key, only for a server started with RULING_API_KEY</label>
63
+ <input id="key" type="password" autocomplete="off">
64
+ </div>
65
+ <button id="run">Decide</button>
66
+ <div id="out"></div>
67
+ </main>
68
+ <script>
69
+ const out = document.getElementById("out");
70
+ const model = document.getElementById("model");
71
+ const picker = document.getElementById("images");
72
+ const thumbs = document.getElementById("thumbs");
73
+ const stateBox = document.getElementById("state");
74
+ const modalities = {};
75
+ let picked = [];
76
+ const keyBox = document.getElementById("key");
77
+ // Kept for this tab only, and shared with the history page, which reads it from the same place.
78
+ try { keyBox.value = sessionStorage.getItem("ruling-key") || ""; } catch {}
79
+ function auth() {
80
+ const key = keyBox.value.trim();
81
+ return key ? { authorization: `Bearer ${key}` } : {};
82
+ }
83
+ keyBox.onchange = () => {
84
+ try { sessionStorage.setItem("ruling-key", keyBox.value.trim()); } catch {}
85
+ listModels();
86
+ };
87
+ function listModels() {
88
+ return fetch("/v1/models", { headers: auth() }).then(async r => {
89
+ const data = await r.json();
90
+ if (r.status === 401) throw new Error("this server needs its API key; enter it above");
91
+ if (!r.ok) throw new Error(`${r.status} ${data.error.code}: ${data.error.message}`);
92
+ return data;
93
+ }).then(showModels).catch(e => { out.innerHTML = `<div class="err">could not list models: ${e.message}</div>`; });
94
+ }
95
+ function showModels(data) {
96
+ const first = !model.options.length;
97
+ model.innerHTML = "";
98
+ out.innerHTML = "";
99
+ for (const m of data.models) {
100
+ modalities[m.name] = m.modalities;
101
+ const option = document.createElement("option");
102
+ option.value = m.name;
103
+ option.textContent = m.adapter ? `${m.name} (${m.base} + adapter ${m.adapter})` : m.name === m.base ? m.name : `${m.name} (${m.base})`;
104
+ model.appendChild(option);
105
+ }
106
+ showModalities();
107
+ // A server whose default model reads images opens on an example that uses one.
108
+ if (first && (modalities[model.value] || []).includes("image")) loadImageExample();
109
+ }
110
+ listModels();
111
+ function parseState(text) { try { return JSON.parse(text); } catch { return text; } }
112
+ function countImages(value) {
113
+ if (Array.isArray(value)) return value.reduce((n, v) => n + countImages(v), 0);
114
+ if (value && typeof value === "object") {
115
+ return value.type === "image" ? 1 : Object.values(value).reduce((n, v) => n + countImages(v), 0);
116
+ }
117
+ return 0;
118
+ }
119
+ // Picked images go after everything already in the state, so they take the next numbers.
120
+ function withImages(state) {
121
+ if (!picked.length) return state;
122
+ const images = picked.map(({ media_type, data }) => ({ type: "image", media_type, data }));
123
+ return state && typeof state === "object" && !Array.isArray(state) ? { ...state, images } : { text: state, images };
124
+ }
125
+ function showThumbs() {
126
+ const first = countImages(parseState(stateBox.value)) + 1;
127
+ thumbs.innerHTML = "";
128
+ picked.forEach((image, i) => {
129
+ const box = document.createElement("div");
130
+ box.className = "thumb";
131
+ box.innerHTML = `<img alt=""><span>&lt;image ${first + i}&gt;</span><button type="button" aria-label="remove">×</button>`;
132
+ box.querySelector("img").src = `data:${image.media_type};base64,${image.data}`;
133
+ box.querySelector("button").onclick = () => { picked.splice(i, 1); showThumbs(); };
134
+ thumbs.appendChild(box);
135
+ });
136
+ }
137
+ function showModalities() {
138
+ const sees = (modalities[model.value] || []).includes("image");
139
+ picker.disabled = !sees;
140
+ document.getElementById("image-example").disabled = !sees;
141
+ document.getElementById("images-note").textContent = sees ? "" : "this model reads text only; serve a vision: model to ask about images";
142
+ }
143
+ // An example to try with a model that reads images: a receipt drawn here, so the page ships no image file.
144
+ const IMAGE_STATE = {"note": "The customer says order 4471 was charged twice and attached the receipt."};
145
+ const IMAGE_QUESTIONS = {
146
+ "kind": {"type": "choice", "instructions": "What is <image 1>?",
147
+ "criteria": {"receipt": "A store or restaurant receipt", "photo": "A photo of a person or a place",
148
+ "screenshot": "A screenshot of an app"}},
149
+ "same_order": {"type": "noul", "instructions": "The order number on <image 1> is the one in the note."},
150
+ "total": {"type": "score", "instructions": "How large is the total on <image 1>?",
151
+ "criteria": ["Under $10", "$10 to $100", "Over $100"]}
152
+ };
153
+ function drawReceipt() {
154
+ const canvas = document.createElement("canvas");
155
+ canvas.width = 360; canvas.height = 480;
156
+ const g = canvas.getContext("2d");
157
+ g.fillStyle = "#fffdf6"; g.fillRect(0, 0, canvas.width, canvas.height);
158
+ g.fillStyle = "#222"; g.textBaseline = "top";
159
+ const lines = [["bold 26px sans-serif", "CORNER CAFE"], ["16px monospace", "12 Market St, Springfield"], ["16px monospace", ""],
160
+ ["18px monospace", "Order #4471"], ["16px monospace", ""], ["18px monospace", "2 x Latte 9.00"],
161
+ ["18px monospace", "1 x Bagel 4.50"], ["18px monospace", "1 x Fruit bowl 6.25"],
162
+ ["18px monospace", "Tip 2.35"], ["16px monospace", ""], ["bold 22px monospace", "TOTAL $22.10"],
163
+ ["16px monospace", ""], ["18px monospace", "PAID VISA ****1881"], ["16px monospace", "Thank you!"]];
164
+ let y = 24;
165
+ for (const [font, text] of lines) { g.font = font; g.fillText(text, 28, y); y += 30; }
166
+ return { media_type: "image/png", data: canvas.toDataURL("image/png").split(",", 2)[1] };
167
+ }
168
+ function loadImageExample() {
169
+ stateBox.value = JSON.stringify(IMAGE_STATE, null, 2);
170
+ document.getElementById("questions").value = JSON.stringify(IMAGE_QUESTIONS, null, 2);
171
+ picked = [drawReceipt()];
172
+ showThumbs();
173
+ }
174
+ document.getElementById("image-example").onclick = loadImageExample;
175
+ model.onchange = showModalities;
176
+ stateBox.oninput = showThumbs;
177
+ picker.onchange = async () => {
178
+ const read = file => new Promise((resolve, reject) => {
179
+ const reader = new FileReader();
180
+ reader.onload = () => resolve({ media_type: file.type, data: reader.result.split(",", 2)[1] });
181
+ reader.onerror = () => reject(reader.error);
182
+ reader.readAsDataURL(file);
183
+ });
184
+ picked = picked.concat(await Promise.all([...picker.files].map(read)));
185
+ picker.value = "";
186
+ showThumbs();
187
+ };
188
+ function bar(label, p) {
189
+ return `<div class="row"><span>${label}</span><div class="bar"><i style="width:${(p * 100).toFixed(1)}%"></i></div><span>${p.toFixed(3)}</span></div>`;
190
+ }
191
+ document.getElementById("run").onclick = async () => {
192
+ out.innerHTML = '<div class="meta">deciding…</div>';
193
+ let questions;
194
+ try { questions = JSON.parse(document.getElementById("questions").value); }
195
+ catch (e) { out.innerHTML = `<div class="err">questions is not valid JSON: ${e.message}</div>`; return; }
196
+ const body = { model: model.value, state: withImages(parseState(stateBox.value)), questions };
197
+ const started = performance.now();
198
+ const res = await fetch("/v1/systemone", { method: "POST", headers: { "content-type": "application/json", ...auth() },
199
+ body: JSON.stringify(body) });
200
+ const ms = Math.round(performance.now() - started);
201
+ const data = await res.json();
202
+ if (!res.ok) { out.innerHTML = `<div class="err">${res.status} ${data.error.code}: ${data.error.message}</div>`; return; }
203
+ let html = `<div class="meta">${data.model} · ${data.usage.input_tokens} input tokens · ${ms} ms</div>`;
204
+ for (const [id, a] of Object.entries(data.answers)) {
205
+ html += `<div class="q"><b>${id} <span class="meta">${a.type}</span></b>`;
206
+ if (a.type === "noul") html += bar("yes", a.noul) + bar("no", 1 - a.noul);
207
+ else {
208
+ for (const [k, p] of Object.entries(a.probabilities)) html += bar(a.type === "score" ? a.legend[k] : k, p);
209
+ html += `<div class="meta">${a.type === "choice" ? "choice: " + a.choice : "score: " + a.score.toFixed(2)} · confidence ${a.confidence.toFixed(2)}</div>`;
210
+ }
211
+ html += "</div>";
212
+ }
213
+ out.innerHTML = html;
214
+ };
215
+ </script>
216
+ </body>
217
+ </html>
ruling/prompt.py ADDED
@@ -0,0 +1,134 @@
1
+ """Render state and questions into the text the model scores.
2
+
3
+ The state is rendered once as a prefix; each question is rendered as a suffix.
4
+ The engine encodes the prefix a single time and evaluates every suffix against
5
+ it, which is what keeps per-question cost flat as questions are added.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+
13
+ from ruling.questions import (
14
+ IMAGE_REFERENCE,
15
+ Choice,
16
+ Noul,
17
+ Score,
18
+ State,
19
+ question_statement,
20
+ render_text,
21
+ state_images,
22
+ without_images,
23
+ )
24
+
25
+ SYSTEM_PROMPT = (
26
+ "You are a decision engine embedded in software. You are shown a state and one "
27
+ "question about it. Answer with a single letter naming the best option. "
28
+ "Never write anything except that letter."
29
+ )
30
+
31
+ # Splits the rendered user message into the shared prefix and the per-question
32
+ # suffix after the chat template has been applied. It must never occur in user data.
33
+ SPLIT_MARKER = "\x00RULING_SPLIT\x00"
34
+
35
+ # A content-free question used to measure how much the model prefers each
36
+ # answer letter regardless of content (Zhao et al. 2021, contextual calibration).
37
+ PRIOR_STATE = "No information is available."
38
+ PRIOR_INSTRUCTIONS = "Pick an option."
39
+ PRIOR_OPTION = "No description."
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RenderedQuestion:
44
+ text: str
45
+ keys: list[str]
46
+ codes: list[str]
47
+ # The parts of `text`, for a layout that orders them differently.
48
+ question: str = ""
49
+ options: str = ""
50
+ closing: str = ""
51
+
52
+
53
+ def render_state(state: State) -> str:
54
+ """The state as prompt text, each image as a reference such as `<image 1>`; an engine that reads
55
+ images supplies them beside the text."""
56
+ state = without_images(state)
57
+ body = state if isinstance(state, str) else json.dumps(state, indent=2, ensure_ascii=False)
58
+ return f"State:\n{body}\n\n"
59
+
60
+
61
+ def render_question(question: Choice | Score | Noul, codes: list[str], rotation: int = 0) -> RenderedQuestion:
62
+ """Render one question under a given `rotation` of its answer order.
63
+
64
+ Choice and Noul rotate cyclically. Score has two orderings, lowest to
65
+ highest and highest to lowest, because level order is part of the question.
66
+ Averaging over orderings cancels the positional bias small models show
67
+ toward particular letters.
68
+ """
69
+ instructions = question_statement(question)
70
+ if isinstance(question, Choice):
71
+ keys = _rotate(list(question.criteria), rotation)
72
+ lines = [_option_line(code, key, question.criteria[key]) for code, key in zip(codes, keys)]
73
+ heading = "Options:"
74
+ closing = "Respond with only the letter of the best option."
75
+ elif isinstance(question, Score):
76
+ levels = list(enumerate(question.criteria))
77
+ if rotation % 2:
78
+ levels.reverse()
79
+ heading = "Levels, from highest to lowest:"
80
+ else:
81
+ heading = "Levels, from lowest to highest:"
82
+ keys = [str(i) for i, _ in levels]
83
+ lines = [_option_line(code, render_text(level), None) for code, (_, level) in zip(codes, levels)]
84
+ closing = "Respond with only the letter of the level that fits best."
85
+ else:
86
+ keys = _rotate(["yes", "no"], rotation)
87
+ descriptions = {"yes": None, "no": None}
88
+ if question.criteria is not None:
89
+ descriptions = {"yes": question.criteria.true, "no": question.criteria.false}
90
+ lines = [_option_line(code, key, descriptions[key]) for code, key in zip(codes, keys)]
91
+ heading = "Is the statement true?"
92
+ closing = "Respond with only the letter."
93
+ used = codes[: len(keys)]
94
+ question = f"Question: {instructions}"
95
+ options = "\n".join([heading, *lines])
96
+ text = "\n".join([question, options, closing])
97
+ return RenderedQuestion(text=text, keys=keys, codes=used, question=question, options=options, closing=closing)
98
+
99
+
100
+ def hosted_user_message(state: State, rendered: RenderedQuestion) -> str | list[dict]:
101
+ """The user turn for an OpenAI-compatible host: the state, then the question,
102
+ its options and the answer cue, the layout the local engine scores.
103
+
104
+ The model answers differently when the question comes first: with the same
105
+ 4-bit weights behind mlx_lm.server, that layout got 115 of authored144's
106
+ 144 against 126 for this one. A host that caches prompt prefixes still
107
+ reuses the state across a request's questions and orderings, which is
108
+ the repeat a decision server sees.
109
+
110
+ A state with images becomes content parts, each image after the state text
111
+ and before the question, as the local vision engine lays them out.
112
+ """
113
+ images = state_images(state)
114
+ if not images:
115
+ return render_state(state) + rendered.text
116
+ # Each image follows the state under the reference the state text uses for it.
117
+ parts = [{"type": "text", "text": render_state(state)}]
118
+ for number, image in enumerate(images, 1):
119
+ parts += [{"type": "text", "text": f"{IMAGE_REFERENCE.format(number)}:"},
120
+ {"type": "image_url", "image_url": {"url": image.data_url()}}]
121
+ return parts + [{"type": "text", "text": "\n\n" + rendered.text}]
122
+
123
+
124
+ def prior_question(cardinality: int) -> Choice:
125
+ return Choice(instructions=PRIOR_INSTRUCTIONS, criteria={f"option{i + 1}": PRIOR_OPTION for i in range(cardinality)})
126
+
127
+
128
+ def _rotate(keys: list[str], rotation: int) -> list[str]:
129
+ shift = rotation % len(keys)
130
+ return keys[shift:] + keys[:shift]
131
+
132
+
133
+ def _option_line(code: str, label: str, description: object) -> str:
134
+ return f"{code}. {label}: {render_text(description)}" if description is not None else f"{code}. {label}"