BRlkl commited on
Commit
bc7437c
·
verified ·
1 Parent(s): b399918

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Samantha Thought-Loop SFT
2
+
3
+ - Base model: `google/t5gemma-l-l-prefixlm-it`
4
+ - Cleaned dataset repo: `BRlkl/timed_natural_conversations_5min4`
5
+ - Raw dataset repo: `BRlkl/timed_natural_conversations_5min4`
6
+ - Latent slots: `256`
7
+ - Fixed tick seconds: `1.0`
8
+ - Chunk ticks: `300`
9
+ - Max horizon ticks: `300`
10
+ - Explicit time features: `False`
11
+ - Gate loss mode: `bce`
12
+ - Hazard soft target window: `16`
13
+ - Hazard soft target tau ticks: `4.0`
14
+ - Hazard censor weight: `0.25`
15
+ - TBPTT by bucket: `{}`
16
+
17
+ ## Best Validation Metrics
18
+
19
+ - `active_ticks`: `1888.000000`
20
+ - `decoder_token_count`: `2494.000000`
21
+ - `gate_accuracy`: `0.715042`
22
+ - `gate_f1`: `0.154088`
23
+ - `gate_positive_weight`: `16.000000`
24
+ - `gate_precision`: `0.091932`
25
+ - `gate_pred_positive_count`: `533.000000`
26
+ - `gate_recall`: `0.475728`
27
+ - `gate_target_positive_count`: `103.000000`
28
+ - `hazard_censor_count`: `0.000000`
29
+ - `hazard_event_count`: `0.000000`
30
+ - `loss_decoder`: `3.564287`
31
+ - `loss_gate`: `1.241260`
32
+ - `loss_gate_ranking`: `0.000000`
33
+ - `loss_state_delta`: `0.258536`
34
+ - `loss_total`: `4.521086`
35
+ - `soft_target_mass`: `0.000000`
36
+ - `soft_target_positive_count`: `0.000000`
best_metrics.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "loss_total": 4.521085739135742,
3
+ "loss_gate": 1.2412604093551636,
4
+ "loss_gate_ranking": 0.0,
5
+ "loss_decoder": 3.564286708831787,
6
+ "loss_state_delta": 0.25853580236434937,
7
+ "gate_accuracy": 0.7150423526763916,
8
+ "gate_precision": 0.09193246066570282,
9
+ "gate_recall": 0.4757281541824341,
10
+ "gate_f1": 0.15408805012702942,
11
+ "gate_target_positive_count": 103.0,
12
+ "gate_pred_positive_count": 533.0,
13
+ "gate_positive_weight": 16.0,
14
+ "hazard_event_count": 0.0,
15
+ "hazard_censor_count": 0.0,
16
+ "soft_target_mass": 0.0,
17
+ "soft_target_positive_count": 0.0,
18
+ "decoder_token_count": 2494.0,
19
+ "active_ticks": 1888.0
20
+ }
chat_template.jinja ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {% if messages[0]['role'] == 'system' %}{{ raise_exception('System role not supported') }}{% endif %}{% for message in messages %}{% if (message['role'] == 'user') != (loop.index0 % 2 == 0) %}{{ raise_exception('Conversation roles must alternate user/assistant/user/assistant/...') }}{% endif %}{% if (message['role'] == 'assistant') %}{% set role = 'model' %}{% else %}{% set role = message['role'] %}{% endif %}{{ '<start_of_turn>' + role + '
2
+ ' + message['content'] | trim + '<end_of_turn>
3
+ ' }}{% endfor %}{% if add_generation_prompt %}{{'<start_of_turn>model
4
+ '}}{% endif %}
config.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ def load_config(path: str | Path) -> dict[str, Any]:
13
+ with Path(path).open("r", encoding="utf-8") as handle:
14
+ data = yaml.safe_load(handle)
15
+ if not isinstance(data, dict):
16
+ raise ValueError(f"Config at {path} must deserialize into a mapping.")
17
+ return data
18
+
19
+
20
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
21
+ merged = deepcopy(base)
22
+ for key, value in override.items():
23
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
24
+ merged[key] = deep_merge(merged[key], value)
25
+ else:
26
+ merged[key] = value
27
+ return merged
28
+
29
+
30
+ def ensure_dir(path: str | Path) -> Path:
31
+ resolved = Path(path)
32
+ resolved.mkdir(parents=True, exist_ok=True)
33
+ return resolved
34
+
35
+
36
+ def save_json(path: str | Path, payload: dict[str, Any]) -> None:
37
+ target = Path(path)
38
+ target.parent.mkdir(parents=True, exist_ok=True)
39
+ with target.open("w", encoding="utf-8") as handle:
40
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
41
+
42
+
43
+ def flatten_for_wandb(payload: dict[str, Any], prefix: str = "") -> dict[str, Any]:
44
+ flat: dict[str, Any] = {}
45
+ for key, value in payload.items():
46
+ full_key = f"{prefix}.{key}" if prefix else key
47
+ if isinstance(value, dict):
48
+ flat.update(flatten_for_wandb(value, prefix=full_key))
49
+ else:
50
+ flat[full_key] = value
51
+ return flat
52
+
53
+
54
+ def fingerprint_payload(payload: dict[str, Any]) -> str:
55
+ serialized = json.dumps(payload, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
56
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16]
data.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import math
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import torch
10
+ from datasets import Dataset as HFDataset
11
+ from datasets import load_dataset
12
+ from torch.utils.data import BatchSampler, Dataset
13
+
14
+ SCRIPT_DIR = Path(__file__).resolve().parent
15
+ if str(SCRIPT_DIR) not in sys.path:
16
+ sys.path.insert(0, str(SCRIPT_DIR))
17
+
18
+ from config import ensure_dir, fingerprint_payload
19
+
20
+
21
+ OBS_ROLE_NONE = 0
22
+ OBS_ROLE_USER = 1
23
+ OBS_ROLE_AGENT_FEEDBACK = 2
24
+ SFT_CACHE_SCHEMA_VERSION = 2
25
+
26
+ DURATION_BUCKET_ORDER = {
27
+ "short": 0,
28
+ "medium": 1,
29
+ "long": 2,
30
+ "hour_scale": 3,
31
+ }
32
+
33
+
34
+ def load_clean_split(dataset_cfg: dict[str, Any], split: str) -> HFDataset:
35
+ local_parquet_dir = dataset_cfg.get("cleaned_local_parquet_dir")
36
+ if local_parquet_dir and Path(local_parquet_dir).exists():
37
+ parquet_path = Path(local_parquet_dir) / f"{split}.parquet"
38
+ if parquet_path.exists():
39
+ return load_dataset("parquet", data_files={split: str(parquet_path)}, split=split)
40
+
41
+ cleaned_repo_id = dataset_cfg.get("cleaned_repo_id")
42
+ if not cleaned_repo_id:
43
+ raise ValueError("Dataset config must provide either cleaned_local_parquet_dir or cleaned_repo_id.")
44
+
45
+ source_split = dataset_cfg.get("source_split")
46
+ if source_split:
47
+ dataset = load_dataset(cleaned_repo_id, split=str(source_split))
48
+ if split not in {"train", "validation"}:
49
+ return dataset
50
+
51
+ validation_ratio = float(dataset_cfg.get("validation_ratio", 0.02))
52
+ split_seed = int(dataset_cfg.get("split_seed", 17))
53
+ selected_indices = [
54
+ index
55
+ for index, row in enumerate(dataset)
56
+ if assign_deterministic_split(str(row["id"]), validation_ratio, split_seed) == split
57
+ ]
58
+ return dataset.select(selected_indices)
59
+
60
+ return load_dataset(cleaned_repo_id, split=split)
61
+
62
+
63
+ def tokenize_text(tokenizer: Any, text: str | None, max_length: int) -> tuple[list[int], list[int]]:
64
+ if not text:
65
+ return [tokenizer.pad_token_id], [0]
66
+ encoded = tokenizer(
67
+ text,
68
+ add_special_tokens=True,
69
+ truncation=True,
70
+ max_length=max_length,
71
+ )
72
+ return encoded["input_ids"], encoded["attention_mask"]
73
+
74
+
75
+ def tokenizer_supports_chat_template(tokenizer: Any) -> bool:
76
+ return bool(getattr(tokenizer, "chat_template", None))
77
+
78
+
79
+ def should_use_base_chat_template(config: dict[str, Any], tokenizer: Any) -> bool:
80
+ dataset_cfg = config.get("dataset", {})
81
+ inference_cfg = config.get("inference", {})
82
+ if "use_base_chat_template" in inference_cfg:
83
+ return bool(inference_cfg["use_base_chat_template"]) and tokenizer_supports_chat_template(tokenizer)
84
+ return bool(dataset_cfg.get("use_base_chat_template", False)) and tokenizer_supports_chat_template(tokenizer)
85
+
86
+
87
+ def build_user_generation_observation(tokenizer: Any, text: str, use_base_chat_template: bool) -> str:
88
+ if use_base_chat_template:
89
+ return tokenizer.apply_chat_template(
90
+ [{"role": "user", "content": text}],
91
+ tokenize=False,
92
+ add_generation_prompt=True,
93
+ )
94
+ return text
95
+
96
+
97
+ def build_assistant_feedback_observation(tokenizer: Any, text: str, use_base_chat_template: bool) -> str:
98
+ del tokenizer
99
+ if use_base_chat_template:
100
+ return f"<start_of_turn>model\n{text}<end_of_turn>\n"
101
+ return text
102
+
103
+
104
+ def build_assistant_decoder_target(tokenizer: Any, text: str, use_base_chat_template: bool) -> str:
105
+ if not use_base_chat_template:
106
+ return text
107
+
108
+ prompt_text = tokenizer.apply_chat_template(
109
+ [{"role": "user", "content": ""}],
110
+ tokenize=False,
111
+ add_generation_prompt=True,
112
+ )
113
+ full_text = tokenizer.apply_chat_template(
114
+ [
115
+ {"role": "user", "content": ""},
116
+ {"role": "assistant", "content": text},
117
+ ],
118
+ tokenize=False,
119
+ add_generation_prompt=False,
120
+ )
121
+ if not full_text.startswith(prompt_text):
122
+ raise ValueError("Chat template full text did not start with the generation prompt prefix.")
123
+ return full_text[len(prompt_text) :]
124
+
125
+
126
+ def duration_bucket_rank(bucket: str | None) -> int:
127
+ return DURATION_BUCKET_ORDER.get(str(bucket), len(DURATION_BUCKET_ORDER))
128
+
129
+
130
+ def assign_deterministic_split(row_id: str, validation_ratio: float, seed: int) -> str:
131
+ digest = hashlib.sha1(f"{seed}:{row_id}".encode("utf-8")).hexdigest()
132
+ score = int(digest[:8], 16) / 0xFFFFFFFF
133
+ return "validation" if score < validation_ratio else "train"
134
+
135
+
136
+ def stable_tick_index(time_seconds: float, tick_seconds: float) -> int:
137
+ ratio = max(0.0, float(time_seconds)) / tick_seconds
138
+ return int(math.ceil(ratio - 1e-9))
139
+
140
+
141
+ def quantize_message_sequence(
142
+ messages: list[dict[str, Any]],
143
+ *,
144
+ tick_seconds: float,
145
+ feedback_delay_seconds: float,
146
+ ) -> list[dict[str, Any]]:
147
+ raw_events: list[dict[str, Any]] = []
148
+ event_order = 0
149
+ for message in messages:
150
+ speaker = str(message["speaker"])
151
+ text = str(message["text"])
152
+ time_seconds = float(message["t"])
153
+
154
+ if speaker == "user":
155
+ raw_events.append(
156
+ {
157
+ "time_seconds": time_seconds,
158
+ "order": event_order,
159
+ "kind": "user",
160
+ "text": text,
161
+ }
162
+ )
163
+ event_order += 1
164
+ continue
165
+
166
+ raw_events.append(
167
+ {
168
+ "time_seconds": time_seconds,
169
+ "order": event_order,
170
+ "kind": "agent_speak",
171
+ "text": text,
172
+ }
173
+ )
174
+ event_order += 1
175
+ raw_events.append(
176
+ {
177
+ "time_seconds": time_seconds + feedback_delay_seconds,
178
+ "order": event_order,
179
+ "kind": "agent_feedback",
180
+ "text": text,
181
+ }
182
+ )
183
+ event_order += 1
184
+
185
+ raw_events.sort(key=lambda item: (float(item["time_seconds"]), int(item["order"])))
186
+
187
+ quantized_events: list[dict[str, Any]] = []
188
+ previous_tick = -1
189
+ for event in raw_events:
190
+ desired_tick = stable_tick_index(float(event["time_seconds"]), tick_seconds)
191
+ assigned_tick = max(desired_tick, previous_tick + 1)
192
+ quantized_events.append(
193
+ {
194
+ "tick": assigned_tick,
195
+ "kind": event["kind"],
196
+ "text": event["text"],
197
+ }
198
+ )
199
+ previous_tick = assigned_tick
200
+
201
+ return quantized_events
202
+
203
+
204
+ def resolve_bucket_horizon_ticks(duration_bucket: str, rollout_cfg: dict[str, Any]) -> int:
205
+ bucket_map = rollout_cfg.get("horizon_ticks_by_bucket", {})
206
+ default_horizon = int(rollout_cfg.get("max_horizon_ticks", 36000))
207
+ return int(bucket_map.get(duration_bucket, default_horizon))
208
+
209
+
210
+ def build_fixed_tick_conversation(
211
+ *,
212
+ row: dict[str, Any],
213
+ tokenizer: Any,
214
+ config: dict[str, Any],
215
+ rollout_cfg: dict[str, Any],
216
+ max_observation_tokens: int,
217
+ max_decoder_tokens: int,
218
+ ) -> dict[str, Any] | None:
219
+ tick_seconds = float(rollout_cfg["tick_seconds"])
220
+ chunk_ticks = int(rollout_cfg["chunk_ticks"])
221
+ feedback_delay_seconds = float(rollout_cfg["post_speech_feedback_delay_seconds"])
222
+ window_strategy = str(rollout_cfg.get("long_window_strategy", "tail"))
223
+ duration_bucket = str(row.get("meta", {}).get("duration_bucket", "short"))
224
+ use_base_chat_template = should_use_base_chat_template(config, tokenizer)
225
+
226
+ quantized_events = quantize_message_sequence(
227
+ messages=row["messages"],
228
+ tick_seconds=tick_seconds,
229
+ feedback_delay_seconds=feedback_delay_seconds,
230
+ )
231
+ total_ticks = max((event["tick"] for event in quantized_events), default=0) + 1
232
+
233
+ horizon_ticks = resolve_bucket_horizon_ticks(duration_bucket, rollout_cfg)
234
+ if bool(rollout_cfg.get("drop_overlong_examples", False)) and total_ticks > horizon_ticks:
235
+ return None
236
+
237
+ if total_ticks <= horizon_ticks:
238
+ effective_start_tick = 0
239
+ effective_total_ticks = total_ticks
240
+ elif window_strategy == "tail":
241
+ effective_start_tick = total_ticks - horizon_ticks
242
+ effective_total_ticks = horizon_ticks
243
+ else:
244
+ effective_start_tick = 0
245
+ effective_total_ticks = horizon_ticks
246
+
247
+ chunk_count = max(1, math.ceil(effective_total_ticks / chunk_ticks))
248
+ chunk_lengths = [
249
+ min(chunk_ticks, max(0, effective_total_ticks - chunk_index * chunk_ticks))
250
+ for chunk_index in range(chunk_count)
251
+ ]
252
+ chunk_events: list[list[dict[str, Any]]] = [[] for _ in range(chunk_count)]
253
+
254
+ event_count = 0
255
+ for event in quantized_events:
256
+ absolute_tick = int(event["tick"])
257
+ if absolute_tick < effective_start_tick or absolute_tick >= effective_start_tick + effective_total_ticks:
258
+ continue
259
+
260
+ relative_tick = absolute_tick - effective_start_tick
261
+ chunk_index = relative_tick // chunk_ticks
262
+ offset = relative_tick % chunk_ticks
263
+
264
+ if event["kind"] == "user":
265
+ observation_role = OBS_ROLE_USER
266
+ observation_text = build_user_generation_observation(
267
+ tokenizer,
268
+ str(event["text"]),
269
+ use_base_chat_template,
270
+ )
271
+ gate_target = 0
272
+ decoder_text = None
273
+ elif event["kind"] == "agent_feedback":
274
+ observation_role = OBS_ROLE_AGENT_FEEDBACK
275
+ observation_text = build_assistant_feedback_observation(
276
+ tokenizer,
277
+ str(event["text"]),
278
+ use_base_chat_template,
279
+ )
280
+ gate_target = 0
281
+ decoder_text = None
282
+ else:
283
+ observation_role = OBS_ROLE_NONE
284
+ observation_text = None
285
+ gate_target = 1
286
+ decoder_text = build_assistant_decoder_target(
287
+ tokenizer,
288
+ str(event["text"]),
289
+ use_base_chat_template,
290
+ )
291
+
292
+ observation_input_ids, observation_attention_mask = tokenize_text(
293
+ tokenizer=tokenizer,
294
+ text=observation_text,
295
+ max_length=max_observation_tokens,
296
+ )
297
+ decoder_labels, _ = tokenize_text(
298
+ tokenizer=tokenizer,
299
+ text=decoder_text,
300
+ max_length=max_decoder_tokens,
301
+ )
302
+ if gate_target == 0:
303
+ decoder_labels = []
304
+
305
+ chunk_events[chunk_index].append(
306
+ {
307
+ "offset": int(offset),
308
+ "absolute_tick": absolute_tick,
309
+ "observation_role": int(observation_role),
310
+ "observation_input_ids": observation_input_ids,
311
+ "observation_attention_mask": observation_attention_mask,
312
+ "gate_target": int(gate_target),
313
+ "decoder_labels": decoder_labels,
314
+ }
315
+ )
316
+ event_count += 1
317
+
318
+ return {
319
+ "row_id": str(row["id"]),
320
+ "scenario_id": row.get("meta", {}).get("scenario_id"),
321
+ "duration_bucket": duration_bucket,
322
+ "bucket_rank": duration_bucket_rank(duration_bucket),
323
+ "total_ticks": int(total_ticks),
324
+ "effective_start_tick": int(effective_start_tick),
325
+ "effective_total_ticks": int(effective_total_ticks),
326
+ "chunk_count": int(chunk_count),
327
+ "chunk_lengths": chunk_lengths,
328
+ "chunk_events": chunk_events,
329
+ "event_count": int(event_count),
330
+ }
331
+
332
+
333
+ def build_chunk_batch(
334
+ *,
335
+ conversations: list[dict[str, Any]],
336
+ chunk_index: int,
337
+ pad_token_id: int,
338
+ chunk_ticks: int,
339
+ tick_seconds: float,
340
+ ) -> dict[str, torch.Tensor] | None:
341
+ active_lengths: list[int] = []
342
+ max_observation_tokens = 1
343
+ max_decoder_tokens = 1
344
+
345
+ for conversation in conversations:
346
+ if chunk_index >= int(conversation["chunk_count"]):
347
+ active_lengths.append(0)
348
+ continue
349
+ step_count = int(conversation["chunk_lengths"][chunk_index])
350
+ active_lengths.append(step_count)
351
+ for event in conversation["chunk_events"][chunk_index]:
352
+ max_observation_tokens = max(max_observation_tokens, len(event["observation_input_ids"]))
353
+ max_decoder_tokens = max(max_decoder_tokens, len(event["decoder_labels"]) or 1)
354
+
355
+ max_steps = max(active_lengths, default=0)
356
+ if max_steps <= 0:
357
+ return None
358
+
359
+ batch_size = len(conversations)
360
+ observation_input_ids = torch.full(
361
+ (batch_size, max_steps, max_observation_tokens),
362
+ fill_value=pad_token_id,
363
+ dtype=torch.long,
364
+ )
365
+ observation_attention_mask = torch.zeros((batch_size, max_steps, max_observation_tokens), dtype=torch.long)
366
+ decoder_labels = torch.full((batch_size, max_steps, max_decoder_tokens), fill_value=-100, dtype=torch.long)
367
+ tick_mask = torch.zeros((batch_size, max_steps), dtype=torch.bool)
368
+ gate_target = torch.zeros((batch_size, max_steps), dtype=torch.float32)
369
+ observation_role = torch.zeros((batch_size, max_steps), dtype=torch.long)
370
+ delta_seconds = torch.zeros((batch_size, max_steps), dtype=torch.float32)
371
+ elapsed_seconds = torch.zeros((batch_size, max_steps), dtype=torch.float32)
372
+
373
+ for batch_index, conversation in enumerate(conversations):
374
+ step_count = active_lengths[batch_index]
375
+ if step_count <= 0:
376
+ continue
377
+
378
+ tick_mask[batch_index, :step_count] = True
379
+ for local_tick in range(step_count):
380
+ global_tick = chunk_index * chunk_ticks + local_tick
381
+ delta_seconds[batch_index, local_tick] = 0.0 if global_tick == 0 else tick_seconds
382
+ elapsed_seconds[batch_index, local_tick] = global_tick * tick_seconds
383
+
384
+ for event in conversation["chunk_events"][chunk_index]:
385
+ offset = int(event["offset"])
386
+ observation_role[batch_index, offset] = int(event["observation_role"])
387
+ gate_target[batch_index, offset] = float(event["gate_target"])
388
+
389
+ observation_ids = event["observation_input_ids"]
390
+ observation_mask = event["observation_attention_mask"]
391
+ observation_input_ids[batch_index, offset, : len(observation_ids)] = torch.tensor(
392
+ observation_ids,
393
+ dtype=torch.long,
394
+ )
395
+ observation_attention_mask[batch_index, offset, : len(observation_mask)] = torch.tensor(
396
+ observation_mask,
397
+ dtype=torch.long,
398
+ )
399
+
400
+ labels = event["decoder_labels"]
401
+ if labels:
402
+ decoder_labels[batch_index, offset, : len(labels)] = torch.tensor(labels, dtype=torch.long)
403
+
404
+ return {
405
+ "tick_mask": tick_mask,
406
+ "gate_target": gate_target,
407
+ "observation_role": observation_role,
408
+ "observation_input_ids": observation_input_ids,
409
+ "observation_attention_mask": observation_attention_mask,
410
+ "decoder_labels": decoder_labels,
411
+ "delta_seconds": delta_seconds,
412
+ "elapsed_seconds": elapsed_seconds,
413
+ }
414
+
415
+
416
+ class ThoughtLoopConversationDataset(Dataset):
417
+ def __init__(self, config: dict[str, Any], tokenizer: Any, split: str) -> None:
418
+ self.config = config
419
+ self.tokenizer = tokenizer
420
+ self.split = split
421
+ self.examples = self._load_or_build()
422
+
423
+ def _cache_path(self) -> Path:
424
+ dataset_cfg = self.config["dataset"]
425
+ model_cfg = self.config["model"]
426
+ rollout_cfg = self.config["rollout"]
427
+ cache_cfg = self.config["cache"]
428
+ cache_root = ensure_dir(cache_cfg["preprocessed_root"])
429
+ payload = {
430
+ "cache_schema_version": SFT_CACHE_SCHEMA_VERSION,
431
+ "split": self.split,
432
+ "dataset": dataset_cfg,
433
+ "model_name": model_cfg["base_model_name"],
434
+ "rollout": rollout_cfg,
435
+ "max_observation_tokens": model_cfg["max_observation_tokens"],
436
+ "max_decoder_tokens": model_cfg["max_decoder_tokens"],
437
+ "tokenizer_vocab_size": getattr(self.tokenizer, "vocab_size", None),
438
+ }
439
+ return cache_root / f"{self.split}_{fingerprint_payload(payload)}.pt"
440
+
441
+ def _load_or_build(self) -> list[dict[str, Any]]:
442
+ cache_path = self._cache_path()
443
+ if cache_path.exists():
444
+ return torch.load(cache_path, map_location="cpu")
445
+
446
+ dataset = load_clean_split(self.config["dataset"], self.split)
447
+ rollout_cfg = self.config["rollout"]
448
+ model_cfg = self.config["model"]
449
+
450
+ examples: list[dict[str, Any]] = []
451
+ for row in dataset:
452
+ example = build_fixed_tick_conversation(
453
+ row=row,
454
+ tokenizer=self.tokenizer,
455
+ config=self.config,
456
+ rollout_cfg=rollout_cfg,
457
+ max_observation_tokens=int(model_cfg["max_observation_tokens"]),
458
+ max_decoder_tokens=int(model_cfg["max_decoder_tokens"]),
459
+ )
460
+ if example is not None:
461
+ examples.append(example)
462
+
463
+ if bool(rollout_cfg.get("sort_by_duration_bucket", True)):
464
+ examples.sort(key=lambda item: (int(item["bucket_rank"]), int(item["total_ticks"]), str(item["row_id"])))
465
+
466
+ torch.save(examples, cache_path)
467
+ return examples
468
+
469
+ def __len__(self) -> int:
470
+ return len(self.examples)
471
+
472
+ def __getitem__(self, index: int) -> dict[str, Any]:
473
+ return self.examples[index]
474
+
475
+
476
+ class DurationBucketBatchSampler(BatchSampler):
477
+ def __init__(
478
+ self,
479
+ dataset: ThoughtLoopConversationDataset,
480
+ batch_size: int,
481
+ ) -> None:
482
+ self.dataset = dataset
483
+ self.batch_size = batch_size
484
+ self.batches: list[list[int]] = []
485
+
486
+ current_bucket: str | None = None
487
+ current_batch: list[int] = []
488
+ for index, example in enumerate(self.dataset.examples):
489
+ bucket = str(example["duration_bucket"])
490
+ if current_batch and (bucket != current_bucket or len(current_batch) >= self.batch_size):
491
+ self.batches.append(current_batch)
492
+ current_batch = []
493
+ current_bucket = bucket
494
+ current_batch.append(index)
495
+ if len(current_batch) >= self.batch_size:
496
+ self.batches.append(current_batch)
497
+ current_batch = []
498
+
499
+ if current_batch:
500
+ self.batches.append(current_batch)
501
+
502
+ def __iter__(self) -> Any:
503
+ return iter(self.batches)
504
+
505
+ def __len__(self) -> int:
506
+ return len(self.batches)
507
+
508
+
509
+ def identity_collate(batch: list[dict[str, Any]]) -> list[dict[str, Any]]:
510
+ return batch
511
+
512
+
513
+ def estimate_total_chunk_microsteps(
514
+ *,
515
+ dataset: ThoughtLoopConversationDataset,
516
+ batch_sampler: DurationBucketBatchSampler,
517
+ ) -> int:
518
+ total_microsteps = 0
519
+ for batch_indices in batch_sampler.batches:
520
+ total_microsteps += max(int(dataset.examples[index]["chunk_count"]) for index in batch_indices)
521
+ return total_microsteps
inference.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections import deque
5
+ import json
6
+ import math
7
+ import sys
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import torch
14
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
15
+
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
17
+ if str(SCRIPT_DIR) not in sys.path:
18
+ sys.path.insert(0, str(SCRIPT_DIR))
19
+
20
+ from data import (
21
+ OBS_ROLE_AGENT_FEEDBACK,
22
+ OBS_ROLE_NONE,
23
+ OBS_ROLE_USER,
24
+ build_assistant_feedback_observation,
25
+ build_user_generation_observation,
26
+ quantize_message_sequence,
27
+ should_use_base_chat_template,
28
+ tokenize_text,
29
+ )
30
+ from model import ThoughtLoopT5Gemma
31
+
32
+
33
+ def parse_args() -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser(
35
+ description="Interactive inference for Samantha thought-loop checkpoints, with a direct HF baseline fallback."
36
+ )
37
+ parser.add_argument("--model-path", required=True, help="Local export directory or Hugging Face repo id.")
38
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
39
+ parser.add_argument("--history-json", default=None, help="Optional JSON array of {t, speaker, text} turns to replay.")
40
+ parser.add_argument("--user", default=None, help="Optional single user message to answer.")
41
+ parser.add_argument("--interactive", action="store_true", help="Run a live terminal session.")
42
+ parser.add_argument("--tick-seconds", type=float, default=None, help="Runtime tick size. Defaults to the trained value.")
43
+ parser.add_argument("--max-thought-seconds", type=float, default=300.0)
44
+ parser.add_argument("--settle-thought-seconds", type=float, default=1.0)
45
+ parser.add_argument("--max-autonomous-utterances", type=int, default=4, help="One-shot --user safety cap.")
46
+ parser.add_argument("--gate-threshold", type=float, default=0.5, help="Trained binary gate decision threshold.")
47
+ parser.add_argument("--max-new-tokens", type=int, default=160)
48
+ parser.add_argument("--temperature", type=float, default=0.8)
49
+ parser.add_argument("--top-p", type=float, default=0.95)
50
+ parser.add_argument(
51
+ "--debug-gate",
52
+ "--show-gate",
53
+ dest="debug_gate",
54
+ action="store_true",
55
+ help="Print the gate head probability on every runtime tick.",
56
+ )
57
+ return parser.parse_args()
58
+
59
+
60
+ def build_generation_kwargs(args: argparse.Namespace) -> dict[str, Any]:
61
+ kwargs: dict[str, Any] = {
62
+ "max_new_tokens": int(args.max_new_tokens),
63
+ "do_sample": float(args.temperature) > 0.0,
64
+ }
65
+ if kwargs["do_sample"]:
66
+ kwargs["temperature"] = float(args.temperature)
67
+ kwargs["top_p"] = float(args.top_p)
68
+ return kwargs
69
+
70
+
71
+ def normalize_history_speaker(speaker: str | None) -> str | None:
72
+ normalized = str(speaker or "").strip().lower()
73
+ if normalized in {"user", "human", "prompter"}:
74
+ return "user"
75
+ if normalized in {"assistant", "agent", "model", "gpt"}:
76
+ return "assistant"
77
+ return None
78
+
79
+
80
+ def load_base_seq2seq_model(
81
+ model_path: str,
82
+ *,
83
+ device: str | torch.device,
84
+ ) -> tuple[torch.nn.Module, Any]:
85
+ resolved_device = torch.device(device)
86
+ dtype = torch.bfloat16 if resolved_device.type == "cuda" else torch.float32
87
+ model = AutoModelForSeq2SeqLM.from_pretrained(model_path, torch_dtype=dtype)
88
+ tokenizer = AutoTokenizer.from_pretrained(model_path)
89
+ model.to(resolved_device)
90
+ model.eval()
91
+ return model, tokenizer
92
+
93
+
94
+ def load_inference_backend(
95
+ model_path: str,
96
+ *,
97
+ device: str | torch.device,
98
+ ) -> tuple[str, Any, Any | None]:
99
+ try:
100
+ model = ThoughtLoopT5Gemma.from_pretrained(model_path, device=device, map_location=device)
101
+ model.eval()
102
+ return "thought_loop", model, None
103
+ except FileNotFoundError as exc:
104
+ if Path(str(exc.filename)).name != "sft_config.json":
105
+ raise
106
+ print(
107
+ "[inference] No sft_config.json found at model path; loading as a raw Hugging Face seq2seq baseline.",
108
+ file=sys.stderr,
109
+ )
110
+ base_model, tokenizer = load_base_seq2seq_model(model_path, device=device)
111
+ return "base_seq2seq", base_model, tokenizer
112
+
113
+
114
+ class BaseSeq2SeqSession:
115
+ def __init__(
116
+ self,
117
+ model: torch.nn.Module,
118
+ tokenizer: Any,
119
+ device: str | torch.device,
120
+ generation_kwargs: dict[str, Any],
121
+ ) -> None:
122
+ self.model = model
123
+ self.tokenizer = tokenizer
124
+ self.device = torch.device(device)
125
+ self.generation_kwargs = generation_kwargs
126
+ self.history: list[dict[str, str]] = []
127
+
128
+ def replay_history(self, messages: list[dict[str, Any]]) -> None:
129
+ ordered_messages = sorted(messages, key=lambda item: float(item.get("t", 0.0)))
130
+ for message in ordered_messages:
131
+ role = normalize_history_speaker(message.get("speaker"))
132
+ text = str(message.get("text", "")).strip()
133
+ if role is None or not text:
134
+ continue
135
+ self.history.append({"role": role, "content": text})
136
+
137
+ def _format_prompt(self, user_text: str) -> str:
138
+ messages = [*self.history, {"role": "user", "content": user_text}]
139
+ if getattr(self.tokenizer, "chat_template", None):
140
+ return self.tokenizer.apply_chat_template(
141
+ messages,
142
+ tokenize=False,
143
+ add_generation_prompt=True,
144
+ )
145
+
146
+ lines: list[str] = []
147
+ for message in messages:
148
+ role = "User" if message["role"] == "user" else "Assistant"
149
+ lines.append(f"{role}: {message['content']}")
150
+ lines.append("Assistant:")
151
+ return "\n".join(lines)
152
+
153
+ def respond(
154
+ self,
155
+ user_text: str,
156
+ *,
157
+ max_thought_seconds: float,
158
+ settle_thought_seconds: float,
159
+ max_autonomous_utterances: int,
160
+ ) -> tuple[list[str], float]:
161
+ del max_thought_seconds, settle_thought_seconds, max_autonomous_utterances
162
+ prompt = self._format_prompt(user_text)
163
+ encoded = self.tokenizer(prompt, return_tensors="pt").to(self.device)
164
+ with torch.no_grad():
165
+ generated = self.model.generate(**encoded, **self.generation_kwargs)
166
+ response = self.tokenizer.decode(generated[0], skip_special_tokens=True).strip()
167
+ self.history.append({"role": "user", "content": user_text})
168
+ self.history.append({"role": "assistant", "content": response})
169
+ return [response], 0.0
170
+
171
+
172
+ class TerminalInputBuffer:
173
+ def __init__(self) -> None:
174
+ self._lines: deque[str] = deque()
175
+ self._lock = threading.Lock()
176
+
177
+ def push(self, line: str) -> None:
178
+ with self._lock:
179
+ self._lines.append(line)
180
+
181
+ def drain(self) -> list[str]:
182
+ with self._lock:
183
+ lines = list(self._lines)
184
+ self._lines.clear()
185
+ return lines
186
+
187
+ def prepend_many(self, lines: list[str]) -> None:
188
+ if not lines:
189
+ return
190
+ with self._lock:
191
+ for line in reversed(lines):
192
+ self._lines.appendleft(line)
193
+
194
+ def __len__(self) -> int:
195
+ with self._lock:
196
+ return len(self._lines)
197
+
198
+
199
+ def start_terminal_reader(line_buffer: TerminalInputBuffer, stop_event: threading.Event) -> threading.Thread:
200
+ def read_loop() -> None:
201
+ while not stop_event.is_set():
202
+ line = sys.stdin.readline()
203
+ if line == "":
204
+ stop_event.set()
205
+ break
206
+ line_buffer.push(line.rstrip("\n"))
207
+
208
+ thread = threading.Thread(target=read_loop, name="terminal-input-reader", daemon=True)
209
+ thread.start()
210
+ return thread
211
+
212
+
213
+ def print_gate_debug(tick_index: int, event_name: str, gate_probability: float) -> None:
214
+ print(f"[tick={tick_index} event={event_name} gate={gate_probability:.6f}]", flush=True)
215
+
216
+
217
+ class ThoughtLoopSession:
218
+ def __init__(
219
+ self,
220
+ model: ThoughtLoopT5Gemma,
221
+ tick_seconds: float,
222
+ gate_threshold: float,
223
+ generation_kwargs: dict[str, Any],
224
+ ) -> None:
225
+ self.model = model
226
+ self.tokenizer = model.tokenizer
227
+ self.tick_seconds = tick_seconds
228
+ self.gate_threshold = gate_threshold
229
+ self.generation_kwargs = generation_kwargs
230
+ self.z_state = model.initial_state(batch_size=1, device=model.device)
231
+ self.z_mask = model.initial_state_mask(batch_size=1, device=model.device)
232
+ self.use_base_chat_template = should_use_base_chat_template(model.config, self.tokenizer)
233
+ self.elapsed_seconds = 0.0
234
+ self.next_tick_index = 0
235
+ self.feedback_delay_seconds = float(model.config.get("rollout", {}).get("post_speech_feedback_delay_seconds", tick_seconds))
236
+
237
+ def _format_observation(self, observation_role: int, observation_text: str | None) -> str | None:
238
+ if observation_text is None:
239
+ return None
240
+ if observation_role == OBS_ROLE_USER:
241
+ return build_user_generation_observation(
242
+ self.tokenizer,
243
+ observation_text,
244
+ self.use_base_chat_template,
245
+ )
246
+ if observation_role == OBS_ROLE_AGENT_FEEDBACK:
247
+ return build_assistant_feedback_observation(
248
+ self.tokenizer,
249
+ observation_text,
250
+ self.use_base_chat_template,
251
+ )
252
+ return observation_text
253
+
254
+ def _advance_raw(self, delta_seconds: float, observation_role: int, observation_text: str | None) -> float:
255
+ formatted_observation_text = self._format_observation(observation_role, observation_text)
256
+ input_ids, attention_mask = tokenize_text(
257
+ tokenizer=self.tokenizer,
258
+ text=formatted_observation_text,
259
+ max_length=int(self.model.config["model"]["max_observation_tokens"]),
260
+ )
261
+ input_ids_tensor = torch.tensor([input_ids], dtype=torch.long, device=self.model.device)
262
+ attention_mask_tensor = torch.tensor([attention_mask], dtype=torch.long, device=self.model.device)
263
+ role_tensor = torch.tensor([observation_role], dtype=torch.long, device=self.model.device)
264
+
265
+ self.elapsed_seconds += delta_seconds
266
+
267
+ with torch.no_grad():
268
+ self.z_state, gate_logits, self.z_mask = self.model.rollout_step_with_mask(
269
+ z_state=self.z_state,
270
+ state_mask=self.z_mask,
271
+ observation_input_ids=input_ids_tensor,
272
+ observation_attention_mask=attention_mask_tensor,
273
+ observation_role_ids=role_tensor,
274
+ delta_seconds=torch.tensor([delta_seconds], dtype=torch.float32, device=self.model.device),
275
+ elapsed_seconds=torch.tensor([self.elapsed_seconds], dtype=torch.float32, device=self.model.device),
276
+ since_last_user_seconds=torch.zeros(1, dtype=torch.float32, device=self.model.device),
277
+ since_last_agent_seconds=torch.zeros(1, dtype=torch.float32, device=self.model.device),
278
+ )
279
+ return torch.sigmoid(gate_logits).item()
280
+
281
+ def _advance_tick(self, observation_role: int, observation_text: str | None) -> float:
282
+ delta_seconds = 0.0 if self.next_tick_index == 0 else self.tick_seconds
283
+ gate_probability = self._advance_raw(delta_seconds, observation_role, observation_text)
284
+ self.next_tick_index += 1
285
+ return gate_probability
286
+
287
+ def advance_tick(self, observation_role: int, observation_text: str | None) -> float:
288
+ return self._advance_tick(observation_role, observation_text)
289
+
290
+ def replay_history(self, messages: list[dict[str, Any]]) -> None:
291
+ quantized_events = quantize_message_sequence(
292
+ messages=messages,
293
+ tick_seconds=self.tick_seconds,
294
+ feedback_delay_seconds=self.feedback_delay_seconds,
295
+ )
296
+ events_by_tick = {int(event["tick"]): event for event in quantized_events}
297
+ if not events_by_tick:
298
+ return
299
+
300
+ last_tick = max(events_by_tick)
301
+ while self.next_tick_index <= last_tick:
302
+ event = events_by_tick.get(self.next_tick_index)
303
+ if event is None:
304
+ self._advance_tick(OBS_ROLE_NONE, None)
305
+ continue
306
+
307
+ if event["kind"] == "user":
308
+ self._advance_tick(OBS_ROLE_USER, str(event["text"]))
309
+ elif event["kind"] == "agent_feedback":
310
+ self._advance_tick(OBS_ROLE_AGENT_FEEDBACK, str(event["text"]))
311
+ else:
312
+ self._advance_tick(OBS_ROLE_NONE, None)
313
+
314
+ def _generate_response(self) -> str:
315
+ generated = self.model.generate_from_state(
316
+ self.z_state,
317
+ encoder_attention_mask=self.z_mask,
318
+ **self.generation_kwargs,
319
+ )
320
+ return self.tokenizer.decode(generated[0], skip_special_tokens=True).strip()
321
+
322
+ def generate_response(self) -> str:
323
+ return self._generate_response()
324
+
325
+ def respond(
326
+ self,
327
+ user_text: str,
328
+ *,
329
+ max_thought_seconds: float,
330
+ settle_thought_seconds: float,
331
+ max_autonomous_utterances: int,
332
+ debug_gate: bool = False,
333
+ ) -> tuple[list[str], float]:
334
+ gate_probability = self._advance_tick(OBS_ROLE_USER, user_text)
335
+ if debug_gate:
336
+ print_gate_debug(self.next_tick_index - 1, "user", gate_probability)
337
+ utterances: list[str] = []
338
+ thought_ticks = 0
339
+ settle_ticks = max(1, int(math.ceil(settle_thought_seconds / self.tick_seconds)))
340
+ max_thought_ticks = max(1, int(math.ceil(max_thought_seconds / self.tick_seconds)))
341
+ idle_ticks_after_last_speech = 0
342
+ pending_assistant_feedback: str | None = None
343
+
344
+ while thought_ticks < max_thought_ticks:
345
+ if pending_assistant_feedback is not None:
346
+ event_name = "assistant_feedback"
347
+ gate_probability = self._advance_tick(OBS_ROLE_AGENT_FEEDBACK, pending_assistant_feedback)
348
+ pending_assistant_feedback = None
349
+ else:
350
+ event_name = "idle"
351
+ gate_probability = self._advance_tick(OBS_ROLE_NONE, None)
352
+ if debug_gate:
353
+ print_gate_debug(self.next_tick_index - 1, event_name, gate_probability)
354
+ thought_ticks += 1
355
+ idle_ticks_after_last_speech += 1
356
+
357
+ if gate_probability >= self.gate_threshold:
358
+ response = self._generate_response()
359
+ utterances.append(response)
360
+ idle_ticks_after_last_speech = 0
361
+ if len(utterances) >= max_autonomous_utterances:
362
+ break
363
+ pending_assistant_feedback = response
364
+ continue
365
+
366
+ if utterances and idle_ticks_after_last_speech >= settle_ticks:
367
+ break
368
+
369
+ return utterances, thought_ticks * self.tick_seconds
370
+
371
+
372
+ def consume_next_live_user_line(
373
+ line_buffer: TerminalInputBuffer,
374
+ *,
375
+ stop_event: threading.Event,
376
+ status_text: str,
377
+ ) -> str | None:
378
+ lines = line_buffer.drain()
379
+ if not lines:
380
+ return None
381
+
382
+ selected_line: str | None = None
383
+ deferred_lines: list[str] = []
384
+ for raw_line in lines:
385
+ line = raw_line.strip()
386
+ if not line:
387
+ continue
388
+ command = line.lower()
389
+ if command in {"/exit", "/quit", "exit", "quit"}:
390
+ stop_event.set()
391
+ continue
392
+ if command == "/status":
393
+ print(status_text, flush=True)
394
+ continue
395
+ if selected_line is None:
396
+ selected_line = line
397
+ else:
398
+ deferred_lines.append(line)
399
+
400
+ line_buffer.prepend_many(deferred_lines)
401
+ return selected_line
402
+
403
+
404
+ def run_live_thought_loop_terminal(
405
+ session: ThoughtLoopSession,
406
+ *,
407
+ gate_threshold: float,
408
+ debug_gate: bool,
409
+ ) -> None:
410
+ line_buffer = TerminalInputBuffer()
411
+ stop_event = threading.Event()
412
+ start_terminal_reader(line_buffer, stop_event)
413
+ pending_assistant_feedback: str | None = None
414
+
415
+ print("Live Samantha session.", flush=True)
416
+ print("Type anytime and press Enter. Each line is one user event on the next 1 Hz tick.", flush=True)
417
+ print("Commands: /status, /exit", flush=True)
418
+
419
+ while not stop_event.is_set():
420
+ tick_started_at = time.monotonic()
421
+ status_text = (
422
+ f"[status] tick={session.next_tick_index} elapsed={session.elapsed_seconds:.1f}s "
423
+ f"queued_user_lines={len(line_buffer)} pending_assistant_feedback={pending_assistant_feedback is not None}"
424
+ )
425
+
426
+ if pending_assistant_feedback is not None:
427
+ observation_role = OBS_ROLE_AGENT_FEEDBACK
428
+ observation_text = pending_assistant_feedback
429
+ event_name = "assistant_feedback"
430
+ pending_assistant_feedback = None
431
+ else:
432
+ user_line = consume_next_live_user_line(
433
+ line_buffer,
434
+ stop_event=stop_event,
435
+ status_text=status_text,
436
+ )
437
+ if stop_event.is_set():
438
+ break
439
+
440
+ if user_line is not None:
441
+ observation_role = OBS_ROLE_USER
442
+ observation_text = user_line
443
+ event_name = "user"
444
+ print(f"user> {user_line}", flush=True)
445
+ else:
446
+ observation_role = OBS_ROLE_NONE
447
+ observation_text = None
448
+ event_name = "idle"
449
+
450
+ gate_probability = session.advance_tick(observation_role, observation_text)
451
+ if debug_gate:
452
+ print_gate_debug(session.next_tick_index - 1, event_name, gate_probability)
453
+
454
+ if observation_role != OBS_ROLE_USER and gate_probability >= gate_threshold:
455
+ response = session.generate_response()
456
+ print(f"assistant> {response}", flush=True)
457
+ pending_assistant_feedback = response
458
+
459
+ sleep_seconds = session.tick_seconds - (time.monotonic() - tick_started_at)
460
+ if sleep_seconds > 0.0:
461
+ time.sleep(sleep_seconds)
462
+
463
+
464
+ def run_blocking_base_terminal(session: BaseSeq2SeqSession) -> None:
465
+ print("Raw HF baseline terminal mode. This model has no Samantha Z state or speech gate.", flush=True)
466
+ print("Type a message and press Enter. Commands: /exit", flush=True)
467
+ while True:
468
+ user_text = input("user> ").strip()
469
+ if user_text.lower() in {"/exit", "/quit", "exit", "quit"}:
470
+ break
471
+ if not user_text:
472
+ continue
473
+ responses, _ = session.respond(
474
+ user_text,
475
+ max_thought_seconds=0.0,
476
+ settle_thought_seconds=0.0,
477
+ max_autonomous_utterances=1,
478
+ )
479
+ for response in responses:
480
+ print(f"assistant> {response}", flush=True)
481
+
482
+
483
+ def main() -> None:
484
+ args = parse_args()
485
+ backend_kind, model, tokenizer = load_inference_backend(args.model_path, device=args.device)
486
+ generation_kwargs = build_generation_kwargs(args)
487
+
488
+ if backend_kind == "thought_loop":
489
+ tick_seconds = (
490
+ float(args.tick_seconds)
491
+ if args.tick_seconds is not None
492
+ else float(model.config.get("rollout", {}).get("tick_seconds", 1.0))
493
+ )
494
+ session: Any = ThoughtLoopSession(
495
+ model=model,
496
+ tick_seconds=tick_seconds,
497
+ gate_threshold=args.gate_threshold,
498
+ generation_kwargs=generation_kwargs,
499
+ )
500
+ else:
501
+ assert tokenizer is not None
502
+ session = BaseSeq2SeqSession(
503
+ model=model,
504
+ tokenizer=tokenizer,
505
+ device=args.device,
506
+ generation_kwargs=generation_kwargs,
507
+ )
508
+
509
+ if args.history_json:
510
+ history = json.loads(Path(args.history_json).read_text(encoding="utf-8"))
511
+ if not isinstance(history, list):
512
+ raise ValueError("History JSON must be a list of turn objects.")
513
+ session.replay_history(history)
514
+
515
+ if args.user:
516
+ responses, thought_time = session.respond(
517
+ args.user,
518
+ max_thought_seconds=args.max_thought_seconds,
519
+ settle_thought_seconds=args.settle_thought_seconds,
520
+ max_autonomous_utterances=args.max_autonomous_utterances,
521
+ debug_gate=bool(args.debug_gate),
522
+ )
523
+ print(
524
+ json.dumps(
525
+ {
526
+ "responses": responses,
527
+ "thought_seconds": thought_time,
528
+ },
529
+ indent=2,
530
+ ensure_ascii=False,
531
+ )
532
+ )
533
+
534
+ if args.interactive or args.user is None:
535
+ if backend_kind == "thought_loop":
536
+ run_live_thought_loop_terminal(
537
+ session,
538
+ gate_threshold=float(args.gate_threshold),
539
+ debug_gate=bool(args.debug_gate),
540
+ )
541
+ else:
542
+ run_blocking_base_terminal(session)
543
+
544
+
545
+ if __name__ == "__main__":
546
+ main()
initial_latent_z.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9c8a7ba9282608a91981002cf45998c493d47ba4ccb5538fae1f183e9659d344
3
+ size 1048656
model.py ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+ import torch.nn.functional as F
12
+ from huggingface_hub import snapshot_download
13
+ from safetensors.torch import load_model, save_file, save_model
14
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
15
+ from transformers.modeling_outputs import BaseModelOutput
16
+
17
+
18
+ SCRIPT_DIR = Path(__file__).resolve().parent
19
+ if str(SCRIPT_DIR) not in sys.path:
20
+ sys.path.insert(0, str(SCRIPT_DIR))
21
+
22
+
23
+ class ParameterlessRMSNorm(nn.Module):
24
+ def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
25
+ super().__init__()
26
+ self.hidden_size = hidden_size
27
+ self.eps = eps
28
+
29
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
30
+ original_dtype = inputs.dtype
31
+ normalized = inputs.float()
32
+ normalized = normalized * torch.rsqrt(normalized.pow(2).mean(dim=-1, keepdim=True) + self.eps)
33
+ return normalized.to(dtype=original_dtype)
34
+
35
+
36
+ class TemporalFeatureProjector(nn.Module):
37
+ def __init__(self, hidden_size: int, num_time_tokens: int, scalar_count: int = 4) -> None:
38
+ super().__init__()
39
+ self.hidden_size = hidden_size
40
+ self.num_time_tokens = num_time_tokens
41
+ self.mlp = nn.Sequential(
42
+ nn.Linear(scalar_count, hidden_size),
43
+ nn.GELU(),
44
+ nn.Linear(hidden_size, hidden_size * num_time_tokens),
45
+ )
46
+ self.norm = nn.LayerNorm(hidden_size)
47
+
48
+ def forward(self, temporal_features: torch.Tensor) -> torch.Tensor:
49
+ projected = self.mlp(temporal_features)
50
+ projected = projected.view(temporal_features.size(0), self.num_time_tokens, self.hidden_size)
51
+ return self.norm(projected)
52
+
53
+
54
+ class ThoughtLoopT5Gemma(nn.Module):
55
+ def __init__(self, config: dict[str, Any]) -> None:
56
+ super().__init__()
57
+ self.config = config
58
+ model_cfg = config["model"]
59
+ training_cfg = config.get("training", {})
60
+
61
+ backbone_dtype = getattr(torch, model_cfg.get("dtype", "bfloat16"))
62
+ backbone_kwargs: dict[str, Any] = {
63
+ # transformers currently warns that torch_dtype is deprecated in favor of dtype,
64
+ # but torch_dtype remains compatible across more installed versions.
65
+ "torch_dtype": backbone_dtype,
66
+ }
67
+ attn_implementation = model_cfg.get("attn_implementation")
68
+ if attn_implementation:
69
+ backbone_kwargs["attn_implementation"] = attn_implementation
70
+
71
+ self.backbone = AutoModelForSeq2SeqLM.from_pretrained(model_cfg["base_model_name"], **backbone_kwargs)
72
+ self.tokenizer = AutoTokenizer.from_pretrained(model_cfg["base_model_name"])
73
+
74
+ if training_cfg.get("gradient_checkpointing", True):
75
+ self.backbone.gradient_checkpointing_enable()
76
+ if getattr(self.backbone.config, "use_cache", None) is not None:
77
+ self.backbone.config.use_cache = False
78
+
79
+ self.encoder = self.backbone.get_encoder()
80
+ self.decoder = self.backbone.get_decoder()
81
+ self.input_embeddings = self.backbone.get_input_embeddings()
82
+ hidden_size = int(self.backbone.config.decoder.hidden_size)
83
+ self.hidden_size = hidden_size
84
+ self.z_slots = int(model_cfg["z_slots"])
85
+ self.thought_loop_proposal_mode = str(
86
+ model_cfg.get("thought_loop_proposal_mode", "latent_prefix")
87
+ ).strip().lower()
88
+ if self.thought_loop_proposal_mode not in {"latent_prefix", "observation_hidden_compression"}:
89
+ raise ValueError(f"Unsupported thought_loop_proposal_mode: {self.thought_loop_proposal_mode}")
90
+ self.preserve_observation_encoder_manifold = bool(
91
+ model_cfg.get(
92
+ "preserve_observation_encoder_manifold",
93
+ self.thought_loop_proposal_mode == "observation_hidden_compression",
94
+ )
95
+ )
96
+ self.observation_encoder_use_state_context = bool(
97
+ model_cfg.get("observation_encoder_use_state_context", False)
98
+ )
99
+ self.latent_attention_mask_mode = str(
100
+ model_cfg.get("latent_attention_mask_mode", "observed")
101
+ ).strip().lower()
102
+ if self.latent_attention_mask_mode not in {"observed", "full"}:
103
+ raise ValueError(f"Unsupported latent_attention_mask_mode: {self.latent_attention_mask_mode}")
104
+ self.use_explicit_time_features = bool(model_cfg.get("use_explicit_time_features", False))
105
+ self.num_time_tokens = int(model_cfg["num_time_tokens"]) if self.use_explicit_time_features else 0
106
+ self.observation_role_count = 3
107
+ magicnorm_eps = float(model_cfg.get("magicnorm_eps", 1e-6))
108
+
109
+ # Keep the newly initialized recurrent/gating modules in fp32. We cast only the
110
+ # tensors handed into the bf16 backbone. This avoids dtype crashes while preserving
111
+ # stable optimizer state for the custom modules.
112
+ self.z_init = nn.Parameter(torch.randn(self.z_slots, hidden_size) * 0.02)
113
+ self.segment_embeddings = nn.Embedding(3, hidden_size)
114
+ self.observation_role_embeddings = nn.Embedding(self.observation_role_count, hidden_size)
115
+ self.temporal_projector = (
116
+ TemporalFeatureProjector(hidden_size, self.num_time_tokens) if self.use_explicit_time_features else None
117
+ )
118
+
119
+ self.state_gate = nn.Sequential(
120
+ nn.Linear(hidden_size * 2, hidden_size),
121
+ nn.GELU(),
122
+ nn.Linear(hidden_size, hidden_size),
123
+ )
124
+ self._initialize_update_gate_bias(model_cfg)
125
+ self.state_gate_input_norm = ParameterlessRMSNorm(hidden_size * 2, eps=magicnorm_eps)
126
+ self.proposed_state_norm = ParameterlessRMSNorm(hidden_size, eps=magicnorm_eps)
127
+ self.recurrent_state_norm = ParameterlessRMSNorm(hidden_size, eps=magicnorm_eps)
128
+ self.gate_context_norm = ParameterlessRMSNorm(hidden_size, eps=magicnorm_eps)
129
+
130
+ self.gate_query = nn.Parameter(torch.randn(1, 1, hidden_size) * 0.02)
131
+ self.gate_pool = nn.MultiheadAttention(
132
+ embed_dim=hidden_size,
133
+ num_heads=int(model_cfg.get("gate_attention_heads", 4)),
134
+ batch_first=True,
135
+ )
136
+ self.gate_head = nn.Sequential(
137
+ nn.LayerNorm(hidden_size),
138
+ nn.Linear(hidden_size, hidden_size),
139
+ nn.GELU(),
140
+ nn.Linear(hidden_size, 1),
141
+ )
142
+
143
+ self._drop_unused_vision_modules()
144
+
145
+ def _initialize_update_gate_bias(self, model_cfg: dict[str, Any]) -> None:
146
+ if "initial_update_gate_bias" not in model_cfg:
147
+ return
148
+ final_gate_layer = self.state_gate[-1]
149
+ if not isinstance(final_gate_layer, nn.Linear) or final_gate_layer.bias is None:
150
+ raise RuntimeError("state_gate must end in a Linear layer with bias to use initial_update_gate_bias.")
151
+ nn.init.constant_(final_gate_layer.bias, float(model_cfg["initial_update_gate_bias"]))
152
+
153
+ def _drop_unused_vision_modules(self) -> None:
154
+ for module_path in (
155
+ "encoder.vision_tower",
156
+ "encoder.vision_model",
157
+ "model.encoder.vision_tower",
158
+ "model.encoder.vision_model",
159
+ ):
160
+ parent: Any = self.backbone
161
+ path_parts = module_path.split(".")
162
+ try:
163
+ for part in path_parts[:-1]:
164
+ parent = getattr(parent, part)
165
+ except AttributeError:
166
+ continue
167
+
168
+ child_name = path_parts[-1]
169
+ child = getattr(parent, child_name, None)
170
+ if isinstance(child, nn.Module):
171
+ setattr(parent, child_name, None)
172
+
173
+ @property
174
+ def device(self) -> torch.device:
175
+ return next(self.parameters()).device
176
+
177
+ @property
178
+ def backbone_dtype(self) -> torch.dtype:
179
+ return self.input_embeddings.weight.dtype
180
+
181
+ @property
182
+ def recurrent_dtype(self) -> torch.dtype:
183
+ return self.z_init.dtype
184
+
185
+ def trainable_parameter_count(self) -> int:
186
+ return sum(parameter.numel() for parameter in self.parameters() if parameter.requires_grad)
187
+
188
+ def _decoder_blocks(self) -> Any:
189
+ for attribute_name in ("block", "layers", "h"):
190
+ blocks = getattr(self.decoder, attribute_name, None)
191
+ if blocks is not None:
192
+ return blocks
193
+ return None
194
+
195
+ def decoder_layer_count(self) -> int:
196
+ blocks = self._decoder_blocks()
197
+ return len(blocks) if blocks is not None else 0
198
+
199
+ def set_gate_trainable(self, trainable: bool) -> None:
200
+ self.gate_query.requires_grad_(trainable)
201
+ self.gate_pool.requires_grad_(trainable)
202
+ self.gate_head.requires_grad_(trainable)
203
+ self.gate_context_norm.requires_grad_(trainable)
204
+
205
+ def set_decoder_trainable_fraction(self, trainable_fraction: float) -> int:
206
+ blocks = self._decoder_blocks()
207
+ if blocks is None:
208
+ return 0
209
+
210
+ clamped_fraction = min(max(trainable_fraction, 0.0), 1.0)
211
+ total_blocks = len(blocks)
212
+ trainable_blocks = min(total_blocks, math.ceil(total_blocks * clamped_fraction)) if clamped_fraction > 0 else 0
213
+ first_trainable_index = total_blocks - trainable_blocks
214
+
215
+ for block_index, block in enumerate(blocks):
216
+ block.requires_grad_(block_index >= first_trainable_index)
217
+
218
+ for attribute_name in ("final_layer_norm", "norm", "layer_norm"):
219
+ maybe_module = getattr(self.decoder, attribute_name, None)
220
+ if isinstance(maybe_module, nn.Module):
221
+ maybe_module.requires_grad_(trainable_blocks > 0)
222
+
223
+ decoder_embeddings = getattr(self.decoder, "embed_tokens", None)
224
+ if isinstance(decoder_embeddings, nn.Module):
225
+ decoder_embeddings.requires_grad_(trainable_blocks > 0)
226
+
227
+ for module_path in (
228
+ "lm_head",
229
+ "model.lm_head",
230
+ "backbone.lm_head",
231
+ "shared",
232
+ "model.shared",
233
+ ):
234
+ parent: Any = self
235
+ path_parts = module_path.split(".")
236
+ try:
237
+ for part in path_parts[:-1]:
238
+ parent = getattr(parent, part)
239
+ except AttributeError:
240
+ continue
241
+
242
+ child_name = path_parts[-1]
243
+ child = getattr(parent, child_name, None)
244
+ if isinstance(child, nn.Module):
245
+ child.requires_grad_(trainable_blocks > 0)
246
+
247
+ return trainable_blocks
248
+
249
+ def initial_state(self, batch_size: int, device: torch.device | None = None) -> torch.Tensor:
250
+ target_device = device or self.device
251
+ initial_state = self.z_init.unsqueeze(0).expand(batch_size, -1, -1).to(device=target_device)
252
+ return self.recurrent_state_norm(initial_state)
253
+
254
+ def initial_state_mask(self, batch_size: int, device: torch.device | None = None) -> torch.Tensor:
255
+ target_device = device or self.device
256
+ if self.latent_attention_mask_mode == "full":
257
+ return torch.ones(batch_size, self.z_slots, dtype=torch.long, device=target_device)
258
+ if self.thought_loop_proposal_mode == "observation_hidden_compression":
259
+ return torch.zeros(batch_size, self.z_slots, dtype=torch.long, device=target_device)
260
+ return torch.ones(batch_size, self.z_slots, dtype=torch.long, device=target_device)
261
+
262
+ def _build_temporal_tensor(
263
+ self,
264
+ delta_seconds: torch.Tensor,
265
+ elapsed_seconds: torch.Tensor,
266
+ since_last_user_seconds: torch.Tensor,
267
+ since_last_agent_seconds: torch.Tensor,
268
+ ) -> torch.Tensor:
269
+ if not self.use_explicit_time_features:
270
+ raise RuntimeError("Temporal features were requested, but this model is configured to disable them.")
271
+ stacked = torch.stack(
272
+ [
273
+ torch.log1p(delta_seconds),
274
+ torch.log1p(elapsed_seconds),
275
+ torch.log1p(since_last_user_seconds),
276
+ torch.log1p(since_last_agent_seconds),
277
+ ],
278
+ dim=-1,
279
+ )
280
+ return stacked
281
+
282
+ def compress_hidden_states_to_slots(
283
+ self,
284
+ hidden_states: torch.Tensor,
285
+ attention_mask: torch.Tensor,
286
+ target_slots: int | None = None,
287
+ ) -> tuple[torch.Tensor, torch.Tensor]:
288
+ slots = int(target_slots if target_slots is not None else self.z_slots)
289
+ batch_size, _, hidden_size = hidden_states.shape
290
+ compressed_hidden = hidden_states.new_zeros((batch_size, slots, hidden_size))
291
+ compressed_mask = torch.zeros(batch_size, slots, dtype=torch.long, device=hidden_states.device)
292
+ attention_mask = attention_mask.to(device=hidden_states.device, dtype=torch.bool)
293
+
294
+ for batch_index in range(batch_size):
295
+ valid_states = hidden_states[batch_index, attention_mask[batch_index]]
296
+ valid_length = int(valid_states.shape[0])
297
+ if valid_length <= 0:
298
+ continue
299
+
300
+ if valid_length <= slots:
301
+ compressed_hidden[batch_index, :valid_length] = valid_states
302
+ compressed_mask[batch_index, :valid_length] = 1
303
+ continue
304
+
305
+ for slot_index in range(slots):
306
+ start = (slot_index * valid_length) // slots
307
+ end = ((slot_index + 1) * valid_length) // slots
308
+ if end <= start:
309
+ end = min(valid_length, start + 1)
310
+ compressed_hidden[batch_index, slot_index] = valid_states[start:end].mean(dim=0)
311
+ compressed_mask[batch_index, slot_index] = 1
312
+
313
+ return compressed_hidden, compressed_mask
314
+
315
+ def _rollout_step_impl(
316
+ self,
317
+ z_state: torch.Tensor,
318
+ observation_input_ids: torch.Tensor,
319
+ observation_attention_mask: torch.Tensor,
320
+ observation_role_ids: torch.Tensor,
321
+ delta_seconds: torch.Tensor,
322
+ elapsed_seconds: torch.Tensor,
323
+ since_last_user_seconds: torch.Tensor,
324
+ since_last_agent_seconds: torch.Tensor,
325
+ previous_state_mask: torch.Tensor | None = None,
326
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
327
+ batch_size = z_state.size(0)
328
+ recurrent_dtype = z_state.dtype
329
+ backbone_dtype = self.backbone_dtype
330
+
331
+ if self.thought_loop_proposal_mode == "latent_prefix":
332
+ aux_tokens: list[torch.Tensor] = []
333
+ aux_masks: list[torch.Tensor] = []
334
+ if self.use_explicit_time_features:
335
+ temporal_features = self._build_temporal_tensor(
336
+ delta_seconds=delta_seconds,
337
+ elapsed_seconds=elapsed_seconds,
338
+ since_last_user_seconds=since_last_user_seconds,
339
+ since_last_agent_seconds=since_last_agent_seconds,
340
+ ).to(dtype=recurrent_dtype)
341
+ assert self.temporal_projector is not None
342
+ time_tokens = self.temporal_projector(temporal_features)
343
+ time_tokens = time_tokens + self.segment_embeddings.weight[1].view(1, 1, -1).to(dtype=recurrent_dtype)
344
+ aux_tokens.append(time_tokens)
345
+ aux_masks.append(torch.ones(batch_size, self.num_time_tokens, device=z_state.device, dtype=torch.long))
346
+
347
+ role_tokens = self.observation_role_embeddings(observation_role_ids).unsqueeze(1).to(dtype=recurrent_dtype)
348
+ role_tokens = role_tokens + self.segment_embeddings.weight[1].view(1, 1, -1).to(dtype=recurrent_dtype)
349
+ aux_tokens.append(role_tokens)
350
+ aux_masks.append(torch.ones(batch_size, 1, device=z_state.device, dtype=torch.long))
351
+
352
+ observation_embeds = self.input_embeddings(observation_input_ids).to(dtype=recurrent_dtype)
353
+ observation_embeds = observation_embeds + self.segment_embeddings.weight[2].view(1, 1, -1).to(
354
+ dtype=recurrent_dtype
355
+ )
356
+ observation_embeds = observation_embeds + self.observation_role_embeddings(observation_role_ids).unsqueeze(1).to(
357
+ dtype=recurrent_dtype
358
+ )
359
+
360
+ z_tokens = z_state + self.segment_embeddings.weight[0].view(1, 1, -1).to(dtype=recurrent_dtype)
361
+ encoder_inputs = torch.cat([z_tokens, *aux_tokens, observation_embeds], dim=1)
362
+
363
+ z_mask = torch.ones(batch_size, self.z_slots, device=z_state.device, dtype=torch.long)
364
+ encoder_attention_mask = torch.cat([z_mask, *aux_masks, observation_attention_mask.long()], dim=1)
365
+
366
+ # The pretrained T5Gemma backbone was loaded in bf16. Passing fp32 inputs_embeds
367
+ # into its bf16 Linear layers causes: expected mat1 and mat2 to have same dtype.
368
+ encoder_outputs = self.encoder(
369
+ inputs_embeds=encoder_inputs.to(dtype=backbone_dtype),
370
+ attention_mask=encoder_attention_mask,
371
+ return_dict=True,
372
+ )
373
+ latent_prefix_state = encoder_outputs.last_hidden_state[:, : self.z_slots, :].to(dtype=recurrent_dtype)
374
+ proposed_state = latent_prefix_state
375
+ proposal_mask = torch.ones(batch_size, self.z_slots, dtype=torch.long, device=z_state.device)
376
+ else:
377
+ use_state_context = (
378
+ self.observation_encoder_use_state_context
379
+ and previous_state_mask is not None
380
+ and torch.any(previous_state_mask > 0)
381
+ )
382
+ if use_state_context:
383
+ observation_embeds = self.input_embeddings(observation_input_ids).to(dtype=recurrent_dtype)
384
+ encoder_inputs = torch.cat([z_state, observation_embeds], dim=1)
385
+ encoder_attention_mask = torch.cat(
386
+ [
387
+ previous_state_mask.to(device=z_state.device, dtype=torch.long),
388
+ observation_attention_mask.long(),
389
+ ],
390
+ dim=1,
391
+ )
392
+ observation_encoder_outputs = self.encoder(
393
+ inputs_embeds=encoder_inputs.to(dtype=backbone_dtype),
394
+ attention_mask=encoder_attention_mask,
395
+ return_dict=True,
396
+ )
397
+ if self.latent_attention_mask_mode == "full":
398
+ proposed_state = observation_encoder_outputs.last_hidden_state[:, : self.z_slots, :].to(
399
+ dtype=recurrent_dtype
400
+ )
401
+ proposal_mask = torch.ones(batch_size, self.z_slots, dtype=torch.long, device=z_state.device)
402
+ observation_outputs = None
403
+ else:
404
+ observation_outputs = observation_encoder_outputs.last_hidden_state[:, self.z_slots :, :].to(
405
+ dtype=recurrent_dtype
406
+ )
407
+ else:
408
+ observation_encoder_outputs = self.encoder(
409
+ input_ids=observation_input_ids,
410
+ attention_mask=observation_attention_mask.long(),
411
+ return_dict=True,
412
+ )
413
+ observation_outputs = observation_encoder_outputs.last_hidden_state.to(dtype=recurrent_dtype)
414
+ if observation_outputs is not None:
415
+ proposed_state, proposal_mask = self.compress_hidden_states_to_slots(
416
+ observation_outputs,
417
+ observation_attention_mask,
418
+ target_slots=self.z_slots,
419
+ )
420
+ proposed_state = proposed_state.to(dtype=recurrent_dtype)
421
+ if self.latent_attention_mask_mode == "full":
422
+ proposed_state = proposed_state.clone()
423
+ inactive_slot_mask = ~proposal_mask.bool()
424
+ proposed_state[inactive_slot_mask] = z_state[inactive_slot_mask]
425
+ proposal_mask = torch.ones(batch_size, self.z_slots, dtype=torch.long, device=z_state.device)
426
+
427
+ no_observation_mask = proposal_mask.sum(dim=1) <= 0
428
+ if torch.any(no_observation_mask):
429
+ proposed_state = proposed_state.clone()
430
+ proposal_mask = proposal_mask.clone()
431
+ proposed_state[no_observation_mask] = z_state[no_observation_mask]
432
+ if previous_state_mask is None:
433
+ proposal_mask[no_observation_mask] = 1
434
+ else:
435
+ proposal_mask[no_observation_mask] = previous_state_mask[no_observation_mask].to(
436
+ device=z_state.device,
437
+ dtype=torch.long,
438
+ )
439
+
440
+ if not self.preserve_observation_encoder_manifold:
441
+ proposed_state = self.proposed_state_norm(proposed_state)
442
+
443
+ gate_inputs = self.state_gate_input_norm(torch.cat([z_state, proposed_state], dim=-1))
444
+ update_gate = torch.sigmoid(self.state_gate(gate_inputs))
445
+ raw_next_state = update_gate * proposed_state + (1.0 - update_gate) * z_state
446
+ active_slot_mask = proposal_mask.bool().unsqueeze(-1)
447
+ next_state = torch.where(active_slot_mask, raw_next_state, z_state)
448
+ if not self.preserve_observation_encoder_manifold:
449
+ next_state = self.recurrent_state_norm(next_state)
450
+ if previous_state_mask is not None:
451
+ next_state_mask = torch.maximum(
452
+ previous_state_mask.to(device=z_state.device, dtype=torch.long),
453
+ proposal_mask,
454
+ )
455
+ else:
456
+ next_state_mask = proposal_mask
457
+
458
+ pooled_query = self.gate_query.to(dtype=next_state.dtype).expand(batch_size, -1, -1)
459
+ gate_context = self.gate_context_norm(next_state)
460
+ pooled_state, _ = self.gate_pool(pooled_query, gate_context, gate_context, need_weights=False)
461
+ gate_logits = self.gate_head(pooled_state.squeeze(1)).squeeze(-1)
462
+ return next_state, gate_logits, next_state_mask
463
+
464
+ def rollout_step(
465
+ self,
466
+ z_state: torch.Tensor,
467
+ observation_input_ids: torch.Tensor,
468
+ observation_attention_mask: torch.Tensor,
469
+ observation_role_ids: torch.Tensor,
470
+ delta_seconds: torch.Tensor,
471
+ elapsed_seconds: torch.Tensor,
472
+ since_last_user_seconds: torch.Tensor,
473
+ since_last_agent_seconds: torch.Tensor,
474
+ ) -> tuple[torch.Tensor, torch.Tensor]:
475
+ next_state, gate_logits, _ = self._rollout_step_impl(
476
+ z_state=z_state,
477
+ observation_input_ids=observation_input_ids,
478
+ observation_attention_mask=observation_attention_mask,
479
+ observation_role_ids=observation_role_ids,
480
+ delta_seconds=delta_seconds,
481
+ elapsed_seconds=elapsed_seconds,
482
+ since_last_user_seconds=since_last_user_seconds,
483
+ since_last_agent_seconds=since_last_agent_seconds,
484
+ previous_state_mask=None,
485
+ )
486
+ return next_state, gate_logits
487
+
488
+ def rollout_step_with_mask(
489
+ self,
490
+ z_state: torch.Tensor,
491
+ state_mask: torch.Tensor,
492
+ observation_input_ids: torch.Tensor,
493
+ observation_attention_mask: torch.Tensor,
494
+ observation_role_ids: torch.Tensor,
495
+ delta_seconds: torch.Tensor,
496
+ elapsed_seconds: torch.Tensor,
497
+ since_last_user_seconds: torch.Tensor,
498
+ since_last_agent_seconds: torch.Tensor,
499
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
500
+ return self._rollout_step_impl(
501
+ z_state=z_state,
502
+ observation_input_ids=observation_input_ids,
503
+ observation_attention_mask=observation_attention_mask,
504
+ observation_role_ids=observation_role_ids,
505
+ delta_seconds=delta_seconds,
506
+ elapsed_seconds=elapsed_seconds,
507
+ since_last_user_seconds=since_last_user_seconds,
508
+ since_last_agent_seconds=since_last_agent_seconds,
509
+ previous_state_mask=state_mask,
510
+ )
511
+
512
+ def _resolve_generation_eos_token_id(self) -> int | list[int] | None:
513
+ eos_token_id = getattr(self.backbone.generation_config, "eos_token_id", None)
514
+ if eos_token_id is None:
515
+ eos_token_id = getattr(self.backbone.config, "eos_token_id", None)
516
+ return eos_token_id
517
+
518
+ def _resolve_fill_token_id(self) -> int:
519
+ eos_token_id = self._resolve_generation_eos_token_id()
520
+ if isinstance(eos_token_id, list) and eos_token_id:
521
+ return int(eos_token_id[0])
522
+ if isinstance(eos_token_id, int):
523
+ return eos_token_id
524
+ if self.tokenizer.eos_token_id is not None:
525
+ return int(self.tokenizer.eos_token_id)
526
+ if self.tokenizer.pad_token_id is not None:
527
+ return int(self.tokenizer.pad_token_id)
528
+ return 0
529
+
530
+ def _teacher_forced_decoder_inputs(self, labels: torch.Tensor) -> torch.Tensor:
531
+ if labels.ndim != 2 or labels.size(0) <= 0:
532
+ raise ValueError("labels must be a rank-2 tensor with non-zero batch size.")
533
+ safe_labels = labels.clone()
534
+ safe_labels[safe_labels == -100] = self._resolve_fill_token_id()
535
+ if hasattr(self.backbone, "prepare_decoder_input_ids_from_labels"):
536
+ return self.backbone.prepare_decoder_input_ids_from_labels(labels=safe_labels)
537
+ return self.backbone._shift_right(safe_labels)
538
+
539
+ def _build_self_generated_decoder_inputs(
540
+ self,
541
+ labels: torch.Tensor,
542
+ generated: torch.Tensor,
543
+ *,
544
+ self_generated_prefix_tokens: int,
545
+ ) -> torch.Tensor:
546
+ batch_size, target_length = labels.shape
547
+ teacher_inputs = self._teacher_forced_decoder_inputs(labels).to(device=labels.device)
548
+ if target_length <= 0:
549
+ return teacher_inputs
550
+
551
+ prefix_token_count = min(max(self_generated_prefix_tokens, 0), max(target_length - 1, 0))
552
+ if prefix_token_count <= 0:
553
+ return teacher_inputs
554
+
555
+ generated_tokens = generated.to(device=labels.device, dtype=torch.long)
556
+ if generated_tokens.ndim != 2:
557
+ raise ValueError("generated tokens must be rank-2.")
558
+ if generated_tokens.size(0) != batch_size:
559
+ raise ValueError("generated batch size must match labels batch size.")
560
+ if generated_tokens.size(1) > 0 and torch.equal(generated_tokens[:, :1], teacher_inputs[:, :1]):
561
+ generated_tokens = generated_tokens[:, 1:]
562
+
563
+ fill_token_id = self._resolve_fill_token_id()
564
+ if generated_tokens.size(1) < prefix_token_count:
565
+ padding = labels.new_full(
566
+ (batch_size, prefix_token_count - generated_tokens.size(1)),
567
+ fill_value=fill_token_id,
568
+ )
569
+ generated_tokens = torch.cat([generated_tokens, padding], dim=1)
570
+ else:
571
+ generated_tokens = generated_tokens[:, :prefix_token_count]
572
+
573
+ hybrid_inputs = teacher_inputs.clone()
574
+ hybrid_inputs[:, 1 : 1 + prefix_token_count] = generated_tokens
575
+ return hybrid_inputs
576
+
577
+ def decoder_loss(
578
+ self,
579
+ z_state: torch.Tensor,
580
+ labels: torch.Tensor,
581
+ *,
582
+ encoder_attention_mask: torch.Tensor | None = None,
583
+ ) -> tuple[torch.Tensor, torch.Tensor]:
584
+ if z_state.numel() == 0:
585
+ zero = torch.zeros((), device=self.device)
586
+ return zero, zero
587
+ encoder_mask = (
588
+ encoder_attention_mask.to(device=z_state.device, dtype=torch.long)
589
+ if encoder_attention_mask is not None
590
+ else torch.ones(z_state.shape[:2], dtype=torch.long, device=z_state.device)
591
+ )
592
+ token_count = (labels != -100).sum()
593
+ if token_count.item() == 0:
594
+ zero = torch.zeros((), device=z_state.device)
595
+ return zero, zero
596
+ outputs = self.backbone(
597
+ encoder_outputs=BaseModelOutput(last_hidden_state=z_state.to(dtype=self.backbone_dtype)),
598
+ attention_mask=encoder_mask,
599
+ labels=labels,
600
+ return_dict=True,
601
+ )
602
+ loss_sum = outputs.loss * token_count
603
+ return loss_sum, token_count
604
+
605
+ def decoder_self_generated_loss(
606
+ self,
607
+ z_state: torch.Tensor,
608
+ labels: torch.Tensor,
609
+ *,
610
+ generation_kwargs: dict[str, Any] | None = None,
611
+ self_generated_prefix_tokens: int | None = None,
612
+ encoder_attention_mask: torch.Tensor | None = None,
613
+ ) -> tuple[torch.Tensor, torch.Tensor]:
614
+ if z_state.numel() == 0:
615
+ zero = torch.zeros((), device=self.device)
616
+ return zero, zero
617
+
618
+ encoder_mask = (
619
+ encoder_attention_mask.to(device=z_state.device, dtype=torch.long)
620
+ if encoder_attention_mask is not None
621
+ else torch.ones(z_state.shape[:2], dtype=torch.long, device=z_state.device)
622
+ )
623
+ token_count = (labels != -100).sum()
624
+ if token_count.item() == 0:
625
+ zero = torch.zeros((), device=z_state.device)
626
+ return zero, zero
627
+
628
+ prefix_token_count = max(0, int(self_generated_prefix_tokens if self_generated_prefix_tokens is not None else labels.shape[1]))
629
+ if prefix_token_count <= 0:
630
+ return self.decoder_loss(z_state, labels, encoder_attention_mask=encoder_attention_mask)
631
+
632
+ effective_generation_kwargs = {
633
+ "max_new_tokens": prefix_token_count,
634
+ "do_sample": False,
635
+ "num_beams": 1,
636
+ "return_dict_in_generate": False,
637
+ }
638
+ if generation_kwargs:
639
+ effective_generation_kwargs.update(generation_kwargs)
640
+
641
+ was_training = self.backbone.training
642
+ if was_training:
643
+ self.backbone.eval()
644
+ try:
645
+ with torch.no_grad():
646
+ generated = self.backbone.generate(
647
+ encoder_outputs=BaseModelOutput(last_hidden_state=z_state.to(dtype=self.backbone_dtype)),
648
+ attention_mask=encoder_mask,
649
+ **effective_generation_kwargs,
650
+ )
651
+ finally:
652
+ if was_training:
653
+ self.backbone.train()
654
+
655
+ decoder_input_ids = self._build_self_generated_decoder_inputs(
656
+ labels,
657
+ generated,
658
+ self_generated_prefix_tokens=prefix_token_count,
659
+ )
660
+ outputs = self.backbone(
661
+ encoder_outputs=BaseModelOutput(last_hidden_state=z_state.to(dtype=self.backbone_dtype)),
662
+ attention_mask=encoder_mask,
663
+ decoder_input_ids=decoder_input_ids,
664
+ return_dict=True,
665
+ )
666
+ logits = outputs.logits.float()
667
+ loss_sum = F.cross_entropy(
668
+ logits.reshape(-1, logits.size(-1)),
669
+ labels.reshape(-1),
670
+ ignore_index=-100,
671
+ reduction="sum",
672
+ )
673
+ return loss_sum, token_count
674
+
675
+ @torch.no_grad()
676
+ def first_token_exact_match_stats(
677
+ self,
678
+ z_state: torch.Tensor,
679
+ labels: torch.Tensor,
680
+ *,
681
+ encoder_attention_mask: torch.Tensor | None = None,
682
+ ) -> tuple[torch.Tensor, torch.Tensor]:
683
+ if z_state.numel() == 0:
684
+ zero = torch.zeros((), device=self.device)
685
+ return zero, zero
686
+
687
+ first_logits, valid_mask = self.first_token_logits(
688
+ z_state,
689
+ labels,
690
+ encoder_attention_mask=encoder_attention_mask,
691
+ )
692
+ valid_count = valid_mask.sum()
693
+ if valid_count.item() == 0:
694
+ zero = torch.zeros((), device=self.device)
695
+ return zero, zero
696
+
697
+ first_targets = labels[:, 0]
698
+ predicted_first_tokens = first_logits.argmax(dim=-1)
699
+ correct = ((predicted_first_tokens == first_targets) & valid_mask).sum()
700
+ return correct, valid_count
701
+
702
+ def first_token_logits(
703
+ self,
704
+ z_state: torch.Tensor,
705
+ labels: torch.Tensor,
706
+ *,
707
+ encoder_attention_mask: torch.Tensor | None = None,
708
+ ) -> tuple[torch.Tensor, torch.Tensor]:
709
+ if z_state.numel() == 0:
710
+ empty_logits = torch.zeros((0, 0), device=self.device)
711
+ empty_mask = torch.zeros((0,), dtype=torch.bool, device=self.device)
712
+ return empty_logits, empty_mask
713
+
714
+ first_targets = labels[:, 0]
715
+ valid_mask = first_targets != -100
716
+ encoder_mask = (
717
+ encoder_attention_mask.to(device=z_state.device, dtype=torch.long)
718
+ if encoder_attention_mask is not None
719
+ else torch.ones(z_state.shape[:2], dtype=torch.long, device=z_state.device)
720
+ )
721
+ decoder_input_ids = self._teacher_forced_decoder_inputs(labels)[:, :1].to(device=z_state.device)
722
+ outputs = self.backbone(
723
+ encoder_outputs=BaseModelOutput(last_hidden_state=z_state.to(dtype=self.backbone_dtype)),
724
+ attention_mask=encoder_mask,
725
+ decoder_input_ids=decoder_input_ids,
726
+ return_dict=True,
727
+ )
728
+ return outputs.logits[:, 0, :], valid_mask
729
+
730
+ @torch.no_grad()
731
+ def generate_from_state(
732
+ self,
733
+ z_state: torch.Tensor,
734
+ *,
735
+ encoder_attention_mask: torch.Tensor | None = None,
736
+ **generation_kwargs: Any,
737
+ ) -> torch.Tensor:
738
+ encoder_mask = (
739
+ encoder_attention_mask.to(device=z_state.device, dtype=torch.long)
740
+ if encoder_attention_mask is not None
741
+ else torch.ones(z_state.shape[:2], dtype=torch.long, device=z_state.device)
742
+ )
743
+ return self.backbone.generate(
744
+ encoder_outputs=BaseModelOutput(last_hidden_state=z_state.to(dtype=self.backbone_dtype)),
745
+ attention_mask=encoder_mask,
746
+ **generation_kwargs,
747
+ )
748
+
749
+ def save_pretrained(self, output_dir: str | Path, tokenizer: Any | None = None) -> None:
750
+ output_path = Path(output_dir)
751
+ output_path.mkdir(parents=True, exist_ok=True)
752
+ (output_path / "sft_config.json").write_text(
753
+ json.dumps(self.config, indent=2, ensure_ascii=False),
754
+ encoding="utf-8",
755
+ )
756
+ # The T5Gemma backbone exposes tied/shared weights, which raw save_file refuses.
757
+ save_model(self, str(output_path / "model.safetensors"))
758
+ save_file({"z_init": self.z_init.detach().cpu()}, str(output_path / "initial_latent_z.safetensors"))
759
+ active_tokenizer = tokenizer or self.tokenizer
760
+ active_tokenizer.save_pretrained(output_path)
761
+
762
+ @classmethod
763
+ def from_pretrained(
764
+ cls,
765
+ model_path_or_repo_id: str,
766
+ device: str | torch.device = "cpu",
767
+ map_location: str | torch.device = "cpu",
768
+ ) -> "ThoughtLoopT5Gemma":
769
+ local_path = Path(model_path_or_repo_id)
770
+ if not local_path.exists():
771
+ local_path = Path(snapshot_download(repo_id=model_path_or_repo_id))
772
+
773
+ config = json.loads((local_path / "sft_config.json").read_text(encoding="utf-8"))
774
+ model = cls(config)
775
+ load_model(model, str(local_path / "model.safetensors"), device=str(map_location))
776
+ model.to(device)
777
+ return model
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0f7b8bf028336d8f0695dc3fd48cbe296efc8501953bca95d3a8727f3a3e86a1
3
+ size 2518360980
sft_b200.yaml ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ dataset:
2
+ raw_repo_id: BRlkl/timed_natural_conversations_5min4
3
+ cleaned_repo_id: BRlkl/timed_natural_conversations_5min4
4
+ cleaned_local_parquet_dir: artifacts/timed_natural_conversations_5min4/parquet
5
+ source_split: train
6
+ validation_ratio: 0.02
7
+ split_seed: 17
8
+ use_base_chat_template: true
9
+
10
+ model:
11
+ base_model_name: google/t5gemma-l-l-prefixlm-it
12
+ initial_model_path: BRlkl/test_multiturn2
13
+ dtype: bfloat16
14
+ attn_implementation: sdpa
15
+ magicnorm_eps: 1.0e-6
16
+ z_slots: 256
17
+ num_time_tokens: 0
18
+ use_explicit_time_features: false
19
+ gate_attention_heads: 4
20
+ max_observation_tokens: 256
21
+ max_decoder_tokens: 256
22
+ thought_loop_proposal_mode: observation_hidden_compression
23
+ preserve_observation_encoder_manifold: true
24
+ observation_encoder_use_state_context: true
25
+ latent_attention_mask_mode: full
26
+ initial_update_gate_bias: 6.0
27
+
28
+ rollout:
29
+ tick_seconds: 1.0
30
+ chunk_ticks: 300
31
+ max_horizon_ticks: 300
32
+ sort_by_duration_bucket: false
33
+ drop_overlong_examples: true
34
+ long_window_strategy: tail
35
+ post_speech_feedback_delay_seconds: 1.0
36
+
37
+ training:
38
+ seed: 17
39
+ micro_batch_size: 12
40
+ eval_batch_size: 1
41
+ gradient_accumulation_steps: 3
42
+ num_workers: 0
43
+ num_train_epochs: 1
44
+ learning_rate: 5.0e-6
45
+ weight_decay: 0.05
46
+ adam_beta1: 0.9
47
+ adam_beta2: 0.95
48
+ adam_epsilon: 1.0e-8
49
+ warmup_ratio: 0.03
50
+ gradient_checkpointing: true
51
+ fused_adamw: true
52
+ mixed_precision: bf16
53
+ max_grad_norm: 0.1
54
+ tbptt_start_steps: 300
55
+ tbptt_end_steps: 300
56
+ gate_loss_mode: bce
57
+ gate_soft_target_early_window_ticks: 5
58
+ gate_soft_target_late_window_ticks: 5
59
+ gate_soft_target_tau_ticks: 1.5
60
+ gate_soft_target_shape: gaussian
61
+ gate_soft_target_peak: 1.0
62
+ gate_soft_target_floor: 0.0
63
+ gate_soft_target_block_user_ticks: true
64
+ hazard_soft_target_window_ticks: 16
65
+ hazard_soft_target_tau_ticks: 4.0
66
+ hazard_censor_weight: 0.25
67
+ use_ranked_gate_objective: false
68
+ gate_bce_weight: 1.0
69
+ gate_ranking_weight: 1.0
70
+ gate_ranking_margin: 0.5
71
+ gate_ranking_lookback: 64
72
+ gradual_unfreeze_decoder: false
73
+ decoder_initial_trainable_fraction: 0.0
74
+ decoder_unfreeze_start_fraction: 0.0
75
+ decoder_unfreeze_end_fraction: 0.5
76
+ freeze_gate_head_first_half: false
77
+ gate_head_freeze_fraction: 0.5
78
+ gate_teacher_logit_scale: 20.0
79
+ gate_positive_weight: 16.0
80
+ use_dynamic_gate_positive_weight: false
81
+ dynamic_gate_positive_weight_min: 8.0
82
+ dynamic_gate_positive_weight_max: 16.0
83
+ gate_loss_weight: 0.75
84
+ decoder_loss_weight: 1.0
85
+ state_delta_weight: 0.1
86
+ shuffle_train: true
87
+ sort_dataset_by_length: false
88
+ logging_steps: 1
89
+ eval_steps: 51
90
+ checkpoint_steps: 50
91
+ eval_max_batches: 12
92
+
93
+ cache:
94
+ preprocessed_root: cache/preprocessed_5min
95
+
96
+ paths:
97
+ run_root: runs
98
+ export_root: exports
99
+
100
+ inference:
101
+ use_base_chat_template: true
102
+
103
+ wandb:
104
+ enabled: true
105
+ project: samantha-sft-5min
106
+ run_name: t5gemma2-thoughtloop-5min-from-multiturn3
107
+
108
+ hub:
109
+ model_repo_id: BRlkl/test-true2
110
+ private: false
111
+ push_checkpoints: false
112
+ checkpoint_path_prefix: checkpoints
sft_config.json ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dataset": {
3
+ "raw_repo_id": "BRlkl/timed_natural_conversations_5min4",
4
+ "cleaned_repo_id": "BRlkl/timed_natural_conversations_5min4",
5
+ "cleaned_local_parquet_dir": "artifacts/timed_natural_conversations_5min4/parquet",
6
+ "source_split": "train",
7
+ "validation_ratio": 0.02,
8
+ "split_seed": 17,
9
+ "use_base_chat_template": true
10
+ },
11
+ "model": {
12
+ "base_model_name": "google/t5gemma-l-l-prefixlm-it",
13
+ "initial_model_path": "BRlkl/test_multiturn2",
14
+ "dtype": "bfloat16",
15
+ "attn_implementation": "sdpa",
16
+ "magicnorm_eps": 1e-06,
17
+ "z_slots": 256,
18
+ "num_time_tokens": 0,
19
+ "use_explicit_time_features": false,
20
+ "gate_attention_heads": 4,
21
+ "max_observation_tokens": 256,
22
+ "max_decoder_tokens": 256,
23
+ "thought_loop_proposal_mode": "observation_hidden_compression",
24
+ "preserve_observation_encoder_manifold": true,
25
+ "observation_encoder_use_state_context": true,
26
+ "latent_attention_mask_mode": "full",
27
+ "initial_update_gate_bias": 6.0
28
+ },
29
+ "rollout": {
30
+ "tick_seconds": 1.0,
31
+ "chunk_ticks": 300,
32
+ "max_horizon_ticks": 300,
33
+ "sort_by_duration_bucket": false,
34
+ "drop_overlong_examples": true,
35
+ "long_window_strategy": "tail",
36
+ "post_speech_feedback_delay_seconds": 1.0
37
+ },
38
+ "training": {
39
+ "seed": 17,
40
+ "micro_batch_size": 12,
41
+ "eval_batch_size": 1,
42
+ "gradient_accumulation_steps": 3,
43
+ "num_workers": 0,
44
+ "num_train_epochs": 1,
45
+ "learning_rate": 5e-06,
46
+ "weight_decay": 0.05,
47
+ "adam_beta1": 0.9,
48
+ "adam_beta2": 0.95,
49
+ "adam_epsilon": 1e-08,
50
+ "warmup_ratio": 0.03,
51
+ "gradient_checkpointing": true,
52
+ "fused_adamw": true,
53
+ "mixed_precision": "bf16",
54
+ "max_grad_norm": 0.1,
55
+ "tbptt_start_steps": 300,
56
+ "tbptt_end_steps": 300,
57
+ "gate_loss_mode": "bce",
58
+ "gate_soft_target_early_window_ticks": 5,
59
+ "gate_soft_target_late_window_ticks": 5,
60
+ "gate_soft_target_tau_ticks": 1.5,
61
+ "gate_soft_target_shape": "gaussian",
62
+ "gate_soft_target_peak": 1.0,
63
+ "gate_soft_target_floor": 0.0,
64
+ "gate_soft_target_block_user_ticks": true,
65
+ "hazard_soft_target_window_ticks": 16,
66
+ "hazard_soft_target_tau_ticks": 4.0,
67
+ "hazard_censor_weight": 0.25,
68
+ "use_ranked_gate_objective": false,
69
+ "gate_bce_weight": 1.0,
70
+ "gate_ranking_weight": 1.0,
71
+ "gate_ranking_margin": 0.5,
72
+ "gate_ranking_lookback": 64,
73
+ "gradual_unfreeze_decoder": false,
74
+ "decoder_initial_trainable_fraction": 0.0,
75
+ "decoder_unfreeze_start_fraction": 0.0,
76
+ "decoder_unfreeze_end_fraction": 0.5,
77
+ "freeze_gate_head_first_half": false,
78
+ "gate_head_freeze_fraction": 0.5,
79
+ "gate_teacher_logit_scale": 20.0,
80
+ "gate_positive_weight": 16.0,
81
+ "use_dynamic_gate_positive_weight": false,
82
+ "dynamic_gate_positive_weight_min": 8.0,
83
+ "dynamic_gate_positive_weight_max": 16.0,
84
+ "gate_loss_weight": 0.75,
85
+ "decoder_loss_weight": 1.0,
86
+ "state_delta_weight": 0.1,
87
+ "shuffle_train": true,
88
+ "sort_dataset_by_length": false,
89
+ "logging_steps": 1,
90
+ "eval_steps": 51,
91
+ "checkpoint_steps": 50,
92
+ "eval_max_batches": 12
93
+ },
94
+ "cache": {
95
+ "preprocessed_root": "cache/preprocessed_5min"
96
+ },
97
+ "paths": {
98
+ "run_root": "runs",
99
+ "export_root": "exports"
100
+ },
101
+ "inference": {
102
+ "use_base_chat_template": true
103
+ },
104
+ "wandb": {
105
+ "enabled": true,
106
+ "project": "samantha-sft-5min",
107
+ "run_name": "t5gemma2-thoughtloop-5min-from-multiturn3"
108
+ },
109
+ "hub": {
110
+ "model_repo_id": "BRlkl/test-true2",
111
+ "private": false,
112
+ "push_checkpoints": false,
113
+ "checkpoint_path_prefix": "checkpoints"
114
+ }
115
+ }
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:923de64f4705efd22f6880dc5e6a9b2993de2706a03d0d48773e0173e32a074f
3
+ size 34362428
tokenizer_config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "bos_token": "<bos>",
4
+ "clean_up_tokenization_spaces": false,
5
+ "eos_token": "<eos>",
6
+ "is_local": false,
7
+ "local_files_only": false,
8
+ "mask_token": "<mask>",
9
+ "model_max_length": 1000000000000000019884624838656,
10
+ "pad_token": "<pad>",
11
+ "padding_side": "right",
12
+ "sp_model_kwargs": {},
13
+ "spaces_between_special_tokens": false,
14
+ "tokenizer_class": "GemmaTokenizer",
15
+ "unk_token": "<unk>",
16
+ "use_default_system_prompt": false
17
+ }
train.py ADDED
@@ -0,0 +1,1402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import os
7
+ import random
8
+ import secrets
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn.functional as F
17
+ from accelerate import Accelerator
18
+ from huggingface_hub import HfApi
19
+ from torch.utils.data import DataLoader
20
+ from tqdm.auto import tqdm
21
+ from transformers import get_cosine_schedule_with_warmup
22
+
23
+ SCRIPT_DIR = Path(__file__).resolve().parent
24
+ if str(SCRIPT_DIR) not in sys.path:
25
+ sys.path.insert(0, str(SCRIPT_DIR))
26
+
27
+ from config import ensure_dir, flatten_for_wandb, load_config, save_json
28
+ from data import (
29
+ OBS_ROLE_NONE,
30
+ OBS_ROLE_USER,
31
+ ThoughtLoopConversationDataset,
32
+ build_chunk_batch,
33
+ identity_collate,
34
+ resolve_bucket_horizon_ticks,
35
+ )
36
+ from model import ThoughtLoopT5Gemma
37
+
38
+ try:
39
+ import wandb
40
+ except ImportError: # pragma: no cover
41
+ wandb = None
42
+
43
+
44
+ def parse_args() -> argparse.Namespace:
45
+ parser = argparse.ArgumentParser(description="Train the recurrent T5Gemma thought-loop model.")
46
+ parser.add_argument("--config", default="configs/sft_b200.yaml")
47
+ parser.add_argument(
48
+ "--resume-from-checkpoint",
49
+ default=None,
50
+ help="Local checkpoint path to resume from, or 'latest'/'auto' to use the newest checkpoint in the run dir.",
51
+ )
52
+ parser.add_argument(
53
+ "--wandb-run-id",
54
+ default=None,
55
+ help="Optional W&B run id to resume. If omitted, train.py reuses a stored/local run id when available.",
56
+ )
57
+ return parser.parse_args()
58
+
59
+
60
+ def set_seed(seed: int) -> None:
61
+ random.seed(seed)
62
+ np.random.seed(seed)
63
+ torch.manual_seed(seed)
64
+ torch.cuda.manual_seed_all(seed)
65
+
66
+
67
+ def build_optimizer(model: torch.nn.Module, training_cfg: dict[str, Any]) -> torch.optim.Optimizer:
68
+ no_decay_terms = ("bias", "norm", "Norm")
69
+ decay_params = []
70
+ no_decay_params = []
71
+ for name, parameter in model.named_parameters():
72
+ target = no_decay_params if any(term in name for term in no_decay_terms) else decay_params
73
+ target.append(parameter)
74
+
75
+ fused = bool(training_cfg.get("fused_adamw", True) and torch.cuda.is_available())
76
+ return torch.optim.AdamW(
77
+ [
78
+ {"params": decay_params, "weight_decay": float(training_cfg["weight_decay"])},
79
+ {"params": no_decay_params, "weight_decay": 0.0},
80
+ ],
81
+ lr=float(training_cfg["learning_rate"]),
82
+ betas=(float(training_cfg["adam_beta1"]), float(training_cfg["adam_beta2"])),
83
+ eps=float(training_cfg["adam_epsilon"]),
84
+ fused=fused,
85
+ )
86
+
87
+
88
+ def resolve_bucket_tbptt_steps(training_cfg: dict[str, Any], duration_bucket: str) -> int | None:
89
+ bucket_map = training_cfg.get("tbptt_steps_by_bucket", {})
90
+ if duration_bucket in bucket_map:
91
+ return int(bucket_map[duration_bucket])
92
+
93
+ start_steps = int(training_cfg.get("tbptt_start_steps", 0))
94
+ end_steps = int(training_cfg.get("tbptt_end_steps", start_steps))
95
+ if start_steps <= 0 or end_steps <= 0:
96
+ return None
97
+ return end_steps
98
+
99
+
100
+ def resolve_linear_schedule_value(
101
+ *,
102
+ enabled: bool,
103
+ global_step: int,
104
+ total_update_steps: int,
105
+ start_fraction: float,
106
+ end_fraction: float,
107
+ start_value: float,
108
+ end_value: float,
109
+ ) -> float | None:
110
+ if not enabled:
111
+ return None
112
+
113
+ clamped_total_steps = max(total_update_steps, 1)
114
+ start_step = int(round(clamped_total_steps * start_fraction))
115
+ end_step = int(round(clamped_total_steps * end_fraction))
116
+ if end_step <= start_step:
117
+ end_step = start_step + 1
118
+
119
+ if global_step <= start_step:
120
+ return start_value
121
+ if global_step >= end_step:
122
+ return end_value
123
+
124
+ progress = (global_step - start_step) / max(end_step - start_step, 1)
125
+ return start_value + progress * (end_value - start_value)
126
+
127
+
128
+ def should_freeze_gate_head(
129
+ training_cfg: dict[str, Any],
130
+ global_step: int,
131
+ total_update_steps: int,
132
+ ) -> bool:
133
+ if not bool(training_cfg.get("freeze_gate_head_first_half", False)):
134
+ return False
135
+ freeze_fraction = float(training_cfg.get("gate_head_freeze_fraction", 0.5))
136
+ return global_step < int(round(max(total_update_steps, 1) * freeze_fraction))
137
+
138
+
139
+ def resolve_decoder_trainable_fraction(
140
+ training_cfg: dict[str, Any],
141
+ global_step: int,
142
+ total_update_steps: int,
143
+ ) -> float | None:
144
+ return resolve_linear_schedule_value(
145
+ enabled=bool(training_cfg.get("gradual_unfreeze_decoder", False)),
146
+ global_step=global_step,
147
+ total_update_steps=total_update_steps,
148
+ start_fraction=float(training_cfg.get("decoder_unfreeze_start_fraction", 0.0)),
149
+ end_fraction=float(training_cfg.get("decoder_unfreeze_end_fraction", 0.5)),
150
+ start_value=float(training_cfg.get("decoder_initial_trainable_fraction", 0.0)),
151
+ end_value=1.0,
152
+ )
153
+
154
+
155
+ def resolve_batch_duration_bucket(conversations: list[dict[str, Any]]) -> str:
156
+ if not conversations:
157
+ return "short"
158
+ buckets = {str(conversation["duration_bucket"]) for conversation in conversations}
159
+ if len(buckets) == 1:
160
+ return next(iter(buckets))
161
+ return "mixed"
162
+
163
+
164
+ def ensure_single_chunk_dataset(dataset: ThoughtLoopConversationDataset, split: str) -> None:
165
+ offending_examples = [
166
+ example for example in dataset.examples if int(example.get("chunk_count", 0)) != 1
167
+ ]
168
+ if offending_examples:
169
+ raise ValueError(
170
+ f"{split} dataset expected exactly one chunk per conversation, but found "
171
+ f"{len(offending_examples)} multi-chunk examples. Check chunk_ticks/max_horizon_ticks."
172
+ )
173
+
174
+
175
+ def maybe_sort_dataset_by_length(
176
+ dataset: ThoughtLoopConversationDataset,
177
+ *,
178
+ enabled: bool,
179
+ ) -> None:
180
+ if not enabled:
181
+ return
182
+ dataset.examples.sort(
183
+ key=lambda item: (
184
+ int(item.get("effective_total_ticks", item.get("total_ticks", 0))),
185
+ int(item.get("total_ticks", 0)),
186
+ str(item.get("row_id", "")),
187
+ )
188
+ )
189
+
190
+
191
+ def move_chunk_batch_to_device(batch: dict[str, torch.Tensor], device: torch.device) -> dict[str, torch.Tensor]:
192
+ return {key: value.to(device) for key, value in batch.items()}
193
+
194
+
195
+ def apply_runtime_model_config(model: ThoughtLoopT5Gemma, config: dict[str, Any]) -> None:
196
+ model.config = config
197
+ model_cfg = config["model"]
198
+ expected_z_slots = int(model_cfg["z_slots"])
199
+ if int(model.z_slots) != expected_z_slots:
200
+ raise ValueError(
201
+ f"Loaded model has z_slots={model.z_slots}, but config requested z_slots={expected_z_slots}."
202
+ )
203
+ model.thought_loop_proposal_mode = str(
204
+ model_cfg.get("thought_loop_proposal_mode", model.thought_loop_proposal_mode)
205
+ ).strip().lower()
206
+ model.preserve_observation_encoder_manifold = bool(
207
+ model_cfg.get(
208
+ "preserve_observation_encoder_manifold",
209
+ model.thought_loop_proposal_mode == "observation_hidden_compression",
210
+ )
211
+ )
212
+ model.observation_encoder_use_state_context = bool(
213
+ model_cfg.get("observation_encoder_use_state_context", False)
214
+ )
215
+ model.latent_attention_mask_mode = str(
216
+ model_cfg.get("latent_attention_mask_mode", getattr(model, "latent_attention_mask_mode", "observed"))
217
+ ).strip().lower()
218
+ if model.latent_attention_mask_mode not in {"observed", "full"}:
219
+ raise ValueError(f"Unsupported latent_attention_mask_mode: {model.latent_attention_mask_mode}")
220
+
221
+
222
+ def build_initial_model(config: dict[str, Any]) -> ThoughtLoopT5Gemma:
223
+ initial_model_path = config["model"].get("initial_model_path")
224
+ if initial_model_path:
225
+ model = ThoughtLoopT5Gemma.from_pretrained(str(initial_model_path), device="cpu", map_location="cpu")
226
+ apply_runtime_model_config(model, config)
227
+ return model
228
+ return ThoughtLoopT5Gemma(config)
229
+
230
+
231
+ def rollout_active_rows_only(
232
+ *,
233
+ model: ThoughtLoopT5Gemma,
234
+ batch: dict[str, torch.Tensor],
235
+ z_state: torch.Tensor,
236
+ state_mask: torch.Tensor,
237
+ step_index: int,
238
+ active_mask: torch.Tensor,
239
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
240
+ active_indices = torch.nonzero(active_mask, as_tuple=False).flatten()
241
+ active_next_z, active_gate_logits, active_next_mask = model.rollout_step_with_mask(
242
+ z_state=z_state[active_indices],
243
+ state_mask=state_mask[active_indices],
244
+ observation_input_ids=batch["observation_input_ids"][active_indices, step_index, :],
245
+ observation_attention_mask=batch["observation_attention_mask"][active_indices, step_index, :],
246
+ observation_role_ids=batch["observation_role"][active_indices, step_index],
247
+ delta_seconds=batch["delta_seconds"][active_indices, step_index],
248
+ elapsed_seconds=batch["elapsed_seconds"][active_indices, step_index],
249
+ since_last_user_seconds=torch.zeros_like(batch["elapsed_seconds"][active_indices, step_index]),
250
+ since_last_agent_seconds=torch.zeros_like(batch["elapsed_seconds"][active_indices, step_index]),
251
+ )
252
+
253
+ next_z = z_state.clone()
254
+ next_z[active_indices] = active_next_z
255
+ next_state_mask = state_mask.clone()
256
+ next_state_mask[active_indices] = active_next_mask
257
+ gate_logits = torch.zeros(batch["tick_mask"].shape[0], device=z_state.device, dtype=active_gate_logits.dtype)
258
+ gate_logits[active_indices] = active_gate_logits
259
+ return next_z, next_state_mask, gate_logits
260
+
261
+
262
+ def compute_gate_positive_weight(batch: dict[str, torch.Tensor], training_cfg: dict[str, Any]) -> torch.Tensor:
263
+ if not bool(training_cfg.get("use_dynamic_gate_positive_weight", True)):
264
+ return torch.tensor(float(training_cfg["gate_positive_weight"]), device=batch["tick_mask"].device)
265
+
266
+ active_targets = batch["gate_target"][batch["tick_mask"]]
267
+ positive_count = active_targets.sum()
268
+ negative_count = active_targets.numel() - positive_count
269
+ if positive_count.item() <= 0.0:
270
+ fallback = float(training_cfg["gate_positive_weight"])
271
+ return torch.tensor(fallback, device=batch["tick_mask"].device)
272
+
273
+ dynamic_weight = negative_count / positive_count.clamp_min(1.0)
274
+ min_weight = float(training_cfg.get("dynamic_gate_positive_weight_min", 1.0))
275
+ max_weight = float(training_cfg.get("dynamic_gate_positive_weight_max", 256.0))
276
+ dynamic_weight = dynamic_weight.clamp(min=min_weight, max=max_weight)
277
+ return dynamic_weight.to(device=batch["tick_mask"].device)
278
+
279
+
280
+ def compute_distance_aware_hazard_gate_loss(
281
+ gate_logits: torch.Tensor,
282
+ gate_targets: torch.Tensor,
283
+ gate_loss_mask: torch.Tensor,
284
+ training_cfg: dict[str, Any],
285
+ ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
286
+ device = gate_logits.device
287
+ batch_size, max_steps = gate_logits.shape
288
+ support_window_ticks = int(training_cfg.get("hazard_soft_target_window_ticks", 16))
289
+ target_tau_ticks = float(training_cfg.get("hazard_soft_target_tau_ticks", 4.0))
290
+ censor_weight = float(training_cfg.get("hazard_censor_weight", 0.25))
291
+
292
+ log_hazard = F.logsigmoid(gate_logits)
293
+ log_survival = F.logsigmoid(-gate_logits)
294
+
295
+ gate_loss_sum = torch.zeros((), device=device)
296
+ gate_loss_count = torch.zeros((), device=device)
297
+ hazard_event_count = torch.zeros((), device=device)
298
+ hazard_censor_count = torch.zeros((), device=device)
299
+
300
+ for batch_index in range(batch_size):
301
+ active_len = int(gate_loss_mask[batch_index].sum().item())
302
+ if active_len <= 0:
303
+ continue
304
+
305
+ active_targets = gate_targets[batch_index, :active_len]
306
+ active_log_hazard = log_hazard[batch_index, :active_len]
307
+ active_log_survival = log_survival[batch_index, :active_len]
308
+ positive_indices = torch.nonzero(active_targets > 0.5, as_tuple=False).flatten().tolist()
309
+
310
+ segment_start = 0
311
+ for positive_index in positive_indices:
312
+ segment_log_hazard = active_log_hazard[segment_start : positive_index + 1]
313
+ segment_log_survival = active_log_survival[segment_start : positive_index + 1]
314
+ if segment_log_hazard.numel() <= 0:
315
+ segment_start = positive_index + 1
316
+ continue
317
+
318
+ prefix_log_survival = torch.cumsum(segment_log_survival, dim=0) - segment_log_survival
319
+ segment_log_event = prefix_log_survival + segment_log_hazard
320
+
321
+ if support_window_ticks > 0:
322
+ support_start = max(0, segment_log_event.numel() - support_window_ticks)
323
+ else:
324
+ support_start = 0
325
+
326
+ support_log_event = segment_log_event[support_start:]
327
+ support_indices = torch.arange(
328
+ support_start,
329
+ segment_log_event.numel(),
330
+ device=device,
331
+ dtype=torch.float32,
332
+ )
333
+ distances = (segment_log_event.numel() - 1) - support_indices
334
+
335
+ if target_tau_ticks > 0.0:
336
+ target_weights = torch.exp(-distances / target_tau_ticks)
337
+ else:
338
+ target_weights = torch.zeros_like(distances)
339
+ target_weights[-1] = 1.0
340
+ target_weights = target_weights / target_weights.sum().clamp_min(1e-8)
341
+
342
+ segment_loss = -(target_weights * support_log_event).sum()
343
+ gate_loss_sum = gate_loss_sum + segment_loss
344
+ gate_loss_count = gate_loss_count + 1.0
345
+ hazard_event_count = hazard_event_count + 1.0
346
+ segment_start = positive_index + 1
347
+
348
+ if segment_start < active_len:
349
+ tail_log_survival = active_log_survival[segment_start:active_len]
350
+ if tail_log_survival.numel() > 0:
351
+ censored_loss = -tail_log_survival.mean()
352
+ gate_loss_sum = gate_loss_sum + censor_weight * censored_loss
353
+ gate_loss_count = gate_loss_count + censor_weight
354
+ hazard_censor_count = hazard_censor_count + 1.0
355
+
356
+ gate_loss = gate_loss_sum / gate_loss_count.clamp_min(1.0)
357
+ metrics = {
358
+ "gate_loss_sum": gate_loss_sum.detach(),
359
+ "gate_count": gate_loss_count.detach(),
360
+ "hazard_event_count": hazard_event_count.detach(),
361
+ "hazard_censor_count": hazard_censor_count.detach(),
362
+ }
363
+ return gate_loss, metrics
364
+
365
+
366
+ def build_temporally_soft_gate_targets(
367
+ *,
368
+ gate_targets: torch.Tensor,
369
+ gate_loss_mask: torch.Tensor,
370
+ observation_roles: torch.Tensor,
371
+ training_cfg: dict[str, Any],
372
+ ) -> torch.Tensor:
373
+ early_window = max(0, int(training_cfg.get("gate_soft_target_early_window_ticks", 3)))
374
+ late_window = max(0, int(training_cfg.get("gate_soft_target_late_window_ticks", 3)))
375
+ tau_ticks = max(1e-6, float(training_cfg.get("gate_soft_target_tau_ticks", 1.5)))
376
+ peak = float(training_cfg.get("gate_soft_target_peak", 1.0))
377
+ floor = max(0.0, float(training_cfg.get("gate_soft_target_floor", 0.0)))
378
+ shape = str(training_cfg.get("gate_soft_target_shape", "gaussian")).strip().lower()
379
+ block_user_ticks = bool(training_cfg.get("gate_soft_target_block_user_ticks", True))
380
+
381
+ soft_targets = torch.zeros_like(gate_targets)
382
+ candidate_mask = gate_loss_mask > 0
383
+ if block_user_ticks:
384
+ candidate_mask = candidate_mask & (observation_roles != OBS_ROLE_USER)
385
+
386
+ batch_size, _ = gate_targets.shape
387
+ for batch_index in range(batch_size):
388
+ active_len = int(gate_loss_mask[batch_index].sum().item())
389
+ if active_len <= 0:
390
+ continue
391
+
392
+ positive_indices = torch.nonzero(
393
+ gate_targets[batch_index, :active_len] > 0.5,
394
+ as_tuple=False,
395
+ ).flatten().tolist()
396
+ for positive_index in positive_indices:
397
+ start_index = max(0, positive_index - early_window)
398
+ end_index = min(active_len, positive_index + late_window + 1)
399
+ if end_index <= start_index:
400
+ continue
401
+
402
+ indices = torch.arange(start_index, end_index, device=gate_targets.device)
403
+ distances = (indices - positive_index).abs().to(dtype=gate_targets.dtype)
404
+ if shape == "exponential":
405
+ values = torch.exp(-distances / tau_ticks)
406
+ elif shape == "linear":
407
+ side_windows = torch.where(
408
+ indices <= positive_index,
409
+ torch.full_like(indices, max(early_window, 1)),
410
+ torch.full_like(indices, max(late_window, 1)),
411
+ ).to(dtype=gate_targets.dtype)
412
+ values = 1.0 - distances / (side_windows + 1.0)
413
+ else:
414
+ values = torch.exp(-(distances.pow(2)) / (2.0 * tau_ticks * tau_ticks))
415
+
416
+ values = (values * peak).clamp(0.0, 1.0)
417
+ if floor > 0.0:
418
+ values = torch.where(values > 0.0, values.clamp_min(floor), values)
419
+
420
+ valid = candidate_mask[batch_index, indices]
421
+ if not torch.any(valid):
422
+ continue
423
+
424
+ valid_indices = indices[valid]
425
+ soft_targets[batch_index, valid_indices] = torch.maximum(
426
+ soft_targets[batch_index, valid_indices],
427
+ values[valid],
428
+ )
429
+
430
+ return torch.maximum(soft_targets, gate_targets).clamp(0.0, 1.0)
431
+
432
+
433
+ def compute_soft_bce_gate_loss(
434
+ *,
435
+ gate_logits: torch.Tensor,
436
+ hard_gate_targets: torch.Tensor,
437
+ gate_loss_mask: torch.Tensor,
438
+ observation_roles: torch.Tensor,
439
+ positive_weight: torch.Tensor,
440
+ training_cfg: dict[str, Any],
441
+ ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
442
+ soft_targets = build_temporally_soft_gate_targets(
443
+ gate_targets=hard_gate_targets,
444
+ gate_loss_mask=gate_loss_mask,
445
+ observation_roles=observation_roles,
446
+ training_cfg=training_cfg,
447
+ )
448
+ loss_raw = F.binary_cross_entropy_with_logits(gate_logits, soft_targets, reduction="none")
449
+ soft_weight = 1.0 + (positive_weight.to(device=gate_logits.device) - 1.0) * soft_targets
450
+ masked_loss = loss_raw * soft_weight * gate_loss_mask
451
+ gate_loss_sum = masked_loss.sum()
452
+ gate_count = gate_loss_mask.sum()
453
+ gate_loss = gate_loss_sum / gate_count.clamp_min(1.0)
454
+ metrics = {
455
+ "gate_loss_sum": gate_loss_sum.detach(),
456
+ "gate_count": gate_count.detach(),
457
+ "soft_target_mass": (soft_targets * gate_loss_mask).sum().detach(),
458
+ "soft_target_positive_count": ((soft_targets >= 0.5) & (gate_loss_mask > 0)).float().sum().detach(),
459
+ }
460
+ return gate_loss, metrics
461
+
462
+
463
+ def compute_gate_ranking_loss(
464
+ gate_logits: torch.Tensor,
465
+ gate_targets: torch.Tensor,
466
+ gate_loss_mask: torch.Tensor,
467
+ ranking_margin: float,
468
+ ranking_lookback: int,
469
+ ) -> tuple[torch.Tensor, torch.Tensor]:
470
+ device = gate_logits.device
471
+ ranking_loss_sum = torch.zeros((), device=device)
472
+ ranking_pair_count = torch.zeros((), device=device)
473
+
474
+ batch_size, max_steps = gate_logits.shape
475
+ for batch_index in range(batch_size):
476
+ for step_index in range(max_steps):
477
+ if float(gate_loss_mask[batch_index, step_index]) <= 0.0:
478
+ continue
479
+ if float(gate_targets[batch_index, step_index]) <= 0.5:
480
+ continue
481
+
482
+ positive_logit = gate_logits[batch_index, step_index]
483
+ search_start = max(0, step_index - ranking_lookback)
484
+ for previous_index in range(search_start, step_index):
485
+ if float(gate_loss_mask[batch_index, previous_index]) <= 0.0:
486
+ continue
487
+ if float(gate_targets[batch_index, previous_index]) >= 0.5:
488
+ continue
489
+
490
+ negative_logit = gate_logits[batch_index, previous_index]
491
+ ranking_loss_sum = ranking_loss_sum + F.relu(ranking_margin - positive_logit + negative_logit)
492
+ ranking_pair_count = ranking_pair_count + 1
493
+
494
+ return ranking_loss_sum, ranking_pair_count
495
+
496
+
497
+ def compute_chunk_losses(
498
+ model: ThoughtLoopT5Gemma,
499
+ batch: dict[str, torch.Tensor],
500
+ training_cfg: dict[str, Any],
501
+ z_state: torch.Tensor,
502
+ state_mask: torch.Tensor,
503
+ tbptt_steps: int | None = None,
504
+ freeze_gate_head_to_targets: bool = False,
505
+ ) -> tuple[torch.Tensor, dict[str, torch.Tensor], torch.Tensor, torch.Tensor]:
506
+ device = batch["tick_mask"].device
507
+ batch_size, max_steps = batch["tick_mask"].shape
508
+ gate_loss_mode = str(training_cfg.get("gate_loss_mode", "hazard")).strip().lower()
509
+ use_hazard_gate_loss = gate_loss_mode == "hazard"
510
+ use_soft_bce_gate_loss = gate_loss_mode == "soft_bce"
511
+ pos_weight = (
512
+ torch.zeros((), device=device)
513
+ if use_hazard_gate_loss
514
+ else compute_gate_positive_weight(batch, training_cfg)
515
+ )
516
+
517
+ gate_loss_sum = torch.zeros((), device=device)
518
+ gate_count = torch.zeros((), device=device)
519
+ decoder_loss_sum = torch.zeros((), device=device)
520
+ decoder_token_count = torch.zeros((), device=device)
521
+ state_penalty_sum = torch.zeros((), device=device)
522
+ state_penalty_count = torch.zeros((), device=device)
523
+
524
+ tp = torch.zeros((), device=device)
525
+ fp = torch.zeros((), device=device)
526
+ fn = torch.zeros((), device=device)
527
+ correct = torch.zeros((), device=device)
528
+ counted = torch.zeros((), device=device)
529
+ target_positive_count = torch.zeros((), device=device)
530
+ predicted_positive_count = torch.zeros((), device=device)
531
+
532
+ state_delta_weight = float(training_cfg["state_delta_weight"])
533
+ steps_since_detach = 0
534
+ gate_logits_history: list[torch.Tensor] = []
535
+ gate_target_history: list[torch.Tensor] = []
536
+ gate_loss_mask_history: list[torch.Tensor] = []
537
+ observation_role_history: list[torch.Tensor] = []
538
+ hazard_event_count = torch.zeros((), device=device)
539
+ hazard_censor_count = torch.zeros((), device=device)
540
+ soft_target_mass = torch.zeros((), device=device)
541
+ soft_target_positive_count = torch.zeros((), device=device)
542
+ teacher_logit_scale = float(training_cfg.get("gate_teacher_logit_scale", 20.0))
543
+
544
+ for step_index in range(max_steps):
545
+ active_mask = batch["tick_mask"][:, step_index]
546
+ if not torch.any(active_mask):
547
+ break
548
+
549
+ previous_z = z_state
550
+ next_z, next_state_mask, gate_logits = rollout_active_rows_only(
551
+ model=model,
552
+ batch=batch,
553
+ z_state=z_state,
554
+ state_mask=state_mask,
555
+ step_index=step_index,
556
+ active_mask=active_mask,
557
+ )
558
+ z_state = next_z
559
+ state_mask = next_state_mask
560
+
561
+ step_loss_mask = active_mask.float()
562
+ step_targets = batch["gate_target"][:, step_index]
563
+ if freeze_gate_head_to_targets:
564
+ gate_logits = torch.where(
565
+ step_targets > 0.5,
566
+ torch.full_like(step_targets, teacher_logit_scale),
567
+ torch.full_like(step_targets, -teacher_logit_scale),
568
+ )
569
+
570
+ gate_logits_history.append(gate_logits)
571
+ gate_target_history.append(step_targets)
572
+ gate_loss_mask_history.append(step_loss_mask)
573
+ observation_role_history.append(batch["observation_role"][:, step_index])
574
+
575
+ if torch.any(step_loss_mask > 0):
576
+ step_predictions = (torch.sigmoid(gate_logits) >= 0.5).float()
577
+ valid = step_loss_mask > 0
578
+ correct = correct + ((step_predictions == step_targets) & valid).float().sum()
579
+ counted = counted + valid.float().sum()
580
+ tp = tp + ((step_predictions == 1) & (step_targets == 1) & valid).float().sum()
581
+ fp = fp + ((step_predictions == 1) & (step_targets == 0) & valid).float().sum()
582
+ fn = fn + ((step_predictions == 0) & (step_targets == 1) & valid).float().sum()
583
+ target_positive_count = target_positive_count + ((step_targets == 1) & valid).float().sum()
584
+ predicted_positive_count = predicted_positive_count + ((step_predictions == 1) & valid).float().sum()
585
+
586
+ if (
587
+ torch.any(step_loss_mask > 0)
588
+ and not use_hazard_gate_loss
589
+ and not use_soft_bce_gate_loss
590
+ and not freeze_gate_head_to_targets
591
+ ):
592
+ gate_loss_raw = F.binary_cross_entropy_with_logits(gate_logits, step_targets, reduction="none")
593
+ gate_weight = torch.where(step_targets > 0.5, pos_weight, torch.ones_like(step_targets))
594
+ gate_loss_sum = gate_loss_sum + (gate_loss_raw * gate_weight * step_loss_mask).sum()
595
+ gate_count = gate_count + step_loss_mask.sum()
596
+
597
+ if state_delta_weight > 0.0:
598
+ state_delta = (next_z - previous_z).pow(2).mean(dim=(1, 2))
599
+ state_penalty_sum = state_penalty_sum + (state_delta * step_loss_mask).sum()
600
+ state_penalty_count = state_penalty_count + step_loss_mask.sum()
601
+ elif torch.any(step_loss_mask > 0) and state_delta_weight > 0.0:
602
+ state_delta = (next_z - previous_z).pow(2).mean(dim=(1, 2))
603
+ state_penalty_sum = state_penalty_sum + (state_delta * step_loss_mask).sum()
604
+ state_penalty_count = state_penalty_count + step_loss_mask.sum()
605
+
606
+ step_labels = batch["decoder_labels"][:, step_index, :]
607
+ decoder_rows = active_mask & torch.any(step_labels != -100, dim=-1)
608
+ if torch.any(decoder_rows):
609
+ step_loss_sum, step_token_count = model.decoder_loss(
610
+ z_state[decoder_rows],
611
+ step_labels[decoder_rows],
612
+ encoder_attention_mask=state_mask[decoder_rows],
613
+ )
614
+ decoder_loss_sum = decoder_loss_sum + step_loss_sum
615
+ decoder_token_count = decoder_token_count + step_token_count
616
+
617
+ if tbptt_steps is not None:
618
+ steps_since_detach += 1
619
+ if steps_since_detach >= tbptt_steps:
620
+ z_state = z_state.detach()
621
+ state_mask = state_mask.detach()
622
+ steps_since_detach = 0
623
+
624
+ if gate_logits_history:
625
+ stacked_gate_logits = torch.stack(gate_logits_history, dim=1)
626
+ stacked_gate_targets = torch.stack(gate_target_history, dim=1)
627
+ stacked_gate_loss_mask = torch.stack(gate_loss_mask_history, dim=1)
628
+ stacked_observation_roles = torch.stack(observation_role_history, dim=1)
629
+ else:
630
+ stacked_gate_logits = torch.zeros((batch_size, 0), device=device)
631
+ stacked_gate_targets = torch.zeros((batch_size, 0), device=device)
632
+ stacked_gate_loss_mask = torch.zeros((batch_size, 0), device=device)
633
+ stacked_observation_roles = torch.zeros((batch_size, 0), device=device, dtype=torch.long)
634
+
635
+ gate_ranking_loss = torch.zeros((), device=device)
636
+ gate_ranking_loss_sum = torch.zeros((), device=device)
637
+ gate_ranking_pair_count = torch.zeros((), device=device)
638
+ if freeze_gate_head_to_targets:
639
+ gate_loss = torch.zeros((), device=device)
640
+ gate_loss_sum = torch.zeros((), device=device)
641
+ gate_count = torch.zeros((), device=device)
642
+ hazard_event_count = torch.zeros((), device=device)
643
+ hazard_censor_count = torch.zeros((), device=device)
644
+ elif use_hazard_gate_loss and gate_logits_history:
645
+ gate_loss, hazard_metrics = compute_distance_aware_hazard_gate_loss(
646
+ gate_logits=stacked_gate_logits,
647
+ gate_targets=stacked_gate_targets,
648
+ gate_loss_mask=stacked_gate_loss_mask,
649
+ training_cfg=training_cfg,
650
+ )
651
+ gate_loss_sum = hazard_metrics["gate_loss_sum"]
652
+ gate_count = hazard_metrics["gate_count"]
653
+ hazard_event_count = hazard_metrics["hazard_event_count"]
654
+ hazard_censor_count = hazard_metrics["hazard_censor_count"]
655
+ elif use_soft_bce_gate_loss and gate_logits_history:
656
+ gate_loss, soft_bce_metrics = compute_soft_bce_gate_loss(
657
+ gate_logits=stacked_gate_logits,
658
+ hard_gate_targets=stacked_gate_targets,
659
+ gate_loss_mask=stacked_gate_loss_mask,
660
+ observation_roles=stacked_observation_roles,
661
+ positive_weight=pos_weight,
662
+ training_cfg=training_cfg,
663
+ )
664
+ gate_loss_sum = soft_bce_metrics["gate_loss_sum"]
665
+ gate_count = soft_bce_metrics["gate_count"]
666
+ soft_target_mass = soft_bce_metrics["soft_target_mass"]
667
+ soft_target_positive_count = soft_bce_metrics["soft_target_positive_count"]
668
+ else:
669
+ gate_loss = gate_loss_sum / gate_count.clamp_min(1.0)
670
+
671
+ if (
672
+ not use_hazard_gate_loss
673
+ and bool(training_cfg.get("use_ranked_gate_objective", False))
674
+ and gate_logits_history
675
+ and not freeze_gate_head_to_targets
676
+ ):
677
+ gate_ranking_loss_sum, gate_ranking_pair_count = compute_gate_ranking_loss(
678
+ gate_logits=stacked_gate_logits,
679
+ gate_targets=stacked_gate_targets,
680
+ gate_loss_mask=stacked_gate_loss_mask,
681
+ ranking_margin=float(training_cfg.get("gate_ranking_margin", 0.5)),
682
+ ranking_lookback=int(training_cfg.get("gate_ranking_lookback", 64)),
683
+ )
684
+ gate_ranking_loss = gate_ranking_loss_sum / gate_ranking_pair_count.clamp_min(1.0)
685
+ gate_loss = (
686
+ float(training_cfg.get("gate_bce_weight", 1.0)) * gate_loss
687
+ + float(training_cfg.get("gate_ranking_weight", 1.0)) * gate_ranking_loss
688
+ )
689
+
690
+ decoder_loss = decoder_loss_sum / decoder_token_count.clamp_min(1.0)
691
+ state_delta_loss = state_penalty_sum / state_penalty_count.clamp_min(1.0)
692
+ total_loss = (
693
+ float(training_cfg["gate_loss_weight"]) * gate_loss
694
+ + float(training_cfg["decoder_loss_weight"]) * decoder_loss
695
+ + state_delta_weight * state_delta_loss
696
+ )
697
+
698
+ metrics = {
699
+ "loss_total": total_loss.detach(),
700
+ "gate_loss_sum": gate_loss_sum.detach(),
701
+ "gate_count": gate_count.detach(),
702
+ "decoder_loss_sum": decoder_loss_sum.detach(),
703
+ "decoder_token_count": decoder_token_count.detach(),
704
+ "state_penalty_sum": state_penalty_sum.detach(),
705
+ "state_penalty_count": state_penalty_count.detach(),
706
+ "gate_tp": tp.detach(),
707
+ "gate_fp": fp.detach(),
708
+ "gate_fn": fn.detach(),
709
+ "gate_correct": correct.detach(),
710
+ "gate_eval_count": counted.detach(),
711
+ "gate_target_positive_count": target_positive_count.detach(),
712
+ "gate_pred_positive_count": predicted_positive_count.detach(),
713
+ "gate_ranking_loss_sum": gate_ranking_loss_sum.detach(),
714
+ "gate_ranking_pair_count": gate_ranking_pair_count.detach(),
715
+ "hazard_event_count": hazard_event_count.detach(),
716
+ "hazard_censor_count": hazard_censor_count.detach(),
717
+ "soft_target_mass": soft_target_mass.detach(),
718
+ "soft_target_positive_count": soft_target_positive_count.detach(),
719
+ "active_ticks": batch["tick_mask"].float().sum().detach(),
720
+ "gate_positive_weight": pos_weight.detach(),
721
+ "metrics_count": torch.ones((), device=device),
722
+ }
723
+ return total_loss, metrics, z_state, state_mask
724
+
725
+
726
+ def reduce_metrics(
727
+ accelerator: Accelerator,
728
+ metric_list: list[dict[str, torch.Tensor]],
729
+ training_cfg: dict[str, Any],
730
+ ) -> dict[str, float]:
731
+ totals: dict[str, torch.Tensor] = {}
732
+ for metrics in metric_list:
733
+ for key, value in metrics.items():
734
+ totals[key] = totals.get(key, torch.zeros_like(value)) + value
735
+
736
+ reduced = {key: accelerator.reduce(value, reduction="sum") for key, value in totals.items()}
737
+
738
+ gate_loss_mode = str(training_cfg.get("gate_loss_mode", "hazard")).strip().lower()
739
+ gate_loss = reduced["gate_loss_sum"] / reduced["gate_count"].clamp_min(1.0)
740
+ gate_ranking_loss = reduced["gate_ranking_loss_sum"] / reduced["gate_ranking_pair_count"].clamp_min(1.0)
741
+ decoder_loss = reduced["decoder_loss_sum"] / reduced["decoder_token_count"].clamp_min(1.0)
742
+ state_delta_loss = reduced["state_penalty_sum"] / reduced["state_penalty_count"].clamp_min(1.0)
743
+ gate_precision = reduced["gate_tp"] / (reduced["gate_tp"] + reduced["gate_fp"]).clamp_min(1.0)
744
+ gate_recall = reduced["gate_tp"] / (reduced["gate_tp"] + reduced["gate_fn"]).clamp_min(1.0)
745
+ gate_f1 = 2 * gate_precision * gate_recall / (gate_precision + gate_recall).clamp_min(1e-8)
746
+ if gate_loss_mode != "hazard" and bool(training_cfg.get("use_ranked_gate_objective", False)):
747
+ gate_loss = (
748
+ float(training_cfg.get("gate_bce_weight", 1.0)) * gate_loss
749
+ + float(training_cfg.get("gate_ranking_weight", 1.0)) * gate_ranking_loss
750
+ )
751
+ total_loss = (
752
+ float(training_cfg["gate_loss_weight"]) * gate_loss
753
+ + float(training_cfg["decoder_loss_weight"]) * decoder_loss
754
+ + float(training_cfg["state_delta_weight"]) * state_delta_loss
755
+ )
756
+
757
+ return {
758
+ "loss_total": float(total_loss),
759
+ "loss_gate": float(gate_loss),
760
+ "loss_gate_ranking": float(gate_ranking_loss),
761
+ "loss_decoder": float(decoder_loss),
762
+ "loss_state_delta": float(state_delta_loss),
763
+ "gate_accuracy": float(reduced["gate_correct"] / reduced["gate_eval_count"].clamp_min(1.0)),
764
+ "gate_precision": float(gate_precision),
765
+ "gate_recall": float(gate_recall),
766
+ "gate_f1": float(gate_f1),
767
+ "gate_target_positive_count": float(reduced["gate_target_positive_count"]),
768
+ "gate_pred_positive_count": float(reduced["gate_pred_positive_count"]),
769
+ "gate_positive_weight": float(reduced["gate_positive_weight"] / reduced["metrics_count"].clamp_min(1.0)),
770
+ "hazard_event_count": float(reduced["hazard_event_count"]),
771
+ "hazard_censor_count": float(reduced["hazard_censor_count"]),
772
+ "soft_target_mass": float(reduced["soft_target_mass"]),
773
+ "soft_target_positive_count": float(reduced["soft_target_positive_count"]),
774
+ "decoder_token_count": float(reduced["decoder_token_count"]),
775
+ "active_ticks": float(reduced["active_ticks"]),
776
+ }
777
+
778
+
779
+ @torch.no_grad()
780
+ def evaluate(
781
+ accelerator: Accelerator,
782
+ model: ThoughtLoopT5Gemma,
783
+ dataloader: DataLoader,
784
+ training_cfg: dict[str, Any],
785
+ rollout_cfg: dict[str, Any],
786
+ tokenizer_pad_token_id: int,
787
+ max_batches: int | None = None,
788
+ ) -> dict[str, float]:
789
+ model.eval()
790
+ all_metrics: list[dict[str, torch.Tensor]] = []
791
+ chunk_ticks = int(rollout_cfg["chunk_ticks"])
792
+ tick_seconds = float(rollout_cfg["tick_seconds"])
793
+
794
+ for batch_index, conversations in enumerate(dataloader):
795
+ if max_batches is not None and batch_index >= max_batches:
796
+ break
797
+
798
+ batch_size = len(conversations)
799
+ z_state = model.initial_state(batch_size=batch_size, device=accelerator.device)
800
+ state_mask = model.initial_state_mask(batch_size=batch_size, device=accelerator.device)
801
+ max_chunks = max(int(conversation["chunk_count"]) for conversation in conversations)
802
+ for chunk_index in range(max_chunks):
803
+ chunk_batch = build_chunk_batch(
804
+ conversations=conversations,
805
+ chunk_index=chunk_index,
806
+ pad_token_id=tokenizer_pad_token_id,
807
+ chunk_ticks=chunk_ticks,
808
+ tick_seconds=tick_seconds,
809
+ )
810
+ if chunk_batch is None:
811
+ break
812
+
813
+ chunk_batch = move_chunk_batch_to_device(chunk_batch, accelerator.device)
814
+ _, metrics, z_state, state_mask = compute_chunk_losses(
815
+ model=model,
816
+ batch=chunk_batch,
817
+ training_cfg=training_cfg,
818
+ z_state=z_state,
819
+ state_mask=state_mask,
820
+ tbptt_steps=None,
821
+ freeze_gate_head_to_targets=False,
822
+ )
823
+ z_state = z_state.detach()
824
+ state_mask = state_mask.detach()
825
+ all_metrics.append(metrics)
826
+
827
+ if not all_metrics:
828
+ model.train()
829
+ return {}
830
+
831
+ reduced = reduce_metrics(accelerator, all_metrics, training_cfg)
832
+ model.train()
833
+ return reduced
834
+
835
+
836
+ def copy_supporting_files(output_dir: Path, config_path: Path) -> None:
837
+ for filename in ("config.py", "data.py", "model.py", "inference.py", "train.py"):
838
+ shutil.copy2(SCRIPT_DIR / filename, output_dir / filename)
839
+ shutil.copy2(config_path, output_dir / config_path.name)
840
+
841
+
842
+ def build_model_card(config: dict[str, Any], best_metrics: dict[str, float]) -> str:
843
+ return "\n".join(
844
+ [
845
+ "# Samantha Thought-Loop SFT",
846
+ "",
847
+ f"- Base model: `{config['model']['base_model_name']}`",
848
+ f"- Cleaned dataset repo: `{config['dataset'].get('cleaned_repo_id')}`",
849
+ f"- Raw dataset repo: `{config['dataset'].get('raw_repo_id')}`",
850
+ f"- Latent slots: `{config['model']['z_slots']}`",
851
+ f"- Fixed tick seconds: `{config['rollout']['tick_seconds']}`",
852
+ f"- Chunk ticks: `{config['rollout']['chunk_ticks']}`",
853
+ f"- Max horizon ticks: `{config['rollout'].get('max_horizon_ticks', 36000)}`",
854
+ f"- Explicit time features: `{config['model'].get('use_explicit_time_features', False)}`",
855
+ f"- Gate loss mode: `{config['training'].get('gate_loss_mode', 'hazard')}`",
856
+ f"- Hazard soft target window: `{config['training'].get('hazard_soft_target_window_ticks', 16)}`",
857
+ f"- Hazard soft target tau ticks: `{config['training'].get('hazard_soft_target_tau_ticks', 4.0)}`",
858
+ f"- Hazard censor weight: `{config['training'].get('hazard_censor_weight', 0.25)}`",
859
+ f"- TBPTT by bucket: `{config['training'].get('tbptt_steps_by_bucket', {})}`",
860
+ "",
861
+ "## Best Validation Metrics",
862
+ "",
863
+ *[f"- `{key}`: `{value:.6f}`" for key, value in sorted(best_metrics.items())],
864
+ "",
865
+ ]
866
+ )
867
+
868
+
869
+ def export_model(
870
+ accelerator: Accelerator,
871
+ model: ThoughtLoopT5Gemma,
872
+ output_dir: Path,
873
+ config: dict[str, Any],
874
+ config_path: Path,
875
+ best_metrics: dict[str, float],
876
+ ) -> None:
877
+ if not accelerator.is_main_process:
878
+ return
879
+ output_dir.mkdir(parents=True, exist_ok=True)
880
+ unwrapped = accelerator.unwrap_model(model)
881
+ unwrapped.save_pretrained(output_dir)
882
+ copy_supporting_files(output_dir, config_path)
883
+ (output_dir / "README.md").write_text(build_model_card(config, best_metrics), encoding="utf-8")
884
+ save_json(output_dir / "best_metrics.json", best_metrics)
885
+
886
+
887
+ def maybe_push_to_hub(config: dict[str, Any], export_dir: Path) -> None:
888
+ model_repo_id = config["hub"].get("model_repo_id")
889
+ if not model_repo_id:
890
+ return
891
+ api = HfApi()
892
+ api.create_repo(repo_id=model_repo_id, repo_type="model", exist_ok=True, private=bool(config["hub"].get("private")))
893
+ api.upload_folder(folder_path=str(export_dir), repo_id=model_repo_id, repo_type="model")
894
+
895
+
896
+ def maybe_push_checkpoint_to_hub(
897
+ accelerator: Accelerator,
898
+ config: dict[str, Any],
899
+ checkpoint_dir: Path,
900
+ ) -> None:
901
+ model_repo_id = config["hub"].get("model_repo_id")
902
+ if not accelerator.is_main_process or not model_repo_id:
903
+ return
904
+ if not bool(config["hub"].get("push_checkpoints", True)):
905
+ return
906
+
907
+ run_name = str(config["wandb"].get("run_name", "run"))
908
+ checkpoint_prefix = str(config["hub"].get("checkpoint_path_prefix", "checkpoints")).strip("/")
909
+ path_parts = [part for part in (checkpoint_prefix, SCRIPT_DIR.name, run_name, checkpoint_dir.name) if part]
910
+ path_in_repo = "/".join(path_parts)
911
+
912
+ api = HfApi()
913
+ api.create_repo(repo_id=model_repo_id, repo_type="model", exist_ok=True, private=bool(config["hub"].get("private")))
914
+ api.upload_folder(
915
+ folder_path=str(checkpoint_dir),
916
+ repo_id=model_repo_id,
917
+ repo_type="model",
918
+ path_in_repo=path_in_repo,
919
+ commit_message=f"Add {SCRIPT_DIR.name} {checkpoint_dir.name}",
920
+ )
921
+
922
+
923
+ def read_json(path: Path) -> dict[str, Any]:
924
+ if not path.exists():
925
+ return {}
926
+ with path.open("r", encoding="utf-8") as handle:
927
+ payload = json.load(handle)
928
+ if not isinstance(payload, dict):
929
+ raise ValueError(f"Expected a JSON object in {path}.")
930
+ return payload
931
+
932
+
933
+ def checkpoint_global_step(checkpoint_dir: Path) -> int | None:
934
+ state = read_json(checkpoint_dir / "trainer_state.json")
935
+ if "global_step" in state:
936
+ return int(state["global_step"])
937
+
938
+ prefix = "checkpoint-"
939
+ if checkpoint_dir.name.startswith(prefix):
940
+ suffix = checkpoint_dir.name[len(prefix) :]
941
+ if suffix.isdigit():
942
+ return int(suffix)
943
+ return None
944
+
945
+
946
+ def find_latest_checkpoint(run_dir: Path) -> Path | None:
947
+ checkpoints = []
948
+ for candidate in run_dir.glob("checkpoint-*"):
949
+ if not candidate.is_dir():
950
+ continue
951
+ step = checkpoint_global_step(candidate)
952
+ if step is not None:
953
+ checkpoints.append((step, candidate))
954
+ if not checkpoints:
955
+ return None
956
+ return max(checkpoints, key=lambda item: item[0])[1]
957
+
958
+
959
+ def resolve_resume_checkpoint(requested: str | None, run_dir: Path) -> Path | None:
960
+ if requested is None:
961
+ return None
962
+
963
+ value = str(requested).strip()
964
+ if not value:
965
+ return None
966
+
967
+ if value.lower() in {"1", "true", "yes", "auto", "latest"}:
968
+ checkpoint_dir = find_latest_checkpoint(run_dir)
969
+ if checkpoint_dir is None:
970
+ raise FileNotFoundError(f"No checkpoint-* directory found under {run_dir}.")
971
+ return checkpoint_dir
972
+
973
+ checkpoint_dir = Path(value).expanduser()
974
+ if not checkpoint_dir.is_absolute():
975
+ checkpoint_dir = SCRIPT_DIR / checkpoint_dir
976
+ checkpoint_dir = checkpoint_dir.resolve()
977
+ if not checkpoint_dir.is_dir():
978
+ raise FileNotFoundError(f"Resume checkpoint does not exist: {checkpoint_dir}")
979
+ return checkpoint_dir
980
+
981
+
982
+ def parse_wandb_run_id_from_dir(path: Path) -> str | None:
983
+ name = path.resolve(strict=False).name if path.is_symlink() else path.name
984
+ for prefix in ("offline-run-", "run-"):
985
+ if name.startswith(prefix):
986
+ parts = name.split("-")
987
+ if parts:
988
+ return parts[-1]
989
+ return None
990
+
991
+
992
+ def discover_local_wandb_run_id(run_name: str) -> str | None:
993
+ wandb_dir = SCRIPT_DIR / "wandb"
994
+ if not wandb_dir.exists():
995
+ return None
996
+
997
+ candidates: list[Path] = []
998
+ latest = wandb_dir / "latest-run"
999
+ if latest.exists() or latest.is_symlink():
1000
+ candidates.append(latest)
1001
+ candidates.extend(sorted(wandb_dir.glob("*run-*"), key=lambda path: path.stat().st_mtime, reverse=True))
1002
+
1003
+ for candidate in candidates:
1004
+ run_id = parse_wandb_run_id_from_dir(candidate)
1005
+ if not run_id:
1006
+ continue
1007
+ resolved = candidate.resolve(strict=False)
1008
+ config_path = resolved / "files" / "config.yaml"
1009
+ debug_log_path = resolved / "logs" / "debug.log"
1010
+ if config_path.exists() and run_name in config_path.read_text(encoding="utf-8", errors="ignore"):
1011
+ return run_id
1012
+ if debug_log_path.exists() and run_name in debug_log_path.read_text(encoding="utf-8", errors="ignore"):
1013
+ return run_id
1014
+ return None
1015
+
1016
+
1017
+ def resolve_wandb_run_id(args: argparse.Namespace, config: dict[str, Any], run_dir: Path) -> str:
1018
+ run_id_path = run_dir / "wandb_run_id.txt"
1019
+ explicit = args.wandb_run_id or os.environ.get("WANDB_RUN_ID") or config["wandb"].get("run_id")
1020
+ if explicit:
1021
+ run_id = str(explicit).strip()
1022
+ elif run_id_path.exists():
1023
+ run_id = run_id_path.read_text(encoding="utf-8").strip()
1024
+ else:
1025
+ run_id = discover_local_wandb_run_id(str(config["wandb"].get("run_name", ""))) or secrets.token_hex(4)
1026
+
1027
+ if not run_id:
1028
+ run_id = secrets.token_hex(4)
1029
+ run_id_path.write_text(run_id + "\n", encoding="utf-8")
1030
+ return run_id
1031
+
1032
+
1033
+ def save_trainer_state(
1034
+ checkpoint_dir: Path,
1035
+ *,
1036
+ global_step: int,
1037
+ epoch: int,
1038
+ next_batch_index: int,
1039
+ best_validation_loss: float,
1040
+ best_metrics: dict[str, float],
1041
+ wandb_run_id: str | None,
1042
+ ) -> None:
1043
+ payload: dict[str, Any] = {
1044
+ "global_step": int(global_step),
1045
+ "epoch": int(epoch),
1046
+ "next_batch_index": int(next_batch_index),
1047
+ "best_validation_loss": best_validation_loss if math.isfinite(best_validation_loss) else None,
1048
+ "best_metrics": best_metrics,
1049
+ "wandb_run_id": wandb_run_id,
1050
+ }
1051
+ save_json(checkpoint_dir / "trainer_state.json", payload)
1052
+
1053
+
1054
+ def load_resume_progress(
1055
+ checkpoint_dir: Path,
1056
+ *,
1057
+ total_chunk_microsteps: int,
1058
+ gradient_accumulation_steps: int,
1059
+ num_train_epochs: int,
1060
+ ) -> tuple[int, int, int, dict[str, Any]]:
1061
+ state = read_json(checkpoint_dir / "trainer_state.json")
1062
+ global_step = int(state.get("global_step") or checkpoint_global_step(checkpoint_dir) or 0)
1063
+ if "epoch" in state and "next_batch_index" in state:
1064
+ start_epoch = int(state["epoch"])
1065
+ batches_to_skip = int(state["next_batch_index"])
1066
+ else:
1067
+ updates_per_epoch = max(1, math.ceil(total_chunk_microsteps / max(gradient_accumulation_steps, 1)))
1068
+ start_epoch = min(global_step // updates_per_epoch, max(num_train_epochs - 1, 0))
1069
+ updates_into_epoch = global_step - start_epoch * updates_per_epoch
1070
+ batches_to_skip = updates_into_epoch * max(gradient_accumulation_steps, 1)
1071
+
1072
+ while batches_to_skip >= total_chunk_microsteps and start_epoch < num_train_epochs:
1073
+ batches_to_skip -= total_chunk_microsteps
1074
+ start_epoch += 1
1075
+
1076
+ return global_step, start_epoch, batches_to_skip, state
1077
+
1078
+
1079
+ def load_best_metrics(export_dir: Path, resume_state: dict[str, Any]) -> tuple[float, dict[str, float]]:
1080
+ best_metrics = resume_state.get("best_metrics")
1081
+ if not isinstance(best_metrics, dict):
1082
+ best_metrics = read_json(export_dir / "best_metrics.json")
1083
+
1084
+ loss = resume_state.get("best_validation_loss")
1085
+ if loss is None and isinstance(best_metrics, dict):
1086
+ loss = best_metrics.get("loss_total")
1087
+ best_validation_loss = float(loss) if loss is not None else float("inf")
1088
+ return best_validation_loss, {str(key): float(value) for key, value in best_metrics.items()}
1089
+
1090
+
1091
+ def main() -> None:
1092
+ args = parse_args()
1093
+ config_path = Path(args.config)
1094
+ config = load_config(config_path)
1095
+ training_cfg = config["training"]
1096
+ rollout_cfg = config["rollout"]
1097
+
1098
+ set_seed(int(training_cfg["seed"]))
1099
+ wandb_enabled = bool(config["wandb"].get("enabled", True) and wandb is not None)
1100
+
1101
+ accelerator = Accelerator(
1102
+ gradient_accumulation_steps=int(training_cfg["gradient_accumulation_steps"]),
1103
+ mixed_precision=training_cfg.get("mixed_precision", "bf16"),
1104
+ log_with="wandb" if wandb_enabled else None,
1105
+ project_dir=config["paths"]["run_root"],
1106
+ )
1107
+
1108
+ run_root = ensure_dir(config["paths"]["run_root"])
1109
+ export_root = ensure_dir(config["paths"]["export_root"])
1110
+ run_name = config["wandb"]["run_name"]
1111
+ run_dir = ensure_dir(run_root / run_name)
1112
+ export_dir = ensure_dir(export_root / run_name)
1113
+ resume_request = args.resume_from_checkpoint or training_cfg.get("resume_from_checkpoint")
1114
+ resume_checkpoint = resolve_resume_checkpoint(resume_request, run_dir)
1115
+ wandb_run_id = resolve_wandb_run_id(args, config, run_dir) if wandb_enabled else None
1116
+
1117
+ model = build_initial_model(config)
1118
+ if model.use_explicit_time_features:
1119
+ raise ValueError("This trainer expects model.use_explicit_time_features=false for fixed-cadence training.")
1120
+ tokenizer = model.tokenizer
1121
+
1122
+ train_dataset = ThoughtLoopConversationDataset(config=config, tokenizer=tokenizer, split="train")
1123
+ validation_dataset = ThoughtLoopConversationDataset(config=config, tokenizer=tokenizer, split="validation")
1124
+ sort_dataset_by_length = bool(training_cfg.get("sort_dataset_by_length", False))
1125
+
1126
+ ensure_single_chunk_dataset(train_dataset, "train")
1127
+ ensure_single_chunk_dataset(validation_dataset, "validation")
1128
+ maybe_sort_dataset_by_length(train_dataset, enabled=sort_dataset_by_length)
1129
+ maybe_sort_dataset_by_length(validation_dataset, enabled=sort_dataset_by_length)
1130
+
1131
+ train_loader = DataLoader(
1132
+ train_dataset,
1133
+ batch_size=int(training_cfg["micro_batch_size"]),
1134
+ shuffle=bool(training_cfg.get("shuffle_train", True)) and not sort_dataset_by_length,
1135
+ num_workers=int(training_cfg["num_workers"]),
1136
+ pin_memory=True,
1137
+ persistent_workers=bool(int(training_cfg["num_workers"]) > 0),
1138
+ collate_fn=identity_collate,
1139
+ )
1140
+ validation_loader = DataLoader(
1141
+ validation_dataset,
1142
+ batch_size=int(training_cfg["eval_batch_size"]),
1143
+ shuffle=False,
1144
+ num_workers=int(training_cfg["num_workers"]),
1145
+ pin_memory=True,
1146
+ persistent_workers=bool(int(training_cfg["num_workers"]) > 0),
1147
+ collate_fn=identity_collate,
1148
+ )
1149
+
1150
+ optimizer = build_optimizer(model, training_cfg)
1151
+ total_chunk_microsteps = len(train_loader)
1152
+ total_update_steps = max(
1153
+ 1,
1154
+ math.ceil(total_chunk_microsteps / max(int(training_cfg["gradient_accumulation_steps"]), 1))
1155
+ * int(training_cfg["num_train_epochs"]),
1156
+ )
1157
+ warmup_steps = max(1, int(total_update_steps * float(training_cfg["warmup_ratio"])))
1158
+ scheduler = get_cosine_schedule_with_warmup(
1159
+ optimizer=optimizer,
1160
+ num_warmup_steps=warmup_steps,
1161
+ num_training_steps=max(1, total_update_steps),
1162
+ )
1163
+
1164
+ model, optimizer, train_loader, validation_loader, scheduler = accelerator.prepare(
1165
+ model, optimizer, train_loader, validation_loader, scheduler
1166
+ )
1167
+
1168
+ resume_state: dict[str, Any] = {}
1169
+ global_step = 0
1170
+ start_epoch = 0
1171
+ batches_to_skip = 0
1172
+ if resume_checkpoint is not None:
1173
+ accelerator.print(f"Loading local checkpoint from {resume_checkpoint}")
1174
+ accelerator.load_state(str(resume_checkpoint))
1175
+ global_step, start_epoch, batches_to_skip, resume_state = load_resume_progress(
1176
+ resume_checkpoint,
1177
+ total_chunk_microsteps=total_chunk_microsteps,
1178
+ gradient_accumulation_steps=int(training_cfg["gradient_accumulation_steps"]),
1179
+ num_train_epochs=int(training_cfg["num_train_epochs"]),
1180
+ )
1181
+ accelerator.print(
1182
+ f"Resuming at global_step={global_step}, epoch={start_epoch + 1}, "
1183
+ f"skipping {batches_to_skip} batch(es)."
1184
+ )
1185
+
1186
+ if accelerator.is_main_process and wandb_enabled:
1187
+ accelerator.print(f"W&B run id: {wandb_run_id}")
1188
+ accelerator.init_trackers(
1189
+ project_name=config["wandb"]["project"],
1190
+ config=flatten_for_wandb(config),
1191
+ init_kwargs={
1192
+ "wandb": {
1193
+ "name": run_name,
1194
+ "id": wandb_run_id,
1195
+ "resume": str(config["wandb"].get("resume", "allow")),
1196
+ }
1197
+ },
1198
+ )
1199
+
1200
+ unwrapped_model = accelerator.unwrap_model(model)
1201
+ current_decoder_fraction = resolve_decoder_trainable_fraction(training_cfg, global_step, total_update_steps)
1202
+ if current_decoder_fraction is None:
1203
+ current_decoder_fraction = 1.0
1204
+ current_decoder_trainable_layers = unwrapped_model.set_decoder_trainable_fraction(current_decoder_fraction)
1205
+
1206
+ current_gate_head_frozen = should_freeze_gate_head(training_cfg, global_step, total_update_steps)
1207
+ unwrapped_model.set_gate_trainable(not current_gate_head_frozen)
1208
+
1209
+ progress_bar = tqdm(
1210
+ total=total_update_steps,
1211
+ initial=global_step,
1212
+ disable=not accelerator.is_local_main_process,
1213
+ desc="Training",
1214
+ dynamic_ncols=True,
1215
+ )
1216
+
1217
+ best_validation_loss, best_metrics = load_best_metrics(export_dir, resume_state)
1218
+ model.train()
1219
+
1220
+ chunk_ticks = int(rollout_cfg["chunk_ticks"])
1221
+ tick_seconds = float(rollout_cfg["tick_seconds"])
1222
+
1223
+ for epoch in range(start_epoch, int(training_cfg["num_train_epochs"])):
1224
+ epoch_train_loader = train_loader
1225
+ batch_index_offset = 0
1226
+ if epoch == start_epoch and batches_to_skip > 0:
1227
+ epoch_train_loader = accelerator.skip_first_batches(train_loader, batches_to_skip)
1228
+ batch_index_offset = batches_to_skip
1229
+
1230
+ for batch_index, conversations in enumerate(epoch_train_loader, start=batch_index_offset):
1231
+ batch_bucket = resolve_batch_duration_bucket(conversations)
1232
+ current_tbptt_steps = resolve_bucket_tbptt_steps(training_cfg, batch_bucket)
1233
+ current_horizon_ticks = resolve_bucket_horizon_ticks(batch_bucket, rollout_cfg)
1234
+
1235
+ z_state = model.initial_state(batch_size=len(conversations), device=accelerator.device)
1236
+ state_mask = model.initial_state_mask(batch_size=len(conversations), device=accelerator.device)
1237
+ max_chunks = max(int(conversation["chunk_count"]) for conversation in conversations)
1238
+
1239
+ for chunk_index in range(max_chunks):
1240
+ scheduled_decoder_fraction = resolve_decoder_trainable_fraction(training_cfg, global_step, total_update_steps)
1241
+ if scheduled_decoder_fraction is None:
1242
+ scheduled_decoder_fraction = 1.0
1243
+ if abs(scheduled_decoder_fraction - current_decoder_fraction) > 1e-6:
1244
+ current_decoder_fraction = scheduled_decoder_fraction
1245
+ current_decoder_trainable_layers = unwrapped_model.set_decoder_trainable_fraction(
1246
+ current_decoder_fraction
1247
+ )
1248
+
1249
+ scheduled_gate_head_frozen = should_freeze_gate_head(training_cfg, global_step, total_update_steps)
1250
+ if scheduled_gate_head_frozen != current_gate_head_frozen:
1251
+ current_gate_head_frozen = scheduled_gate_head_frozen
1252
+ unwrapped_model.set_gate_trainable(not current_gate_head_frozen)
1253
+
1254
+ chunk_batch = build_chunk_batch(
1255
+ conversations=conversations,
1256
+ chunk_index=chunk_index,
1257
+ pad_token_id=tokenizer.pad_token_id,
1258
+ chunk_ticks=chunk_ticks,
1259
+ tick_seconds=tick_seconds,
1260
+ )
1261
+ if chunk_batch is None:
1262
+ break
1263
+ chunk_batch = move_chunk_batch_to_device(chunk_batch, accelerator.device)
1264
+
1265
+ with accelerator.accumulate(model):
1266
+ total_loss, metrics, z_state, state_mask = compute_chunk_losses(
1267
+ model=model,
1268
+ batch=chunk_batch,
1269
+ training_cfg=training_cfg,
1270
+ z_state=z_state,
1271
+ state_mask=state_mask,
1272
+ tbptt_steps=current_tbptt_steps,
1273
+ freeze_gate_head_to_targets=current_gate_head_frozen,
1274
+ )
1275
+ accelerator.backward(total_loss)
1276
+ accelerator.clip_grad_norm_(model.parameters(), float(training_cfg["max_grad_norm"]))
1277
+ optimizer.step()
1278
+ scheduler.step()
1279
+ optimizer.zero_grad(set_to_none=True)
1280
+
1281
+ z_state = z_state.detach()
1282
+ state_mask = state_mask.detach()
1283
+
1284
+ if accelerator.sync_gradients:
1285
+ global_step += 1
1286
+ progress_bar.update(1)
1287
+ progress_bar.set_postfix(
1288
+ loss=f"{float(total_loss.detach()):.4f}",
1289
+ bucket=batch_bucket,
1290
+ tbptt=int(current_tbptt_steps or 0),
1291
+ horizon=int(current_horizon_ticks),
1292
+ decoder=current_decoder_trainable_layers,
1293
+ gate="locked" if current_gate_head_frozen else "train",
1294
+ refresh=False,
1295
+ )
1296
+
1297
+ if global_step % int(training_cfg["logging_steps"]) == 0:
1298
+ reduced = reduce_metrics(accelerator, [metrics], training_cfg)
1299
+ reduced["lr"] = scheduler.get_last_lr()[0]
1300
+ reduced["tbptt_steps"] = float(current_tbptt_steps or 0)
1301
+ reduced["horizon_ticks"] = float(current_horizon_ticks)
1302
+ reduced["chunk_ticks"] = float(chunk_ticks)
1303
+ reduced["decoder_trainable_layers"] = float(current_decoder_trainable_layers)
1304
+ reduced["gate_head_frozen"] = float(current_gate_head_frozen)
1305
+ reduced["epoch"] = epoch + 1
1306
+ reduced["global_step"] = global_step
1307
+ accelerator.log({f"train/{key}": value for key, value in reduced.items()}, step=global_step)
1308
+
1309
+ if global_step % int(training_cfg["eval_steps"]) == 0:
1310
+ validation_metrics = evaluate(
1311
+ accelerator=accelerator,
1312
+ model=model,
1313
+ dataloader=validation_loader,
1314
+ training_cfg=training_cfg,
1315
+ rollout_cfg=rollout_cfg,
1316
+ tokenizer_pad_token_id=tokenizer.pad_token_id,
1317
+ max_batches=training_cfg.get("eval_max_batches"),
1318
+ )
1319
+ accelerator.log(
1320
+ {f"validation/{key}": value for key, value in validation_metrics.items()},
1321
+ step=global_step,
1322
+ )
1323
+
1324
+ if validation_metrics and validation_metrics["loss_total"] < best_validation_loss:
1325
+ best_validation_loss = validation_metrics["loss_total"]
1326
+ best_metrics = validation_metrics
1327
+ export_model(
1328
+ accelerator=accelerator,
1329
+ model=model,
1330
+ output_dir=export_dir,
1331
+ config=config,
1332
+ config_path=config_path,
1333
+ best_metrics=best_metrics,
1334
+ )
1335
+
1336
+ if global_step % int(training_cfg["checkpoint_steps"]) == 0:
1337
+ checkpoint_dir = ensure_dir(run_dir / f"checkpoint-{global_step}")
1338
+ accelerator.save_state(str(checkpoint_dir))
1339
+ if accelerator.is_main_process:
1340
+ save_trainer_state(
1341
+ checkpoint_dir,
1342
+ global_step=global_step,
1343
+ epoch=epoch,
1344
+ next_batch_index=batch_index + 1,
1345
+ best_validation_loss=best_validation_loss,
1346
+ best_metrics=best_metrics,
1347
+ wandb_run_id=wandb_run_id,
1348
+ )
1349
+ accelerator.wait_for_everyone()
1350
+ maybe_push_checkpoint_to_hub(
1351
+ accelerator=accelerator,
1352
+ config=config,
1353
+ checkpoint_dir=checkpoint_dir,
1354
+ )
1355
+ accelerator.wait_for_everyone()
1356
+
1357
+ if not best_metrics:
1358
+ best_metrics = evaluate(
1359
+ accelerator=accelerator,
1360
+ model=model,
1361
+ dataloader=validation_loader,
1362
+ training_cfg=training_cfg,
1363
+ rollout_cfg=rollout_cfg,
1364
+ tokenizer_pad_token_id=tokenizer.pad_token_id,
1365
+ max_batches=training_cfg.get("eval_max_batches"),
1366
+ )
1367
+ export_model(
1368
+ accelerator=accelerator,
1369
+ model=model,
1370
+ output_dir=export_dir,
1371
+ config=config,
1372
+ config_path=config_path,
1373
+ best_metrics=best_metrics,
1374
+ )
1375
+
1376
+ if accelerator.is_main_process:
1377
+ training_summary = {
1378
+ "train_conversations": len(train_dataset),
1379
+ "validation_conversations": len(validation_dataset),
1380
+ "train_chunk_microsteps": total_chunk_microsteps,
1381
+ "trainable_parameters": accelerator.unwrap_model(model).trainable_parameter_count(),
1382
+ "best_metrics": best_metrics,
1383
+ "global_step": global_step,
1384
+ "resumed_from_checkpoint": str(resume_checkpoint) if resume_checkpoint is not None else None,
1385
+ "wandb_run_id": wandb_run_id,
1386
+ "decoder_layer_count": unwrapped_model.decoder_layer_count(),
1387
+ "tick_seconds": tick_seconds,
1388
+ "chunk_ticks": chunk_ticks,
1389
+ "tbptt_steps_by_bucket": training_cfg.get("tbptt_steps_by_bucket", {}),
1390
+ "horizon_ticks_by_bucket": rollout_cfg.get("horizon_ticks_by_bucket", {}),
1391
+ "shuffle_train": bool(training_cfg.get("shuffle_train", True)),
1392
+ "sort_dataset_by_length": sort_dataset_by_length,
1393
+ }
1394
+ save_json(run_dir / "training_summary.json", training_summary)
1395
+ maybe_push_to_hub(config, export_dir)
1396
+
1397
+ progress_bar.close()
1398
+ accelerator.end_training()
1399
+
1400
+
1401
+ if __name__ == "__main__":
1402
+ main()