File size: 40,613 Bytes
c855547
f2e0f25
c855547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2e0f25
c855547
 
 
f2e0f25
c855547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2e0f25
 
c855547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2e0f25
c855547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2e0f25
c855547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
#!/usr/bin/env python3
"""Standalone inference for KerzgrAI/PromptTercet-0.5B.

PromptTercet writes the user's side of a chat: give it a conversation and it
asks the next question, give it nothing and it opens one. You, or any
OpenAI-compatible model, write the assistant's side.

Examples:
  python inference.py                                   # it asks, you answer
  python inference.py --system "You are a cooking assistant."
  python inference.py --conversation chat.json          # print the next question
  python inference.py --count 500 --output prompts.jsonl
  python inference.py --count 100 --turns 3 --output chats.jsonl \\
      --answer-url https://openrouter.ai/api/v1 --answer-model openai/gpt-4o-mini
"""

from __future__ import annotations

import argparse
import importlib
import json
import os
import platform
import secrets
import shutil
import subprocess
import sys
import threading
import time
import unicodedata
import urllib.error
import urllib.request
import warnings
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
from typing import Any, TextIO

import torch
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from tokenizers import Tokenizer


def _silence_runtime_warnings() -> None:
    patterns = (
        r"tl\.make_block_ptr is deprecated",
        r"Memory efficient kernel not used because",
        r"Memory Efficient attention has been runtime disabled",
        r"Flash attention kernel not used because",
        r"Torch was not compiled with flash attention",
        r"cuDNN attention kernel not used because",
        r"cuDNN attention has been runtime disabled",
    )
    for pattern in patterns:
        warnings.filterwarnings("ignore", message=pattern)


REPO_ID = "KerzgrAI/PromptTercet-0.5B"
MODEL_NAME = "PromptTercet-0.5B"
FLA_COMMIT = "cbb0a72efb55c18ca0ef4f298298317573ad2cb3"
FLA_REPO = "https://github.com/fla-org/flash-linear-attention.git"
FLA_RUNTIME_REQUIREMENT = "transformers>=4.45.0"
# Parallax attention is not in a flash-linear-attention release yet, so fla
# installs from git at the commit the model was trained with.
FLA_MODULES = (
    "fla.layers.gdn2",
    "fla.models.utils",
    "fla.modules.rotary",
    "fla.ops.parallax.parallel",
    "fla.ops.parallax.decode",
)
PATCH_FILES = (
    "fla/__init__.py",
    "fla/ops/__init__.py",
    "fla/layers/__init__.py",
    "fla/ops/simple_gla/__init__.py",
)
TINY_GDN_FILES = (
    "tiny_gdn/__init__.py",
    "tiny_gdn/config.py",
    "tiny_gdn/model.py",
    "tiny_gdn/haiku_layers.py",
    "tiny_gdn/nn_common.py",
    "tiny_gdn/fused_ops.py",
    "tiny_gdn/senryu2.py",
    "tiny_gdn/parallax_ops.py",
    "tiny_gdn/gated_residual_ops.py",
    "tiny_gdn/ngram.py",
    "tiny_gdn/tools.py",
    "tiny_gdn/smoltalk_chat.py",
    "tiny_gdn/chatml.py",
    "tiny_gdn/detokenize.py",
    "tiny_gdn/repetition.py",
)
NGRAM_TABLES_FILE = "ngram_tables.safetensors"
ANSWER_REQUEST_RETRIES = 6
# A conversation's own turns, as PromptTercet's chat template expects them:
# its questions are the template's assistant turns and the replies its user turns.
PROMPTER_ROLES = {"system": "system", "user": "assistant", "assistant": "user"}


@dataclass(frozen=True)
class Source:
    repo_id: str
    local_dir: Path | None
    # A full copy of this repo next to inference.py, used instead of the Hub.
    local_copy: Path | None


def _local_copy() -> Path | None:
    here = Path(__file__).resolve().parent
    config = here / "config.json"
    if not all((here / name).is_file() for name in ("config.json", "model.safetensors", NGRAM_TABLES_FILE)):
        return None
    try:
        name = json.loads(config.read_text(encoding="utf-8")).get("model_name")
    except (OSError, ValueError):
        return None
    return here if name == MODEL_NAME else None


def _download(source: Source, filename: str) -> Path:
    if source.local_copy is not None and (source.local_copy / filename).is_file():
        return source.local_copy / filename
    return Path(
        hf_hub_download(
            repo_id=source.repo_id,
            filename=filename,
            local_dir=str(source.local_dir) if source.local_dir else None,
        )
    )


def _run(cmd: list[str], *, cwd: Path | None = None) -> None:
    print("+", " ".join(cmd), flush=True)
    merged = os.environ.copy()
    merged.setdefault("PYTHONUTF8", "1")
    merged.setdefault("PYTHONIOENCODING", "utf-8")
    subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=merged)


def _pip_install(*args: str) -> None:
    _run([sys.executable, "-m", "pip", "install", *args])


def _fla_importable() -> tuple[bool, str]:
    try:
        for name in FLA_MODULES:
            importlib.import_module(name)
    except Exception as error:  # noqa: BLE001
        return False, str(error)
    return True, ""


def _cache_root() -> Path:
    override = os.environ.get("PROMPTTERCET_CACHE")
    path = Path(override).expanduser().resolve() if override else Path.home() / ".cache" / "prompttercet"
    path.mkdir(parents=True, exist_ok=True)
    return path


def _install_fla(source: Source) -> None:
    print("flash-linear-attention missing/broken — installing automatically …", flush=True)
    _pip_install("einops", "numpy", FLA_RUNTIME_REQUIREMENT)
    if platform.system() != "Windows":
        _pip_install("--no-deps", "--force-reinstall", f"git+{FLA_REPO}@{FLA_COMMIT}")
        return
    if shutil.which("git") is None:
        raise RuntimeError("git is required to auto-install flash-linear-attention. Install Git and put it on PATH.")
    fla_root = _cache_root() / "flash-linear-attention"
    if not (fla_root / ".git").is_dir():
        if fla_root.exists():
            shutil.rmtree(fla_root)
        _run(["git", "clone", "--filter=blob:none", FLA_REPO, str(fla_root)])
    _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root)
    _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root)
    print("Applying Windows FLA import patches …", flush=True)
    for relative in PATCH_FILES:
        target = fla_root / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(_download(source, f"windows_fla_patches/{relative}"), target)
    _pip_install("--no-build-isolation", "--no-deps", "-e", str(fla_root))


def _ensure_fla(source: Source) -> None:
    ok, error = _fla_importable()
    if ok:
        return
    print(f"FLA not ready ({error})", flush=True)
    try:
        _install_fla(source)
    except Exception as install_error:
        raise RuntimeError(
            "Automatic flash-linear-attention install failed.\n"
            f"Original import error: {error}\n"
            f"Install error: {install_error}"
        ) from install_error
    for name in list(sys.modules):
        if name == "fla" or name.startswith("fla."):
            del sys.modules[name]
    importlib.invalidate_caches()
    ok, error = _fla_importable()
    if not ok:
        raise RuntimeError(f"flash-linear-attention installed but still failed to import: {error}")
    print("flash-linear-attention ready.", flush=True)


def _ensure_tiny_gdn(source: Source) -> Path:
    for root in (Path(__file__).resolve().parent, source.local_copy, source.local_dir):
        if root is not None and all((root / name).is_file() for name in TINY_GDN_FILES):
            return root
    for name in TINY_GDN_FILES:
        _download(source, name)
    return _download(source, "tiny_gdn/__init__.py").parent.parent


def _import_stack() -> SimpleNamespace:
    # tiny_gdn is fetched from the Hub and FLA may be installed at runtime, so
    # these modules only become importable after _ensure_tiny_gdn/_ensure_fla.
    config = importlib.import_module("tiny_gdn.config")
    senryu2 = importlib.import_module("tiny_gdn.senryu2")
    chatml = importlib.import_module("tiny_gdn.chatml")
    detokenize = importlib.import_module("tiny_gdn.detokenize")
    repetition = importlib.import_module("tiny_gdn.repetition")
    return SimpleNamespace(
        TinyGDNConfig=config.TinyGDNConfig,
        Senryu2ForCausalLM=senryu2.Senryu2ForCausalLM,
        encode_prompter_messages=chatml.encode_prompter_messages,
        IncrementalUtf8Decoder=detokenize.IncrementalUtf8Decoder,
        apply_repetition_penalty=repetition.apply_repetition_penalty,
        structural_token_flags=repetition.structural_token_flags,
    )


@dataclass(frozen=True)
class Sampling:
    temperature: float
    top_p: float
    top_k: int
    repetition_penalty: float
    repetition_window: int
    max_new_tokens: int
    context_length: int


@dataclass(frozen=True)
class Question:
    text: str
    tokens: int
    # "stop" ended the turn itself; "length" hit --max-new-tokens and is cut off.
    stop_reason: str

    @property
    def usable(self) -> bool:
        return self.stop_reason == "stop" and bool(self.text)


def _sample(logits: torch.Tensor, sampling: Sampling, generator: torch.Generator) -> int:
    logits = logits.float()
    if sampling.temperature <= 1e-5:
        return int(torch.argmax(logits).item())
    logits = logits / sampling.temperature
    if 0 < sampling.top_k < logits.shape[-1]:
        threshold = torch.topk(logits, sampling.top_k).values[-1]
        logits = logits.masked_fill(logits < threshold, -torch.inf)
    if sampling.top_p < 1.0:
        sorted_logits, sorted_indices = torch.sort(logits, descending=True)
        remove = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1) > sampling.top_p
        remove[1:] = remove[:-1].clone()
        remove[0] = False
        sorted_logits = sorted_logits.masked_fill(remove, -torch.inf)
        logits = torch.full_like(logits, -torch.inf)
        logits.scatter_(0, sorted_indices, sorted_logits)
    probs = torch.softmax(logits, dim=-1).cpu()
    return int(torch.multinomial(probs, 1, generator=generator).item())


def prompter_messages(conversation: list[dict[str, str]]) -> list[dict[str, str]]:
    return [{"role": PROMPTER_ROLES[item["role"]], "content": item["content"]} for item in conversation]


class Prompter:
    """PromptTercet on one GPU. Generations run one at a time, so threads can share it."""

    def __init__(self, model: Any, tokenizer: Tokenizer, stack: SimpleNamespace, device: torch.device, sampling: Sampling, seed: int | None) -> None:
        self.model = model
        self.tokenizer = tokenizer
        self.stack = stack
        self.device = device
        self.sampling = sampling
        self.stop_ids = {int(model.config.eos_token_id)}
        im_end = tokenizer.token_to_id("<|im_end|>")
        if im_end is not None:
            self.stop_ids.add(im_end)
        # The penalty skips whitespace, punctuation and chat tokens, so turns can still end.
        self.structural = stack.structural_token_flags(tokenizer) if sampling.repetition_penalty != 1.0 else ()
        self._seed = seed
        self._calls = 0
        self._lock = threading.Lock()

    def prompt_length(self, conversation: list[dict[str, str]]) -> int:
        return len(self.stack.encode_prompter_messages(self.tokenizer, prompter_messages(conversation)))

    def ask(
        self,
        conversation: list[dict[str, str]],
        *,
        on_text: Callable[[str], None] | None = None,
        stop: threading.Event | None = None,
    ) -> Question:
        prompt_ids = self.stack.encode_prompter_messages(self.tokenizer, prompter_messages(conversation))
        with self._lock:
            if self._seed is None:
                seed = secrets.randbits(63)
            else:
                seed = self._seed + self._calls
                self._calls += 1
            return self._generate(prompt_ids, seed, on_text, stop)

    @torch.inference_mode()
    def _generate(
        self,
        prompt_ids: list[int],
        seed: int,
        on_text: Callable[[str], None] | None,
        stop: threading.Event | None,
    ) -> Question:
        sampling = self.sampling
        token_ids = list(prompt_ids[-sampling.context_length :])
        generated = 0
        decoder = self.stack.IncrementalUtf8Decoder(self.tokenizer)
        generator = torch.Generator(device="cpu")
        generator.manual_seed(seed)
        stop_reason = "length"
        try:
            output = self.model(
                torch.tensor([token_ids], dtype=torch.long, device=self.device),
                return_logits=True,
                logits_to_keep=1,
                use_cache=True,
            )
            for step in range(sampling.max_new_tokens):
                if stop is not None and stop.is_set():
                    stop_reason = "interrupted"
                    break
                if output.logits is None:
                    raise RuntimeError("Model returned no logits")
                logits = output.logits[0, -1].float()
                if self.structural:
                    self.stack.apply_repetition_penalty(
                        logits,
                        token_ids,
                        penalty=sampling.repetition_penalty,
                        window=sampling.repetition_window,
                        structural=self.structural,
                    )
                next_id = _sample(logits, sampling, generator)
                if next_id in self.stop_ids:
                    stop_reason = "stop"
                    break
                token_ids.append(next_id)
                generated += 1
                delta = decoder.push(next_id)
                if delta and on_text is not None:
                    on_text(delta)
                if step + 1 == sampling.max_new_tokens:
                    break
                if output.past_key_values is None:
                    raise RuntimeError("Model returned no decode cache")
                if output.past_key_values.length + 1 > self.model.config.max_position_embeddings:
                    stop_reason = "context_full"
                    break
                output = self.model(
                    torch.tensor([[next_id]], dtype=torch.long, device=self.device),
                    return_logits=True,
                    logits_to_keep=1,
                    past_key_values=output.past_key_values,
                    use_cache=True,
                )
        except KeyboardInterrupt:
            stop_reason = "interrupted"
        text = decoder.finalize().strip()
        return Question(text=text, tokens=generated, stop_reason=stop_reason)


class AnswerError(RuntimeError):
    pass


@dataclass(frozen=True)
class AnswerModel:
    """An OpenAI-compatible chat completions endpoint that writes the assistant turns."""

    base_url: str
    model: str
    api_key: str
    max_tokens: int
    temperature: float
    extra: dict[str, Any]
    timeout: float

    def reply(self, messages: list[dict[str, str]], stop: threading.Event) -> tuple[str, str]:
        """``(content, finish_reason)`` of one completion, retrying transient failures."""

        body = {
            **self.extra,
            "model": self.model,
            "messages": messages,
            "max_tokens": self.max_tokens,
            "temperature": self.temperature,
        }
        headers = {"Content-Type": "application/json", "User-Agent": "PromptTercet-0.5B/inference.py"}
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"
        url = f"{self.base_url.rstrip('/')}/chat/completions"
        last = "no attempt"
        for attempt in range(ANSWER_REQUEST_RETRIES):
            if stop.is_set():
                raise AnswerError("stopped")
            delay = min(2.0**attempt, 30.0)
            try:
                request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
                with urllib.request.urlopen(request, timeout=self.timeout) as response:
                    payload = json.loads(response.read().decode("utf-8"))
            except urllib.error.HTTPError as error:
                detail = error.read().decode("utf-8", errors="replace")[:300]
                last = f"HTTP {error.code}: {detail}"
                if error.code not in {408, 409, 425, 429} and error.code < 500:
                    raise AnswerError(last) from error
                retry_after = error.headers.get("Retry-After") if error.headers else None
                if retry_after and retry_after.isdigit():
                    delay = min(float(retry_after), 60.0)
            except (urllib.error.URLError, TimeoutError, ConnectionError, OSError, ValueError) as error:
                last = f"{type(error).__name__}: {error}"
            else:
                if isinstance(payload, dict) and payload.get("error"):
                    last = f"API error: {json.dumps(payload['error'])[:300]}"
                else:
                    try:
                        choice = payload["choices"][0]
                        content = choice["message"].get("content") or ""
                    except (KeyError, IndexError, TypeError, AttributeError) as error:
                        raise AnswerError(f"Unexpected response: {json.dumps(payload)[:300]}") from error
                    return str(content).strip(), str(choice.get("finish_reason") or "")
            if stop.wait(delay):
                raise AnswerError("stopped")
        raise AnswerError(f"{ANSWER_REQUEST_RETRIES} requests failed; last: {last}")


def opening_key(text: str) -> str:
    return unicodedata.normalize("NFC", text).strip()


def load_conversation(path: str) -> list[dict[str, str]]:
    raw = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
    payload = json.loads(raw)
    messages = payload.get("messages") if isinstance(payload, dict) else payload
    if not isinstance(messages, list):
        raise ValueError("A conversation is a JSON list of messages or an object with a 'messages' list")
    conversation: list[dict[str, str]] = []
    for index, item in enumerate(messages):
        if not isinstance(item, dict) or item.get("role") not in PROMPTER_ROLES:
            raise ValueError(f"Message {index} needs a role of system, user or assistant")
        content = item.get("content")
        if not isinstance(content, str) or not content.strip():
            raise ValueError(f"Message {index} needs non-empty text content")
        if item["role"] == "system" and index != 0:
            raise ValueError("Only the first message can be a system prompt")
        conversation.append({"role": item["role"], "content": content})
    if conversation and conversation[-1]["role"] == "user":
        raise ValueError("The conversation ends with a user turn. PromptTercet writes the user turn after an assistant reply.")
    return conversation


class Style:
    def __init__(self, enabled: bool) -> None:
        self.enabled = enabled

    def wrap(self, code: str, text: str) -> str:
        return f"\033[{code}m{text}\033[0m" if self.enabled else text

    def user(self, text: str) -> str:
        return self.wrap("1;36", text)

    def assistant(self, text: str) -> str:
        return self.wrap("1;32", text)

    def note(self, text: str) -> str:
        return self.wrap("2", text)


INTERACTIVE_HELP = (
    "Commands: /retry asks again, /system TEXT sets the system prompt and starts over, "
    "/reset starts over, /save FILE appends the chat as a JSON line, /exit quits."
)


def run_interactive(prompter: Prompter, system: str, style: Style) -> int:
    print(
        f"{MODEL_NAME} plays the user and you play the assistant: answer each question it asks.\n"
        f"{INTERACTIVE_HELP} Ctrl+C stops a question.",
        flush=True,
    )

    def fresh(prompt: str) -> list[dict[str, str]]:
        return [{"role": "system", "content": prompt}] if prompt.strip() else []

    conversation = fresh(system)
    ask_now = True
    while True:
        if ask_now:
            ask_now = False
            if prompter.prompt_length(conversation) > prompter.sampling.context_length:
                print(style.note(f"(the chat is over {prompter.sampling.context_length} tokens; the oldest are dropped)"))
            print(f"\n{style.user('user>')} ", end="", flush=True)
            try:
                question = prompter.ask(conversation, on_text=lambda delta: print(delta, end="", flush=True))
            except KeyboardInterrupt:
                question = Question(text="", tokens=0, stop_reason="interrupted")
            print(flush=True)
            if question.stop_reason == "interrupted":
                print(style.note("(stopped; /retry asks again)"))
            elif not question.text:
                print(style.note("(empty question; /retry asks again)"))
            else:
                if question.stop_reason == "length":
                    print(style.note(f"(cut off at --max-new-tokens {prompter.sampling.max_new_tokens}; /retry asks again)"))
                conversation.append({"role": "user", "content": question.text})
        try:
            line = input(f"{style.assistant('assistant>')} ")
        except (EOFError, KeyboardInterrupt):
            print()
            return 0
        text = line.strip()
        if not text:
            continue
        if text.startswith("/"):
            command, _, rest = text.partition(" ")
            if command in {"/exit", "/quit"}:
                return 0
            if command == "/help":
                print(INTERACTIVE_HELP)
            elif command == "/reset":
                conversation = fresh(system)
                ask_now = True
                print(style.note("(new chat)"))
            elif command == "/system":
                system = rest.strip()
                conversation = fresh(system)
                ask_now = True
                print(style.note("(system prompt set; new chat)" if system else "(system prompt cleared; new chat)"))
            elif command == "/retry":
                if conversation and conversation[-1]["role"] == "user":
                    conversation.pop()
                ask_now = True
            elif command == "/save":
                path = Path(rest.strip() or "conversations.jsonl")
                if not any(item["role"] == "user" for item in conversation):
                    print(style.note("(nothing to save yet)"))
                    continue
                with path.open("a", encoding="utf-8") as handle:
                    handle.write(json.dumps({"messages": conversation}, ensure_ascii=False) + "\n")
                print(style.note(f"(appended to {path})"))
            else:
                print(style.note(f"(unknown command {command}; /help lists them)"))
            continue
        if not conversation or conversation[-1]["role"] != "user":
            print(style.note("(no question to answer; /retry asks again)"))
            continue
        conversation.append({"role": "assistant", "content": text})
        ask_now = True


def run_next_question(prompter: Prompter, conversation: list[dict[str, str]]) -> int:
    question = prompter.ask(conversation)
    if question.stop_reason == "interrupted":
        return 130
    print(question.text, flush=True)
    if not question.usable:
        print(f"[warn] question ended with {question.stop_reason}", file=sys.stderr, flush=True)
        return 1
    return 0


@dataclass
class BatchState:
    count: int
    written: int = 0
    in_flight: int = 0
    failed: int = 0
    redone_questions: int = 0
    repeated_openings: int = 0
    redone_answers: int = 0
    last_error: str = ""
    seen: set[str] = field(default_factory=set)
    lock: threading.Lock = field(default_factory=threading.Lock)

    def claim(self) -> bool:
        with self.lock:
            if self.written + self.in_flight >= self.count:
                return False
            self.in_flight += 1
            return True

    def bump(self, name: str) -> None:
        with self.lock:
            setattr(self, name, getattr(self, name) + 1)

    def note(self, error: str) -> None:
        with self.lock:
            self.last_error = error

    def finish(self, ok: bool, stopping: bool) -> bool:
        """Settle one claimed chat; True when failures say the batch should stop."""

        with self.lock:
            self.in_flight -= 1
            if ok:
                self.written += 1
            elif not stopping:
                self.failed += 1
            return self.failed >= 20 and self.failed > self.written

    def summary(self) -> str:
        parts = [f"{self.written}/{self.count} written"]
        if self.failed:
            parts.append(f"{self.failed} failed")
        if self.repeated_openings:
            parts.append(f"{self.repeated_openings} repeated openings resampled")
        if self.redone_questions:
            parts.append(f"{self.redone_questions} cut-off questions resampled")
        if self.redone_answers:
            parts.append(f"{self.redone_answers} cut-off answers redone")
        return ", ".join(parts)


def seen_openings(path: Path) -> set[str]:
    seen: set[str] = set()
    if not path.is_file():
        return seen
    with path.open(encoding="utf-8") as handle:
        for line in handle:
            try:
                messages = json.loads(line).get("messages", [])
            except (ValueError, AttributeError):
                continue
            first = next((item for item in messages if isinstance(item, dict) and item.get("role") == "user"), None)
            if first and isinstance(first.get("content"), str):
                seen.add(opening_key(first["content"]))
    return seen


def run_batch(prompter: Prompter, args: argparse.Namespace, answer: AnswerModel | None) -> int:
    output_path = Path(args.output) if args.output else None
    state = BatchState(count=args.count)
    if output_path is not None:
        state.seen = seen_openings(output_path)
        if state.seen:
            print(f"{output_path} already has {len(state.seen)} openings; new ones will differ.", file=sys.stderr)
    sink: TextIO = output_path.open("a", encoding="utf-8") if output_path else sys.stdout
    write_lock = threading.Lock()
    stop = threading.Event()
    system = args.system.strip()
    answer_system = system if args.answer_system is None else args.answer_system.strip()

    def ask(conversation: list[dict[str, str]], opening: bool) -> str | None:
        for _ in range(args.attempts):
            question = prompter.ask(conversation, stop=stop)
            if question.stop_reason == "interrupted" or stop.is_set():
                return None
            if not question.usable:
                state.bump("redone_questions")
                continue
            if opening:
                key = opening_key(question.text)
                with state.lock:
                    if key in state.seen:
                        state.repeated_openings += 1
                        continue
                    state.seen.add(key)
            return question.text
        state.note(f"no usable question in {args.attempts} tries")
        return None

    def reply(conversation: list[dict[str, str]]) -> str | None:
        assert answer is not None
        messages = ([{"role": "system", "content": answer_system}] if answer_system else []) + [
            item for item in conversation if item["role"] != "system"
        ]
        for _ in range(args.attempts):
            try:
                content, finish = answer.reply(messages, stop)
            except AnswerError as error:
                if not stop.is_set():
                    state.note(f"answer model: {error}")
                return None
            if content and finish != "length":
                return content
            state.bump("redone_answers")
        state.note(f"{args.attempts} answers in a row hit --answer-max-tokens {args.answer_max_tokens} or came back empty")
        return None

    def conversation_once() -> bool:
        """One chat of exactly --turns questions, written only when complete."""

        conversation = [{"role": "system", "content": system}] if system else []
        for turn in range(args.turns):
            question = ask(conversation, opening=turn == 0)
            if question is None:
                return False
            conversation.append({"role": "user", "content": question})
            if answer is not None:
                text = reply(conversation)
                if text is None:
                    return False
                conversation.append({"role": "assistant", "content": text})
        if stop.is_set():
            return False
        with write_lock:
            sink.write(json.dumps({"messages": conversation}, ensure_ascii=False) + "\n")
            sink.flush()
        return True

    def worker() -> None:
        while not stop.is_set() and state.claim():
            ok = False
            try:
                ok = conversation_once()
            except Exception as error:  # noqa: BLE001
                state.note(f"{type(error).__name__}: {error}")
            if state.finish(ok, stop.is_set()):
                state.note(f"stopped after {state.failed} failed chats; last: {state.last_error}")
                stop.set()

    workers = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, args.workers))]
    for thread in workers:
        thread.start()
    tty = sys.stderr.isatty()
    shown = ""
    shown_at = 0.0
    interrupted = False
    try:
        while any(thread.is_alive() for thread in workers):
            for thread in workers:
                thread.join(timeout=0.5)
            line = state.summary()
            if line != shown and (tty or time.monotonic() - shown_at >= 15):
                shown, shown_at = line, time.monotonic()
                print(f"\r[{MODEL_NAME}] {line}\033[K" if tty else f"[{MODEL_NAME}] {line}", end="" if tty else "\n", file=sys.stderr, flush=True)
        if tty:
            print(file=sys.stderr)
    except KeyboardInterrupt:
        interrupted = True
        stop.set()
        print("\nStopping after the requests in flight (Ctrl+C again quits now) …", file=sys.stderr, flush=True)
        try:
            for thread in workers:
                thread.join()
        except KeyboardInterrupt:
            pass
    finally:
        if output_path is not None:
            sink.close()
    print(f"[{MODEL_NAME}] done: {state.summary()}", file=sys.stderr, flush=True)
    if state.last_error and state.written < state.count:
        print(f"[{MODEL_NAME}] last failure: {state.last_error}", file=sys.stderr, flush=True)
    if interrupted:
        return 130
    return 0 if state.written >= state.count else 1


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=f"{MODEL_NAME}: writes the user's side of a chat",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    mode = parser.add_argument_group("what to do (default: an interactive chat where you answer)")
    mode.add_argument("--system", default="", help="System prompt of the chat; it steers what PromptTercet asks about")
    mode.add_argument("--conversation", default=None, help="JSON chat (list of messages, or {messages: [...]}); print the next question. '-' reads stdin")
    mode.add_argument("--count", type=int, default=0, help="Batch mode: write this many chats as JSON lines")
    mode.add_argument("--turns", type=int, default=1, help="Questions per chat in batch mode; above 1 needs --answer-url")
    mode.add_argument("--output", default=None, help="JSONL file for batch mode (default: stdout). Appends, and new openings differ from those already in it")
    mode.add_argument("--attempts", type=int, default=8, help="Samples per turn before a chat is given up: cut-off or repeated questions, cut-off answers")
    mode.add_argument("--workers", type=int, default=None, help="Chats in flight in batch mode (default: 8 with an answer model, else 1)")

    answers = parser.add_argument_group("answer model for batch mode (any OpenAI-compatible API)")
    answers.add_argument("--answer-url", default=None, help="Base URL, e.g. https://openrouter.ai/api/v1 or http://localhost:8000/v1")
    answers.add_argument("--answer-model", default=None, help="Model name sent to the API")
    answers.add_argument("--answer-key-env", default="OPENAI_API_KEY", help="Environment variable holding the API key (unset sends none)")
    answers.add_argument("--answer-system", default=None, help="System prompt only the answer model sees, not saved (default: --system)")
    answers.add_argument("--answer-max-tokens", type=int, default=4096, help="Answers that hit this are discarded and redone")
    answers.add_argument("--answer-temperature", type=float, default=0.7)
    answers.add_argument("--answer-extra", default="{}", help='Extra request fields as JSON, e.g. \'{"reasoning": {"effort": "low"}}\'')
    answers.add_argument("--answer-timeout", type=float, default=600.0, help="Seconds per request")

    sampling = parser.add_argument_group("PromptTercet sampling")
    sampling.add_argument("--temperature", type=float, default=0.6)
    sampling.add_argument("--top-p", type=float, default=0.95)
    sampling.add_argument("--top-k", type=int, default=0, help="0 turns top-k off")
    sampling.add_argument(
        "--repetition-penalty",
        type=float,
        default=1.36,
        help="Penalty on words already used twice in the chat, which keeps follow-ups from repeating it (1 turns it off)",
    )
    sampling.add_argument("--repetition-window", type=int, default=0, help="Tokens the penalty looks back over (0: the whole chat)")
    sampling.add_argument("--max-new-tokens", type=int, default=1024, help="Questions that hit this are cut off")
    sampling.add_argument("--context-length", type=int, default=8192, help="Chat tokens kept, oldest dropped first (up to 32768)")
    sampling.add_argument("--seed", type=int, default=None, help="Fix the sampling seed (default: a fresh seed per question)")

    runtime = parser.add_argument_group("runtime")
    runtime.add_argument("--device", default="cuda", help=f"CUDA device (cuda, cuda:1, …); {MODEL_NAME} needs a GPU")
    runtime.add_argument("--ngram-device", default=None, help="Where the 2.2 GB n-gram tables live: the GPU (default, --device) or cpu")
    runtime.add_argument("--no-color", action="store_true")
    runtime.add_argument("--local-dir", default=None, help="Download the model files into this folder")
    runtime.add_argument("--repo-id", default=REPO_ID)
    return parser.parse_args()


def validate(args: argparse.Namespace) -> str | None:
    if args.count < 0 or args.turns < 1 or args.attempts < 1:
        return "--count must be 0 or more, --turns and --attempts at least 1"
    if args.workers is not None and args.workers < 1:
        return "--workers must be at least 1"
    if args.count and args.conversation:
        return "Use --count or --conversation, not both"
    if bool(args.answer_url) != bool(args.answer_model):
        return "--answer-url and --answer-model go together"
    if args.answer_url and not args.count:
        return "--answer-url is for batch mode; add --count"
    if args.answer_url and not args.answer_url.startswith(("http://", "https://")):
        return "--answer-url must start with http:// or https://"
    if args.answer_max_tokens < 1 or args.answer_timeout <= 0:
        return "--answer-max-tokens and --answer-timeout must be positive"
    if args.count and args.turns > 1 and not args.answer_url:
        return "--turns above 1 needs an answer model: add --answer-url and --answer-model"
    if not 1 <= args.max_new_tokens <= 32768 or not 256 <= args.context_length <= 32768:
        return "--max-new-tokens must be 1-32768 and --context-length 256-32768"
    if args.temperature < 0 or not 0 < args.top_p <= 1 or args.top_k < 0 or args.repetition_penalty <= 0:
        return "Sampling needs --temperature >= 0, 0 < --top-p <= 1, --top-k >= 0 and --repetition-penalty > 0"
    if args.repetition_window < 0:
        return "--repetition-window must be 0 (the whole chat) or more"
    try:
        extra = json.loads(args.answer_extra)
    except json.JSONDecodeError as error:
        return f"--answer-extra is not JSON: {error}"
    if not isinstance(extra, dict):
        return "--answer-extra must be a JSON object"
    return None


def main() -> int:
    _silence_runtime_warnings()
    args = parse_args()
    problem = validate(args)
    if problem:
        print(problem, file=sys.stderr)
        return 2
    conversation: list[dict[str, str]] | None = None
    if args.conversation is not None:
        try:
            conversation = load_conversation(args.conversation)
        except (OSError, ValueError) as error:
            print(f"Cannot read --conversation: {error}", file=sys.stderr)
            return 2
        if args.system.strip() and not (conversation and conversation[0]["role"] == "system"):
            conversation.insert(0, {"role": "system", "content": args.system.strip()})

    try:
        device = torch.device(args.device)
        ngram_device = torch.device(args.ngram_device) if args.ngram_device else device
    except RuntimeError as error:
        print(f"Invalid device: {error}", file=sys.stderr)
        return 2
    if device.type != "cuda" or not torch.cuda.is_available():
        print(
            f"{MODEL_NAME} needs a CUDA GPU: its Gated DeltaNet-2 and Parallax layers run on "
            f"flash-linear-attention Triton kernels. torch {torch.__version__} reports "
            f"CUDA available={torch.cuda.is_available()}.",
            file=sys.stderr,
        )
        return 1
    if ngram_device.type not in {"cuda", "cpu"}:
        print("--ngram-device must be a CUDA device or cpu", file=sys.stderr)
        return 2

    source = Source(
        repo_id=args.repo_id,
        local_dir=Path(args.local_dir).resolve() if args.local_dir else None,
        local_copy=_local_copy() if args.repo_id == REPO_ID else None,
    )
    where = source.local_copy or f"huggingface.co/{source.repo_id}"
    print(f"Loading {MODEL_NAME} from {where} …", file=sys.stderr, flush=True)
    try:
        package_root = _ensure_tiny_gdn(source)
        if str(package_root) not in sys.path:
            sys.path.insert(0, str(package_root))
        _ensure_fla(source)
        stack = _import_stack()
    except Exception as error:  # noqa: BLE001
        print(f"Setup failed: {error}", file=sys.stderr)
        return 1

    config = stack.TinyGDNConfig.from_json(_download(source, "config.json"))
    with torch.device(device):
        model = stack.Senryu2ForCausalLM(config)
    model = model.to(dtype=torch.bfloat16)
    model.load_state_dict(load_file(str(_download(source, "model.safetensors")), device=str(device)), strict=True)
    if model.ngram is not None:
        model.ngram.load_tables(_download(source, NGRAM_TABLES_FILE), device=ngram_device, dtype=torch.bfloat16)
    model.eval()
    model.requires_grad_(False)
    tokenizer = Tokenizer.from_file(str(_download(source, "tokenizer.json")))
    sampling = Sampling(
        temperature=args.temperature,
        top_p=args.top_p,
        top_k=args.top_k,
        repetition_penalty=args.repetition_penalty,
        repetition_window=args.repetition_window,
        max_new_tokens=args.max_new_tokens,
        context_length=min(args.context_length, config.max_position_embeddings),
    )
    prompter = Prompter(model, tokenizer, stack, device, sampling, args.seed)
    print(
        f"Ready: {config.num_hidden_layers} layers on {device}, n-gram tables on {ngram_device}.",
        file=sys.stderr,
        flush=True,
    )

    if conversation is not None:
        return run_next_question(prompter, conversation)
    if args.count:
        answer = None
        if args.answer_url:
            answer = AnswerModel(
                base_url=args.answer_url,
                model=args.answer_model,
                api_key=os.environ.get(args.answer_key_env, "").strip(),
                max_tokens=args.answer_max_tokens,
                temperature=args.answer_temperature,
                extra=json.loads(args.answer_extra),
                timeout=args.answer_timeout,
            )
        if args.workers is None:
            args.workers = 8 if answer else 1
        return run_batch(prompter, args, answer)
    style = Style(not args.no_color and sys.stdout.isatty())
    return run_interactive(prompter, args.system, style)


if __name__ == "__main__":
    raise SystemExit(main())