sysone 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.
sysone/cache.py ADDED
@@ -0,0 +1,196 @@
1
+ """The on-disk feature cache for a frozen backbone (`cache_dir`).
2
+
3
+ Vectors are grouped into **sets**, one folder per combination of everything that determines them
4
+ (base model and revision, kept layers, layout, precision, prompt format, token limits). Within a
5
+ set, each question is looked up by a hash of its encoded tokens, so growing the data only
6
+ computes the new questions. Each write adds one shard file to the set.
7
+
8
+ The whole folder is capped at `cache_max_gb`: the least recently used sets are deleted first, and
9
+ a set that would exceed the cap on its own is never written. Nothing is written if it would
10
+ leave less than 10% of the disk free.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import shutil
19
+ import time
20
+ from array import array
21
+ from collections.abc import Callable, Sequence
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ import torch
26
+
27
+ from ._util import format_bytes
28
+ from .features import CACHE_DTYPE, Features
29
+ from .layout import Encoded
30
+
31
+ FORMAT = 1
32
+ SET_PREFIX = "set-"
33
+ META_FILE = "set.json"
34
+ #: Writing is skipped when it would leave less than this fraction of the disk free.
35
+ MIN_FREE_FRACTION = 0.10
36
+
37
+
38
+ def question_key(encoded: Encoded) -> bytes:
39
+ """A 16-byte hash of everything about an encoded question that its vectors depend on."""
40
+ h = hashlib.blake2b(digest_size=16)
41
+ for values in (encoded.input_ids, encoded.position_ids, encoded.option_ids, encoded.read_positions,
42
+ [encoded.summary_position]):
43
+ h.update(array("q", values).tobytes())
44
+ h.update(b"|")
45
+ return h.digest()
46
+
47
+
48
+ def _set_name(parts: dict) -> str:
49
+ text = json.dumps({"format": FORMAT, **parts}, sort_keys=True, default=str)
50
+ return SET_PREFIX + hashlib.sha256(text.encode()).hexdigest()[:16]
51
+
52
+
53
+ def _folder_bytes(path: Path) -> int:
54
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
55
+
56
+
57
+ def _read_meta(path: Path) -> dict | None:
58
+ try:
59
+ with open(path / META_FILE, encoding="utf-8") as f:
60
+ return json.load(f)
61
+ except (OSError, ValueError):
62
+ return None
63
+
64
+
65
+ def _write_json(path: Path, data: dict) -> None:
66
+ tmp = path.with_suffix(".tmp")
67
+ with open(tmp, "w", encoding="utf-8") as f:
68
+ json.dump(data, f, indent=2, default=str)
69
+ os.replace(tmp, path)
70
+
71
+
72
+ def _sets(root: Path) -> list[tuple[Path, dict]]:
73
+ if not root.is_dir():
74
+ return []
75
+ found = []
76
+ for path in sorted(root.glob(SET_PREFIX + "*")):
77
+ meta = _read_meta(path) if path.is_dir() else None
78
+ if meta is not None:
79
+ found.append((path, meta))
80
+ return found
81
+
82
+
83
+ class FeatureCache:
84
+ """One set of cached vectors in `root`, identified by `parts`."""
85
+
86
+ def __init__(self, root, parts: dict, max_bytes: float, log: Callable[[str], None]):
87
+ self.root = Path(root).expanduser()
88
+ self.parts = parts
89
+ self.path = self.root / _set_name(parts)
90
+ self.max_bytes = max_bytes
91
+ self.log = log
92
+
93
+ def _touch(self, meta: dict) -> None:
94
+ meta["last_used"] = time.time()
95
+ _write_json(self.path / META_FILE, meta)
96
+
97
+ def lookup(self, keys: Sequence[bytes]) -> dict[bytes, Features]:
98
+ """The cached vectors for whichever of `keys` are in this set."""
99
+ from safetensors.torch import load_file
100
+
101
+ meta = _read_meta(self.path)
102
+ if meta is None:
103
+ return {}
104
+ wanted, found = set(keys), {}
105
+ for shard in sorted(self.path.glob("shard-*.safetensors")):
106
+ data = load_file(str(shard))
107
+ offsets = data["offsets"].tolist()
108
+ for i, key in enumerate(bytes(row) for row in data["keys"].numpy()):
109
+ if key in wanted and key not in found:
110
+ found[key] = Features(data["options"][offsets[i]: offsets[i + 1]].clone(),
111
+ data["summary"][i].clone())
112
+ self._touch(meta)
113
+ return found
114
+
115
+ def add(self, keys: Sequence[bytes], features: Sequence[Features]) -> int:
116
+ """Write new vectors as one shard; returns the bytes written (0 when skipped)."""
117
+ from safetensors.torch import save_file
118
+
119
+ if not keys:
120
+ return 0
121
+ tensors = {
122
+ "keys": torch.tensor([list(k) for k in keys], dtype=torch.uint8),
123
+ "offsets": torch.tensor([0] + [f.options.shape[0] for f in features], dtype=torch.int64).cumsum(0),
124
+ "options": torch.cat([f.options for f in features]).to(CACHE_DTYPE).contiguous(),
125
+ "summary": torch.stack([f.summary for f in features]).to(CACHE_DTYPE).contiguous(),
126
+ }
127
+ size = sum(t.numel() * t.element_size() for t in tensors.values())
128
+ current = _folder_bytes(self.path) if self.path.is_dir() else 0
129
+ if current + size > self.max_bytes:
130
+ self.log("feature cache: this set would take %s, more than cache_max_gb=%g; not writing it"
131
+ % (format_bytes(current + size), self.max_bytes / 1024**3))
132
+ return 0
133
+ self.root.mkdir(parents=True, exist_ok=True)
134
+ disk = shutil.disk_usage(self.root)
135
+ if disk.free - size < MIN_FREE_FRACTION * disk.total:
136
+ self.log("feature cache: writing %s would leave less than %d%% of the disk free; not writing it"
137
+ % (format_bytes(size), int(100 * MIN_FREE_FRACTION)))
138
+ return 0
139
+ self._evict(size)
140
+ self.path.mkdir(exist_ok=True)
141
+ meta = _read_meta(self.path) or {"format": FORMAT, "parts": self.parts, "created": time.time(),
142
+ "n_questions": 0}
143
+ n_shards = len(list(self.path.glob("shard-*.safetensors")))
144
+ shard = self.path / ("shard-%05d.safetensors" % n_shards)
145
+ tmp = shard.with_suffix(".tmp")
146
+ save_file(tensors, str(tmp))
147
+ os.replace(tmp, shard)
148
+ meta["n_questions"] += len(keys)
149
+ self._touch(meta)
150
+ return shard.stat().st_size
151
+
152
+ def _evict(self, incoming: int) -> None:
153
+ """Delete the least recently used other sets until `incoming` more bytes fit the cap."""
154
+ others = sorted(((p, m) for p, m in _sets(self.root) if p != self.path),
155
+ key=lambda pm: pm[1].get("last_used", 0))
156
+ total = _folder_bytes(self.root)
157
+ while others and total + incoming > self.max_bytes:
158
+ path, _ = others.pop(0)
159
+ freed = _folder_bytes(path)
160
+ shutil.rmtree(path, ignore_errors=True)
161
+ total -= freed
162
+ self.log("feature cache: deleted the least recently used set %s (%s) to stay under cache_max_gb"
163
+ % (path.name, format_bytes(freed)))
164
+
165
+ def folder_bytes(self) -> int:
166
+ return _folder_bytes(self.root) if self.root.is_dir() else 0
167
+
168
+
169
+ def cache_info(cache_dir) -> list[dict]:
170
+ """The cache sets in `cache_dir`, most recently used first: their folder, size, number of
171
+ questions, last use, and the settings that identify them."""
172
+ rows = []
173
+ for path, meta in _sets(Path(cache_dir).expanduser()):
174
+ size = _folder_bytes(path)
175
+ last = meta.get("last_used")
176
+ rows.append({
177
+ "set": path.name,
178
+ "path": str(path),
179
+ "model": meta.get("parts", {}).get("model_name"),
180
+ "n_questions": meta.get("n_questions", 0),
181
+ "size_bytes": size,
182
+ "size": format_bytes(size),
183
+ "last_used": datetime.fromtimestamp(last, timezone.utc).isoformat(timespec="seconds") if last else None,
184
+ "parts": meta.get("parts", {}),
185
+ })
186
+ return sorted(rows, key=lambda r: r["last_used"] or "", reverse=True)
187
+
188
+
189
+ def clear_cache(cache_dir) -> int:
190
+ """Delete every cache set in `cache_dir` and return the bytes freed. Other files in the folder
191
+ are left alone."""
192
+ freed = 0
193
+ for path, _ in _sets(Path(cache_dir).expanduser()):
194
+ freed += _folder_bytes(path)
195
+ shutil.rmtree(path, ignore_errors=True)
196
+ return freed
sysone/checkpoint.py ADDED
@@ -0,0 +1,96 @@
1
+ """Files of a saved model beyond `config.json` and the head.
2
+
3
+ - `adapter/`: LoRA adapters in peft's format (`adapter_config.json`, `adapter_model.safetensors`);
4
+ - `backbone.safetensors`: only the fully trained layers and embeddings, in the backbone's precision;
5
+ - `training_state/`: what a later `fit` needs to resume the same run.
6
+
7
+ Frozen backbone weights are never saved.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import torch
18
+ from torch import nn
19
+
20
+ from .training import TrainState
21
+ from .tuning import add_adapters, is_adapter
22
+
23
+ ADAPTER_DIR = "adapter"
24
+ ADAPTER_WEIGHTS = "adapter_model.safetensors"
25
+ BACKBONE_FILE = "backbone.safetensors"
26
+ STATE_DIR = "training_state"
27
+ _STATE_META = "state.json"
28
+ _STATE_TENSORS = "state.pt"
29
+ #: peft stores adapter weights without the adapter's name, under the wrapper's prefix.
30
+ _PEFT_PREFIX = "base_model.model."
31
+ _ADAPTER_KEY = re.compile(r"\.(lora_[AB])\.default\.")
32
+ _PEFT_KEY = re.compile(r"\.(lora_[AB])\.")
33
+
34
+
35
+ def save_adapter(backbone: nn.Module, path: Path) -> None:
36
+ from safetensors.torch import save_file
37
+
38
+ path.mkdir(parents=True, exist_ok=True)
39
+ backbone.sysone_lora_config.save_pretrained(str(path))
40
+ weights = {_PEFT_PREFIX + _ADAPTER_KEY.sub(r".\1.", name): p.detach().cpu().contiguous()
41
+ for name, p in backbone.named_parameters() if is_adapter(name)}
42
+ save_file(weights, str(path / ADAPTER_WEIGHTS))
43
+
44
+
45
+ def load_adapter(backbone: nn.Module, path: Path) -> None:
46
+ from peft import LoraConfig
47
+ from safetensors.torch import load_file
48
+
49
+ add_adapters(backbone, LoraConfig.from_pretrained(str(path)))
50
+ params = dict(backbone.named_parameters())
51
+ for key, value in load_file(str(path / ADAPTER_WEIGHTS)).items():
52
+ name = _PEFT_KEY.sub(r".\1.default.", key.removeprefix(_PEFT_PREFIX))
53
+ if name not in params:
54
+ raise ValueError("the saved adapter has a weight %r that this model doesn't" % key)
55
+ with torch.no_grad():
56
+ params[name].copy_(value)
57
+
58
+
59
+ def save_backbone_weights(named: list[tuple[str, nn.Parameter]], dtype: torch.dtype, path: Path) -> None:
60
+ from safetensors.torch import save_file
61
+
62
+ save_file({name: p.detach().to("cpu", dtype).contiguous() for name, p in named}, str(path))
63
+
64
+
65
+ def load_backbone_weights(backbone: nn.Module, path: Path) -> None:
66
+ from safetensors.torch import load_file
67
+
68
+ weights = load_file(str(path))
69
+ missing = set(weights) - set(dict(backbone.named_parameters()))
70
+ if missing:
71
+ raise ValueError("%s has weights this model doesn't: %s" % (path.name, sorted(missing)[:5]))
72
+ backbone.load_state_dict(weights, strict=False)
73
+
74
+
75
+ def save_training_state(state: TrainState, path: Path) -> None:
76
+ path.mkdir(parents=True, exist_ok=True)
77
+ meta = {k: getattr(state, k) for k in ("fingerprint", "step", "last_check", "best_step", "running_loss",
78
+ "running_n")}
79
+ meta["best_loss"] = state.best_loss if math.isfinite(state.best_loss) else None
80
+ with open(path / _STATE_META, "w", encoding="utf-8") as f:
81
+ json.dump(meta, f, indent=2)
82
+ torch.save({"epoch_generator": state.epoch_generator, "rng": state.rng, "optimizer": state.optimizer,
83
+ "current": state.current}, path / _STATE_TENSORS)
84
+
85
+
86
+ def load_training_state(path: Path) -> TrainState | None:
87
+ if not (path / _STATE_META).is_file():
88
+ return None
89
+ with open(path / _STATE_META, encoding="utf-8") as f:
90
+ meta = json.load(f)
91
+ tensors = torch.load(path / _STATE_TENSORS, map_location="cpu", weights_only=True)
92
+ best = meta.pop("best_loss")
93
+ if "stale" in meta: # saved before patience was counted in epochs: assume the best check was the last one
94
+ meta.pop("stale")
95
+ meta["best_step"] = meta["last_check"]
96
+ return TrainState(**meta, best_loss=math.inf if best is None else best, **tensors)
sysone/data.py ADDED
@@ -0,0 +1,190 @@
1
+ """Turn the accepted input forms into examples: one per (state, question, label).
2
+
3
+ Accepted forms:
4
+
5
+ 1. Records: an iterable of dicts with `state`, `questions`, and labels in `expected`, `gold` or a
6
+ `label` inside each question. Rows may mix question sets freely.
7
+ 2. Records plus a question catalog (`questions={id: question_dict}`), so rows carry only `state`
8
+ and their labels.
9
+ 3. A long DataFrame, one row per (state, question): `state | question_id | answer` with a
10
+ catalog, or self-contained `state | type | instructions | criteria | answer`.
11
+
12
+ A path to a `.jsonl`, `.json`, `.csv` or `.parquet` file is read first. A `datasets.Dataset` is
13
+ iterated as records. Fields holding JSON text (common in published datasets) are decoded.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from collections import Counter
21
+ from collections.abc import Iterable, Mapping
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .questions import Question, parse_question, target_from_gold, target_from_label
27
+
28
+ _MISSING = object()
29
+
30
+
31
+ @dataclass
32
+ class Example:
33
+ """One question about one state. `target` is None at prediction time."""
34
+
35
+ state: str
36
+ question: Question
37
+ qid: str
38
+ target: list[float] | None = None
39
+ category: str | None = None
40
+ row: int = 0 # index of the source row; questions from one row share a state
41
+
42
+
43
+ def state_text(state: Any) -> str:
44
+ if isinstance(state, str):
45
+ return state
46
+ return json.dumps(state, ensure_ascii=False, default=str)
47
+
48
+
49
+ def _maybe_json(value: Any) -> Any:
50
+ if isinstance(value, str):
51
+ stripped = value.strip()
52
+ if stripped[:1] in ("{", "["):
53
+ try:
54
+ return json.loads(stripped)
55
+ except json.JSONDecodeError:
56
+ return value
57
+ return value
58
+
59
+
60
+ def _is_frame(data: Any) -> bool:
61
+ return hasattr(data, "columns") and hasattr(data, "to_dict") and hasattr(data, "iterrows")
62
+
63
+
64
+ def _read_path(path: Path) -> Any:
65
+ suffix = path.suffix.lower()
66
+ if suffix == ".jsonl":
67
+ with open(path, encoding="utf-8") as f:
68
+ return [json.loads(line) for line in f if line.strip()]
69
+ if suffix == ".json":
70
+ with open(path, encoding="utf-8") as f:
71
+ return json.load(f)
72
+ if suffix in (".csv", ".parquet"):
73
+ try:
74
+ import pandas as pd
75
+ except ImportError as exc:
76
+ raise ImportError("reading %s files needs pandas: pip install sysone[pandas]" % suffix) from exc
77
+ return pd.read_csv(path) if suffix == ".csv" else pd.read_parquet(path)
78
+ raise ValueError("cannot read %s: use a .jsonl, .json, .csv or .parquet file" % path)
79
+
80
+
81
+ def _frame_rows(frame: Any) -> Iterable[dict]:
82
+ columns = set(frame.columns)
83
+ if "state" not in columns:
84
+ raise ValueError("a DataFrame needs a 'state' column")
85
+ if "question_id" in columns:
86
+ mode = "catalog"
87
+ elif {"type", "instructions"} <= columns:
88
+ mode = "inline"
89
+ else:
90
+ raise ValueError("a DataFrame needs either a 'question_id' column (with questions=catalog) or "
91
+ "'type' and 'instructions' columns")
92
+ has_answer = "answer" in columns
93
+ for record in frame.to_dict(orient="records"):
94
+ row: dict = {"state": record["state"]}
95
+ if "category" in columns and record.get("category") is not None:
96
+ row["category"] = record["category"]
97
+ if mode == "catalog":
98
+ qid = str(record["question_id"])
99
+ row["questions"] = [qid]
100
+ else:
101
+ qid = "question"
102
+ qdef = {"type": record["type"], "instructions": record["instructions"]}
103
+ criteria = _maybe_json(record.get("criteria"))
104
+ if criteria is not None and not (isinstance(criteria, float) and criteria != criteria): # NaN
105
+ qdef["criteria"] = criteria
106
+ row["questions"] = {qid: qdef}
107
+ if has_answer and not _is_null(record["answer"]):
108
+ row["expected"] = {qid: record["answer"]}
109
+ yield row
110
+
111
+
112
+ def _is_null(value: Any) -> bool:
113
+ return value is None or (isinstance(value, float) and value != value)
114
+
115
+
116
+ def _rows(data: Any) -> Iterable[dict]:
117
+ if isinstance(data, (str, os.PathLike)):
118
+ data = _read_path(Path(data))
119
+ if _is_frame(data):
120
+ return _frame_rows(data)
121
+ if isinstance(data, Mapping):
122
+ raise TypeError("pass a list of rows (dicts), not a single dict")
123
+ return data
124
+
125
+
126
+ def _resolve_questions(row: dict, catalog: Mapping | None, where: str) -> dict:
127
+ questions = _maybe_json(row.get("questions", _MISSING))
128
+ if questions is _MISSING or questions is None:
129
+ if catalog is None:
130
+ raise ValueError("%s: no 'questions' and no questions= catalog was given" % where)
131
+ referenced = list(_maybe_json(row.get("expected")) or {}) + list(_maybe_json(row.get("gold")) or {})
132
+ questions = {qid: catalog[qid] for qid in dict.fromkeys(referenced) if _in_catalog(catalog, qid, where)}
133
+ elif isinstance(questions, (list, tuple)):
134
+ # a list of question ids, looked up in the catalog
135
+ if catalog is None:
136
+ raise ValueError("%s: 'questions' lists ids but no questions= catalog was given" % where)
137
+ questions = {qid: catalog[qid] for qid in questions if _in_catalog(catalog, qid, where)}
138
+ elif not isinstance(questions, dict):
139
+ raise ValueError("%s: 'questions' must be a dict of question id -> question" % where)
140
+ return {qid: _maybe_json(qdef) for qid, qdef in questions.items()}
141
+
142
+
143
+ def _in_catalog(catalog: Mapping, qid: Any, where: str) -> bool:
144
+ if qid not in catalog:
145
+ raise ValueError("%s: question %r is not in the questions= catalog" % (where, qid))
146
+ return True
147
+
148
+
149
+ def examples_from(data: Any, questions: Mapping | None = None, *, labeled: bool = True,
150
+ label_smoothing: float = 0.0) -> tuple[list[Example], Counter]:
151
+ """Examples from any accepted input form.
152
+
153
+ With `labeled=True` (training), questions without a label are skipped and counted in the
154
+ returned `Counter` under "unlabeled". Malformed rows or questions raise `ValueError` naming
155
+ the row and question.
156
+ """
157
+ examples: list[Example] = []
158
+ skipped: Counter = Counter()
159
+ for i, row in enumerate(_rows(data)):
160
+ where = "row %d" % i
161
+ if not isinstance(row, Mapping):
162
+ raise ValueError("%s: expected a dict, got %s" % (where, type(row).__name__))
163
+ if "state" not in row:
164
+ raise ValueError("%s: missing 'state'" % where)
165
+ state = state_text(row["state"])
166
+ category = row.get("category")
167
+ category = None if _is_null(category) else str(category)
168
+ qdefs = _resolve_questions(row, questions, where)
169
+ expected = _maybe_json(row.get("expected")) or {}
170
+ gold = _maybe_json(row.get("gold")) or {}
171
+ for qid, qdef in qdefs.items():
172
+ qwhere = "%s, question %r" % (where, qid)
173
+ question = parse_question(qid, qdef)
174
+ target = None
175
+ if labeled:
176
+ sources = [name for name, present in (("expected", qid in expected), ("gold", qid in gold),
177
+ ("label", isinstance(qdef, dict) and "label" in qdef))
178
+ if present]
179
+ if len(sources) > 1:
180
+ raise ValueError("%s: has labels in both %s; give exactly one" % (qwhere, " and ".join(sources)))
181
+ if not sources:
182
+ skipped["unlabeled"] += 1
183
+ continue
184
+ if sources[0] == "gold":
185
+ target = target_from_gold(question, _maybe_json(gold[qid]), where=qwhere)
186
+ else:
187
+ value = expected[qid] if sources[0] == "expected" else qdef["label"]
188
+ target = target_from_label(question, value, label_smoothing, where=qwhere)
189
+ examples.append(Example(state, question, str(qid), target, category, i))
190
+ return examples, skipped
sysone/encoding.py ADDED
@@ -0,0 +1,80 @@
1
+ """Turn (state, question) pairs into token layouts, with truncation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ from .architectures.base import Architecture, encode_text
9
+ from .layout import Encoded, build
10
+ from .prompts import PromptFormat
11
+ from .questions import Question
12
+
13
+ #: The percentile of training-data lengths that "auto" token limits are set to.
14
+ AUTO_PERCENTILE = 95
15
+ #: Room kept for the question, options and delimiters when capping an "auto" state limit.
16
+ _RESERVED_TOKENS = 256
17
+ _MAX_AUTO_OPTION_TOKENS = 256
18
+
19
+
20
+ class Encoder:
21
+ def __init__(self, arch: Architecture, tokenizer, prompt: PromptFormat, layout: str,
22
+ max_state_tokens: int, max_option_tokens: int, max_len: int):
23
+ self.arch = arch
24
+ self.tokenizer = tokenizer
25
+ self.prompt = prompt
26
+ self.layout = layout
27
+ self.max_state_tokens = max_state_tokens
28
+ self.max_option_tokens = max_option_tokens
29
+ self.max_len = max_len
30
+ self._cache: dict[str, list[int]] = {}
31
+
32
+ def tokens(self, text: str) -> list[int]:
33
+ ids = self._cache.get(text)
34
+ if ids is None:
35
+ ids = encode_text(self.tokenizer, text)
36
+ self._cache[text] = ids
37
+ return ids
38
+
39
+ def encode(self, state: str, question: Question, order: Sequence[int] | None = None) -> Encoded:
40
+ """Lay out one question about one state. Slot `s` holds option `order[s]` (default: the
41
+ question's own order)."""
42
+ head = self.tokens(self.prompt.head(question))
43
+ texts = self.prompt.options(question)
44
+ if order is not None:
45
+ texts = [texts[i] for i in order]
46
+ options = [self.tokens(t)[: self.max_option_tokens] for t in texts]
47
+ state_ids = self.tokens(state)[: self.max_state_tokens]
48
+ encoded = build(self.arch.pieces(self.tokenizer, head, state_ids, options), self.layout)
49
+ overflow = len(encoded.input_ids) - self.max_len
50
+ if overflow > 0:
51
+ if overflow > len(state_ids):
52
+ raise ValueError("the question %r with %d options needs more than max_len=%d tokens even "
53
+ "with no state; shorten its options or raise max_len"
54
+ % (question.instructions[:60], question.k, self.max_len))
55
+ state_ids = state_ids[: len(state_ids) - overflow]
56
+ encoded = build(self.arch.pieces(self.tokenizer, head, state_ids, options), self.layout)
57
+ return encoded
58
+
59
+
60
+ def _percentile(values: list[int], q: float) -> int:
61
+ if not values:
62
+ return 1
63
+ ordered = sorted(values)
64
+ index = min(len(ordered) - 1, max(0, math.ceil(q / 100 * len(ordered)) - 1))
65
+ return max(1, ordered[index])
66
+
67
+
68
+ def measure_limits(encoder: Encoder, states: Sequence[str], questions: Sequence[Question],
69
+ max_state_tokens, max_option_tokens) -> tuple[int, int]:
70
+ """Resolve "auto" token limits from the training data: the 95th percentile of token lengths,
71
+ capped so a sequence still fits the model."""
72
+ if max_state_tokens == "auto":
73
+ lengths = [len(encoder.tokens(s)) for s in dict.fromkeys(states)]
74
+ cap = max(1, encoder.max_len - _RESERVED_TOKENS)
75
+ max_state_tokens = min(_percentile(lengths, AUTO_PERCENTILE), cap)
76
+ if max_option_tokens == "auto":
77
+ texts = {t for q in dict.fromkeys(questions) for t in encoder.prompt.options(q)}
78
+ lengths = [len(encoder.tokens(t)) for t in texts]
79
+ max_option_tokens = min(_percentile(lengths, AUTO_PERCENTILE), _MAX_AUTO_OPTION_TOKENS)
80
+ return int(max_state_tokens), int(max_option_tokens)