qreflex 0.0.1__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.
qreflex/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ QreFLEX (reFLEX) — Responsive Flexible Learning and EXperience.
3
+
4
+ A small transformer whose central reasoning stream (Main) can selectively
5
+ consult two side channels, Intent (what is this input about) and
6
+ Experience (have I seen something like this before), without either
7
+ channel being able to override Main's own reasoning. See the project
8
+ README for the full architecture writeup and the "what changed from the
9
+ original prototype" history of real bugs found and fixed by actually
10
+ running this code against real data.
11
+ """
12
+
13
+ from qreflex.model import (
14
+ ReFLEX,
15
+ ReFLEXConfig,
16
+ IntentEncoder,
17
+ ExperienceEncoder,
18
+ ExperienceBank,
19
+ count_parameters,
20
+ small_reflex_config,
21
+ )
22
+ from qreflex.losses import retrieval_contrastive_loss
23
+ from qreflex.api import reFLEX
24
+
25
+ __version__ = "0.2.0"
26
+
27
+ __all__ = [
28
+ "reFLEX",
29
+ "ReFLEX",
30
+ "ReFLEXConfig",
31
+ "IntentEncoder",
32
+ "ExperienceEncoder",
33
+ "ExperienceBank",
34
+ "count_parameters",
35
+ "small_reflex_config",
36
+ "retrieval_contrastive_loss",
37
+ "__version__",
38
+ ]
qreflex/api.py ADDED
@@ -0,0 +1,311 @@
1
+ """
2
+ High-level "load it and generate" API for reFLEX models.
3
+
4
+ from qreflex import reFLEX
5
+
6
+ model = reFLEX(directory="./my-reflex-model", from_pretrained=True)
7
+ answer = model.generate("A: hello there\\nB:")
8
+
9
+ # streaming
10
+ for chunk in model.generate("A: hello there\\nB:", stream=True):
11
+ print(chunk, end="", flush=True)
12
+
13
+ This sits on top of the lower-level pieces that already exist in this
14
+ package (qreflex.model, qreflex.train.load_checkpoint,
15
+ qreflex.data.load_tokenizer) and adds two things neither the CLI
16
+ (qreflex-generate) nor the raw classes give you directly:
17
+
18
+ 1. `directory` can be a local folder OR a Hugging Face Hub repo id
19
+ ("YourUser/your-reflex-model") — resolved automatically.
20
+ 2. `from_pretrained=False` gives you a randomly-initialized model
21
+ (still needs a tokenizer.json, since a model's vocab isn't
22
+ meaningful without one) for when you want to point a fresh model
23
+ at a directory before/instead of loading trained weights.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ from typing import Generator, Optional, Union
32
+
33
+ import torch
34
+ import torch.nn.functional as F
35
+
36
+ from qreflex.model import ReFLEX, ReFLEXConfig, ExperienceBank, small_reflex_config
37
+ from qreflex.data import load_tokenizer
38
+
39
+ _CHECKPOINT_NAMES = ("reFLEX-v1-15M.pt",)
40
+ _TOKENIZER_NAMES = ("tokenizer.json",)
41
+ _CONFIG_NAMES = ("config.json",)
42
+
43
+ # "user/repo-name" or "user/repo-name:revision" — deliberately conservative;
44
+ # anything that doesn't match this and isn't a local path is rejected with a
45
+ # clear error rather than silently trying (and failing) a Hub download.
46
+ _HF_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")
47
+
48
+
49
+ def _looks_like_hf_repo_id(s: str) -> bool:
50
+ if os.path.exists(s) or s.startswith((".", "/", "~")):
51
+ return False
52
+ return bool(_HF_REPO_ID_RE.match(s))
53
+
54
+
55
+ def _find_file(local_dir: str, preferred_names, contains: str, exts, kind: str) -> Optional[str]:
56
+ """Look for one of `preferred_names` first; otherwise fall back to the
57
+ single file in `local_dir` whose name contains `contains` and whose
58
+ extension is in `exts`. Raises if that fallback is ambiguous."""
59
+ for name in preferred_names:
60
+ p = os.path.join(local_dir, name)
61
+ if os.path.isfile(p):
62
+ return p
63
+ candidates = sorted(
64
+ f for f in os.listdir(local_dir)
65
+ if f.lower().endswith(exts) and contains in f.lower()
66
+ )
67
+ if len(candidates) == 1:
68
+ return os.path.join(local_dir, candidates[0])
69
+ if len(candidates) > 1:
70
+ raise FileNotFoundError(
71
+ f"Found multiple possible {kind} files in '{local_dir}': {candidates}. "
72
+ f"Rename the one you want to one of {preferred_names}, or pass it explicitly "
73
+ f"via the matching *_file argument."
74
+ )
75
+ return None
76
+
77
+
78
+ class reFLEX:
79
+ """Load a reFLEX checkpoint and generate text from it.
80
+
81
+ Args:
82
+ directory: A local folder, or a Hugging Face Hub repo id
83
+ (e.g. "YourUser/your-reflex-model"). Should contain a
84
+ tokenizer.json, and (if from_pretrained=True) a checkpoint
85
+ .pt file in the format written by qreflex.train.save_checkpoint.
86
+ from_pretrained: True loads model weights from a checkpoint found
87
+ in `directory`. False builds a randomly-initialized model
88
+ instead (a config.json in `directory` is used if present,
89
+ otherwise a small default config sized to the tokenizer).
90
+ device: "cuda" / "cpu" / etc. Defaults to cuda if available.
91
+ use_experience: Default for whether generate() consults the
92
+ experience bank stored in the checkpoint (if any). Can be
93
+ overridden per-call.
94
+ checkpoint_file / tokenizer_file / config_file: explicit paths,
95
+ for when auto-detection in `directory` is ambiguous or the
96
+ files use non-standard names.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ directory: str,
102
+ from_pretrained: bool = True,
103
+ device: Optional[str] = None,
104
+ use_experience: bool = True,
105
+ checkpoint_file: Optional[str] = None,
106
+ tokenizer_file: Optional[str] = None,
107
+ config_file: Optional[str] = None,
108
+ ):
109
+ self.directory = directory
110
+ self.from_pretrained = from_pretrained
111
+ self.use_experience = use_experience
112
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
113
+
114
+ local_dir = self._resolve_directory(directory)
115
+
116
+ tok_path = tokenizer_file or _find_file(
117
+ local_dir, _TOKENIZER_NAMES, "tokenizer", (".json",), "tokenizer"
118
+ )
119
+ if tok_path is None:
120
+ raise FileNotFoundError(
121
+ f"No tokenizer.json found in '{local_dir}'. A reFLEX model always needs the "
122
+ f"tokenizer it was (or will be) trained with — point `directory` at a folder "
123
+ f"that has one, or pass `tokenizer_file=` explicitly."
124
+ )
125
+ self.tokenizer = load_tokenizer(tok_path)
126
+
127
+ self.bank: Optional[ExperienceBank] = None
128
+ self.step = 0
129
+ self._experience_encoder = None
130
+
131
+ if from_pretrained:
132
+ ckpt_path = checkpoint_file or _find_file(
133
+ local_dir, _CHECKPOINT_NAMES, "checkpoint", (".pt", ".bin"), "checkpoint"
134
+ )
135
+ if ckpt_path is None:
136
+ raise FileNotFoundError(
137
+ f"from_pretrained=True but no checkpoint (.pt) file found in '{local_dir}'. "
138
+ f"Pass `checkpoint_file=` explicitly, or use from_pretrained=False to start "
139
+ f"from a randomly-initialized model instead."
140
+ )
141
+ # Imported lazily: qreflex.train pulls in the training loop's deps
142
+ # (DataLoader etc.) that a pure inference user shouldn't need to
143
+ # pay for just to import qreflex.
144
+ from qreflex.train import load_checkpoint
145
+
146
+ self.model, self._experience_encoder, self.bank, self.step = load_checkpoint(
147
+ ckpt_path, device=self.device
148
+ )
149
+ else:
150
+ cfg_path = config_file or _find_file(
151
+ local_dir, _CONFIG_NAMES, "config", (".json",), "config"
152
+ )
153
+ if cfg_path is not None:
154
+ with open(cfg_path) as f:
155
+ cfg = ReFLEXConfig(**json.load(f))
156
+ else:
157
+ cfg = small_reflex_config(vocab_size=self.tokenizer.get_vocab_size())
158
+ self.model = ReFLEX(cfg).to(self.device)
159
+
160
+ self.model.eval()
161
+
162
+ # -- directory / hub resolution ------------------------------------------
163
+
164
+ def _resolve_directory(self, directory: str) -> str:
165
+ if os.path.isdir(directory):
166
+ return os.path.abspath(directory)
167
+
168
+ if _looks_like_hf_repo_id(directory):
169
+ try:
170
+ from huggingface_hub import snapshot_download
171
+ except ImportError as e:
172
+ raise ImportError(
173
+ "huggingface_hub is required to load a reFLEX model straight from the "
174
+ "Hugging Face Hub. Install it with `pip install huggingface_hub`."
175
+ ) from e
176
+ return snapshot_download(repo_id=directory, allow_patterns=["*.json", "*.pt", "*.bin"])
177
+
178
+ raise FileNotFoundError(
179
+ f"'{directory}' is neither an existing local directory nor a Hugging Face Hub "
180
+ f"repo id (e.g. 'YourUser/your-reflex-model'). Point `directory` at a local "
181
+ f"folder or a public/private Hub repo."
182
+ )
183
+
184
+ # -- generation ------------------------------------------------------------
185
+
186
+ def _encode_prompt(self, prompt: str) -> torch.Tensor:
187
+ ids = [self.model.cfg.bos_token_id] + self.tokenizer.encode(prompt).ids
188
+ if len(ids) > self.model.cfg.max_seq_len:
189
+ ids = ids[-self.model.cfg.max_seq_len:] # keep the most recent context
190
+ return torch.tensor([ids], dtype=torch.long, device=self.device)
191
+
192
+ @staticmethod
193
+ def _sample_next(logits: torch.Tensor, temperature: float, top_k: Optional[int]) -> torch.Tensor:
194
+ if temperature <= 0:
195
+ return logits.argmax(dim=-1, keepdim=True)
196
+ logits = logits / temperature
197
+ if top_k is not None:
198
+ k = min(top_k, logits.size(-1))
199
+ values, _ = torch.topk(logits, k)
200
+ threshold = values[:, -1].unsqueeze(-1)
201
+ logits = logits.masked_fill(logits < threshold, float("-inf"))
202
+ probs = F.softmax(logits, dim=-1)
203
+ return torch.multinomial(probs, num_samples=1)
204
+
205
+ def generate(
206
+ self,
207
+ prompt: str,
208
+ max_new_tokens: int = 64,
209
+ temperature: float = 0.8,
210
+ top_k: int = 50,
211
+ use_experience: Optional[bool] = None,
212
+ stream: bool = False,
213
+ stop: Optional[Union[str, list]] = None,
214
+ ) -> Union[str, Generator[str, None, None]]:
215
+ """Generate a continuation for `prompt`.
216
+
217
+ stream=False (default): returns the full generated text as one string.
218
+ stream=True: returns a generator yielding text chunks as they're produced.
219
+
220
+ stop: one string, or a list of strings, that ends generation as soon
221
+ as any of them appears in the output. The stop string itself is cut
222
+ from the returned/streamed text (e.g. stop="\\nA:" on a model that
223
+ drifts into writing the next turn will give you just that turn's
224
+ reply, with "\\nA:" and everything after it removed).
225
+ """
226
+ exp = self.use_experience if use_experience is None else use_experience
227
+ if stop is None:
228
+ stop_strs = None
229
+ elif isinstance(stop, str):
230
+ stop_strs = [stop]
231
+ else:
232
+ stop_strs = list(stop)
233
+
234
+ gen = self._generate(prompt, max_new_tokens, temperature, top_k, exp, stop_strs)
235
+ if stream:
236
+ return gen
237
+ return "".join(gen)
238
+
239
+ @torch.no_grad()
240
+ def _generate(
241
+ self, prompt, max_new_tokens, temperature, top_k, use_experience, stop_strs
242
+ ) -> Generator[str, None, None]:
243
+ self.model.eval()
244
+ tokens = self._encode_prompt(prompt)
245
+ prompt_len = tokens.size(1)
246
+ eos_id = self.model.cfg.eos_token_id
247
+ emitted = "" # text already released to the caller so far
248
+
249
+ # A stop string can straddle a token boundary (e.g. "A" comes out one
250
+ # step, ":" the next). If we released "A" the instant it appeared,
251
+ # we couldn't take it back once "A:" completes the match one step
252
+ # later. So once text could still be the start of a stop string, we
253
+ # withhold up to (longest stop string - 1) trailing characters until
254
+ # either the match completes (cut it, done) or a later character
255
+ # rules it out (release it, it was never part of a stop string).
256
+ hold_back = (max(len(s) for s in stop_strs) - 1) if stop_strs else 0
257
+
258
+ for _ in range(max_new_tokens):
259
+ context = tokens[:, -self.model.cfg.max_seq_len:]
260
+ output = self.model(
261
+ context,
262
+ experience_bank=self.bank if use_experience else None,
263
+ use_experience=use_experience,
264
+ )
265
+ next_token = self._sample_next(output["logits"][:, -1], temperature, top_k)
266
+ tokens = torch.cat([tokens, next_token], dim=1)
267
+
268
+ if next_token.item() == eos_id:
269
+ break
270
+
271
+ # Re-decode the whole generated span each step rather than each
272
+ # token id in isolation — some merges only resolve to the right
273
+ # text once neighboring tokens are known.
274
+ full_text = self.tokenizer.decode(tokens[0, prompt_len:].tolist())
275
+
276
+ if stop_strs:
277
+ hits = [i for i in (full_text.find(s) for s in stop_strs) if i != -1]
278
+ if hits:
279
+ cut_at = min(hits)
280
+ delta = full_text[len(emitted):cut_at]
281
+ if delta:
282
+ yield delta
283
+ return # stop string reached — it and everything after is discarded
284
+
285
+ safe_upto = max(0, len(full_text) - hold_back)
286
+ delta = full_text[len(emitted):safe_upto]
287
+ if delta:
288
+ emitted = full_text[:safe_upto]
289
+ yield delta
290
+ else:
291
+ delta = full_text[len(emitted):]
292
+ if delta:
293
+ emitted = full_text
294
+ yield delta
295
+
296
+ # Generation ended (eos or max_new_tokens) without ever completing a
297
+ # stop-string match — release whatever was withheld as a possible
298
+ # prefix, since we now know for certain it wasn't one.
299
+ if stop_strs:
300
+ final_text = self.tokenizer.decode(tokens[0, prompt_len:].tolist())
301
+ delta = final_text[len(emitted):]
302
+ if delta:
303
+ yield delta
304
+
305
+ def __repr__(self) -> str:
306
+ n_params = sum(p.numel() for p in self.model.parameters())
307
+ return (
308
+ f"reFLEX(directory={self.directory!r}, from_pretrained={self.from_pretrained}, "
309
+ f"device={self.device!r}, params={n_params:,}, step={self.step}, "
310
+ f"bank_size={len(self.bank) if self.bank else 0})"
311
+ )
qreflex/data.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Iterator, List, Optional
7
+
8
+ import torch
9
+ from torch.utils.data import Dataset, DataLoader
10
+ from tokenizers import Tokenizer, models, pre_tokenizers, trainers, decoders
11
+
12
+
13
+ SPECIAL_TOKENS = ["<pad>", "<bos>", "<eos>", "<unk>"]
14
+
15
+
16
+ def train_tokenizer(
17
+ text_files: List[str],
18
+ vocab_size: int,
19
+ save_path: str,
20
+ ) -> Tokenizer:
21
+ tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
22
+ tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
23
+ tokenizer.decoder = decoders.ByteLevel()
24
+
25
+ trainer = trainers.BpeTrainer(
26
+ vocab_size=vocab_size,
27
+ special_tokens=SPECIAL_TOKENS,
28
+ show_progress=True,
29
+ )
30
+ tokenizer.train(text_files, trainer)
31
+
32
+ os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
33
+ tokenizer.save(save_path)
34
+ return tokenizer
35
+
36
+
37
+ def load_tokenizer(path: str) -> Tokenizer:
38
+ return Tokenizer.from_file(path)
39
+
40
+
41
+ @dataclass
42
+ class PackedExample:
43
+ input_ids: List[int]
44
+ labels: List[int]
45
+ experience_tokens: List[int]
46
+
47
+
48
+ class PackedTextDataset(Dataset):
49
+ """
50
+ Tokenizes a list of raw text documents, packs them into fixed-length
51
+ sequences for LM training, and separately produces an `experience_tokens`
52
+ field per example: the SAME window of tokens (not shifted), used as the
53
+ input to the ExperienceEncoder for that example.
54
+
55
+ Using the example's own tokens as its experience-encoder input, paired
56
+ with the query built from a prefix of that same example inside the model,
57
+ is what makes the contrastive retrieval loss meaningful: the model learns
58
+ to build queries that point at keys describing the content actually
59
+ relevant to it, rather than being trained to point at a fixed lookup
60
+ that has no relation to the current input.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ texts: List[str],
66
+ tokenizer: Tokenizer,
67
+ seq_len: int,
68
+ pad_token_id: int = 0,
69
+ bos_token_id: int = 1,
70
+ eos_token_id: int = 2,
71
+ ):
72
+ self.tokenizer = tokenizer
73
+ self.seq_len = seq_len
74
+ self.pad_token_id = pad_token_id
75
+ self.bos_token_id = bos_token_id
76
+ self.eos_token_id = eos_token_id
77
+
78
+ self.examples: List[PackedExample] = []
79
+ self._pack(texts)
80
+
81
+ def _pack(self, texts: List[str]):
82
+ buffer: List[int] = []
83
+ for text in texts:
84
+ ids = self.tokenizer.encode(text).ids
85
+ buffer.extend([self.bos_token_id] + ids + [self.eos_token_id])
86
+
87
+ while len(buffer) >= self.seq_len + 1:
88
+ chunk = buffer[: self.seq_len + 1]
89
+ buffer = buffer[self.seq_len:]
90
+
91
+ input_ids = chunk[:-1]
92
+ labels = chunk[1:]
93
+ self.examples.append(
94
+ PackedExample(
95
+ input_ids=input_ids,
96
+ labels=labels,
97
+ experience_tokens=input_ids,
98
+ )
99
+ )
100
+
101
+ def __len__(self) -> int:
102
+ return len(self.examples)
103
+
104
+ def __getitem__(self, idx: int):
105
+ ex = self.examples[idx]
106
+ return {
107
+ "input_ids": torch.tensor(ex.input_ids, dtype=torch.long),
108
+ "labels": torch.tensor(ex.labels, dtype=torch.long),
109
+ "experience_tokens": torch.tensor(ex.experience_tokens, dtype=torch.long),
110
+ }
111
+
112
+
113
+ def load_jsonl_texts(path: str, text_field: str = "text") -> List[str]:
114
+ texts = []
115
+ with open(path, "r", encoding="utf-8") as f:
116
+ for line in f:
117
+ line = line.strip()
118
+ if not line:
119
+ continue
120
+ obj = json.loads(line)
121
+ texts.append(obj[text_field])
122
+ return texts
123
+
124
+
125
+ def build_dataloader(
126
+ dataset: Dataset,
127
+ batch_size: int,
128
+ shuffle: bool = True,
129
+ num_workers: int = 0,
130
+ ) -> DataLoader:
131
+ return DataLoader(
132
+ dataset,
133
+ batch_size=batch_size,
134
+ shuffle=shuffle,
135
+ num_workers=num_workers,
136
+ drop_last=True,
137
+ pin_memory=torch.cuda.is_available(),
138
+ )
qreflex/eval.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+
7
+ import torch
8
+
9
+ from qreflex.train import load_checkpoint
10
+ from qreflex.data import load_tokenizer, load_jsonl_texts, PackedTextDataset, build_dataloader
11
+
12
+
13
+ @torch.no_grad()
14
+ def evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=True, max_batches=None):
15
+ model.eval()
16
+ total_loss = 0.0
17
+ total_tokens = 0
18
+ n_batches = 0
19
+
20
+ for batch in dataloader:
21
+ if max_batches is not None and n_batches >= max_batches:
22
+ break
23
+ tokens = batch["input_ids"].to(device)
24
+ targets = batch["labels"].to(device)
25
+
26
+ out = model(
27
+ tokens,
28
+ targets=targets,
29
+ experience_bank=bank if use_experience else None,
30
+ use_experience=use_experience,
31
+ )
32
+ n_valid = (targets != -100).sum().item()
33
+ total_loss += out["lm_loss"].item() * n_valid
34
+ total_tokens += n_valid
35
+ n_batches += 1
36
+
37
+ avg_loss = total_loss / max(1, total_tokens)
38
+ ppl = math.exp(min(avg_loss, 20))
39
+ return avg_loss, ppl
40
+
41
+
42
+ @torch.no_grad()
43
+ def evaluate_retrieval_alignment(model, experience_encoder, dataloader, device, max_batches=20):
44
+ """
45
+ Measures whether the query built for an example is closer to its OWN
46
+ experience key than to other examples' keys in the batch (retrieval
47
+ rank / top-1 accuracy). This is a direct check on whether the
48
+ contrastive retrieval loss actually taught anything, independent of
49
+ downstream LM performance.
50
+
51
+ max_batches=None means no limit, matching evaluate_perplexity's
52
+ contract for the same parameter name.
53
+ """
54
+ model.eval()
55
+ experience_encoder.eval()
56
+
57
+ correct = 0
58
+ total = 0
59
+ mean_rank = 0.0
60
+
61
+ for i, batch in enumerate(dataloader):
62
+ if max_batches is not None and i >= max_batches:
63
+ break
64
+ tokens = batch["input_ids"].to(device)
65
+ exp_tokens = batch["experience_tokens"].to(device)
66
+
67
+ query, _ = model.build_query(tokens)
68
+ keys, _ = experience_encoder(exp_tokens)
69
+
70
+ sims = query @ keys.T # [B, B]
71
+ ranks = sims.argsort(dim=-1, descending=True)
72
+ labels = torch.arange(tokens.size(0), device=device)
73
+
74
+ top1 = (ranks[:, 0] == labels).sum().item()
75
+ correct += top1
76
+ total += tokens.size(0)
77
+
78
+ for b in range(tokens.size(0)):
79
+ rank_of_true = (ranks[b] == b).nonzero(as_tuple=True)[0].item()
80
+ mean_rank += rank_of_true
81
+
82
+ top1_acc = correct / max(1, total)
83
+ mean_rank = mean_rank / max(1, total)
84
+ return top1_acc, mean_rank
85
+
86
+
87
+ def main(args):
88
+ device = "cuda" if torch.cuda.is_available() else "cpu"
89
+ model, experience_encoder, bank, step = load_checkpoint(args.checkpoint, device=device)
90
+ print(f"loaded checkpoint at step {step}, bank size {len(bank) if bank else 0}")
91
+
92
+ tokenizer = load_tokenizer(args.tokenizer_path)
93
+ texts = load_jsonl_texts(args.eval_data, text_field=args.text_field)
94
+ dataset = PackedTextDataset(texts, tokenizer, seq_len=model.cfg.max_seq_len)
95
+ dataloader = build_dataloader(dataset, batch_size=args.batch_size, shuffle=False)
96
+
97
+ print(f"eval examples: {len(dataset)}")
98
+
99
+ loss_no_exp, ppl_no_exp = evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=False, max_batches=args.max_batches)
100
+ print(f"[no experience] loss={loss_no_exp:.4f} ppl={ppl_no_exp:.2f}")
101
+
102
+ if bank is not None and len(bank) > 0:
103
+ loss_exp, ppl_exp = evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=True, max_batches=args.max_batches)
104
+ print(f"[with experience] loss={loss_exp:.4f} ppl={ppl_exp:.2f}")
105
+ print(f"delta (negative = experience helps): {loss_exp - loss_no_exp:+.4f}")
106
+
107
+ top1_acc, mean_rank = evaluate_retrieval_alignment(model, experience_encoder, dataloader, device, max_batches=args.max_batches)
108
+ print(f"[retrieval alignment] top-1 self-match accuracy={top1_acc:.4f} mean_rank={mean_rank:.2f}")
109
+ else:
110
+ print("no experience bank in checkpoint; skipping experience-conditioned eval")
111
+
112
+ results = {
113
+ "step": step,
114
+ "loss_no_experience": loss_no_exp,
115
+ "ppl_no_experience": ppl_no_exp,
116
+ }
117
+ if bank is not None and len(bank) > 0:
118
+ results["loss_with_experience"] = loss_exp
119
+ results["ppl_with_experience"] = ppl_exp
120
+ results["retrieval_top1_accuracy"] = top1_acc
121
+ results["retrieval_mean_rank"] = mean_rank
122
+
123
+ if args.output_json:
124
+ with open(args.output_json, "w") as f:
125
+ json.dump(results, f, indent=2)
126
+ print(f"wrote results to {args.output_json}")
127
+
128
+
129
+ def build_arg_parser() -> argparse.ArgumentParser:
130
+ p = argparse.ArgumentParser(description="Evaluate a reFLEX checkpoint")
131
+ p.add_argument("--checkpoint", type=str, required=True)
132
+ p.add_argument("--tokenizer_path", type=str, required=True)
133
+ p.add_argument("--eval_data", type=str, required=True)
134
+ p.add_argument("--text_field", type=str, default="text")
135
+ p.add_argument("--batch_size", type=int, default=16)
136
+ p.add_argument("--max_batches", type=int, default=None)
137
+ p.add_argument("--output_json", type=str, default=None)
138
+ return p
139
+
140
+
141
+ def main_cli():
142
+ """Entry point for the qreflex-eval console script."""
143
+ parser = build_arg_parser()
144
+ args = parser.parse_args()
145
+ main(args)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main_cli()