qarar 1.0.2__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.
nomeda/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """NOMEDA-Decision v1: one Arabic state -> many independent typed decisions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .config import (NomedaConfig, NomedConfig, DEFAULT_CONFIG, k_bucket,
8
+ K_BUCKETS, K_BUCKET_NAMES)
9
+ from .model import NomedaModel, NomedModel
10
+ from .runtime import NomedaRuntime, NomedRuntime, validate_questions
11
+
12
+ __version__ = "1.0.0"
13
+ __all__ = ["load", "Nomeda", "Nomed",
14
+ "NomedaConfig", "NomedaModel", "NomedaRuntime",
15
+ "NomedConfig", "NomedModel", "NomedRuntime",
16
+ "validate_questions", "k_bucket"]
17
+
18
+
19
+ class Nomeda:
20
+ """Loaded model + calibration; ``predict`` mirrors the Laya output shape."""
21
+
22
+ def __init__(self, model: NomedaModel, temperatures: dict | None = None,
23
+ thresholds: dict | None = None, device: str = "cpu"):
24
+ self.model = model
25
+ self.encoder_id = model.cfg.encoder_id
26
+ self.encoder_revision = model.cfg.encoder_revision
27
+ self.runtime = NomedaRuntime(model, temperatures or {}, thresholds or {}, device=device)
28
+
29
+ def predict(self, state, questions):
30
+ return self.runtime.predict(state, questions)
31
+
32
+ def predict_batch(self, states, questions):
33
+ return self.runtime.predict_batch(states, questions)
34
+
35
+ def param_report(self) -> dict:
36
+ return self.model.param_report()
37
+
38
+
39
+ def load(path_or_hub_id: str, device: str = "auto", tiny: bool = False) -> Nomeda:
40
+ """Load a NOMEDA-Decision model from a local export directory or a Hub id."""
41
+ if device == "auto":
42
+ import torch
43
+ device = "cuda" if torch.cuda.is_available() else "cpu"
44
+ if Path(path_or_hub_id).is_dir():
45
+ root = Path(path_or_hub_id)
46
+ raw = json.loads((root / "config.json").read_text(encoding="utf-8"))
47
+ cfg = NomedaConfig(**{k: v for k, v in raw.items() if k in NomedaConfig.__dataclass_fields__})
48
+ enc_dir = root / "encoder"
49
+ model = NomedaModel(cfg, device=device, tiny=tiny)
50
+ if enc_dir.exists():
51
+ from transformers import AutoModel, AutoTokenizer
52
+ model.encoder.tok = AutoTokenizer.from_pretrained(enc_dir)
53
+ model.encoder.model = AutoModel.from_pretrained(enc_dir).to(device)
54
+ head = root / "decision_head.safetensors"
55
+ if head.exists():
56
+ from safetensors.torch import load_file
57
+ state = load_file(str(head))
58
+ model.load_state_dict(state, strict=False)
59
+ temps = json.loads((root / "temperatures.json").read_text(encoding="utf-8")) if (root / "temperatures.json").exists() else {}
60
+ thr = json.loads((root / "thresholds.json").read_text(encoding="utf-8")) if (root / "thresholds.json").exists() else {}
61
+ model.eval()
62
+ model.to(device)
63
+ return Nomeda(model, temps, thr, device=device)
64
+ # Hub id
65
+ from huggingface_hub import snapshot_download
66
+ d = snapshot_download(repo_id=path_or_hub_id)
67
+ return load(d, device=device, tiny=tiny)
68
+
69
+
70
+ # ---- backward-compatible alias (published as `Nomed` in v0.1-beta) --------
71
+ Nomed = Nomeda
nomeda/blocks.py ADDED
@@ -0,0 +1,112 @@
1
+ """Decision blocks, option-state block and attention pooling (§3.4–3.6)."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.nn.functional as F
7
+
8
+
9
+ class MultiheadAttention(nn.Module):
10
+ def __init__(self, d: int, n_heads: int, dropout: float = 0.0):
11
+ super().__init__()
12
+ assert d % n_heads == 0
13
+ self.d, self.h = d, n_heads
14
+ self.dh = d // n_heads
15
+ self.q = nn.Linear(d, d)
16
+ self.k = nn.Linear(d, d)
17
+ self.v = nn.Linear(d, d)
18
+ self.o = nn.Linear(d, d)
19
+ self.dropout = dropout
20
+
21
+ def forward(self, q, k, v, q_mask=None, k_mask=None):
22
+ B, Lq, _ = q.shape
23
+ Lk = k.shape[1]
24
+ Q = self.q(q).view(B, Lq, self.h, self.dh).transpose(1, 2)
25
+ K = self.k(k).view(B, Lk, self.h, self.dh).transpose(1, 2)
26
+ V = self.v(v).view(B, Lk, self.h, self.dh).transpose(1, 2)
27
+ attn_mask = None
28
+ if k_mask is not None:
29
+ attn_mask = torch.zeros(B, 1, 1, Lk, device=q.device, dtype=Q.dtype)
30
+ attn_mask = attn_mask.masked_fill(~k_mask[:, None, None, :].bool(), -1e4)
31
+ out = F.scaled_dot_product_attention(
32
+ Q, K, V, attn_mask=attn_mask,
33
+ dropout_p=self.dropout if self.training else 0.0)
34
+ out = out.transpose(1, 2).reshape(B, Lq, self.d)
35
+ return self.o(out)
36
+
37
+
38
+ class FFN(nn.Module):
39
+ def __init__(self, d: int, mult: int, dropout: float):
40
+ super().__init__()
41
+ self.net = nn.Sequential(nn.Linear(d, d * mult), nn.GELU(), nn.Dropout(dropout), nn.Linear(d * mult, d))
42
+
43
+ def forward(self, x):
44
+ return self.net(x)
45
+
46
+
47
+ class DecisionBlock(nn.Module):
48
+ """Self-attn within a question -> cross-attn to its own state -> FFN (pre-norm)."""
49
+
50
+ def __init__(self, d, n_heads, mult, dropout):
51
+ super().__init__()
52
+ self.ln1, self.ln2, self.ln3 = nn.LayerNorm(d), nn.LayerNorm(d), nn.LayerNorm(d)
53
+ self.self_attn = MultiheadAttention(d, n_heads, dropout)
54
+ self.cross_attn = MultiheadAttention(d, n_heads, dropout)
55
+ self.ffn = FFN(d, mult, dropout)
56
+
57
+ def forward(self, Q, q_mask, S_pad, S_mask, state_idx):
58
+ Q = Q + self.self_attn(self.ln1(Q), self.ln1(Q), self.ln1(Q), q_mask=q_mask, k_mask=q_mask)
59
+ # cross-attention grouped by state: gather each question's own state (vectorised).
60
+ # Equivalent to a per-state loop but without one kernel launch + sync per state.
61
+ x = self.ln2(Q)
62
+ k = S_pad.index_select(0, state_idx)
63
+ kmask = S_mask.index_select(0, state_idx)
64
+ Q = Q + self.cross_attn(x, k, k, k_mask=kmask)
65
+ Q = Q + self.ffn(self.ln3(Q))
66
+ return Q
67
+
68
+
69
+ class OptionStateBlock(nn.Module):
70
+ def __init__(self, d, n_heads, mult, dropout):
71
+ super().__init__()
72
+ self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
73
+ self.cross_attn = MultiheadAttention(d, n_heads, dropout)
74
+ self.ffn = FFN(d, mult, dropout)
75
+
76
+ def forward(self, E, S_pad, S_mask, state_idx):
77
+ x = self.ln1(E).unsqueeze(1) # [Nopt,1,d]
78
+ k = S_pad.index_select(0, state_idx)
79
+ kmask = S_mask.index_select(0, state_idx)
80
+ out = self.cross_attn(x, k, k, k_mask=kmask)
81
+ E = E + out.squeeze(1)
82
+ E = E + self.ffn(self.ln2(E))
83
+ return E
84
+
85
+
86
+ class AttnPool(nn.Module):
87
+ def __init__(self, d):
88
+ super().__init__()
89
+ self.query = nn.Parameter(torch.randn(d) * 0.02)
90
+ self.ln = nn.LayerNorm(d)
91
+
92
+ def forward(self, Q, mask):
93
+ attn = (Q @ self.query) / (Q.shape[-1] ** 0.5) # [B,L]
94
+ attn = attn.masked_fill(~mask.bool(), -1e4)
95
+ p = torch.softmax(attn, dim=-1).unsqueeze(1)
96
+ pooled = torch.bmm(p, Q).squeeze(1)
97
+ return self.ln(pooled)
98
+
99
+
100
+ class CompatibilityScorer(nn.Module):
101
+ def __init__(self, d, dropout):
102
+ super().__init__()
103
+ self.ln = nn.LayerNorm(4 * d)
104
+ self.w1 = nn.Linear(4 * d, d)
105
+ self.w2 = nn.Linear(d, 1)
106
+ self.dropout = nn.Dropout(dropout)
107
+
108
+ def forward(self, q, E):
109
+ h = torch.cat([q, E, q * E, (q - E).abs()], dim=-1)
110
+ h = self.ln(h)
111
+ h = self.dropout(F.gelu(self.w1(h)))
112
+ return self.w2(h).squeeze(-1)
nomeda/calibrate.py ADDED
@@ -0,0 +1,165 @@
1
+ """Calibration: temperatures per (type, K-bucket) and abstention thresholds (§6)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ from collections import defaultdict
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+
13
+ from .config import k_bucket
14
+
15
+
16
+ def ece(probs: List[float], correct: List[int], n_bins: int = 15) -> float:
17
+ """Expected calibration error, confidence = max(p), equal-width bins (Laya's definition)."""
18
+ if not probs:
19
+ return 0.0
20
+ total = len(probs)
21
+ out = 0.0
22
+ for b in range(n_bins):
23
+ lo, hi = b / n_bins, (b + 1) / n_bins
24
+ idx = [i for i, p in enumerate(probs) if (p > lo or (b == 0 and p >= lo)) and p <= hi]
25
+ if not idx:
26
+ continue
27
+ conf = sum(probs[i] for i in idx) / len(idx)
28
+ acc = sum(correct[i] for i in idx) / len(idx)
29
+ out += (len(idx) / total) * abs(conf - acc)
30
+ return out
31
+
32
+
33
+ def fit_temperature(logits_list, targets_list, clamp: Tuple[float, float] = (0.5, 5.0)) -> float:
34
+ """Minimise NLL over a single temperature; logits_list holds per-item 1-D tensors
35
+ (K may vary between items)."""
36
+ logT = torch.zeros(1, requires_grad=True)
37
+ opt = torch.optim.LBFGS([logT], lr=0.1, max_iter=50)
38
+ tg = [int(t) for t in targets_list]
39
+
40
+ def closure():
41
+ opt.zero_grad()
42
+ T = torch.exp(logT).clamp(*clamp)
43
+ loss = 0.0
44
+ for l, t in zip(logits_list, tg):
45
+ loss = loss + F.cross_entropy((l / T).unsqueeze(0).float(), torch.tensor([t]))
46
+ loss = loss / max(1, len(logits_list))
47
+ loss.backward()
48
+ return loss
49
+
50
+ opt.step(closure)
51
+ with torch.no_grad():
52
+ return float(torch.exp(logT).clamp(*clamp))
53
+
54
+
55
+ def fit_temperatures(buckets: Dict[Tuple[str, str], Tuple[list, list]],
56
+ min_items: int = 200) -> dict:
57
+ """buckets: {(type, K-bucket): ([logits_1d...], [targets...])} -> temperatures.json dict."""
58
+ out: Dict[str, Dict[str, float]] = {}
59
+ per_type: Dict[str, Tuple[list, list]] = {}
60
+ for (qtype, kb), (logits, targets) in buckets.items():
61
+ per_type.setdefault(qtype, ([], []))
62
+ per_type[qtype][0].extend(logits)
63
+ per_type[qtype][1].extend(targets)
64
+ if len(logits) < min_items:
65
+ continue
66
+ out.setdefault(qtype, {})[kb] = fit_temperature(logits, targets)
67
+ for qtype, (ls, ts) in per_type.items():
68
+ out.setdefault(qtype, {})["default"] = fit_temperature(ls, ts)
69
+ return out
70
+
71
+
72
+ def fit_abstention(conf: List[float], correct: List[int], target_acc: float = 0.95) -> float:
73
+ """Smallest threshold so answered questions reach accuracy >= target_acc."""
74
+ pairs = sorted(zip(conf, correct), reverse=True)
75
+ if not pairs:
76
+ return 0.0
77
+ best_t = 1.0
78
+ n_ok = 0
79
+ for i, (c, ok) in enumerate(pairs):
80
+ n_ok += ok
81
+ acc = n_ok / (i + 1)
82
+ if acc >= target_acc:
83
+ best_t = c
84
+ return float(best_t)
85
+
86
+
87
+ def coverage(conf: List[float], threshold: float) -> float:
88
+ if not conf:
89
+ return 0.0
90
+ return sum(1 for c in conf if c >= threshold) / len(conf)
91
+
92
+
93
+ def save(path: str, obj: dict) -> None:
94
+ with open(path, "w", encoding="utf-8") as f:
95
+ json.dump(obj, f, ensure_ascii=False, indent=2)
96
+
97
+
98
+ # ---- fair calibrated comparison (cal_cv, §0.4) ---------------------------
99
+ def group_of(record_id, view_group) -> str:
100
+ """Fold-group id: view_group and V2 en↔ar pairs stay together."""
101
+ if view_group:
102
+ return f"vg:{view_group}"
103
+ rid = str(record_id)
104
+ if ":en:" in rid or ":ar:" in rid:
105
+ return "pair:" + rid.replace(":en:", ":*:").replace(":ar:", ":*:")
106
+ return f"solo:{rid}"
107
+
108
+
109
+ def _fold_of(group_id: str, folds: int) -> int:
110
+ h = int(hashlib.sha1(str(group_id).encode()).hexdigest()[:8], 16)
111
+ return h % folds
112
+
113
+
114
+ def _fit_temps_from_items(items: List[dict], min_items: int,
115
+ clamp: Tuple[float, float] = (0.5, 5.0)) -> dict:
116
+ buckets: Dict[Tuple[str, str], Tuple[list, list]] = defaultdict(lambda: ([], []))
117
+ per_type: Dict[str, Tuple[list, list]] = defaultdict(lambda: ([], []))
118
+ for it in items:
119
+ kb = it["kb"]
120
+ buckets[(it["type"], kb)][0].append(it["logits"])
121
+ buckets[(it["type"], kb)][1].append(it["idx"])
122
+ per_type[it["type"]][0].append(it["logits"])
123
+ per_type[it["type"]][1].append(it["idx"])
124
+ out: Dict[str, dict] = {}
125
+ for (qt, kb), (L, T) in buckets.items():
126
+ if len(L) >= min_items:
127
+ out.setdefault(qt, {})[kb] = fit_temperature(L, T, clamp)
128
+ for qt, (L, T) in per_type.items():
129
+ out.setdefault(qt, {})["default"] = fit_temperature(L, T, clamp)
130
+ return out
131
+
132
+
133
+ def _score_items(items: List[dict], temps: dict) -> Tuple[float, float, List[float], List[int]]:
134
+ nll = 0.0
135
+ confs, oks = [], []
136
+ for it in items:
137
+ T = float(temps.get(it["type"], {}).get(it["kb"], temps.get(it["type"], {}).get("default", 1.0)))
138
+ p = torch.softmax(it["logits"] / T, dim=-1)
139
+ nll += -math.log(max(float(p[it["idx"]]), 1e-12))
140
+ confs.append(float(p.max()))
141
+ oks.append(int(int(torch.argmax(p)) == it["idx"]))
142
+ return nll / max(1, len(items)), ece(confs, oks), confs, oks
143
+
144
+
145
+ def cal_cv_from_rows(rows: List[dict], folds: int = 2, min_items: int = 100) -> dict:
146
+ """Fit on fold A / score on B then swap; average NLL and ECE (§0.4)."""
147
+ items = []
148
+ for r in rows:
149
+ if "logits" not in r or "labels" not in r:
150
+ continue
151
+ items.append({
152
+ "group": group_of(r.get("record_id"), r.get("view_group")),
153
+ "type": r["type"], "kb": k_bucket(len(r["labels"])),
154
+ "logits": torch.tensor(r["logits"]), "idx": r["labels"].index(r["gold"]),
155
+ })
156
+ fold_of = [_fold_of(it["group"], folds) for it in items]
157
+ nlls, eces = [], []
158
+ for f in range(folds):
159
+ fit = [it for it, ff in zip(items, fold_of) if ff != f]
160
+ ev = [it for it, ff in zip(items, fold_of) if ff == f]
161
+ temps = _fit_temps_from_items(fit, min_items)
162
+ n, e, _, _ = _score_items(ev, temps)
163
+ nlls.append(n); eces.append(e)
164
+ return {"nll_cal_cv": round(sum(nlls) / folds, 4),
165
+ "ece_cal_cv": round(sum(eces) / folds, 4)}
nomeda/config.py ADDED
@@ -0,0 +1,103 @@
1
+ """NOMEDA-Decision v1 configuration.
2
+
3
+ All hyper-parameters live here and are loaded from YAML. Defaults match the
4
+ implementation spec.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field, asdict
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ import yaml
13
+
14
+ ENCODER_ID = "silma-ai/silma-embedding-matryoshka-v0.1"
15
+ # Pinned revision (verified, see docs/VERIFIED.md)
16
+ ENCODER_REVISION = "011e3b07c9ae2e38f2312a237b489b3b0a640307"
17
+
18
+ K_BUCKETS = [(2, 2), (3, 5), (6, 10), (11, 10**9)]
19
+ K_BUCKET_NAMES = ["2", "3-5", "6-10", "11+"]
20
+
21
+
22
+ def k_bucket(k: int) -> str:
23
+ for (lo, hi), name in zip(K_BUCKETS, K_BUCKET_NAMES):
24
+ if lo <= k <= hi:
25
+ return name
26
+ raise ValueError(k)
27
+
28
+
29
+ @dataclass
30
+ class NomedaConfig:
31
+ # encoder
32
+ encoder_id: str = ENCODER_ID
33
+ encoder_revision: str = ENCODER_REVISION
34
+ encoder_dim: int = 768
35
+ encoder_layers: int = 12
36
+ encoder_max_len: int = 512
37
+ encoder_vocab: int = 64000
38
+
39
+ # decision layer
40
+ d_model: int = 384
41
+ n_heads: int = 6
42
+ ffn_mult: int = 4
43
+ n_decision_blocks: int = 4
44
+ dropout: float = 0.1
45
+ option_state_xattn: bool = True
46
+
47
+ # state chunking
48
+ state_max_len: int = 512
49
+ state_chunk_stride: int = 384
50
+ state_max_chunks: int = 8
51
+
52
+ # text
53
+ text_max_len: int = 128
54
+ n_rank_levels: int = 32
55
+ q_bucket: int = 128 # questions per length-bucketed sub-batch (padding control)
56
+
57
+ # freezing
58
+ freeze_encoder: bool = True
59
+ unfreeze_top_layers: int = 0
60
+ # Optional GRADUAL unfreeze. When non-empty, e.g. [0, 2, 4, 6], the number of
61
+ # trainable top encoder layers is re-set at the start of each epoch to the
62
+ # given value (last value repeats if epochs exceed the list). Empty = fixed
63
+ # `unfreeze_top_layers` for the whole run. Only used as an escalation after
64
+ # fixed unfreezing fails to earn its place (plan C4.4).
65
+ unfreeze_schedule: List[int] = field(default_factory=list)
66
+
67
+ # loss
68
+ label_smoothing: float = 0.02
69
+ w_spherical: float = 0.5
70
+ w_rps: float = 1.0
71
+ lambda_kl: float = 0.0
72
+ kl_en_ar_only: bool = True # restrict consistency KL to V2 en<->ar pairs
73
+
74
+ # training
75
+ lr_decision: float = 3e-4
76
+ lr_encoder: float = 2e-5
77
+ weight_decay: float = 0.01
78
+ warmup_frac: float = 0.03
79
+ grad_clip: float = 1.0
80
+ bf16: bool = True
81
+ seed: int = 13
82
+
83
+ @classmethod
84
+ def from_yaml(cls, path: str | Path) -> "NomedaConfig":
85
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
86
+ known = {f for f in cls.__dataclass_fields__}
87
+ unknown = set(data) - known
88
+ if unknown:
89
+ raise ValueError(f"unknown config keys: {sorted(unknown)}")
90
+ return cls(**data)
91
+
92
+ def to_dict(self) -> dict:
93
+ return asdict(self)
94
+
95
+
96
+ DEFAULT_CONFIG = NomedaConfig()
97
+
98
+
99
+ # ---- backward-compatible aliases -----------------------------------------
100
+ # The class was originally published as `NomedConfig` in NOMEDA-Decision
101
+ # v0.1-beta. Keep the old name working so existing code and the published model
102
+ # card do not break; new code should use `NomedaConfig`.
103
+ NomedConfig = NomedaConfig
nomeda/costs.py ADDED
@@ -0,0 +1,93 @@
1
+ """Cost-aware decisions (§Stage 7, step 4a).
2
+
3
+ Given calibrated probabilities p(y) over the supplied options and a cost matrix
4
+ C[action, y], the Bayes-optimal single-step action is
5
+
6
+ a* = argmin_a Σ_y p(y) · C[a, y]
7
+
8
+ This is exact when the probabilities are calibrated, needs no training, and is the
9
+ baseline that the RL policy (4b-4d) must beat.
10
+
11
+ Cost design is an owner product decision: matrices live in ``costs/*.yaml``, are versioned,
12
+ and carry the reason for each number. Nothing here ships a default matrix silently.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Dict, List, Optional, Sequence, Tuple
19
+
20
+ import yaml
21
+
22
+
23
+ @dataclass
24
+ class CostMatrix:
25
+ """Actions x outcomes cost matrix for one question.
26
+
27
+ actions: ordered action ids.
28
+ matrix: {action: {option_label: cost}}; every action must price every option.
29
+ """
30
+ actions: List[str]
31
+ matrix: Dict[str, Dict[str, float]]
32
+ version: str = "unknown"
33
+ notes: str = ""
34
+
35
+ def validate(self, option_labels: Sequence[str]) -> None:
36
+ if not self.actions:
37
+ raise ValueError("cost matrix has no actions")
38
+ for a in self.actions:
39
+ if a not in self.matrix:
40
+ raise ValueError(f"cost matrix missing action {a!r}")
41
+ row = self.matrix[a]
42
+ missing = [lab for lab in option_labels if lab not in row]
43
+ if missing:
44
+ raise ValueError(f"action {a!r} does not price options {missing}")
45
+ for lab in option_labels:
46
+ c = row[lab]
47
+ if not isinstance(c, (int, float)) or c != c:
48
+ raise ValueError(f"cost for {a!r}/{lab!r} is not a number: {c!r}")
49
+ if c < 0:
50
+ raise ValueError(f"cost for {a!r}/{lab!r} is negative: {c}")
51
+
52
+ def as_features(self, option_labels: Sequence[str]) -> List[List[float]]:
53
+ """Matrix rows in canonical option order (for the policy head)."""
54
+ return [[float(self.matrix[a][lab]) for lab in option_labels] for a in self.actions]
55
+
56
+
57
+ def expected_cost(probs: Sequence[float], costs: Sequence[float]) -> float:
58
+ """Σ_y p(y) · C[a, y] for one action."""
59
+ if len(probs) != len(costs):
60
+ raise ValueError(f"length mismatch: {len(probs)} probs vs {len(costs)} costs")
61
+ return float(sum(p * c for p, c in zip(probs, costs)))
62
+
63
+
64
+ def bayes_action(probs: Sequence[float], labels: Sequence[str],
65
+ cost: CostMatrix) -> Tuple[str, float, Dict[str, float]]:
66
+ """Return (best_action, its expected cost, {action: expected cost})."""
67
+ cost.validate(labels)
68
+ ec = {a: expected_cost(probs, [cost.matrix[a][lab] for lab in labels]) for a in cost.actions}
69
+ best = min(cost.actions, key=lambda a: (ec[a], a)) # lexicographic tie-break
70
+ return best, ec[best], ec
71
+
72
+
73
+ def load_costs(path: str | Path, option_labels: Sequence[str]) -> CostMatrix:
74
+ """Load and validate a cost matrix from YAML for a given option list."""
75
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
76
+ cm = CostMatrix(actions=list(data.get("actions", [])),
77
+ matrix=data.get("matrix", {}),
78
+ version=str(data.get("version", "unknown")),
79
+ notes=str(data.get("notes", "")))
80
+ cm.validate(option_labels)
81
+ return cm
82
+
83
+
84
+ def apply_costs(answer: dict, cost: CostMatrix, labels: Sequence[str]) -> dict:
85
+ """Attach `action`, `expected_cost` and per-action expected costs to a runtime answer."""
86
+ probs = [float(answer["probabilities"][lab]) for lab in labels]
87
+ best, best_ec, ec = bayes_action(probs, labels, cost)
88
+ out = dict(answer)
89
+ out["action"] = best
90
+ out["expected_cost"] = round(best_ec, 6)
91
+ out["expected_cost_by_action"] = {a: round(v, 6) for a, v in ec.items()}
92
+ out["cost_version"] = cost.version
93
+ return out
@@ -0,0 +1,2 @@
1
+ from .loader import iter_records, record_to_example, family_of, resolve_gold, strip_evidence, required_split_paths, FORBIDDEN # noqa
2
+ from .batching import iter_batches, augment, pair_key # noqa