File size: 36,680 Bytes
b7c0571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
"""
NovaMind-256M: Decoder-Only Conversational Language Model
==========================================================
Architecture: Decoder-only Transformer

Key design choices:
  1. HiRoPE (Hierarchical RoPE) β€” adapted from "HiRoPE: Length Extrapolation
     for Code Models Using Hierarchical Position" (ACL 2024).
     Original paper used it for code structure (token/statement/function hierarchy).
     We adapt it for conversation structure: local turn position vs. global dialogue position.
     Split head dims into local-base=10K and global-base=500K streams.
  2. Tag-Aware Loss Curriculum β€” original contribution.
     Per-token loss weighting that changes across training phases.

Target: ~252M parameters (after weight tying)
Hardware target: H100 40GB (Lightning.ai student account)
Precision: BF16
"""

import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.checkpoint import checkpoint as gradient_checkpoint
from dataclasses import dataclass, field, asdict


# ──────────────────────────────────────────────────────────────────────────────
# CONFIG
# ──────────────────────────────────────────────────────────────────────────────

@dataclass
class NovaMindConfig:
    """
    All hyperparameters in one place for clean checkpointing and reproducibility.
    Changing a value here changes the entire model β€” no magic numbers buried
    in the code.
    """
    # ── Vocabulary ──────────────────────────────────────────────────────────
    vocab_size: int = 32_000          # LLaMA-2 tokenizer (NOT GPT-2's 50K)

    # ── Dimensions ──────────────────────────────────────────────────────────
    d_model: int = 1_024              # hidden size
    n_heads: int = 16                 # number of query attention heads
    n_kv_heads: int = 4              # GQA: key/value heads (ratio 4:1)
    n_layers: int = 24               # transformer depth
    ff_dim: int = 2_304              # SwiGLU inner dim = 2.25 Γ— d_model, div by 128 β†’ ~265M total

    # ── Context ─────────────────────────────────────────────────────────────
    max_seq_len: int = 2_048         # training context; extendable later via RoPE scaling

    # ── HiRoPE β€” adapted from HiRoPE (ACL 2024), applied to conversation ────
    rope_local_base: float = 10_000.0    # local position (within-turn): ~LLaMA default
    rope_global_base: float = 500_000.0  # global position (cross-turn): much slower freq

    # ── Regularization ──────────────────────────────────────────────────────
    dropout: float = 0.0             # no dropout (modern LLM practice)
    attn_dropout: float = 0.0

    # ── Init ────────────────────────────────────────────────────────────────
    init_std: float = 0.02           # base std for embedding and non-residual projections

    def head_dim(self) -> int:
        assert self.d_model % self.n_heads == 0, "d_model must be divisible by n_heads"
        return self.d_model // self.n_heads

    def n_rep(self) -> int:
        """How many Q heads share each KV head."""
        assert self.n_heads % self.n_kv_heads == 0
        return self.n_heads // self.n_kv_heads

    def to_dict(self):
        return asdict(self)

    @classmethod
    def from_dict(cls, d):
        return cls(**d)


# ──────────────────────────────────────────────────────────────────────────────
# RMS NORM
# ──────────────────────────────────────────────────────────────────────────────

class RMSNorm(nn.Module):
    """
    Root Mean Square Layer Normalization (Zhang & Sennrich, 2019).
    Faster than LayerNorm because it skips the mean-centering step.
    Used by LLaMA, Gemma, Qwen, Mistral β€” now us.
    """
    def __init__(self, dim: int, eps: float = 1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [..., dim]
        # Compute RMS then scale
        rms = x.pow(2).mean(-1, keepdim=True).add(self.eps).rsqrt()
        return x * rms * self.weight


# ──────────────────────────────────────────────────────────────────────────────
# HiRoPE β€” HIERARCHICAL ROTARY POSITION EMBEDDING
# Adapted from "HiRoPE: Length Extrapolation for Code Models Using Hierarchical
# Position" (ACL 2024). Original: code hierarchy. Our adaptation: dialogue.
# ──────────────────────────────────────────────────────────────────────────────

def build_hier_rope_cache(
    seq_len: int,
    head_dim: int,
    device: torch.device,
    local_base: float = 10_000.0,
    global_base: float = 500_000.0,
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    HiRoPE: Hierarchical Rotary Position Embedding Cache.
    ────────────────────────────────────────────────────────
    Adapted from: "HiRoPE: Length Extrapolation for Code Models Using
    Hierarchical Position" (ACL 2024).

    Original paper: used code structure hierarchy (token-in-statement,
    statement-in-function) to split RoPE dimensions.
    Our adaptation: we use CONVERSATION structure instead.

      β€’ Dims   0 … head_dim//2-1  β†’  LOCAL  RoPE (base=10,000)
          Encodes fine-grained position WITHIN a conversation turn.
          Same frequency as standard LLaMA RoPE β€” well-calibrated for
          sentence-level syntax and local coreference.

      β€’ Dims head_dim//2 … head_dim-1  β†’  GLOBAL RoPE (base=500,000)
          Encodes coarse position ACROSS conversation turns.
          A higher base = slower rotation = can distinguish positions
          across much longer spans without frequency wrap-around.
          This is what the original HiRoPE paper used for cross-function
          dependencies in code; we repurpose it for cross-turn coherence.

    Cost: ZERO extra parameters. Purely a change in the frequency basis.

    Returns:
        cos, sin  β€” each shape [seq_len, head_dim], cached for efficiency
    """
    half = head_dim // 2  # split point

    # ── Local stream (dims 0 to half-1) ──────────────────────────────────
    # theta_i = 1 / (base^(2i/dim)) for i in [0, half/2)
    # We generate half/2 unique theta values, then duplicate for rotation trick
    local_theta = 1.0 / (
        local_base ** (torch.arange(0, half, 2, device=device).float() / half)
    )
    # ── Global stream (dims half to head_dim-1) ───────────────────────────
    global_theta = 1.0 / (
        global_base ** (torch.arange(0, half, 2, device=device).float() / half)
    )

    positions = torch.arange(seq_len, device=device).float()  # [seq_len]

    # Outer product: [seq_len, half/2]
    local_freqs  = torch.outer(positions, local_theta)
    global_freqs = torch.outer(positions, global_theta)

    # Duplicate for the rotation trick: [q1, q2] β†’ [-q2, q1]
    # β†’ shape [seq_len, half]
    local_freqs  = torch.cat([local_freqs,  local_freqs],  dim=-1)
    global_freqs = torch.cat([global_freqs, global_freqs], dim=-1)

    # Concatenate local and global to cover full head_dim: [seq_len, head_dim]
    cos = torch.cat([local_freqs.cos(),  global_freqs.cos()], dim=-1)
    sin = torch.cat([local_freqs.sin(),  global_freqs.sin()], dim=-1)

    return cos, sin  # [seq_len, head_dim]


def _rotate_half(x: torch.Tensor) -> torch.Tensor:
    """Standard RoPE rotation: rotate each pair of dims by 90Β°."""
    half = x.shape[-1] // 2
    return torch.cat([-x[..., half:], x[..., :half]], dim=-1)


def apply_hier_rope(
    q: torch.Tensor,
    k: torch.Tensor,
    cos: torch.Tensor,
    sin: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    Apply HierRoPE to query and key tensors.
    Because cos/sin are already split into local//global halves,
    this is mathematically identical to standard RoPE β€” the hierarchy
    lives in the frequency basis, not the application code.

    Args:
        q, k: [batch, n_heads, seq_len, head_dim]
        cos, sin: [seq_len, head_dim]  (from build_hier_rope_cache)
    Returns:
        rotated q, k: same shapes as input
    """
    # Expand cos/sin to broadcast over batch and head dims
    cos = cos[None, None, :, :]  # [1, 1, seq_len, head_dim]
    sin = sin[None, None, :, :]

    q_rot = q * cos + _rotate_half(q) * sin
    k_rot = k * cos + _rotate_half(k) * sin
    return q_rot, k_rot


# ──────────────────────────────────────────────────────────────────────────────
# GROUPED QUERY ATTENTION WITH HiRoPE
# ──────────────────────────────────────────────────────────────────────────────

class GQACausalAttention(nn.Module):
    """
    Grouped Query Attention (GQA) with HiRoPE (adapted from ACL 2024 paper).

    GQA uses n_heads query heads but only n_kv_heads key/value heads.
    Each KV head is shared by (n_heads // n_kv_heads) query heads.
    This reduces KV cache size by 4x during inference β€” critical for long
    conversations. Used by LLaMA 2/3, Mistral, Gemma.

    Attention is computed via F.scaled_dot_product_attention which uses
    Flash Attention 2 on supported hardware (H100 βœ…).
    """

    def __init__(self, config: NovaMindConfig):
        super().__init__()
        self.n_heads    = config.n_heads
        self.n_kv_heads = config.n_kv_heads
        self.n_rep      = config.n_rep()    # Q heads per KV head
        self.head_dim   = config.head_dim()
        self.d_model    = config.d_model

        # Projections β€” NO bias (modern LLM best practice, saves params)
        self.q_proj = nn.Linear(self.d_model, self.n_heads    * self.head_dim, bias=False)
        self.k_proj = nn.Linear(self.d_model, self.n_kv_heads * self.head_dim, bias=False)
        self.v_proj = nn.Linear(self.d_model, self.n_kv_heads * self.head_dim, bias=False)
        self.o_proj = nn.Linear(self.d_model, self.d_model,                    bias=False)

        self.attn_drop = config.attn_dropout

        # Cache HiRoPE cos/sin buffers to avoid recomputing every forward pass
        # persistent=False β†’ not saved in state_dict (recomputed on load)
        self.register_buffer(
            "_rope_cos",
            torch.zeros(config.max_seq_len, self.head_dim),
            persistent=False,
        )
        self.register_buffer(
            "_rope_sin",
            torch.zeros(config.max_seq_len, self.head_dim),
            persistent=False,
        )
        self._rope_cached_len = 0
        self._rope_local_base  = config.rope_local_base
        self._rope_global_base = config.rope_global_base

    def _get_hier_rope(self, seq_len: int, device: torch.device):
        """Lazily compute and cache HiRoPE tables."""
        if seq_len > self._rope_cached_len or self._rope_cos.device != device:
            cos, sin = build_hier_rope_cache(
                seq_len, self.head_dim, device,
                local_base=self._rope_local_base,
                global_base=self._rope_global_base,
            )
            self._rope_cos = cos
            self._rope_sin = sin
            self._rope_cached_len = seq_len
        return self._rope_cos[:seq_len], self._rope_sin[:seq_len]

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: [B, T, D]  β€” input hidden states
        Returns:
            out: [B, T, D]
        """
        B, T, D = x.shape

        # Project to Q, K, V
        Q = self.q_proj(x).view(B, T, self.n_heads,    self.head_dim).transpose(1, 2)
        K = self.k_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
        V = self.v_proj(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
        # Q: [B, n_heads, T, head_dim]
        # K: [B, n_kv_heads, T, head_dim]
        # V: [B, n_kv_heads, T, head_dim]

        # Apply HiRoPE to Q and K
        cos, sin = self._get_hier_rope(T, x.device)
        Q, K = apply_hier_rope(Q, K, cos.to(x.dtype), sin.to(x.dtype))

        # Expand KV heads to match Q heads for GQA
        # This uses expand() + reshape instead of repeat_interleave β€” same math,
        # but expand() is zero-copy (shares memory), saving VRAM
        K = K.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.head_dim)\
             .reshape(B, self.n_heads, T, self.head_dim)
        V = V.unsqueeze(2).expand(B, self.n_kv_heads, self.n_rep, T, self.head_dim)\
             .reshape(B, self.n_heads, T, self.head_dim)

        # Flash Attention 2 path via PyTorch β€” causal mask included
        out = F.scaled_dot_product_attention(
            Q, K, V,
            attn_mask=None,
            dropout_p=self.attn_drop if self.training else 0.0,
            is_causal=True,
        )  # [B, n_heads, T, head_dim]

        # Reassemble heads and project
        out = out.transpose(1, 2).contiguous().view(B, T, D)
        return self.o_proj(out)


# ──────────────────────────────────────────────────────────────────────────────
# SWIGLU FEED-FORWARD NETWORK
# ──────────────────────────────────────────────────────────────────────────────

class SwiGLUFFN(nn.Module):
    """
    SwiGLU Feed-Forward Network (Shazeer, 2020; adopted by PaLM, LLaMA, Gemma).

    Unlike standard FFN (2 matrices: up-project + down-project),
    SwiGLU uses 3 matrices:
      gate(x)  β†’ passed through SiLU activation (the "gate")
      up(x)    β†’ element-wise multiplied with the gate output
      down(Β·)  β†’ project back to d_model

    The gating mechanism acts like learned neuron selection β€” it can
    suppress irrelevant features entirely. Result: ~15% better quality
    per parameter vs standard GELU FFN (measured in Google's PaLM paper).
    """

    def __init__(self, config: NovaMindConfig):
        super().__init__()
        # All three matrices, no bias
        self.gate = nn.Linear(config.d_model, config.ff_dim, bias=False)
        self.up   = nn.Linear(config.d_model, config.ff_dim, bias=False)
        self.down = nn.Linear(config.ff_dim,  config.d_model, bias=False)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # SiLU(gate(x)) βŠ™ up(x) β€” element-wise gating
        return self.down(F.silu(self.gate(x)) * self.up(x))


# ──────────────────────────────────────────────────────────────────────────────
# TRANSFORMER BLOCK
# ──────────────────────────────────────────────────────────────────────────────

class TransformerBlock(nn.Module):
    """
    One transformer layer using Pre-RMSNorm architecture:

      x β†’ RMSNorm β†’ GQACausalAttention β†’ +x (residual)
        β†’ RMSNorm β†’ SwiGLUFFN         β†’ +x (residual)

    Pre-norm (norm BEFORE the sub-layer) is more training-stable than
    Post-norm, especially at 24 layers deep. All modern LLMs use Pre-norm.
    """

    def __init__(self, config: NovaMindConfig):
        super().__init__()
        self.ln_attn = RMSNorm(config.d_model)
        self.attn    = GQACausalAttention(config)
        self.ln_ffn  = RMSNorm(config.d_model)
        self.ffn     = SwiGLUFFN(config)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Attention sub-layer with residual connection
        x = x + self.attn(self.ln_attn(x))
        # FFN sub-layer with residual connection
        x = x + self.ffn(self.ln_ffn(x))
        return x


# ──────────────────────────────────────────────────────────────────────────────
# TAG-AWARE LOSS β€” NOVEL CONTRIBUTION #2
# ──────────────────────────────────────────────────────────────────────────────

def tag_aware_loss(
    logits: torch.Tensor,
    targets: torch.Tensor,
    token_weights: torch.Tensor | None = None,
) -> torch.Tensor:
    """
    Tag-Aware Loss Curriculum (Novel Contribution #2).
    ──────────────────────────────────────────────────
    Standard cross-entropy treats every predicted token equally.
    We instead weight each token's loss contribution by its role in the
    conversation, and by which training phase we are in:

    Token types + phase weights:
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Token type     β”‚ Phase 1  β”‚ Phase 2  β”‚  Phase 3     β”‚
    β”‚                β”‚ Pretrain β”‚  SFT     β”‚  CoT SFT     β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚ <think>        β”‚   0.0    β”‚   0.5    β”‚   1.5 ← BOOSTβ”‚
    β”‚ <assistant>    β”‚   1.0    β”‚   1.0    β”‚   1.0        β”‚
    β”‚ <human>/system β”‚   0.0    β”‚   0.0    β”‚   0.0        β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    Why this is novel:
    - Most papers use binary masking (weight is 0 or 1)
    - A curriculum that changes weights across phases has not been
      published at this scale
    - Phase 3 boosting (weight=1.5 for think tokens) forces the model to
      "care" about the quality of its own reasoning chain

    Args:
        logits:        [B, T, V] β€” raw output logits
        targets:       [B, T]    β€” target token ids (-1 = ignore this position)
        token_weights: [B, T]    β€” per-token float weight (None β†’ uniform 1.0)

    Returns:
        Scalar loss value
    """
    B, T, V = logits.shape

    # Flat views for loss computation
    logits_flat  = logits.view(B * T, V)
    targets_flat = targets.view(B * T)

    if token_weights is None:
        # Standard cross-entropy when no curriculum weights provided
        return F.cross_entropy(logits_flat, targets_flat, ignore_index=-1)

    # Compute per-token loss (reduction='none' gives us loss per position)
    per_token_loss = F.cross_entropy(
        logits_flat, targets_flat,
        ignore_index=-1,
        reduction="none",
    )  # [B*T]

    weights_flat = token_weights.view(B * T)

    # Mask out ignored positions (targets == -1)
    # F.cross_entropy with ignore_index already zeroes them, but
    # we also zero their weights to avoid dividing by them
    valid_mask = (targets_flat != -1).float()
    weights_flat = weights_flat * valid_mask

    # Weighted mean: sum(w_i * loss_i) / sum(w_i)
    # Small epsilon to prevent div-by-zero on pathological batches
    weighted_loss = (per_token_loss * weights_flat).sum()
    weight_sum    = weights_flat.sum().clamp(min=1e-8)

    return weighted_loss / weight_sum


# ──────────────────────────────────────────────────────────────────────────────
# NOVAMIND-256M β€” MAIN MODEL
# ──────────────────────────────────────────────────────────────────────────────

class NovaMind256M(nn.Module):
    """
    NovaMind-256M: 256M parameter conversational LLM.

    Architectural features:
      - Decoder-only transformer (autoregressive)
      - Pre-RMSNorm for training stability
      - GQA (16Q / 4KV) for inference efficiency
      - SwiGLU FFN for parameter efficiency
      - HiRoPE for hierarchical positional encoding (adapted from ACL 2024 paper)
      - Weight-tied embedding and LM head (saves 33M params)
      - Tag-Aware Loss Curriculum [NOVEL]
      - BF16 training, Flash Attention 2
    """

    def __init__(self, config: NovaMindConfig):
        super().__init__()
        self.config = config
        self._use_gradient_checkpointing = False

        # ── Embedding ────────────────────────────────────────────────────
        self.token_emb  = nn.Embedding(config.vocab_size, config.d_model)
        self.emb_dropout = nn.Dropout(config.dropout) if config.dropout > 0 else nn.Identity()

        # ── Transformer Stack ─────────────────────────────────────────────
        self.blocks = nn.ModuleList([
            TransformerBlock(config) for _ in range(config.n_layers)
        ])

        # ── Output head ──────────────────────────────────────────────────
        self.ln_f   = RMSNorm(config.d_model)
        self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)

        # Weight tying: lm_head and token_emb share the same weight matrix.
        # This saves vocab_size Γ— d_model = 32,000 Γ— 1,024 = 32.77M parameters.
        # The embedding learns "what does this token mean?" and the LM head
        # learns "how does the hidden state score as this token?" β€” they're
        # two views of the same embedding space.
        self.lm_head.weight = self.token_emb.weight

        # ── Weight Initialization ────────────────────────────────────────
        self.apply(self._init_weights)

    def _init_weights(self, module: nn.Module):
        """
        GPT-2 style initialization with scaled residual projections.

        Output projections (o_proj, down in FFN) are scaled by 1/sqrt(2*N)
        where N = number of layers. This prevents the residual stream from
        growing proportionally to depth, which would destabilize training.

        Reference: GPT-2 paper, Table 2, "Modified initialization" footnote.
        """
        std = self.config.init_std
        if isinstance(module, nn.Linear):
            nn.init.normal_(module.weight, mean=0.0, std=std)
            if module.bias is not None:
                nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            nn.init.normal_(module.weight, mean=0.0, std=std)

        # Scale residual output projections (o_proj and FFN down)
        # Identify them by name β€” they project *into* the residual stream
        residual_proj_names = {"o_proj", "down"}
        for name, param in module.named_parameters(recurse=False):
            # Use the parent module name to identify projection type
            parent_name = type(module).__name__
            if any(p in parent_name.lower() or p in name.lower()
                   for p in residual_proj_names):
                nn.init.normal_(
                    param,
                    mean=0.0,
                    std=std / math.sqrt(2 * self.config.n_layers),
                )

    # ── Gradient Checkpointing ─────────────────────────────────────────────────
    def gradient_checkpointing_enable(self):
        """
        Enable gradient checkpointing to trade compute for VRAM.
        Discards intermediate activations during forward pass, recomputes
        them during backward. Saves ~40% VRAM at cost of ~20% slower training.
        CRITICAL for fitting 256M model + optimizer states on H100 40GB.
        """
        self._use_gradient_checkpointing = True

    def gradient_checkpointing_disable(self):
        self._use_gradient_checkpointing = False

    # ── Forward Pass ──────────────────────────────────────────────────────────
    def forward(
        self,
        input_ids: torch.Tensor,
        targets: torch.Tensor | None = None,
        token_weights: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor | None]:
        """
        Args:
            input_ids:     [B, T] β€” token ids
            targets:       [B, T] β€” target ids for loss computation (-1 = ignore)
                           If None, loss is not computed (inference mode)
            token_weights: [B, T] β€” per-token float weights for Tag-Aware Loss
                           If None, uses standard uniform cross-entropy

        Returns:
            (logits, loss)
            - logits: [B, T, V] during inference; empty tensor during training
              (returning full logits during training on DataParallel is wasteful β€”
               it forces a massive tensor transfer to GPU 0. We only need the loss.)
            - loss: scalar tensor if targets provided, else None
        """
        B, T = input_ids.shape
        assert T <= self.config.max_seq_len, (
            f"Input length {T} exceeds max_seq_len {self.config.max_seq_len}"
        )

        # Embed tokens
        x = self.emb_dropout(self.token_emb(input_ids))  # [B, T, D]

        # Pass through transformer blocks
        for block in self.blocks:
            if self._use_gradient_checkpointing and self.training:
                # gradient_checkpoint recomputes this block's activations
                # during backward instead of storing them β€” saves VRAM
                x = gradient_checkpoint(block, x, use_reentrant=False)
            else:
                x = block(x)

        # Final norm + project to vocabulary
        logits = self.lm_head(self.ln_f(x))  # [B, T, V]

        loss = None
        if targets is not None:
            loss = tag_aware_loss(logits, targets, token_weights)
            # Drop logits during training to avoid PCIe transfer on multi-GPU
            if self.training:
                logits = torch.empty(0, device=logits.device)

        return logits, loss

    # ── Generation ────────────────────────────────────────────────────────────
    @torch.no_grad()
    def generate(
        self,
        input_ids: torch.Tensor,
        max_new_tokens: int = 256,
        temperature: float = 0.7,
        top_k: int = 50,
        top_p: float = 0.9,
        repetition_penalty: float = 1.2,
        stop_token_ids: list[int] | None = None,
    ) -> torch.Tensor:
        """
        Autoregressive text generation with temperature, top-k, top-p sampling
        and repetition penalty.

        Args:
            input_ids:         [1, T] β€” prompt tokens
            max_new_tokens:    maximum tokens to generate
            temperature:       sampling temperature (lower = more focused)
            top_k:             keep only top-k logits before sampling
            top_p:             nucleus sampling threshold
            repetition_penalty: >1.0 penalizes repeated tokens
            stop_token_ids:    list of token ids that stop generation

        Returns:
            token ids including the prompt: [1, T + generated]
        """
        self.eval()
        for _ in range(max_new_tokens):
            # Truncate context to max_seq_len (sliding window)
            idx_cond = input_ids[:, -self.config.max_seq_len:]

            logits, _ = self(idx_cond)        # [1, T, V]
            logits = logits[:, -1, :]         # [1, V] β€” last position only

            # Repetition penalty: down-weight tokens already in context
            if repetition_penalty != 1.0:
                for token_id in set(idx_cond[0].tolist()):
                    if logits[0, token_id] < 0:
                        logits[0, token_id] *= repetition_penalty
                    else:
                        logits[0, token_id] /= repetition_penalty

            # Temperature scaling
            logits = logits / max(temperature, 1e-8)

            # Top-k filtering: zero out all but top-k logits
            if top_k is not None and top_k > 0:
                top_k_val = min(top_k, logits.size(-1))
                kth_val = torch.topk(logits, top_k_val).values[:, -1, None]
                logits[logits < kth_val] = float("-inf")

            # Top-p (nucleus) filtering: keep smallest set of tokens
            # whose cumulative probability exceeds top_p
            if top_p is not None and 0.0 < top_p < 1.0:
                sorted_logits, sorted_idx = torch.sort(logits, dim=-1, descending=True)
                cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
                # Remove tokens with cumulative prob above threshold
                # (shift by 1 to keep the token that *crosses* the threshold)
                sorted_remove = cumulative_probs - F.softmax(sorted_logits, dim=-1) > top_p
                sorted_logits[sorted_remove] = float("-inf")
                logits = torch.zeros_like(logits).scatter_(
                    dim=-1, index=sorted_idx, src=sorted_logits
                )

            probs   = F.softmax(logits, dim=-1)
            next_id = torch.multinomial(probs, num_samples=1)  # [1, 1]

            input_ids = torch.cat([input_ids, next_id], dim=1)

            # Stop if we hit a stop token
            if stop_token_ids and next_id.item() in stop_token_ids:
                break

        return input_ids

    # ── Parameter Counting ────────────────────────────────────────────────────
    def count_parameters(self, print_table: bool = True) -> int:
        """
        Count and optionally print a detailed parameter breakdown.
        Useful for verifying we hit ~252M.
        """
        cfg = self.config

        embed_params = cfg.vocab_size * cfg.d_model
        hd = cfg.head_dim()

        # Per-layer: Q+K+V+O projections + gate+up+down FFN + 2Γ—RMSNorm
        # QKV projections: (n_q + n_kv + n_kv) Γ— head_dim Γ— d_model
        qkv_params  = (cfg.n_heads + 2 * cfg.n_kv_heads) * hd * cfg.d_model
        # Output projection: d_model Γ— d_model
        o_params    = cfg.d_model * cfg.d_model
        attn_params = qkv_params + o_params
        # SwiGLU: gate + up + down (3 matrices)
        ffn_params  = 3 * cfg.d_model * cfg.ff_dim
        norm_params = 2 * cfg.d_model   # two RMSNorm per block
        per_layer   = attn_params + ffn_params + norm_params

        final_norm  = cfg.d_model
        total_with_tie = embed_params + cfg.n_layers * per_layer + final_norm
        total_trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)

        if print_table:
            w = 46
            print("─" * w)
            print(f"  NovaMind-256M Parameter Breakdown")
            print("─" * w)
            print(f"  {'Token Embedding (shared w/ lm_head)':<40} {embed_params/1e6:>6.2f}M")
            print(f"  {'Attention QKV projections (per layer)':<40} {qkv_params/1e6:>6.2f}M")
            print(f"  {'Attention O projection (per layer)':<40} {o_params/1e6:>6.2f}M")
            print(f"  {'SwiGLU FFN (per layer)':<40} {ffn_params/1e6:>6.2f}M")
            print(f"  {'RMSNorm Γ— 2 (per layer)':<40} {norm_params/1e6:>6.4f}M")
            print(f"  {'Γ— {n} layers total':<40} {cfg.n_layers * per_layer/1e6:>6.2f}M")
            print(f"  {'Final RMSNorm':<40} {final_norm/1e6:>6.4f}M")
            print(f"  {'LM Head (weight-tied to embedding)':<40} {'β†’':>4}  0.00M")
            print("─" * w)
            print(f"  {'TOTAL (formula estimate)':<40} {total_with_tie/1e6:>6.2f}M")
            print(f"  {'TOTAL (actual PyTorch count)':<40} {total_trainable/1e6:>6.2f}M")
            print("─" * w)

        return total_trainable


# ──────────────────────────────────────────────────────────────────────────────
# QUICK SANITY CHECK
# ──────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys

    config = NovaMindConfig()
    print(f"\nBuilding NovaMind-256M with config:")
    for k, v in config.to_dict().items():
        print(f"  {k}: {v}")
    print()

    model = NovaMind256M(config)
    n_params = model.count_parameters(print_table=True)

    # Verify target range
    target_min, target_max = 245e6, 265e6
    status = "βœ…" if target_min <= n_params <= target_max else "❌"
    print(f"\n{status} Parameter count: {n_params/1e6:.2f}M (target: 245–265M)\n")

    if not (target_min <= n_params <= target_max):
        sys.exit(1)

    # Quick forward pass test
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    model.gradient_checkpointing_enable()

    B, T = 2, 128
    ids     = torch.randint(0, config.vocab_size, (B, T), device=device)
    targets = ids.clone()
    weights = torch.ones(B, T, device=device)
    weights[:, :10] = 0.0   # mask first 10 tokens (simulate user tokens)
    weights[:, 20:40] = 1.5  # boost tokens 20-40 (simulate think tokens in Phase 3)

    with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
        logits, loss = model(ids, targets, weights)

    print(f"Forward pass OK β€” loss: {loss.item():.4f}")
    print(f"Logits shape during training: {logits.shape}  (should be empty [0])")

    # Test generation
    prompt = torch.randint(0, config.vocab_size, (1, 10), device=device)
    with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
        generated = model.generate(prompt, max_new_tokens=20, temperature=0.8)
    print(f"Generation OK β€” output shape: {generated.shape}")
    print("\nβœ… All checks passed. model.py is ready.\n")