File size: 7,603 Bytes
b9301f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Chat with an atom-native checkpoint (Atomizer encode → generate → UTF-8).

Usage:
    PYTHONPATH=. python tools/chat_atom_native.py \
      --checkpoint checkpoints/atom_native_chat/atom_native.pt \
      --prompt "Bonjour"

    PYTHONPATH=. python tools/chat_atom_native.py \
      --checkpoint checkpoints/atom_native_chat/atom_native.pt \
      --interactive
"""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

import torch

from src.atom_native import DEFAULT_ENERGY_DECAY_BOUNDS, AtomNativeModel


def load_model(checkpoint: str | Path, device: str = "cpu") -> AtomNativeModel:
    path = Path(checkpoint)
    if not path.exists():
        raise FileNotFoundError(f"checkpoint not found: {path}")
    blob = torch.load(path, map_location="cpu", weights_only=False)
    config = blob.get("config") or {}
    bounds = config.get("energy_decay_bounds")
    energy_decay_bounds = tuple(bounds) if bounds is not None else DEFAULT_ENERGY_DECAY_BOUNDS
    # Prefer checkpoint field_max_rms; fall back to current chat default (3.0).
    field_max_rms = config.get("field_max_rms")
    if field_max_rms is None:
        field_max_rms = 3.0
    hard = bool(config.get("field_obligatory_hard", False))
    model = AtomNativeModel(
        d_model=int(config.get("d_model", 64)),
        n_modes=int(config.get("n_modes", 64)),
        n_atoms_max=int(config.get("n_atoms_max", 512)),
        max_payload_bytes=int(config.get("max_payload_bytes", 16)),
        field_max_rms=field_max_rms,
        energy_decay_bounds=energy_decay_bounds,
        field_obligatory_hard=hard,
        field_obligatory_readout=bool(config.get("field_obligatory_readout", False)) or hard,
        last_atom_readout=bool(config.get("last_atom_readout", False)),
    )
    training = model.load(path)
    if "last_atom_scale" in config:
        model.surface.last_atom_scale = float(config["last_atom_scale"])
    if "last_atom_scale_adaptive" in config:
        model.surface.last_atom_scale_adaptive = bool(config["last_atom_scale_adaptive"])
    if "last_atom_scale_min" in config:
        model.surface.last_atom_scale_min = float(config["last_atom_scale_min"])
    if "last_atom_scale_max" in config:
        model.surface.last_atom_scale_max = float(config["last_atom_scale_max"])
    if hard or bool(config.get("field_obligatory_hard", False)):
        model.field_obligatory_hard = True
        model.field_obligatory_readout = True
        model.surface.set_obligatory_hard(True)
        # Stay on hard α-MLP; prefer_printable is forced in generate_packets.
        assert model.surface.field_obligatory_hard, "chat must stay on hard α-MLP path"
    dyn = (training or {}).get("dynamics_on_load") or {}
    if dyn.get("energy_decay_repaired"):
        print(
            f"[load] repaired energy_decay "
            f"{dyn.get('energy_decay_before')} -> {dyn.get('energy_decay')} "
            f"bounds={dyn.get('energy_decay_bounds')}"
        )
    model.to(device)
    model.eval()
    return model


def generate_reply(
    model: AtomNativeModel,
    prompt: str,
    max_packets: int = 24,
    temperature: float = 0.7,
    top_k: int = 8,
    max_length: int | None = 256,
    deterministic: bool = False,
    role_prime: bool = True,
    merge_enabled: bool = False,
) -> str:
    # Dialogue-style priming: if the user did not already include a role tag,
    # wrap as Utilisateur/Assistant so the field sees the training pattern.
    # MERGE stays off on chat ingest (train still uses enable_merge thr=0.45).
    primed = prompt
    if role_prime and "Assistant:" not in prompt and "Utilisateur:" not in prompt:
        primed = f"Utilisateur: {prompt}\nAssistant:"
    raw = model.generate_packets(
        primed,
        max_packets=max_packets,
        temperature=temperature,
        top_k=top_k,
        deterministic=deterministic,
        max_length=max_length,
        prefer_printable=True,
        reset=True,
        merge_enabled=merge_enabled,
        speech_gate=True,
    )
    text = raw.decode("utf-8", errors="replace")
    # Drop leading whitespace-only noise common before content stabilizes.
    return text.lstrip("\n\r ")


def interactive_loop(model: AtomNativeModel, args: argparse.Namespace) -> None:
    print("ATOM-native chat (Atomizer + toroidal field). Commands: quit | status | save")
    while True:
        try:
            user = input("\nVous: ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            break
        if not user:
            continue
        lower = user.lower()
        if lower in {"quit", "exit", "q"}:
            break
        if lower == "status":
            print(
                f"atoms={len(model.core.atoms)} "
                f"d_model={model.core.encoder.d_model} "
                f"n_modes={model.core.state.n_modes} "
                f"max_payload={model.max_payload_bytes}"
            )
            continue
        if lower == "save":
            out = Path(args.checkpoint).with_name("interactive_atom_native.pt")
            model.save(out)
            print(f"saved {out}")
            continue
        reply = generate_reply(
            model,
            user,
            max_packets=args.max_packets,
            temperature=args.temperature,
            top_k=args.top_k,
            max_length=args.max_length,
            deterministic=args.deterministic,
            role_prime=not args.no_role_prime,
            merge_enabled=bool(args.merge_ingest),
        )
        print(f"ATOM: {reply}")


def main() -> None:
    parser = argparse.ArgumentParser(description="Chat with atom-native checkpoint")
    parser.add_argument("--checkpoint", required=True)
    parser.add_argument("--prompt", default="Bonjour")
    parser.add_argument("--max-packets", type=int, default=96)
    parser.add_argument("--max-length", type=int, default=256)
    parser.add_argument("--temperature", type=float, default=0.7)
    parser.add_argument("--top-k", type=int, default=8)
    parser.add_argument("--deterministic", action="store_true")
    parser.add_argument("--no-role-prime", action="store_true",
                        help="do not wrap prompt as Utilisateur/Assistant")
    parser.add_argument(
        "--merge-ingest",
        action="store_true",
        help="enable MERGE during chat priming (default: off; train MERGE unchanged)",
    )
    parser.add_argument("--device", default="cpu")
    parser.add_argument("--interactive", action="store_true")
    parser.add_argument(
        "--legacy-gen-labels",
        action="store_true",
        help="reproduce the pre-fix byte-tick decode (generated space/punct "
             "committed as whitespace/punctuation; docs/DECODE_FIX_AND_BPTT.md)",
    )
    args = parser.parse_args()

    model = load_model(args.checkpoint, device=args.device)
    model.atomizer.legacy_gen_labels = bool(args.legacy_gen_labels)
    if args.interactive:
        interactive_loop(model, args)
        return

    reply = generate_reply(
        model,
        args.prompt,
        max_packets=args.max_packets,
        temperature=args.temperature,
        top_k=args.top_k,
        max_length=args.max_length,
        deterministic=args.deterministic,
        role_prime=not args.no_role_prime,
        merge_enabled=bool(args.merge_ingest),
    )
    print(f"Prompt: {args.prompt}")
    print(f"Response: {reply}")
    print(
        f"[ingest] merge_enabled={bool(args.merge_ingest)} "
        f"atoms={len(model.core.atoms)} train_enable_merge={model.enable_merge}"
    )


if __name__ == "__main__":
    main()