AlaBoussoffara commited on
Commit
dc8d52b
·
0 Parent(s):

initial commit with working prototype

Browse files
.dockerignore ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # 1. Python cache & build artifacts
3
+ # =========================================
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+ *.so
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+
16
+ # =========================================
17
+ # 2. Virtual environments
18
+ # =========================================
19
+ env/
20
+ .venv/
21
+ venv/
22
+ condaenv.*.requirements.txt
23
+
24
+ # =========================================
25
+ # 3. Jupyter notebooks
26
+ # =========================================
27
+ .ipynb_checkpoints/
28
+ *.nbconvert.ipynb
29
+
30
+ # =========================================
31
+ # 4. Logs & temp files
32
+ # =========================================
33
+ *.log
34
+ *.tmp
35
+ *.swp
36
+ *.swo
37
+ *.bak
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ # =========================================
42
+ # 5. IDE / Editor configs
43
+ # =========================================
44
+ .vscode/
45
+ .idea/
46
+ *.sublime-*
47
+ *.iml
48
+
49
+ # =========================================
50
+ # 6. Machine Learning artifacts
51
+ # =========================================
52
+ data/
53
+ outputs/
54
+ runs/
55
+ artifacts/
56
+ *.pt
57
+ *.pth
58
+ *.ckpt
59
+ *.h5
60
+ *.onnx
61
+
62
+ # =========================================
63
+ # 7. Version control
64
+ # =========================================
65
+ .git
66
+ .gitignore
67
+ .pre-commit-config.yaml
.gitignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # Python cache & build artifacts
3
+ # =========================================
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+ *.so
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+
16
+
17
+ # =========================================
18
+ # Virtual / Conda environments
19
+ # =========================================
20
+ env/
21
+ .venv/
22
+ venv/
23
+ condaenv.*.requirements.txt
24
+
25
+ # =========================================
26
+ # Jupyter / Notebooks
27
+ # =========================================
28
+ .ipynb_checkpoints/
29
+ *.nbconvert.ipynb
30
+
31
+ # =========================================
32
+ # Logs / Temporary files
33
+ # =========================================
34
+ *.log
35
+ *.tmp
36
+ *.swp
37
+ *.swo
38
+ *.bak
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # =========================================
43
+ # IDE / Editor settings
44
+ # =========================================
45
+ .vscode/
46
+ .idea/
47
+ *.sublime-*
48
+ *.iml
49
+
50
+ # =========================================
51
+ # Machine Learning artifacts
52
+ # =========================================
53
+ data/
54
+ outputs/
55
+ runs/
56
+ artifacts/
57
+ *.pt
58
+ *.pth
59
+ *.ckpt
60
+ *.h5
61
+ *.onnx
.pre-commit-config.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.12.12
4
+ hooks:
5
+ - id: ruff # lints
6
+ - id: ruff-format # formats (ruff's formatter is black-compatible)
7
+ - repo: https://github.com/pre-commit/pre-commit-hooks
8
+ rev: v6.0.0
9
+ hooks:
10
+ - id: end-of-file-fixer
11
+ - id: trailing-whitespace
12
+ - repo: https://github.com/pre-commit/mirrors-mypy
13
+ rev: v1.17.1
14
+ hooks:
15
+ - id: mypy
16
+ args: ["--config-file=pyproject.toml"]
17
+ additional_dependencies: [pydantic>=2]
Dockerfile.dev ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.12-slim
3
+ # FROM = choose a base OS + language runtime (Debian slim with Python 3.12)
4
+
5
+ ENV DEBIAN_FRONTEND=noninteractive \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ PYTHONDONTWRITEBYTECODE=1 \
8
+ PYTHONUNBUFFERED=1
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ git build-essential && \
12
+ rm -rf /var/lib/apt/lists/*
13
+ # RUN = execute at build time
14
+ # apt-get update -> refresh package index
15
+ # apt-get install -> install tools you might need (git, compilers)
16
+ # --no-install-recommends -> fewer extra packages
17
+ # rm -rf ... -> clean apt cache to shrink the layer
18
+
19
+ WORKDIR /app
20
+ # WORKDIR = default directory for the following Dockerfile steps and 'docker run' processes
21
+
22
+ COPY requirements.txt pyproject.toml ./
23
+ # COPY = bring files from host into the image
24
+ # Only copying dependency metadata first lets Docker cache layer if code changes later
25
+
26
+ RUN pip install -U pip && pip install -r requirements.txt
27
+ # pip install -U pip -> upgrade pip in the image
28
+ # pip install -r ... -> install your Python runtime dependencies
29
+
30
+ COPY . .
31
+ # Now copy your actual code (done AFTER deps for better caching)
32
+
33
+ RUN pip install -e .[dev]
34
+ # -e . = editable install (your src is importable as a package without rebuilding)
35
+
36
+ CMD ["pytest"]
37
+ # CMD = default command when a container starts from this image
38
+ # Here: run unit tests quietly (-q) in tests/units
Makefile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: help conda-setup precommit lint fmt type test clean docker-build docker-run
2
+
3
+ help:
4
+ @echo "Common Makefile commands:"
5
+ @echo " conda-setup - Create and activate conda env, install all dependencies, setup pre-commit"
6
+ @echo " precommit - Run all pre-commit hooks (lint, fmt, type)"
7
+ @echo " lint - Run ruff linter"
8
+ @echo " fmt - Run ruff formatter and black"
9
+ @echo " type - Run mypy type checks"
10
+ @echo " test - Run all tests with pytest and coverage"
11
+ @echo " clean - Remove build, cache, and venv artifacts"
12
+ @echo " docker-build - Build the Docker dev image"
13
+ @echo " docker-run - Run the Docker dev container (interactive)"
14
+
15
+ conda-setup:
16
+ conda env create -n transformer -f environment.yml|| conda env update -n transformer -f environment.yml
17
+ conda run -n transformer pip install -e .[dev]
18
+ conda run -n transformer pre-commit install
19
+
20
+ precommit:
21
+ conda run -n transformer pre-commit run --all-files
22
+
23
+ lint:
24
+ conda run -n transformer ruff check .
25
+
26
+ fmt:
27
+ conda run -n transformer ruff format .
28
+ conda run -n transformer black src tests
29
+
30
+ type:
31
+ conda run -n transformer mypy src
32
+
33
+ test:
34
+ conda run -n transformer pytest
35
+ clean:
36
+ rm -rf .venv .pytest_cache .mypy_cache dist build *.egg-info
37
+
38
+ docker-build:
39
+ docker compose build dev --no-cache
40
+
41
+ docker-run:
42
+ docker compose run --rm dev bash
README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # transformer
2
+
3
+ Minimal encoder–decoder Transformer.
4
+
5
+ ## Quickstart
6
+ ```bash
7
+ make conda-setup
8
+ make test
architecture.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Transformer:
2
+ embedding:
3
+ input_embedding (same for encoder and decoder)
4
+ positional_embedding
5
+ dropout
6
+ encoder
7
+ encoder_layer *N
8
+ multihead_self_attention
9
+ dropout+norm
10
+ feedforward:
11
+ linear
12
+ relu
13
+ linear
14
+ dropout+norm
15
+ decoder
16
+ decoder_layer*N
17
+ masked_multihead_self_attention
18
+ dropout+norm
19
+ multihead_cross_attention
20
+ dropout+norm
21
+ feedforward:
22
+ linear
23
+ relu
24
+ linear
25
+ dropout+norm
26
+ lm_head:
27
+ linear (same parameters as the embedding)
28
+ softmax
configs/infer_mode.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ defaults:
2
+ - model: test_model
3
+ - runtime: runtime
4
+ - _self_
5
+ input_text: ""
6
+ max_new_tokens: 64
7
+ temperature: 1.0
8
+ top_k: 4
9
+ top_p: 0.7
10
+ do_sample: True
configs/model/test_model.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ d_model: 256
2
+ num_heads: 4
3
+ num_layers: 4
4
+ d_ff: 1024
5
+ dropout_rate: 0.1
6
+ vocab_size: 32100
7
+ max_seq_len: 128
8
+ pad_id: 0
9
+ bos_id: 1
10
+ eos_id: 2
configs/optimizer/optimizer.yaml ADDED
File without changes
configs/runtime/runtime.yaml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ tokenizer_name: t5-small
2
+ weights_path: artifacts/checkpoints/initial_model_weights.pth
3
+ artifact_dir: outputs/${now:%Y-%m-%d}/${now:%H-%M-%S}
configs/train_mode.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ batch_size: 16
3
+ epochs: 3
4
+ lr: 3e-4
5
+ weight_decay: 0.01
6
+ grad_clip: 1.0
7
+ precision: "fp32" # one of: fp32, fp16, bf16
8
+ checkpoint_dir: "artifacts/checkpoints"
9
+ log_dir: "artifacts/mlruns"
docker-compose.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ dev:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile.dev
6
+ image: transformer:dev
7
+ container_name: transformer_dev
8
+ working_dir: /app
9
+ volumes:
10
+ - ./artifacts:/app/artifacts
11
+ stdin_open: true
12
+ tty: true
13
+ gpus: all
14
+ command: bash
environment.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: transformer
2
+ channels:
3
+ - defaults
4
+ dependencies:
5
+ - python=3.12
6
+ - pip
7
+ - pip:
8
+ - --extra-index-url https://download.pytorch.org/whl/cu129
9
+ - torch==2.8.0+cu129
10
+ - transformers
11
+ - omegaconf
12
+ - hydra-core
pyproject.toml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "transformer"
3
+ version = "0.1.0"
4
+ description = "A transformer implementation"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "torch==2.8",
9
+ "transformers",
10
+ "omegaconf",
11
+ "hydra-core",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest",
17
+ "pytest-cov",
18
+ "ruff",
19
+ "black",
20
+ "mypy",
21
+ "pre-commit",
22
+ ]
23
+
24
+ [tool.setuptools]
25
+ package-dir = {"" = "src"}
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=42", "wheel"]
29
+ build-backend = "setuptools.build_meta"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ minversion = "7.0"
36
+ addopts = "-ra -q"
37
+ testpaths = ["tests"]
38
+
39
+ [tool.black]
40
+ line-length = 100
41
+ target-version = ["py310"]
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E","F","I","B","UP"]
48
+ ignore = ["E501"]
49
+
50
+ [tool.mypy]
51
+ python_version = "3.11"
52
+ strict = false
53
+ ignore_missing_imports = true
54
+
55
+ # Make mypy understand the src/ layout
56
+ files = ["src"]
57
+ mypy_path = ["src"]
58
+ explicit_package_bases = true
requirements.txt ADDED
Binary file (233 Bytes). View file
 
scripts/inference.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hydra
2
+ import torch
3
+ from hydra.utils import to_absolute_path
4
+ from omegaconf import DictConfig, OmegaConf
5
+ from transformers import AutoTokenizer
6
+
7
+ from transformer import (
8
+ BasicEncDecCfg,
9
+ BasicEncoderDecoderTransformer,
10
+ InferAppCfg,
11
+ )
12
+
13
+
14
+ @hydra.main(config_path="../configs", config_name="infer_mode", version_base=None)
15
+ def main(cfg: DictConfig):
16
+ scfg_temp = OmegaConf.merge(OmegaConf.structured(InferAppCfg), cfg)
17
+ scfg: InferAppCfg = OmegaConf.to_object(scfg_temp)
18
+ model_cfg = BasicEncDecCfg(**vars(scfg.model))
19
+
20
+ transformer = BasicEncoderDecoderTransformer(model_cfg)
21
+ tokenizer = AutoTokenizer.from_pretrained(
22
+ scfg.runtime.tokenizer_name,
23
+ use_fast=True,
24
+ )
25
+
26
+ if scfg.runtime.weights_path:
27
+ ckpt = torch.load(to_absolute_path(scfg.runtime.weights_path), map_location="cpu")
28
+ state = ckpt.get("model_state_dict", ckpt)
29
+ transformer.load_state_dict(state, strict=False)
30
+ transformer.eval()
31
+
32
+ text = scfg.input_text
33
+ if not text:
34
+ raise SystemExit('Pass text like: input_text="hello world"')
35
+
36
+ encoded = tokenizer(text, padding=True, truncation=True, return_tensors="pt")
37
+ src_ids = encoded["input_ids"]
38
+ src_padd_mask = (encoded["attention_mask"] == 0).unsqueeze(1).unsqueeze(2)
39
+ tgt_ids = transformer.generate(
40
+ src_ids,
41
+ src_padd_mask,
42
+ max_new_tokens=scfg.max_new_tokens,
43
+ temperature=scfg.temperature,
44
+ top_k=scfg.top_k,
45
+ top_p=scfg.top_p,
46
+ do_sample=scfg.do_sample,
47
+ )
48
+ output = tokenizer.batch_decode(tgt_ids, skip_special_tokens=True)
49
+ print(output)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
scripts/train.py ADDED
File without changes
src/transformer/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .configs import BasicEncDecCfg, InferAppCfg, TrainAppCfg
2
+ from .transformer import BasicEncoderDecoderTransformer
3
+
4
+ __all__ = ["BasicEncoderDecoderTransformer", "BasicEncDecCfg", "TrainAppCfg", "InferAppCfg"]
src/transformer/configs.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class BasicEncDecCfg:
6
+ vocab_size: int
7
+ d_model: int
8
+ max_seq_len: int
9
+ num_heads: int
10
+ d_ff: int
11
+ num_layers: int
12
+ dropout_rate: float = 0.1
13
+ pad_id: int = 0
14
+ bos_id: int = 2
15
+ eos_id: int = 1
16
+
17
+
18
+ @dataclass
19
+ class DataCfg:
20
+ train_path: str
21
+ val_path: str
22
+ num_workers: int = 4
23
+ batch_size: int = 32
24
+ max_length: int = 512
25
+
26
+
27
+ @dataclass
28
+ class OptimCfg:
29
+ lr: float = 3e-4
30
+ betas: tuple[float, float] = (0.9, 0.95)
31
+ weight_decay: float = 0.1
32
+ eps: float = 1e-8
33
+
34
+
35
+ @dataclass
36
+ class SchedCfg:
37
+ type: str = "cosine"
38
+ warmup_steps: int = 2000
39
+ total_steps: int = 200000
40
+ min_lr: float = 1e-5
41
+
42
+
43
+ @dataclass
44
+ class TrainerCfg:
45
+ epochs: int = 10
46
+ gradient_accumulation: int = 1
47
+ eval_interval: int = 1000
48
+ save_dir: str = "outputs/run"
49
+
50
+
51
+ @dataclass
52
+ class RuntimeCfg:
53
+ tokenizer_name: str = "t5-small"
54
+ weights_path: str = ""
55
+ artifact_dir: str = "outputs/run"
56
+
57
+
58
+ @dataclass
59
+ class TrainAppCfg:
60
+ seed: int
61
+ model: BasicEncDecCfg
62
+ data: DataCfg
63
+ optim: OptimCfg
64
+ scheduler: SchedCfg
65
+ trainer: TrainerCfg
66
+ runtime: RuntimeCfg
67
+
68
+
69
+ @dataclass
70
+ class InferAppCfg:
71
+ model: BasicEncDecCfg
72
+ runtime: RuntimeCfg
73
+ input_text: str = ""
74
+ max_new_tokens: int = 64
75
+ temperature: float = 1.0
76
+ top_k: int = 0
77
+ top_p: float = 1.0
78
+ do_sample: bool = False
src/transformer/modules/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from transformer.modules.decoder import TransformerDecoder
2
+ from transformer.modules.embedding import InputEmbedding
3
+ from transformer.modules.encoder import TransformerEncoder
4
+ from transformer.modules.lm_head import LMHead
5
+
6
+ __all__ = ["TransformerEncoder", "TransformerDecoder", "InputEmbedding", "LMHead"]
src/transformer/modules/attention.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch import Tensor
4
+
5
+ from transformer.utils import calculate_attention, join_heads, split_heads
6
+
7
+
8
+ class MultiHeadAttention(nn.Module):
9
+ """
10
+ Multi-head attention: linear projections -> split heads -> scaled dot-product
11
+ attention (via utils) -> merge heads -> output projection (+ dropout).
12
+
13
+ Args:
14
+ d_model (int): Model dimension (>0). Must be divisible by num_heads.
15
+ num_heads (int): Number of attention heads (>0).
16
+ dropout_rate (float): Dropout probability in (0,1).
17
+
18
+ Inputs:
19
+ query, key, value: (B, S, D) with D == d_model
20
+ mask (optional): Tensor broadcastable to (B, H, S_q, S_k), either boolean
21
+ (True = masked) or additive float mask.
22
+
23
+ Returns:
24
+ Tensor: (B, S_q, D)
25
+ """
26
+
27
+ def __init__(self, d_model: int, num_heads: int, dropout_rate: float):
28
+ super().__init__()
29
+ # ---- type checks
30
+ if not isinstance(num_heads, int):
31
+ raise TypeError(f"num_heads must be an int, got {type(num_heads)}")
32
+ if not isinstance(d_model, int):
33
+ raise TypeError(f"d_model must be an int, got {type(d_model)}")
34
+ if not isinstance(dropout_rate, float):
35
+ raise TypeError(f"dropout_rate must be a float, got {type(dropout_rate)}")
36
+
37
+ # ---- value checks
38
+ if num_heads <= 0:
39
+ raise ValueError(f"num_heads must be strictly greater than 0, got {num_heads}")
40
+ if d_model <= 0:
41
+ raise ValueError(f"d_model must be strictly greater than 0, got {d_model}")
42
+ if d_model % num_heads != 0:
43
+ raise ValueError("d_model must be divisible by num_heads")
44
+ if not (0 <= dropout_rate <= 1):
45
+ raise ValueError(f"dropout_rate must be between 0 and 1 included, got {dropout_rate}")
46
+
47
+ self.d_model = d_model
48
+ self.num_heads = num_heads
49
+ self.d_head = d_model // num_heads
50
+ self.dropout_rate = dropout_rate
51
+
52
+ self.query_linear = nn.Linear(d_model, d_model, bias=False)
53
+ self.key_linear = nn.Linear(d_model, d_model, bias=False)
54
+ self.value_linear = nn.Linear(d_model, d_model, bias=False)
55
+ self.output_linear = nn.Linear(d_model, d_model, bias=True)
56
+ self.dropout = nn.Dropout(dropout_rate)
57
+
58
+ def forward(
59
+ self,
60
+ query: Tensor,
61
+ key: Tensor,
62
+ value: Tensor,
63
+ mask: Tensor | None,
64
+ ) -> Tensor:
65
+ # ---- basic type checks
66
+ if not isinstance(query, torch.Tensor):
67
+ raise TypeError(f"query must be a torch.Tensor, got {type(query)}")
68
+ if not isinstance(key, torch.Tensor):
69
+ raise TypeError(f"key must be a torch.Tensor, got {type(key)}")
70
+ if not isinstance(value, torch.Tensor):
71
+ raise TypeError(f"value must be a torch.Tensor, got {type(value)}")
72
+ if mask is not None and not isinstance(mask, torch.Tensor):
73
+ raise TypeError(f"mask must be a torch.Tensor or None, got {type(mask)}")
74
+
75
+ # ---- shape checks for q/k/v (3D [B,S,D] and D == d_model)
76
+ if query.dim() != 3 or key.dim() != 3 or value.dim() != 3:
77
+ raise ValueError(
78
+ "query/key/value must be 3D tensors of shape (B, S, D); "
79
+ f"got q={tuple(query.shape)}, k={tuple(key.shape)}, v={tuple(value.shape)}"
80
+ )
81
+ Bq, Sq, Dq = query.shape
82
+ Bk, Sk, Dk = key.shape
83
+ Bv, Sv, Dv = value.shape
84
+
85
+ if not (Dq == Dk == Dv == self.d_model):
86
+ raise ValueError(
87
+ f"Last dimension must equal d_model={self.d_model}; got Dq={Dq}, Dk={Dk}, Dv={Dv}"
88
+ )
89
+ if not (Bq == Bk == Bv):
90
+ raise ValueError(f"Batch size mismatch: q={Bq}, k={Bk}, v={Bv}")
91
+ if Sk != Sv:
92
+ raise ValueError(f"Key/Value seq length mismatch: Sk={Sk} vs Sv={Sv}")
93
+
94
+ # ---- project and split into heads -> (B, H, S, Dh)
95
+ q = split_heads(self.query_linear(query), self.num_heads)
96
+ k = split_heads(self.key_linear(key), self.num_heads)
97
+ v = split_heads(self.value_linear(value), self.num_heads)
98
+
99
+ # ---- attention (utils handles mask broadcasting/device and numeric stability)
100
+ p = self.dropout_rate if self.training else 0.0
101
+ attn = calculate_attention(q, k, v, mask, attn_dropout_p=p) # (B, H, Sq, Dh)
102
+
103
+ # ---- merge heads, project out, dropout
104
+ out = join_heads(attn) # (B, Sq, D)
105
+ return self.dropout(self.output_linear(out))
src/transformer/modules/decoder.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch import Tensor
4
+
5
+ from transformer.modules.attention import MultiHeadAttention
6
+ from transformer.modules.feedforward import FeedForwardLayer
7
+ from transformer.utils import combine_masks
8
+
9
+
10
+ class DecoderLayer(nn.Module):
11
+ def __init__(self, d_model: int, num_heads: int, d_ff: int, dropout_rate: float):
12
+ super().__init__()
13
+
14
+ if not isinstance(d_model, int):
15
+ raise TypeError(f"d_model must be an int, got {type(d_model)}")
16
+ if not isinstance(num_heads, int):
17
+ raise TypeError(f"num_heads must be an int, got {type(num_heads)}")
18
+ if not isinstance(d_ff, int):
19
+ raise TypeError(f"d_ff must be an int, got {type(d_ff)}")
20
+ if not isinstance(dropout_rate, float):
21
+ raise TypeError(f"dropout_rate must be a float, got {type(dropout_rate)}")
22
+
23
+ if not d_model > 0:
24
+ raise ValueError(f"d_model must be strictly greater than 0, got {d_model}")
25
+ if not num_heads > 0:
26
+ raise ValueError(f"num_heads must be strictly greater than 0, got {num_heads}")
27
+ if not d_ff > 0:
28
+ raise ValueError(f"d_ff must be strictly greater than 0, got {d_ff}")
29
+ if not (0.0 <= dropout_rate < 1.0):
30
+ raise ValueError(f"dropout_rate must be between 0 and 1 excluded, got {dropout_rate}")
31
+
32
+ self.self_attention_layer = MultiHeadAttention(d_model, num_heads, dropout_rate)
33
+ self.feed_forward = FeedForwardLayer(d_model, d_ff)
34
+ self.cross_attention_layer = MultiHeadAttention(d_model, num_heads, dropout_rate)
35
+ self.norm1 = nn.LayerNorm(d_model)
36
+ self.dropout1 = nn.Dropout(dropout_rate)
37
+ self.norm2 = nn.LayerNorm(d_model)
38
+ self.dropout2 = nn.Dropout(dropout_rate)
39
+ self.norm3 = nn.LayerNorm(d_model)
40
+ self.dropout3 = nn.Dropout(dropout_rate)
41
+
42
+ def forward(
43
+ self,
44
+ x: Tensor,
45
+ y: Tensor,
46
+ src_padding_mask: Tensor | None,
47
+ tgt_padding_mask: Tensor | None,
48
+ tgt_causal_mask: Tensor | None,
49
+ ) -> Tensor:
50
+ if not isinstance(x, torch.Tensor):
51
+ raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
52
+ if not isinstance(y, torch.Tensor):
53
+ raise TypeError(f"y must be a torch.Tensor, got {type(y)}")
54
+ if not (x.dim() == 3):
55
+ raise ValueError(
56
+ f"x must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(x.shape)}"
57
+ )
58
+ if not (y.dim() == 3):
59
+ raise ValueError(
60
+ f"y must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(y.shape)}"
61
+ )
62
+ if not (x.shape[0] == y.shape[0] and x.shape[-1] == y.shape[-1]):
63
+ raise ValueError(
64
+ "Batch size or d_model mismatch between encoder memory and decoder input"
65
+ )
66
+
67
+ tgt_mask = combine_masks(tgt_padding_mask, tgt_causal_mask)
68
+ y = self.norm1(y + self.dropout1(self.self_attention_layer(y, y, y, tgt_mask)))
69
+ y = self.norm2(y + self.dropout2(self.cross_attention_layer(y, x, x, src_padding_mask)))
70
+ y = self.norm3(y + self.dropout3(self.feed_forward(y)))
71
+ return y
72
+
73
+
74
+ class TransformerDecoder(nn.Module):
75
+ def __init__(
76
+ self, d_model: int, num_heads: int, d_ff: int, num_layers: int, dropout_rate: float
77
+ ):
78
+ super().__init__()
79
+
80
+ if not isinstance(num_layers, int):
81
+ raise TypeError(f"num_layers must be an int, got {type(num_layers)}")
82
+ if not num_layers > 0:
83
+ raise ValueError(f"num_layers must be strictly greater than 0, got {num_layers}")
84
+
85
+ self.layers = nn.ModuleList(
86
+ [DecoderLayer(d_model, num_heads, d_ff, dropout_rate) for _ in range(num_layers)]
87
+ )
88
+
89
+ def forward(
90
+ self,
91
+ x: Tensor,
92
+ y: Tensor,
93
+ src_padding_mask: Tensor | None,
94
+ tgt_padding_mask: Tensor | None,
95
+ tgt_causal_mask: Tensor | None,
96
+ ) -> Tensor:
97
+ for layer in self.layers:
98
+ y = layer(x, y, src_padding_mask, tgt_padding_mask, tgt_causal_mask)
99
+ return y
src/transformer/modules/embedding.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch import Tensor
4
+
5
+ from transformer.utils import sinusoidal_positional_encoding
6
+
7
+
8
+ class InputEmbedding(nn.Module):
9
+ """
10
+ Token + positional embedding module.
11
+
12
+ Args:
13
+ vocab_size (int): Size of vocabulary (>0).
14
+ d_model (int): Embedding dimension (>0).
15
+ sequence_length (int): Maximum sequence length (≥0).
16
+ pad_id (int): Padding token ID (0 <= pad_id < vocab_size).
17
+
18
+ Input:
19
+ x (LongTensor): shape (batch_size, seq_len), token IDs.
20
+
21
+ Output:
22
+ Tensor: shape (batch_size, seq_len, d_model), embeddings.
23
+
24
+ Notes:
25
+ - Zero-length sequences are allowed (returns [B, 0, D]).
26
+ - Positional encodings are sinusoidal and added to token embeddings.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ vocab_size: int,
32
+ d_model: int,
33
+ sequence_length: int,
34
+ pad_id: int,
35
+ dropout_rate: float = 0.0,
36
+ ):
37
+ super().__init__()
38
+ # --- type checks
39
+ if not isinstance(vocab_size, int):
40
+ raise TypeError(f"vocab_size must be int, got {type(vocab_size)}")
41
+ if not isinstance(d_model, int):
42
+ raise TypeError(f"d_model must be int, got {type(d_model)}")
43
+ if not isinstance(sequence_length, int):
44
+ raise TypeError(f"sequence_length must be int, got {type(sequence_length)}")
45
+ if not isinstance(pad_id, int):
46
+ raise TypeError(f"pad_id must be int, got {type(pad_id)}")
47
+ if not isinstance(dropout_rate, float):
48
+ raise TypeError(f"dropout_rate must be a float, got {type(dropout_rate)}")
49
+
50
+ # --- value checks
51
+ if vocab_size <= 0:
52
+ raise ValueError(f"vocab_size must be > 0, got {vocab_size}")
53
+ if d_model <= 0:
54
+ raise ValueError(f"d_model must be > 0, got {d_model}")
55
+ if sequence_length < 0:
56
+ raise ValueError(f"sequence_length must be >= 0, got {sequence_length}")
57
+ if not (0 <= pad_id < vocab_size):
58
+ raise ValueError(f"pad_id must be in [0, {vocab_size - 1}], got {pad_id}")
59
+ if not (0 <= dropout_rate <= 1):
60
+ raise ValueError(f"dropout_rate must be in [0,1], got {dropout_rate}")
61
+ self.vocab_size = vocab_size
62
+ self.d_model = d_model
63
+ self.sequence_length = sequence_length
64
+ self.pad_id = pad_id
65
+ self.dropout_rate = dropout_rate
66
+
67
+ self.token_embed = nn.Embedding(vocab_size, d_model, padding_idx=pad_id)
68
+ self.pos_embed = PositionalEmbedding(sequence_length, d_model)
69
+ self.dropout = nn.Dropout(dropout_rate)
70
+
71
+ def forward(self, x: Tensor) -> Tensor:
72
+ if not isinstance(x, torch.Tensor):
73
+ raise TypeError(f"x must be Tensor, got {type(x)}")
74
+ if x.dtype != torch.long:
75
+ raise TypeError(f"x must be torch.long, got {x.dtype}")
76
+ if x.dim() != 2:
77
+ raise ValueError(f"x must be 2D (B, S), got shape {tuple(x.shape)}")
78
+
79
+ tok = self.token_embed(x) # (B, S, D) — S can be 0
80
+ return self.dropout(self.pos_embed(tok)) # (B, S, D)
81
+
82
+
83
+ class PositionalEmbedding(nn.Module):
84
+ """
85
+ Adds sinusoidal positional encodings.
86
+
87
+ Args:
88
+ sequence_length (int): Maximum sequence length (≥0).
89
+ d_model (int): Embedding dimension (>0).
90
+
91
+ Notes:
92
+ - Zero-length tables are supported (shape [1, 0, D]).
93
+ """
94
+
95
+ def __init__(self, sequence_length: int, d_model: int):
96
+ super().__init__()
97
+
98
+ if sequence_length < 0:
99
+ raise ValueError(f"sequence_length must be >= 0, got {sequence_length}")
100
+ if d_model <= 0:
101
+ raise ValueError(f"d_model must be > 0, got {d_model}")
102
+
103
+ self.sequence_length = sequence_length
104
+ self.d_model = d_model
105
+
106
+ pe = sinusoidal_positional_encoding(sequence_length, d_model) # [S, D]
107
+ self.register_buffer("pe", pe.unsqueeze(0)) # [1, S, D]
108
+
109
+ def forward(self, x: Tensor) -> Tensor:
110
+ if not isinstance(x, torch.Tensor):
111
+ raise TypeError(f"x must be Tensor, got {type(x)}")
112
+ if x.dim() != 3:
113
+ raise ValueError(f"x must be 3D (B, S, D), got shape {tuple(x.shape)}")
114
+
115
+ _, S, D = x.shape
116
+ if D != self.d_model:
117
+ raise ValueError(f"d_model mismatch: got {D}, expected {self.d_model}")
118
+ if S > self.sequence_length:
119
+ raise ValueError(f"seq_len {S} exceeds max_seq_len {self.sequence_length}")
120
+
121
+ return x + self.pe[:, :S].to(dtype=x.dtype, device=x.device)
src/transformer/modules/encoder.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch import Tensor
4
+
5
+ from transformer.modules.attention import MultiHeadAttention
6
+ from transformer.modules.feedforward import FeedForwardLayer
7
+
8
+
9
+ class EncoderLayer(nn.Module):
10
+ def __init__(self, d_model: int, num_heads: int, d_ff: int, dropout_rate: float):
11
+ super().__init__()
12
+
13
+ # --- type checks (match project convention)
14
+ if not isinstance(d_model, int):
15
+ raise TypeError(f"d_model must be an int, got {type(d_model)}")
16
+ if not isinstance(num_heads, int):
17
+ raise TypeError(f"num_heads must be an int, got {type(num_heads)}")
18
+ if not isinstance(d_ff, int):
19
+ raise TypeError(f"d_ff must be an int, got {type(d_ff)}")
20
+ if not isinstance(dropout_rate, float):
21
+ raise TypeError(f"dropout_rate must be a float, got {type(dropout_rate)}")
22
+
23
+ # --- value checks (match wording)
24
+ if not d_model > 0:
25
+ raise ValueError(f"d_model must be strictly greater than 0, got {d_model}")
26
+ if not num_heads > 0:
27
+ raise ValueError(f"num_heads must be strictly greater than 0, got {num_heads}")
28
+ if not d_ff > 0:
29
+ raise ValueError(f"d_ff must be strictly greater than 0, got {d_ff}")
30
+ if not (0.0 <= dropout_rate < 1.0):
31
+ raise ValueError(f"dropout_rate must be between 0 and 1 excluded, got {dropout_rate}")
32
+
33
+ self.attention_layer = MultiHeadAttention(d_model, num_heads, dropout_rate)
34
+ self.feed_forward = FeedForwardLayer(d_model, d_ff)
35
+ self.norm1 = nn.LayerNorm(d_model)
36
+ self.dropout1 = nn.Dropout(dropout_rate)
37
+ self.norm2 = nn.LayerNorm(d_model)
38
+ self.dropout2 = nn.Dropout(dropout_rate)
39
+
40
+ def forward(self, x: Tensor, src_padding_mask: Tensor | None) -> Tensor:
41
+ if not isinstance(x, torch.Tensor):
42
+ raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
43
+ if not (x.dim() == 3):
44
+ raise ValueError(
45
+ f"x must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(x.shape)}"
46
+ )
47
+
48
+ x = self.norm1(x + self.dropout1(self.attention_layer(x, x, x, src_padding_mask)))
49
+ x = self.norm2(x + self.dropout2(self.feed_forward(x)))
50
+ return x
51
+
52
+
53
+ class TransformerEncoder(nn.Module):
54
+ def __init__(
55
+ self, d_model: int, num_heads: int, d_ff: int, num_layers: int, dropout_rate: float
56
+ ):
57
+ super().__init__()
58
+
59
+ if not isinstance(num_layers, int):
60
+ raise TypeError(f"num_layers must be an int, got {type(num_layers)}")
61
+ if not num_layers > 0:
62
+ raise ValueError(f"num_layers must be strictly greater than 0, got {num_layers}")
63
+
64
+ self.layers = nn.ModuleList(
65
+ [EncoderLayer(d_model, num_heads, d_ff, dropout_rate) for _ in range(num_layers)]
66
+ )
67
+
68
+ def forward(self, x: Tensor, src_padding_mask: Tensor | None) -> Tensor:
69
+ for layer in self.layers:
70
+ x = layer(x, src_padding_mask)
71
+ return x
src/transformer/modules/feedforward.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class FeedForwardLayer(nn.Module):
6
+ """
7
+ Position-wise feed-forward layer used in Transformer blocks.
8
+
9
+ Architecture:
10
+ fc1: Linear(d_model -> d_ff)
11
+ activation: ReLU
12
+ dropout: nn.Dropout(dropout_rate)
13
+ fc2: Linear(d_ff -> d_model)
14
+
15
+ Args:
16
+ d_model (int): Dimensionality of model embeddings.
17
+ d_ff (int): Hidden dimensionality of feed-forward layer.
18
+ dropout_rate (float): Dropout probability between 0 and 1 (exclusive).
19
+
20
+ Shape:
21
+ Input: (B, S, D) where D == d_model
22
+ Output: (B, S, D)
23
+ """
24
+
25
+ def __init__(self, d_model: int, d_ff: int, dropout_rate: float = 0.1):
26
+ super().__init__()
27
+
28
+ if not isinstance(d_ff, int):
29
+ raise TypeError(f"d_ff must be an int, got {type(d_ff)}")
30
+ if not isinstance(d_model, int):
31
+ raise TypeError(f"d_model must be an int, got {type(d_model)}")
32
+ if not isinstance(dropout_rate, float):
33
+ raise TypeError(f"dropout_rate must be a float, got {type(dropout_rate)}")
34
+
35
+ if d_ff <= 0:
36
+ raise ValueError(f"d_ff must be strictly greater than 0, got {d_ff}")
37
+ if d_model <= 0:
38
+ raise ValueError(f"d_model must be strictly greater than 0, got {d_model}")
39
+ if not (0.0 <= dropout_rate < 1.0):
40
+ raise ValueError(f"dropout_rate must be in [0,1), got {dropout_rate}")
41
+
42
+ self.d_model = d_model
43
+ self.d_ff = d_ff
44
+ self.dropout_rate = dropout_rate
45
+
46
+ self.fc1 = nn.Linear(d_model, d_ff)
47
+ self.relu = nn.ReLU()
48
+ self.dropout = nn.Dropout(dropout_rate)
49
+ self.fc2 = nn.Linear(d_ff, d_model)
50
+
51
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
52
+ if not isinstance(x, torch.Tensor):
53
+ raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
54
+ if x.ndim != 3:
55
+ raise ValueError(f"x must be 3D of shape (B,S,D); got shape {tuple(x.shape)}")
56
+ if x.shape[-1] != self.d_model:
57
+ raise ValueError(f"Last dim {x.shape[-1]} must match d_model {self.d_model}")
58
+
59
+ return self.fc2(self.dropout(self.relu(self.fc1(x))))
src/transformer/modules/lm_head.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class LMHead(nn.Module):
6
+ """
7
+ Language modeling head: projects hidden states to vocabulary logits.
8
+
9
+ Args:
10
+ d_model (int): Model hidden dimension (>0).
11
+ vocab_size (int): Vocabulary size (>0).
12
+
13
+ Input:
14
+ x (Tensor): shape (B, S, D) with D == d_model. S may be 0.
15
+
16
+ Output:
17
+ logits (Tensor): shape (B, S, V) with V == vocab_size.
18
+ """
19
+
20
+ def __init__(self, d_model: int, vocab_size: int):
21
+ super().__init__()
22
+ if not isinstance(vocab_size, int):
23
+ raise TypeError(f"vocab_size must be an int, got {type(vocab_size)}")
24
+ if not isinstance(d_model, int):
25
+ raise TypeError(f"d_model must be an int, got {type(d_model)}")
26
+ if vocab_size <= 0:
27
+ raise ValueError(f"vocab_size must be strictly greater than 0, got {vocab_size}")
28
+ if d_model <= 0:
29
+ raise ValueError(f"d_model must be strictly greater than 0, got {d_model}")
30
+
31
+ self.d_model = d_model
32
+ self.vocab_size = vocab_size
33
+ self.fc = nn.Linear(d_model, vocab_size, bias=False)
34
+
35
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
36
+ if not isinstance(x, torch.Tensor):
37
+ raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
38
+ if x.dim() != 3:
39
+ raise ValueError(
40
+ f"x must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(x.shape)}"
41
+ )
42
+
43
+ B, S, D = x.shape
44
+ if D != self.d_model:
45
+ raise ValueError(f"Last dim {D} must match d_model {self.d_model}")
46
+
47
+ return self.fc(x)
src/transformer/transformer.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from torch import Tensor
4
+
5
+ from transformer import modules
6
+ from transformer.configs import BasicEncDecCfg
7
+ from transformer.utils import create_causal_mask, sample_from_logits, shift_right
8
+
9
+
10
+ class BasicEncoderDecoderTransformer(nn.Module):
11
+ def __init__(self, cfg: BasicEncDecCfg):
12
+ super().__init__()
13
+
14
+ # Basic type/value guarding for critical cfg fields (mirror project style)
15
+ if not isinstance(cfg, BasicEncDecCfg):
16
+ raise TypeError(f"cfg must be a BasicEncDecCfg, got {type(cfg)}")
17
+ if not isinstance(cfg.vocab_size, int):
18
+ raise TypeError(f"vocab_size must be an int, got {type(cfg.vocab_size)}")
19
+ if not cfg.vocab_size > 0:
20
+ raise ValueError(f"vocab_size must be strictly greater than 0, got {cfg.vocab_size}")
21
+ if not isinstance(cfg.d_model, int):
22
+ raise TypeError(f"d_model must be an int, got {type(cfg.d_model)}")
23
+ if not cfg.d_model > 0:
24
+ raise ValueError(f"d_model must be strictly greater than 0, got {cfg.d_model}")
25
+ if not isinstance(cfg.max_seq_len, int):
26
+ raise TypeError(f"max_seq_len must be an int, got {type(cfg.max_seq_len)}")
27
+ if not cfg.max_seq_len >= 0:
28
+ raise ValueError(
29
+ f"max_seq_len must be greater than or equal to 0, got {cfg.max_seq_len}"
30
+ )
31
+ if not isinstance(cfg.pad_id, int):
32
+ raise TypeError(f"pad_id must be an int, got {type(cfg.pad_id)}")
33
+ if not (0 <= cfg.pad_id < cfg.vocab_size):
34
+ raise ValueError(f"pad_id must be in [0, {cfg.vocab_size - 1}], got {cfg.pad_id}")
35
+ if not isinstance(cfg.bos_id, int):
36
+ raise TypeError(f"bos_id must be an int, got {type(cfg.bos_id)}")
37
+ if not (0 <= cfg.bos_id < cfg.vocab_size):
38
+ raise ValueError(f"bos_id must be in [0, {cfg.vocab_size - 1}], got {cfg.bos_id}")
39
+ if not isinstance(cfg.eos_id, int):
40
+ raise TypeError(f"eos_id must be an int, got {type(cfg.eos_id)}")
41
+ if not (0 <= cfg.eos_id < cfg.vocab_size):
42
+ raise ValueError(f"eos_id must be in [0, {cfg.vocab_size - 1}], got {cfg.eos_id}")
43
+
44
+ self.cfg = cfg
45
+
46
+ self.embed = modules.InputEmbedding(
47
+ cfg.vocab_size, cfg.d_model, cfg.max_seq_len, cfg.pad_id, cfg.dropout_rate
48
+ )
49
+ self.encoder = modules.TransformerEncoder(
50
+ cfg.d_model, cfg.num_heads, cfg.d_ff, cfg.num_layers, cfg.dropout_rate
51
+ )
52
+ self.decoder = modules.TransformerDecoder(
53
+ cfg.d_model, cfg.num_heads, cfg.d_ff, cfg.num_layers, cfg.dropout_rate
54
+ )
55
+ self.lm_head = modules.LMHead(cfg.d_model, cfg.vocab_size)
56
+
57
+ # tie weights
58
+ self.embed.token_embed.weight = self.lm_head.fc.weight
59
+
60
+ # expose a few attrs for convenience
61
+ self.max_seq_len = cfg.max_seq_len
62
+ self.pad_id = cfg.pad_id
63
+ self.bos_id = cfg.bos_id
64
+ self.eos_id = cfg.eos_id
65
+
66
+ def forward(
67
+ self,
68
+ src_ids: Tensor,
69
+ tgt_ids: Tensor,
70
+ src_padding_mask: Tensor | None,
71
+ tgt_padding_mask: Tensor | None,
72
+ ) -> Tensor:
73
+ # Validate inputs (match project style)
74
+ if not isinstance(src_ids, torch.Tensor):
75
+ raise TypeError(f"src_ids must be a torch.Tensor, got {type(src_ids)}")
76
+ if not isinstance(tgt_ids, torch.Tensor):
77
+ raise TypeError(f"tgt_ids must be a torch.Tensor, got {type(tgt_ids)}")
78
+ if src_ids.dim() != 2:
79
+ raise ValueError(
80
+ f"src_ids must be a 2D torch.Tensor of shape (B, S), got shape {tuple(src_ids.shape)}"
81
+ )
82
+ if tgt_ids.dim() != 2:
83
+ raise ValueError(
84
+ f"tgt_ids must be a 2D torch.Tensor of shape (B, S), got shape {tuple(tgt_ids.shape)}"
85
+ )
86
+ if src_ids.dtype != torch.long or tgt_ids.dtype != torch.long:
87
+ raise TypeError("src_ids and tgt_ids must be torch.long (int64)")
88
+ if src_padding_mask is not None and not isinstance(src_padding_mask, torch.Tensor):
89
+ raise TypeError(
90
+ f"src_padding_mask must be a torch.Tensor or None, got {type(src_padding_mask)}"
91
+ )
92
+ if tgt_padding_mask is not None and not isinstance(tgt_padding_mask, torch.Tensor):
93
+ raise TypeError(
94
+ f"tgt_padding_mask must be a torch.Tensor or None, got {type(tgt_padding_mask)}"
95
+ )
96
+
97
+ hidden_states = self.encode(src_ids, src_padding_mask)
98
+ # teacher forcing: shift right with BOS, keep PAD for padding
99
+ shifted_tgt = shift_right(tgt_ids, self.bos_id, self.pad_id)
100
+ shifted_tgt_mask = None
101
+ if tgt_padding_mask is not None:
102
+ m = tgt_padding_mask.to(dtype=torch.bool, device=shifted_tgt.device)
103
+
104
+ # Handle common mask shapes by shifting along the last (time) dimension
105
+ if m.dim() == 4: # [B, 1, 1, T]
106
+ pad_col = torch.zeros_like(m[..., :1]) # [B,1,1,1] False
107
+ shifted_tgt_mask = torch.cat([pad_col, m[..., :-1]], dim=-1)
108
+ elif m.dim() == 3: # [B, 1, T] or [B, H, T]
109
+ pad_col = torch.zeros_like(m[..., :1]) # [B,*,1] False
110
+ shifted_tgt_mask = torch.cat([pad_col, m[..., :-1]], dim=-1)
111
+ elif m.dim() == 2: # [B, T]
112
+ pad_col = torch.zeros_like(m[:, :1]) # [B,1] False
113
+ shifted_tgt_mask = torch.cat([pad_col, m[:, :-1]], dim=-1)
114
+ else:
115
+ raise ValueError(f"tgt_padding_mask must have 2, 3, or 4 dims; got {m.dim()}")
116
+
117
+ dec_hidden_states = self.decode(
118
+ hidden_states, shifted_tgt, src_padding_mask, shifted_tgt_mask
119
+ )
120
+ logits = self.lm_head(dec_hidden_states)
121
+ return logits # (B, T_tgt, V)
122
+
123
+ def encode(self, src_ids: Tensor, src_padding_mask: Tensor | None) -> Tensor:
124
+ if not isinstance(src_ids, torch.Tensor):
125
+ raise TypeError(f"src_ids must be a torch.Tensor, got {type(src_ids)}")
126
+ if src_ids.dim() != 2:
127
+ raise ValueError(
128
+ f"src_ids must be a 2D torch.Tensor of shape (B, S), got shape {tuple(src_ids.shape)}"
129
+ )
130
+ if src_ids.dtype != torch.long:
131
+ raise TypeError("src_ids must be torch.long (int64)")
132
+ if src_padding_mask is not None and not isinstance(src_padding_mask, torch.Tensor):
133
+ raise TypeError(
134
+ f"src_padding_mask must be a torch.Tensor or None, got {type(src_padding_mask)}"
135
+ )
136
+
137
+ x = self.embed(src_ids) # (B, Sx, D)
138
+ hidden_states = self.encoder(x, src_padding_mask)
139
+ return hidden_states # (B, Sx, D)
140
+
141
+ def decode(
142
+ self,
143
+ hidden_states: Tensor,
144
+ tgt_ids: Tensor,
145
+ src_padding_mask: Tensor | None,
146
+ tgt_padding_mask: Tensor | None = None,
147
+ ) -> Tensor:
148
+ if not isinstance(hidden_states, torch.Tensor):
149
+ raise TypeError(f"hidden_states must be a torch.Tensor, got {type(hidden_states)}")
150
+ if hidden_states.dim() != 3:
151
+ raise ValueError(
152
+ f"hidden_states must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(hidden_states.shape)}"
153
+ )
154
+ if not isinstance(tgt_ids, torch.Tensor):
155
+ raise TypeError(f"tgt_ids must be a torch.Tensor, got {type(tgt_ids)}")
156
+ if tgt_ids.dim() != 2:
157
+ raise ValueError(
158
+ f"tgt_ids must be a 2D torch.Tensor of shape (B, S), got shape {tuple(tgt_ids.shape)}"
159
+ )
160
+ if tgt_ids.dtype != torch.long:
161
+ raise TypeError("tgt_ids must be torch.long (int64)")
162
+ if src_padding_mask is not None and not isinstance(src_padding_mask, torch.Tensor):
163
+ raise TypeError(
164
+ f"src_padding_mask must be a torch.Tensor or None, got {type(src_padding_mask)}"
165
+ )
166
+ if tgt_padding_mask is not None and not isinstance(tgt_padding_mask, torch.Tensor):
167
+ raise TypeError(
168
+ f"tgt_padding_mask must be a torch.Tensor or None, got {type(tgt_padding_mask)}"
169
+ )
170
+
171
+ # causal mask per sequence length (utils returns broadcastable boolean mask)
172
+ tgt_causal_mask = create_causal_mask(tgt_ids.size(1)).to(tgt_ids.device)
173
+ y = self.embed(tgt_ids) # (B, Sy, D)
174
+ out = self.decoder(hidden_states, y, src_padding_mask, tgt_padding_mask, tgt_causal_mask)
175
+ return out # (B, Sy, D)
176
+
177
+ def generate(
178
+ self,
179
+ src_ids: Tensor,
180
+ src_padding_mask: Tensor | None,
181
+ max_new_tokens: int = 20,
182
+ temperature: float = 1.0,
183
+ top_k: int | None = None,
184
+ top_p: float | None = None,
185
+ do_sample: bool = False,
186
+ ) -> Tensor:
187
+ # validate args
188
+ if not isinstance(src_ids, torch.Tensor):
189
+ raise TypeError(f"src_ids must be a torch.Tensor, got {type(src_ids)}")
190
+ if src_ids.dim() != 2:
191
+ raise ValueError(
192
+ f"src_ids must be a 2D torch.Tensor of shape (B, S), got shape {tuple(src_ids.shape)}"
193
+ )
194
+ if src_ids.dtype != torch.long:
195
+ raise TypeError("src_ids must be torch.long (int64)")
196
+ if src_padding_mask is not None and not isinstance(src_padding_mask, torch.Tensor):
197
+ raise TypeError(
198
+ f"src_padding_mask must be a torch.Tensor or None, got {type(src_padding_mask)}"
199
+ )
200
+ if not isinstance(max_new_tokens, int):
201
+ raise TypeError(f"max_new_tokens must be an int, got {type(max_new_tokens)}")
202
+ if max_new_tokens < 0:
203
+ raise ValueError(
204
+ f"max_new_tokens must be greater than or equal to 0, got {max_new_tokens}"
205
+ )
206
+ if not isinstance(temperature, float):
207
+ raise TypeError(f"temperature must be a float, got {type(temperature)}")
208
+ if not (temperature > 0.0):
209
+ raise ValueError(f"temperature must be strictly greater than 0, got {temperature}")
210
+ if top_k is not None and (not isinstance(top_k, int)):
211
+ raise TypeError(f"top_k must be an int or None, got {type(top_k)}")
212
+ if top_k is not None and top_k < 0:
213
+ raise ValueError(f"top_k must be strictly greater than 0, got {top_k}")
214
+ if top_p is not None and (not isinstance(top_p, float)):
215
+ raise TypeError(f"top_p must be a float or None, got {type(top_p)}")
216
+ if top_p is not None and not (0.0 < top_p <= 1.0):
217
+ raise ValueError(f"top_p must be in (0, 1], got {top_p}")
218
+ if not isinstance(do_sample, bool):
219
+ raise TypeError(f"do_sample must be a bool, got {type(do_sample)}")
220
+ self.eval()
221
+ with torch.no_grad():
222
+ batch_size, _ = src_ids.shape
223
+ device = src_ids.device
224
+ tgt_ids = torch.full((batch_size, 1), self.bos_id, device=device, dtype=torch.long)
225
+ finished = torch.zeros(batch_size, dtype=torch.bool, device=device)
226
+ hidden_states = self.encode(src_ids, src_padding_mask)
227
+ for _ in range(max_new_tokens):
228
+ # decode on current tgt_ids; take last-step logits
229
+ step_hidden = self.decode(hidden_states, tgt_ids, src_padding_mask) # (B, T, D)
230
+ logits = self.lm_head(step_hidden)[:, -1, :] # (B, V)
231
+ next_ids = sample_from_logits(
232
+ logits, temperature=temperature, top_k=top_k, top_p=top_p, do_sample=do_sample
233
+ ) # (B,)
234
+ next_ids = torch.where(finished, torch.full_like(next_ids, self.eos_id), next_ids)
235
+ tgt_ids = torch.cat([tgt_ids, next_ids.unsqueeze(1)], dim=-1)
236
+ finished |= next_ids == self.eos_id
237
+ if finished.all():
238
+ return tgt_ids
239
+ return tgt_ids
src/transformer/utils.py ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Iterable
5
+
6
+ import torch
7
+ import torch.nn.functional as F
8
+
9
+
10
+ def split_heads(x: torch.Tensor, num_heads: int) -> torch.Tensor:
11
+ """
12
+ Split the last (model) dimension of a 3D torch.Tensor into (num_heads, d_head) and permute to
13
+ (batch_size, num_heads, seq_length, d_head).
14
+
15
+ Args:
16
+ x (torch.Tensor): Input torch.Tensor of shape (batch_size, seq_length, d_model).
17
+ num_heads (int): Number of attention heads to split into. Must be a positive integer
18
+ that divides d_model exactly.
19
+
20
+ Returns:
21
+ torch.Tensor: torch.Tensor of shape (batch_size, num_heads, seq_length, d_head), where
22
+ d_head = d_model // num_heads. The output torch.Tensor retains the same dtype and
23
+ device as the input.
24
+
25
+ Invariants:
26
+ - Output device == x.device
27
+ - Output dtype == x.dtype
28
+ - batch_size and seq_length are preserved from the input shape.
29
+
30
+ Notes:
31
+ - Zero-length sequences (seq_length == 0) are supported and will return a torch.Tensor
32
+ with shape (batch_size, num_heads, 0, d_head).
33
+
34
+ Raises:
35
+ TypeError: If x is not a torch.Tensor or num_heads is not an int.
36
+ ValueError: If x is not 3D, if num_heads <= 0, or if d_model is not divisible by num_heads.
37
+ """
38
+ # Type checks
39
+ if not isinstance(x, torch.Tensor):
40
+ raise TypeError(f"x must be a torch.Tensor, got {type(x)}")
41
+ if not isinstance(num_heads, int):
42
+ raise TypeError(f"num_heads must be an int, got {type(num_heads)}")
43
+
44
+ # Shape checks
45
+ if x.ndim != 3:
46
+ raise ValueError(
47
+ f"x must be a 3D torch.Tensor of shape (B, S, D); got shape {tuple(x.shape)}"
48
+ )
49
+ if num_heads <= 0:
50
+ raise ValueError(f"num_heads must be > 0; got {num_heads}")
51
+
52
+ batch_size, seq_length, d_model = x.shape
53
+
54
+ # Divisibility check
55
+ if d_model % num_heads != 0:
56
+ raise ValueError(
57
+ f"d_model ({d_model}) must be divisible by num_heads ({num_heads}); "
58
+ f"got remainder {d_model % num_heads}"
59
+ )
60
+
61
+ d_head = d_model // num_heads
62
+
63
+ # Reshape and permute: (B, S, D) -> (B, S, H, Dh) -> (B, H, S, Dh)
64
+ x = x.reshape(batch_size, seq_length, num_heads, d_head)
65
+ x = x.permute(0, 2, 1, 3)
66
+
67
+ return x
68
+
69
+
70
+ def calculate_attention(
71
+ query: torch.Tensor,
72
+ key: torch.Tensor,
73
+ value: torch.Tensor,
74
+ mask: torch.Tensor | None,
75
+ *,
76
+ deterministic: bool = False,
77
+ return_probs: bool = False,
78
+ attn_dropout_p: float = 0.0,
79
+ ):
80
+ """
81
+ Scaled dot-product attention with:
82
+ • device-safe masking (mask auto-moved to query.device),
83
+ • explicit broadcastability checks for mask,
84
+ • numerically stable softmax (row-wise max subtraction),
85
+ • fully-masked rows -> zero probabilities and zero outputs,
86
+ • fp16/bf16-safe compute via fp32 upcast, output downcast.
87
+
88
+ Args:
89
+ query: (B, H, S_q, D)
90
+ key: (B, H, S_kv, D)
91
+ value: (B, H, S_kv, D)
92
+ mask: broadcastable to (B, H, S_q, S_kv)
93
+ - bool: True = masked/ignored position
94
+ - float: additive bias (e.g., large negative for masked, ALiBi, etc.)
95
+ deterministic: best-effort reproducibility toggle
96
+ return_probs: if False, only return attention output
97
+
98
+ Returns:
99
+ attention: (B, H, S_q, D)
100
+ probs (optional): (B, H, S_q, S_kv)
101
+ """
102
+ # ---- Basic shape checks
103
+ if query.dim() != 4 or key.dim() != 4 or value.dim() != 4:
104
+ raise ValueError(
105
+ f"q/k/v must be 4D (B,H,S,D). Got "
106
+ f"q={tuple(query.shape)}, k={tuple(key.shape)}, v={tuple(value.shape)}"
107
+ )
108
+
109
+ Bq, Hq, Sq, Dq = query.shape
110
+ Bk, Hk, Sk, Dk = key.shape
111
+ Bv, Hv, Sv, Dv = value.shape
112
+
113
+ if not (Bq == Bk == Bv):
114
+ raise ValueError(f"Batch mismatch: q={Bq}, k={Bk}, v={Bv}")
115
+ if not (Hq == Hk == Hv):
116
+ raise ValueError(f"Heads mismatch: q={Hq}, k={Hk}, v={Hv}")
117
+ if Sk != Sv:
118
+ raise ValueError(f"Key/Value seq length mismatch: Sk={Sk} vs Sv={Sv}")
119
+ if not (Dq == Dk == Dv):
120
+ raise ValueError(f"Head dimension mismatch: Dq={Dq}, Dk={Dk}, Dv={Dv}")
121
+
122
+ target_shape = (Bq, Hq, Sq, Sk)
123
+
124
+ # ---- Device checks: q/k/v must be on the same device (fail fast); mask will be auto-moved
125
+ if not (query.device == key.device == value.device):
126
+ raise RuntimeError(
127
+ f"q/k/v must be on the same device, got "
128
+ f"query={query.device}, key={key.device}, value={value.device}"
129
+ )
130
+
131
+ # ---- Early exit for empty sequences
132
+ if Sq == 0:
133
+ empty_attn = query.new_zeros(Bq, Hq, 0, Dq)
134
+ empty_probs = query.new_zeros(Bq, Hq, 0, Sk)
135
+ return (empty_attn, empty_probs) if return_probs else empty_attn
136
+ if Sk == 0:
137
+ zero_attn = query.new_zeros(Bq, Hq, Sq, Dq)
138
+ zero_probs = query.new_zeros(Bq, Hq, Sq, 0)
139
+ return (zero_attn, zero_probs) if return_probs else zero_attn
140
+
141
+ # ---- Compute dtype policy: upcast to fp32 for stability if needed
142
+ input_dtype = query.dtype
143
+ compute_dtype = torch.float32 if input_dtype in (torch.float16, torch.bfloat16) else input_dtype
144
+ q = query.to(compute_dtype)
145
+ k = key.to(compute_dtype)
146
+ v = value.to(compute_dtype)
147
+
148
+ # ---- Determinism (best effort)
149
+ if deterministic:
150
+ try:
151
+ torch.use_deterministic_algorithms(True)
152
+ except Exception:
153
+ pass
154
+
155
+ # ---- Scores
156
+ d_head = Dq
157
+ scores = torch.matmul(q, k.transpose(-1, -2)) / (d_head**0.5) # (B,H,Sq,Sk)
158
+
159
+ # ---- Mask handling (device + broadcastability + semantics)
160
+ full_row_mask = None # (B,H,Sq,1) True where the entire key row is invalid
161
+ if mask is not None:
162
+ # Move mask to the same device
163
+ if mask.device != q.device:
164
+ mask = mask.to(q.device)
165
+
166
+ # Check broadcastability to target shape, then expand (without copy)
167
+ def _expand_or_error(x: torch.Tensor, name: str) -> torch.Tensor:
168
+ # Try expanding to target_shape; torch.Tensor.expand raises if not broadcastable
169
+ try:
170
+ return x.expand(target_shape)
171
+ except RuntimeError as e:
172
+ raise ValueError(
173
+ f"{name} with shape {tuple(x.shape)} is not broadcastable to {target_shape}. "
174
+ f"Broadcasting rules require dimensions to be equal or 1 in the source."
175
+ ) from e
176
+
177
+ if mask.dtype == torch.bool:
178
+ mask = _expand_or_error(mask, "Boolean mask")
179
+ # Use a large finite negative to avoid (-inf) - (-inf) during stabilization
180
+ neg_large = torch.finfo(compute_dtype).min / 4
181
+ scores = scores.masked_fill(mask, neg_large)
182
+ full_row_mask = mask.all(dim=-1, keepdim=True) # (B,H,Sq,1)
183
+ else:
184
+ # Additive bias mask
185
+ mask = _expand_or_error(mask.to(compute_dtype), "Additive mask")
186
+ scores = scores + mask
187
+ # If caller used -inf as additive, detect fully-masked rows after addition
188
+ full_row_mask = torch.isneginf(scores).all(dim=-1, keepdim=True)
189
+
190
+ # ---- Numerically stable softmax: subtract row-wise max
191
+ row_max = torch.amax(scores, dim=-1, keepdim=True)
192
+ if full_row_mask is None:
193
+ # If any rows are -inf (rare with finite negatives), neutralize their max to 0
194
+ row_max = torch.where(torch.isneginf(row_max), torch.zeros_like(row_max), row_max)
195
+ else:
196
+ # For rows marked fully masked, set max to 0 to avoid -inf - (-inf)
197
+ row_max = torch.where(full_row_mask, torch.zeros_like(row_max), row_max)
198
+
199
+ stable_scores = scores - row_max
200
+ probs = F.softmax(stable_scores, dim=-1)
201
+
202
+ # ---- Zero-out fully masked rows (probabilities and thus outputs)
203
+ if full_row_mask is not None:
204
+ probs = probs * (~full_row_mask).to(probs.dtype)
205
+ if attn_dropout_p and attn_dropout_p > 0.0:
206
+ probs = F.dropout(probs, p=attn_dropout_p, training=True)
207
+ # ---- Weighted sum with values; cast back to input dtype
208
+ attention = torch.matmul(probs, v).to(input_dtype)
209
+
210
+ if return_probs:
211
+ out_probs_dtype = (
212
+ input_dtype if input_dtype in (torch.float16, torch.bfloat16) else probs.dtype
213
+ )
214
+ return attention, probs.to(out_probs_dtype)
215
+ else:
216
+ return attention
217
+
218
+
219
+ def join_heads(x: torch.Tensor) -> torch.Tensor:
220
+ """
221
+ Merge multi-head attention outputs into model dimension.
222
+
223
+ Args:
224
+ x (torch.Tensor): shape (batch_size, num_heads, seq_length, d_head)
225
+
226
+ Returns:
227
+ torch.Tensor: shape (batch_size, seq_length, d_model) where d_model = num_heads * d_head
228
+ """
229
+ # --- Type checks ---
230
+ if not isinstance(x, torch.Tensor):
231
+ raise TypeError(f"Expected torch.Tensor, got {type(x)}")
232
+
233
+ if x.ndim != 4:
234
+ raise ValueError(
235
+ f"Expected 4D torch.Tensor (batch, num_heads, seq_len, d_head), "
236
+ f"got shape {tuple(x.shape)} with ndim={x.ndim}"
237
+ )
238
+
239
+ batch_size, num_heads, seq_length, d_head = x.shape
240
+
241
+ if not all(
242
+ isinstance(dim, int) and dim >= 0 for dim in (batch_size, num_heads, seq_length, d_head)
243
+ ):
244
+ raise ValueError(f"Invalid shape values: {x.shape}")
245
+
246
+ d_model = num_heads * d_head
247
+
248
+ # --- Safe reshape ---
249
+ x = x.permute(0, 2, 1, 3).contiguous().view(batch_size, seq_length, d_model)
250
+
251
+ return x
252
+
253
+
254
+ def sinusoidal_positional_encoding(
255
+ seq_len: int,
256
+ dim: int,
257
+ *,
258
+ base: float = 10_000.0,
259
+ dtype: torch.dtype | None = None,
260
+ device: torch.device | None = None,
261
+ offset: int = 0,
262
+ return_positions: bool = False,
263
+ ) -> torch.Tensor:
264
+ """
265
+ Build a [seq_len, dim] sinusoidal (trig) positional encoding table:
266
+ PE[pos, 2i] = sin(pos / (base^(2i/dim)))
267
+ PE[pos, 2i+1] = cos(pos / (base^(2i/dim)))
268
+
269
+ Design goals:
270
+ - Numerically stable: uses exp with -log(base) to avoid huge powers.
271
+ - Dtype/device safe: computes frequencies in float32 and casts at the end;
272
+ supports fp16/bf16 by upcasting, then downcasting.
273
+ - Odd dimensions handled: the last column is zero-padded when dim is odd.
274
+ - Supports an integer `offset` so you can continue encodings for cached KV.
275
+ - No autograd needed: returns a tensor with requires_grad=False.
276
+
277
+ Args:
278
+ seq_len: length of sequence (>=0).
279
+ dim: embedding dimension (>=1).
280
+ base: frequency base (default 10k like Vaswani et al.).
281
+ dtype: desired dtype of the returned table (defaults to torch.get_default_dtype()).
282
+ device: desired device of the returned table.
283
+ offset: position offset to start from (useful for incremental decoding).
284
+ return_positions: if True, also returns the [seq_len] position indices.
285
+
286
+ Returns:
287
+ pe: [seq_len, dim] tensor on `device` with `dtype`.
288
+ (pos): optional [seq_len] tensor of positions (if return_positions=True).
289
+
290
+ Raises:
291
+ ValueError if seq_len<0 or dim<=0.
292
+ """
293
+ if seq_len < 0:
294
+ raise ValueError(f"seq_len must be >= 0, got {seq_len}")
295
+ if dim <= 0:
296
+ raise ValueError(f"dim must be > 0, got {dim}")
297
+ if dtype is None:
298
+ dtype = torch.get_default_dtype()
299
+
300
+ # Work in float32 for stability regardless of target dtype.
301
+ work_dtype = torch.float32
302
+
303
+ # Positions [offset .. offset+seq_len-1]
304
+ # Note: torch.arange is dtype-agnostic; compute in float32 later.
305
+ pos = torch.arange(offset, offset + seq_len, device=device)
306
+
307
+ # Frequencies for even indices: exp(-(log(base) * (2i)/dim))
308
+ # Avoid base**(2i/dim) directly to reduce overflow/underflow risk.
309
+ half_dim = dim // 2 # number of sin/cos pairs
310
+ if half_dim > 0:
311
+ # [half_dim]
312
+ exponent = torch.arange(0, half_dim, device=device, dtype=work_dtype)
313
+ inv_freq = torch.exp(-math.log(base) * (2.0 * exponent) / float(dim)) # [half_dim]
314
+
315
+ # Outer product: [seq_len, half_dim]
316
+ # Compute phase = pos[:, None] * inv_freq[None, :]
317
+ phase = pos.to(work_dtype).unsqueeze(1) * inv_freq.unsqueeze(0)
318
+
319
+ sin_part = torch.sin(phase)
320
+ cos_part = torch.cos(phase)
321
+
322
+ # Interleave sin and cos along the last dim
323
+ pe_even_odd = torch.stack((sin_part, cos_part), dim=-1).reshape(seq_len, 2 * half_dim)
324
+ if dim % 2 == 1:
325
+ # Pad the last column with zeros if dim is odd
326
+ pad = torch.zeros(seq_len, 1, device=device, dtype=work_dtype)
327
+ pe_work = torch.cat([pe_even_odd, pad], dim=1)
328
+ else:
329
+ pe_work = pe_even_odd
330
+ else:
331
+ # dim == 1 case → just a zero column (consistent with odd-dim padding above)
332
+ pe_work = torch.zeros(seq_len, 1, device=device, dtype=work_dtype)
333
+
334
+ # If somehow dim==0 was allowed earlier, we'd have raised.
335
+
336
+ # Cast once at the end to the requested dtype.
337
+ pe = pe_work.to(dtype=dtype)
338
+
339
+ # Ensure no gradient tracking (users usually register this as a buffer).
340
+ pe.requires_grad_(False)
341
+
342
+ return (pe, pos.to(device)) if return_positions else pe
343
+
344
+
345
+ def combine_masks(mask1, mask2):
346
+ """
347
+ Combine two boolean masks with logical OR.
348
+
349
+ Args:
350
+ mask1 (torch.Tensor or None): first mask
351
+ mask2 (torch.Tensor or None): second mask
352
+
353
+ Returns:
354
+ torch.Tensor or None: combined mask or None if both are None
355
+
356
+ Notes:
357
+ - Both masks must be broadcastable to the same shape.
358
+ - Returns a boolean tensor if inputs are boolean.
359
+ - If one mask is None, returns the other unchanged.
360
+ """
361
+ if mask1 is not None and mask2 is not None:
362
+ m1 = mask1.to(dtype=torch.bool)
363
+ m2 = mask2.to(dtype=torch.bool, device=m1.device)
364
+ if m1.shape != m2.shape:
365
+ try:
366
+ return m1 | m2 # allow broadcasting, but safe
367
+ except RuntimeError as e:
368
+ raise ValueError(
369
+ f"combine_masks: masks not broadcastable: {m1.shape} vs {m2.shape}"
370
+ ) from e
371
+ return m1 | m2
372
+ elif mask1 is not None:
373
+ return mask1
374
+ elif mask2 is not None:
375
+ return mask2
376
+ else:
377
+ return None
378
+
379
+
380
+ def shift_right(labels: torch.Tensor, bos_id: int = 1, pad_id: int = 0) -> torch.Tensor:
381
+ """
382
+ Build decoder input IDs by shifting target labels to the right and
383
+ prepending a BOS token. Any labels marked -100 (ignore_index) are
384
+ converted to PAD *after* shifting so they never appear as inputs.
385
+
386
+ Args:
387
+ labels: Long tensor of shape [B, T] or [T].
388
+ bos_id: Token id to place at position 0.
389
+ pad_id: Token id used where labels == -100 after shifting.
390
+
391
+ Returns:
392
+ decoder_input_ids: Long tensor of shape [B, T] (same device as labels).
393
+
394
+ """
395
+ if labels.dim() == 1:
396
+ labels = labels.unsqueeze(0) # [1, T]
397
+ if labels.dim() != 2:
398
+ raise ValueError(f"shift_right expects 1D or 2D tensor, got shape {tuple(labels.shape)}")
399
+
400
+ B, T = labels.shape
401
+ if T == 0:
402
+ raise ValueError("shift_right: sequence length must be > 0")
403
+
404
+ # Work in long dtype on the same device; do NOT modify `labels` in-place.
405
+ labels = labels.to(torch.long)
406
+
407
+ # Start with PADs everywhere, then copy shifted labels, and set BOS.
408
+ decoder_input_ids = labels.new_full((B, T), pad_id)
409
+ decoder_input_ids[:, 1:] = labels[:, :-1]
410
+ decoder_input_ids[:, 0] = bos_id
411
+
412
+ # Any positions that inherited -100 from labels should be PAD instead.
413
+ # (labels contains -100 for "ignore_index" targets; these should never be inputs)
414
+ mask_ignore = decoder_input_ids.eq(-100)
415
+ if mask_ignore.any():
416
+ decoder_input_ids.masked_fill_(mask_ignore, pad_id)
417
+
418
+ return decoder_input_ids
419
+
420
+
421
+ def _ensure_2d_logits(logits: torch.Tensor) -> torch.Tensor:
422
+ """
423
+ Accepts [B, V] or [..., V]. If rank > 2, flattens leading dims into batch.
424
+ Returns 2D [B', V] along with a flag and original shape for potential future use.
425
+ """
426
+ if logits.dim() == 2:
427
+ return logits
428
+ if logits.dim() >= 3:
429
+ V = logits.size(-1)
430
+ return logits.reshape(-1, V)
431
+ raise ValueError(
432
+ f"sample_from_logits: logits must be at least 2D, got shape {tuple(logits.shape)}"
433
+ )
434
+
435
+
436
+ def _apply_allow_deny_mask(
437
+ logits: torch.Tensor,
438
+ *,
439
+ allowed_tokens: Iterable[int] | None,
440
+ disallowed_tokens: Iterable[int] | None,
441
+ filter_value: float,
442
+ ) -> torch.Tensor:
443
+ if allowed_tokens is not None:
444
+ mask = torch.zeros_like(logits, dtype=torch.bool)
445
+ idx = torch.tensor(list(allowed_tokens), device=logits.device)
446
+ idx = idx[(idx >= 0) & (idx < logits.size(-1))]
447
+ if idx.numel() > 0:
448
+ mask.index_fill_(-1, idx, True)
449
+ logits = torch.where(mask, logits, torch.full_like(logits, filter_value))
450
+ if disallowed_tokens is not None:
451
+ idx = torch.tensor(list(disallowed_tokens), device=logits.device)
452
+ idx = idx[(idx >= 0) & (idx < logits.size(-1))]
453
+ if idx.numel() > 0:
454
+ logits.index_fill_(-1, idx, filter_value)
455
+ return logits
456
+
457
+
458
+ def _top_k_filtering(
459
+ logits: torch.Tensor,
460
+ top_k: int | None,
461
+ *,
462
+ min_tokens_to_keep: int,
463
+ filter_value: float,
464
+ ) -> torch.Tensor:
465
+ if top_k is None or top_k <= 0:
466
+ return logits
467
+ k = min(max(top_k, min_tokens_to_keep), logits.size(-1))
468
+ # threshold per row
469
+ values, _ = torch.topk(logits, k, dim=-1)
470
+ thresh = values[..., -1, None]
471
+ return torch.where(logits < thresh, torch.full_like(logits, filter_value), logits)
472
+
473
+
474
+ def _top_p_filtering(
475
+ logits_scaled: torch.Tensor,
476
+ probs: torch.Tensor,
477
+ top_p: float,
478
+ *,
479
+ min_tokens_to_keep: int,
480
+ filter_value: float,
481
+ ) -> torch.Tensor:
482
+ if top_p is None or not (0.0 < top_p < 1.0):
483
+ return logits_scaled
484
+ # sort by probability
485
+ sorted_probs, sorted_idx = torch.sort(probs, dim=-1, descending=True)
486
+ cum = torch.cumsum(sorted_probs, dim=-1)
487
+ # tokens to remove: everything after first point where cum > top_p
488
+ to_remove = cum > top_p
489
+ # always keep at least min_tokens_to_keep highest-prob tokens
490
+ to_remove[..., :min_tokens_to_keep] = False
491
+ # scatter back to original indices
492
+ scatter_mask = torch.zeros_like(to_remove, dtype=torch.bool).scatter(-1, sorted_idx, to_remove)
493
+ return torch.where(scatter_mask, torch.full_like(logits_scaled, filter_value), logits_scaled)
494
+
495
+
496
+ def sample_from_logits(
497
+ logits: torch.Tensor,
498
+ *,
499
+ do_sample: bool = False,
500
+ temperature: float = 1.0,
501
+ top_k: int | None = None,
502
+ top_p: float | None = None,
503
+ min_tokens_to_keep: int = 1,
504
+ allowed_tokens: Iterable[int] | None = None,
505
+ disallowed_tokens: Iterable[int] | None = None,
506
+ filter_value: float = -float("inf"),
507
+ rng: torch.Generator | None = None,
508
+ return_probs: bool = False,
509
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
510
+ """
511
+ Select token IDs from logits with optional sampling & filtering.
512
+
513
+ Inputs:
514
+ logits: [..., V] or [B, V]; will be treated as 2D [B', V] internally.
515
+ do_sample: False -> greedy argmax; True -> multinomial sampling.
516
+ temperature: >0; values <1 sharpen, >1 flatten.
517
+ top_k: keep only top-k logits (after temperature); None/<=0 disables.
518
+ top_p: nucleus (0<p<1): keep smallest set with prob mass >= p (after temperature).
519
+ min_tokens_to_keep: safety floor so we never filter out *everything*.
520
+ allowed_tokens: iterable of token IDs to allow (others masked out).
521
+ disallowed_tokens: iterable of token IDs to ban (masked out).
522
+ filter_value: value used for filtered logits (-inf by default).
523
+ rng: optional torch.Generator for reproducible sampling.
524
+ return_probs: if True, also returns the final probabilities used.
525
+
526
+ Returns:
527
+ token_ids: [B'] int64 (and optionally probs [B', V] if return_probs=True).
528
+ """
529
+ if temperature <= 0:
530
+ raise ValueError("temperature must be > 0")
531
+
532
+ # Work on a stable float32 copy; keep device and shape
533
+ logits2d = _ensure_2d_logits(logits).to(dtype=torch.float32)
534
+
535
+ # Apply allow/deny lists first (hard mask)
536
+ logits2d = _apply_allow_deny_mask(
537
+ logits2d,
538
+ allowed_tokens=allowed_tokens,
539
+ disallowed_tokens=disallowed_tokens,
540
+ filter_value=filter_value,
541
+ )
542
+
543
+ # Scale by temperature before top-k/top-p
544
+ logits_scaled = logits2d / float(temperature)
545
+
546
+ # Top-k first (logit space)
547
+ logits_scaled = _top_k_filtering(
548
+ logits_scaled, top_k=top_k, min_tokens_to_keep=min_tokens_to_keep, filter_value=filter_value
549
+ )
550
+
551
+ # Compute probs (needed for top-p and sampling)
552
+ probs = F.softmax(logits_scaled, dim=-1)
553
+
554
+ # Top-p (probability space) — re-mask logits_scaled accordingly
555
+ if top_p is not None and 0.0 < top_p < 1.0:
556
+ logits_scaled = _top_p_filtering(
557
+ logits_scaled,
558
+ probs,
559
+ top_p=top_p,
560
+ min_tokens_to_keep=min_tokens_to_keep,
561
+ filter_value=filter_value,
562
+ )
563
+ probs = F.softmax(logits_scaled, dim=-1) # recompute after top-p masking
564
+
565
+ if do_sample:
566
+ # multinomial sampling with optional RNG for reproducibility
567
+ next_ids = torch.multinomial(probs, num_samples=1, replacement=True, generator=rng).squeeze(
568
+ -1
569
+ )
570
+ else:
571
+ # greedy
572
+ next_ids = torch.argmax(probs, dim=-1)
573
+
574
+ next_ids = next_ids.to(dtype=torch.long, device=logits.device) # back to original device
575
+ if return_probs:
576
+ return next_ids, probs.to(device=logits.device, dtype=probs.dtype)
577
+ return next_ids
578
+
579
+
580
+ def create_causal_mask(
581
+ max_seq_len: int,
582
+ *,
583
+ device: torch.device | None = None,
584
+ dtype: torch.dtype = torch.bool,
585
+ ) -> torch.Tensor:
586
+ """
587
+ Build a standard causal mask to prevent attending to future positions.
588
+
589
+ Shape: [1, 1, max_seq_len, max_seq_len]
590
+
591
+ mask[i, j] = True means position j should be masked when predicting i.
592
+
593
+ Args:
594
+ max_seq_len: length of the target sequence (must be > 0).
595
+ device: torch device to place the mask on (defaults to cpu).
596
+ dtype: dtype of mask (default: torch.bool).
597
+ Usually boolean, but some fused kernels want float with -inf/0.
598
+
599
+ Returns:
600
+ Tensor of shape [1, 1, max_seq_len, max_seq_len].
601
+ """
602
+ if max_seq_len <= 0:
603
+ raise ValueError(f"max_seq_len must be > 0, got {max_seq_len}")
604
+
605
+ # upper-triangular (excluding main diagonal)
606
+ mask = torch.triu(
607
+ torch.ones((1, 1, max_seq_len, max_seq_len), device=device, dtype=torch.bool),
608
+ diagonal=1,
609
+ )
610
+ if dtype != torch.bool:
611
+ mask = mask.to(dtype)
612
+ return mask
tests/units/modules/test_attention.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.modules.attention import MultiHeadAttention
5
+
6
+
7
+ # -----------------------
8
+ # Helpers
9
+ # -----------------------
10
+ def _rand(B=2, S=5, D=12, device="cpu", dtype=torch.float32):
11
+ return torch.randn(B, S, D, device=device, dtype=dtype)
12
+
13
+
14
+ # =======================
15
+ # Constructor checks
16
+ # =======================
17
+
18
+
19
+ def test_ctor_type_checks():
20
+ with pytest.raises(TypeError):
21
+ MultiHeadAttention(d_model="64", num_heads=4, dropout_rate=0.1)
22
+ with pytest.raises(TypeError):
23
+ MultiHeadAttention(d_model=64, num_heads=4.0, dropout_rate=0.1)
24
+ with pytest.raises(TypeError):
25
+ MultiHeadAttention(d_model=64, num_heads=4, dropout_rate="0.1")
26
+
27
+
28
+ def test_ctor_value_checks():
29
+ with pytest.raises(ValueError):
30
+ MultiHeadAttention(d_model=0, num_heads=4, dropout_rate=0.1)
31
+ with pytest.raises(ValueError):
32
+ MultiHeadAttention(d_model=64, num_heads=0, dropout_rate=0.1)
33
+ with pytest.raises(ValueError):
34
+ MultiHeadAttention(d_model=63, num_heads=4, dropout_rate=0.1) # not divisible
35
+
36
+
37
+ def test_ctor_happy_path():
38
+ mha = MultiHeadAttention(64, 8, 0.1)
39
+ assert mha.d_model == 64 and mha.num_heads == 8 and mha.d_head == 8
40
+
41
+
42
+ # =======================
43
+ # Forward: type/shape checks
44
+ # =======================
45
+
46
+
47
+ def test_forward_requires_tensors_and_mask_type():
48
+ mha = MultiHeadAttention(32, 4, 0.1)
49
+ q, k, v = _rand(2, 5, 32), _rand(2, 5, 32), _rand(2, 5, 32)
50
+
51
+ with pytest.raises(TypeError):
52
+ mha("q", k, v, None)
53
+ with pytest.raises(TypeError):
54
+ mha(q, "k", v, None)
55
+ with pytest.raises(TypeError):
56
+ mha(q, k, "v", None)
57
+ with pytest.raises(TypeError):
58
+ mha(q, k, v, mask="bad")
59
+
60
+
61
+ def test_forward_rank_and_lastdim_checks():
62
+ mha = MultiHeadAttention(32, 4, 0.1)
63
+ q = torch.randn(2, 5, 32)
64
+ k = torch.randn(2, 5, 32)
65
+ v = torch.randn(2, 5, 32)
66
+
67
+ with pytest.raises(ValueError):
68
+ mha(q.unsqueeze(0), k, v, None) # rank 4 for q
69
+ with pytest.raises(ValueError):
70
+ mha(q, k.view(10, 32), v, None) # rank 2 for k
71
+ with pytest.raises(ValueError):
72
+ mha(q[..., :16], k, v, None) # wrong Dq
73
+ with pytest.raises(ValueError):
74
+ mha(q, k[..., :16], v, None) # wrong Dk
75
+ with pytest.raises(ValueError):
76
+ mha(q, k, v[..., :16], None) # wrong Dv
77
+
78
+
79
+ def test_forward_batch_and_seq_mismatch_checks():
80
+ mha = MultiHeadAttention(24, 3, 0.1)
81
+ q = torch.randn(2, 5, 24)
82
+ k = torch.randn(3, 5, 24) # batch mismatch
83
+ v = torch.randn(2, 5, 24) # seq mismatch with k
84
+
85
+ with pytest.raises(ValueError):
86
+ mha(q, k, torch.randn(3, 5, 24), None) # batch mismatch q vs k/v
87
+ with pytest.raises(ValueError):
88
+ mha(q, torch.randn(2, 6, 24), v, None) # Sk != Sv
89
+
90
+
91
+ # =======================
92
+ # Forward: happy path + masks + zero-length
93
+ # =======================
94
+
95
+
96
+ @pytest.mark.parametrize("B,S,D,H", [(1, 1, 16, 4), (2, 5, 32, 4), (3, 0, 24, 3)])
97
+ def test_forward_shapes_and_device_dtype(B, S, D, H):
98
+ device = (
99
+ torch.device(f"cuda:{torch.cuda.current_device()}")
100
+ if torch.cuda.is_available()
101
+ else torch.device("cpu")
102
+ )
103
+ mha = MultiHeadAttention(D, H, 0.1).to(device)
104
+ q = _rand(B, S, D, device=device)
105
+ k = _rand(B, S, D, device=device)
106
+ v = _rand(B, S, D, device=device)
107
+
108
+ out = mha(q, k, v, mask=None)
109
+ assert out.shape == (B, S, D)
110
+ assert out.device == device
111
+ assert out.dtype == q.dtype
112
+
113
+
114
+ def test_boolean_mask_blocks_positions():
115
+ B, S, D, H = 2, 6, 24, 3
116
+ mha = MultiHeadAttention(D, H, 0.0) # no dropout for determinism
117
+ q = _rand(B, S, D)
118
+ k = _rand(B, S, D)
119
+ v = _rand(B, S, D)
120
+
121
+ # mask last two keys for all heads/queries
122
+ mask = torch.zeros(B, 1, 1, S, dtype=torch.bool)
123
+ mask[..., -2:] = True
124
+
125
+ out1 = mha(q, k, v, mask=None)
126
+ out2 = mha(q, k, v, mask=mask)
127
+ assert not torch.allclose(out1, out2)
128
+
129
+
130
+ def test_additive_mask_supported():
131
+ B, S, D, H = 2, 5, 32, 4
132
+ mha = MultiHeadAttention(D, H, 0.0)
133
+ q, k, v = _rand(B, S, D), _rand(B, S, D), _rand(B, S, D)
134
+ add = torch.zeros(1, 1, 1, S)
135
+ out = mha(q, k, v, add)
136
+ assert out.shape == (B, S, D)
137
+
138
+
139
+ # =======================
140
+ # Gradients smoke
141
+ # =======================
142
+
143
+
144
+ def test_gradients_flow():
145
+ mha = MultiHeadAttention(32, 4, 0.1)
146
+ q = _rand(
147
+ 2,
148
+ 5,
149
+ 32,
150
+ dtype=torch.float32,
151
+ device="cpu",
152
+ )
153
+ k = _rand(
154
+ 2,
155
+ 5,
156
+ 32,
157
+ dtype=torch.float32,
158
+ device="cpu",
159
+ )
160
+ v = _rand(
161
+ 2,
162
+ 5,
163
+ 32,
164
+ dtype=torch.float32,
165
+ device="cpu",
166
+ )
167
+ q.requires_grad_(True)
168
+ k.requires_grad_(True)
169
+ v.requires_grad_(True)
170
+
171
+ out = mha(q, k, v, mask=None)
172
+ loss = out.pow(2).mean()
173
+ loss.backward()
174
+
175
+ for t in (q, k, v):
176
+ assert t.grad is not None
177
+ assert torch.isfinite(t.grad).all()
tests/units/modules/test_decoder.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.modules.decoder import DecoderLayer, TransformerDecoder
5
+
6
+
7
+ def _dev():
8
+ return (
9
+ torch.device(f"cuda:{torch.cuda.current_device()}")
10
+ if torch.cuda.is_available()
11
+ else torch.device("cpu")
12
+ )
13
+
14
+
15
+ # -------- ctor validations --------
16
+
17
+
18
+ def test_decoder_layer_param_checks():
19
+ with pytest.raises(TypeError):
20
+ DecoderLayer("32", 4, 64, 0.1)
21
+ with pytest.raises(TypeError):
22
+ DecoderLayer(32, "4", 64, 0.1)
23
+ with pytest.raises(TypeError):
24
+ DecoderLayer(32, 4, "64", 0.1)
25
+ with pytest.raises(TypeError):
26
+ DecoderLayer(32, 4, 64, "0.1")
27
+
28
+ with pytest.raises(ValueError):
29
+ DecoderLayer(0, 4, 64, 0.1)
30
+ with pytest.raises(ValueError):
31
+ DecoderLayer(32, 0, 64, 0.1)
32
+ with pytest.raises(ValueError):
33
+ DecoderLayer(32, 4, 0, 0.1)
34
+ with pytest.raises(ValueError):
35
+ DecoderLayer(32, 4, 64, 1.0) # upper bound excluded
36
+
37
+
38
+ def test_transformer_decoder_layers_count_checks():
39
+ with pytest.raises(TypeError):
40
+ TransformerDecoder(32, 4, 64, "2", 0.1)
41
+ with pytest.raises(ValueError):
42
+ TransformerDecoder(32, 4, 64, 0, 0.1)
43
+
44
+
45
+ # -------- forward path --------
46
+
47
+
48
+ @pytest.mark.parametrize("B,Sx,Sy,D,H,FF,L", [(2, 5, 6, 24, 3, 48, 2), (1, 0, 0, 16, 1, 32, 1)])
49
+ def test_decoder_forward_happy_path_and_zero_len(B, Sx, Sy, D, H, FF, L):
50
+ device = _dev()
51
+ dec = TransformerDecoder(D, H, FF, L, 0.1).to(device)
52
+
53
+ x = torch.randn(B, Sx, D, device=device)
54
+ y = torch.randn(B, Sy, D, device=device)
55
+
56
+ src_pad = torch.zeros(B, 1, 1, Sx, dtype=torch.bool, device=device) if Sx > 0 else None
57
+ tgt_pad = torch.zeros(B, 1, 1, Sy, dtype=torch.bool, device=device) if Sy > 0 else None
58
+ causal = torch.ones(1, 1, Sy, Sy, dtype=torch.bool, device=device).triu(1) if Sy > 0 else None
59
+
60
+ out = dec(x, y, src_pad, tgt_pad, causal)
61
+ assert out.shape == (B, Sy, D)
62
+ assert out.device == device
63
+
64
+
65
+ def test_decoder_forward_input_checks_and_message_format():
66
+ layer = DecoderLayer(24, 3, 48, 0.1)
67
+
68
+ with pytest.raises(TypeError):
69
+ layer("not a tensor", torch.randn(2, 3, 24), None, None, None)
70
+ with pytest.raises(TypeError):
71
+ layer(torch.randn(2, 3, 24), "not a tensor", None, None, None)
72
+
73
+ with pytest.raises(ValueError) as e1:
74
+ layer(torch.randn(2, 3, 24, 5), torch.randn(2, 3, 24), None, None, None) # x rank 4
75
+ assert "x must be a 3D torch.Tensor of shape (B, S, D)" in str(e1.value)
76
+
77
+ with pytest.raises(ValueError) as e2:
78
+ layer(torch.randn(2, 3, 24), torch.randn(2, 3, 24, 5), None, None, None) # y rank 4
79
+ assert "y must be a 3D torch.Tensor of shape (B, S, D)" in str(e2.value)
80
+
81
+ x = torch.randn(2, 5, 24)
82
+ y = torch.randn(3, 6, 24) # batch mismatch
83
+ with pytest.raises(ValueError) as e3:
84
+ layer(x, y, None, None, None)
85
+ assert "Batch size or d_model mismatch between encoder memory and decoder input" in str(
86
+ e3.value
87
+ )
88
+
89
+ y2 = torch.randn(2, 6, 16) # d_model mismatch
90
+ with pytest.raises(ValueError) as e4:
91
+ layer(x, y2, None, None, None)
92
+ assert "Batch size or d_model mismatch between encoder memory and decoder input" in str(
93
+ e4.value
94
+ )
tests/units/modules/test_embedding.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from transformer.modules.embedding import InputEmbedding, PositionalEmbedding
7
+
8
+
9
+ # -----------------------
10
+ # Helpers
11
+ # -----------------------
12
+ def _rand_ids(B=2, S=5, vocab=11, device="cpu"):
13
+ return torch.randint(0, vocab, (B, S), dtype=torch.long, device=device)
14
+
15
+
16
+ # =======================
17
+ # InputEmbedding — ctor
18
+ # =======================
19
+
20
+
21
+ def test_ctor_type_checks():
22
+ with pytest.raises(TypeError):
23
+ InputEmbedding("10", 8, 16, 0) # vocab_size
24
+ with pytest.raises(TypeError):
25
+ InputEmbedding(10, "8", 16, 0) # d_model
26
+ with pytest.raises(TypeError):
27
+ InputEmbedding(10, 8, "16", 0) # sequence_length
28
+ with pytest.raises(TypeError):
29
+ InputEmbedding(10, 8, 16, "0") # pad_id
30
+
31
+
32
+ def test_ctor_value_checks_basic():
33
+ with pytest.raises(ValueError): # vocab_size <= 0
34
+ InputEmbedding(0, 8, 16, 0)
35
+ with pytest.raises(ValueError): # d_model <= 0
36
+ InputEmbedding(10, 0, 16, 0)
37
+ with pytest.raises(ValueError): # sequence_length < 0 (now allowed to be 0)
38
+ InputEmbedding(10, 8, -1, 0)
39
+ with pytest.raises(ValueError): # pad_id out of range
40
+ InputEmbedding(10, 8, 16, 10)
41
+ with pytest.raises(ValueError):
42
+ InputEmbedding(10, 8, 16, -1)
43
+
44
+
45
+ def test_ctor_allows_zero_length_and_sets_attrs():
46
+ m = InputEmbedding(11, 8, 0, 0)
47
+ assert m.sequence_length == 0
48
+
49
+
50
+ def test_padding_row_zero_and_stays_zero_after_step():
51
+ m = InputEmbedding(13, 6, 10, pad_id=3)
52
+ with torch.no_grad():
53
+ assert torch.allclose(
54
+ m.token_embed.weight[3], torch.zeros(6, dtype=m.token_embed.weight.dtype)
55
+ )
56
+
57
+ ids = torch.tensor([[3, 4, 5, 6]], dtype=torch.long) # includes pad token 3
58
+ out = m(ids).sum()
59
+ out.backward()
60
+ opt = torch.optim.SGD(m.parameters(), lr=0.1)
61
+ opt.step()
62
+ with torch.no_grad():
63
+ assert torch.allclose(
64
+ m.token_embed.weight[3], torch.zeros(6, dtype=m.token_embed.weight.dtype)
65
+ )
66
+
67
+
68
+ # =======================
69
+ # InputEmbedding — forward
70
+ # =======================
71
+
72
+
73
+ def test_forward_type_and_shape_checks():
74
+ m = InputEmbedding(10, 8, 16, 0)
75
+
76
+ with pytest.raises(TypeError):
77
+ m("not a tensor") # wrong type
78
+
79
+ with pytest.raises(TypeError):
80
+ m(torch.ones(2, 5, dtype=torch.float32)) # wrong dtype, must be long
81
+
82
+ with pytest.raises(ValueError):
83
+ m(torch.ones(2, 5, 1, dtype=torch.long)) # rank != 2
84
+
85
+
86
+ def test_forward_out_of_range_ids_raise_index_error():
87
+ m = InputEmbedding(10, 8, 16, 0)
88
+ x = torch.tensor([[0, 9, 10]], dtype=torch.long) # 10 is out of range for vocab_size=10
89
+ with pytest.raises((IndexError, RuntimeError)): # PyTorch may raise either
90
+ m(x)
91
+
92
+
93
+ def test_forward_happy_path_shape_dtype_device_and_zero_len():
94
+ device = (
95
+ torch.device(f"cuda:{torch.cuda.current_device()}")
96
+ if torch.cuda.is_available()
97
+ else torch.device("cpu")
98
+ )
99
+ m = InputEmbedding(32, 24, 64, 0).to(device)
100
+
101
+ # non-empty
102
+ x = _rand_ids(B=3, S=7, vocab=32, device=device)
103
+ out = m(x)
104
+ assert out.shape == (3, 7, 24)
105
+ assert out.device == device
106
+ assert out.dtype == torch.get_default_dtype()
107
+
108
+ # zero-length sequence allowed
109
+ x0 = _rand_ids(B=2, S=0, vocab=32, device=device)
110
+ out0 = m(x0)
111
+ assert out0.shape == (2, 0, 24)
112
+
113
+
114
+ def test_forward_adds_positions_not_just_tokens():
115
+ m = InputEmbedding(20, 12, 32, 0)
116
+ x = _rand_ids(B=2, S=5, vocab=20)
117
+ tok_only = m.token_embed(x)
118
+ out = m(x)
119
+ assert not torch.allclose(out, tok_only)
120
+
121
+
122
+ def test_forward_respects_sequence_length_limit():
123
+ m = InputEmbedding(16, 8, 5, 0)
124
+ x_ok = _rand_ids(B=2, S=5, vocab=16)
125
+ _ = m(x_ok) # should not raise
126
+ x_bad = _rand_ids(B=2, S=6, vocab=16)
127
+ with pytest.raises(ValueError) as ei:
128
+ _ = m(x_bad)
129
+ assert "exceeds max_seq_len" in str(ei.value)
130
+
131
+
132
+ # =======================
133
+ # PositionalEmbedding — ctor
134
+ # =======================
135
+
136
+
137
+ def test_positional_ctor_value_checks():
138
+ with pytest.raises(ValueError):
139
+ PositionalEmbedding(-1, 8) # sequence_length < 0
140
+ with pytest.raises(ValueError):
141
+ PositionalEmbedding(16, 0) # d_model <= 0
142
+
143
+
144
+ def test_positional_buffer_registered_and_constant_zero_ok():
145
+ pe = PositionalEmbedding(0, 10) # zero supported
146
+ assert hasattr(pe, "pe")
147
+ assert isinstance(pe.pe, torch.Tensor)
148
+ assert pe.pe.shape == (1, 0, 10)
149
+ assert pe.pe.requires_grad is False
150
+
151
+
152
+ # =======================
153
+ # PositionalEmbedding — forward
154
+ # =======================
155
+
156
+
157
+ def test_positional_forward_type_and_shape_checks():
158
+ pe = PositionalEmbedding(16, 8)
159
+
160
+ with pytest.raises(TypeError):
161
+ pe("not a tensor")
162
+ with pytest.raises(ValueError):
163
+ pe(torch.zeros(2, 5)) # rank != 3
164
+ with pytest.raises(ValueError):
165
+ pe(torch.zeros(2, 4, 6)) # d_model mismatch
166
+ with pytest.raises(ValueError):
167
+ pe(torch.zeros(2, 17, 8)) # seq_len exceeds max
168
+
169
+
170
+ def test_positional_forward_adds_nonzero_positions_and_preserves_shape():
171
+ pe = PositionalEmbedding(32, 12)
172
+ x = torch.zeros(2, 7, 12)
173
+ y = pe(x)
174
+ assert y.shape == x.shape
175
+ assert not torch.allclose(y, x)
176
+
177
+
178
+ def test_positional_forward_device_and_dtype_follow_input_and_zero_len():
179
+ pe = PositionalEmbedding(64, 16)
180
+
181
+ # CPU float32
182
+ x = torch.zeros(1, 5, 16, dtype=torch.float32, device="cpu")
183
+ y = pe(x)
184
+ assert y.device.type == "cpu" and y.dtype == torch.float32
185
+
186
+ # zero length
187
+ x0 = torch.zeros(1, 0, 16)
188
+ y0 = pe(x0)
189
+ assert y0.shape == (1, 0, 16)
190
+
191
+ # bfloat16 on CPU (if supported)
192
+ try:
193
+ x_bf = torch.zeros(1, 5, 16, dtype=torch.bfloat16)
194
+ y_bf = pe(x_bf)
195
+ assert y_bf.dtype == torch.bfloat16
196
+ except Exception:
197
+ pass
198
+
199
+ # CUDA & half if available
200
+ if torch.cuda.is_available():
201
+ xh = torch.zeros(1, 5, 16, dtype=torch.float16, device="cuda")
202
+ yh = pe(xh)
203
+ assert yh.device.type == "cuda" and yh.dtype == torch.float16
204
+
205
+
206
+ def test_positional_matches_known_small_reference():
207
+ # Cross-check first few values with textbook sin/cos
208
+ S, D = 4, 6
209
+ pe = PositionalEmbedding(S, D)
210
+ x = torch.zeros(1, S, D)
211
+ y = pe(x)
212
+ added = y[0] # [S, D]
213
+
214
+ ref = torch.zeros_like(added)
215
+ base = 10_000.0
216
+ for pos in range(S):
217
+ for i in range(D // 2):
218
+ denom = base ** (2 * i / D)
219
+ ref[pos, 2 * i] = math.sin(pos / denom)
220
+ ref[pos, 2 * i + 1] = math.cos(pos / denom)
221
+
222
+ assert torch.allclose(added, ref, atol=1e-6, rtol=1e-6)
tests/units/modules/test_encoder.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.modules.encoder import EncoderLayer, TransformerEncoder
5
+
6
+
7
+ def _dev():
8
+ return (
9
+ torch.device(f"cuda:{torch.cuda.current_device()}")
10
+ if torch.cuda.is_available()
11
+ else torch.device("cpu")
12
+ )
13
+
14
+
15
+ # -------- ctor validations --------
16
+
17
+
18
+ def test_encoder_layer_param_checks():
19
+ with pytest.raises(TypeError):
20
+ EncoderLayer("32", 4, 64, 0.1)
21
+ with pytest.raises(TypeError):
22
+ EncoderLayer(32, "4", 64, 0.1)
23
+ with pytest.raises(TypeError):
24
+ EncoderLayer(32, 4, "64", 0.1)
25
+ with pytest.raises(TypeError):
26
+ EncoderLayer(32, 4, 64, "0.1")
27
+
28
+ with pytest.raises(ValueError):
29
+ EncoderLayer(0, 4, 64, 0.1)
30
+ with pytest.raises(ValueError):
31
+ EncoderLayer(32, 0, 64, 0.1)
32
+ with pytest.raises(ValueError):
33
+ EncoderLayer(32, 4, 0, 0.1)
34
+ with pytest.raises(ValueError):
35
+ EncoderLayer(32, 4, 64, 1.0) # upper bound excluded
36
+
37
+
38
+ def test_transformer_encoder_layers_count_checks():
39
+ with pytest.raises(TypeError):
40
+ TransformerEncoder(32, 4, 64, "2", 0.1)
41
+ with pytest.raises(ValueError):
42
+ TransformerEncoder(32, 4, 64, 0, 0.1)
43
+
44
+
45
+ # -------- forward path --------
46
+
47
+
48
+ @pytest.mark.parametrize("B,S,D,H,FF,L", [(2, 5, 24, 3, 48, 2), (1, 0, 16, 1, 32, 1)])
49
+ def test_encoder_forward_happy_path_and_zero_len(B, S, D, H, FF, L):
50
+ device = _dev()
51
+ enc = TransformerEncoder(D, H, FF, L, 0.1).to(device)
52
+
53
+ x = torch.randn(B, S, D, device=device)
54
+ src_pad = torch.zeros(B, 1, 1, S, dtype=torch.bool, device=device) if S > 0 else None
55
+
56
+ out = enc(x, src_pad)
57
+ assert out.shape == (B, S, D)
58
+ assert out.device == device
59
+
60
+
61
+ def test_encoder_forward_input_checks_and_message_format():
62
+ layer = EncoderLayer(24, 3, 48, 0.1)
63
+
64
+ with pytest.raises(TypeError):
65
+ layer("not a tensor", None)
66
+
67
+ with pytest.raises(ValueError) as ei:
68
+ layer(torch.randn(2, 3, 4, 5), None) # rank 4
69
+ assert "x must be a 3D torch.Tensor of shape (B, S, D)" in str(ei.value)
tests/units/modules/test_feedforward.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.modules.feedforward import FeedForwardLayer
5
+
6
+ # =======================
7
+ # Constructor checks
8
+ # =======================
9
+
10
+
11
+ def test_ctor_type_checks():
12
+ with pytest.raises(TypeError):
13
+ FeedForwardLayer("64", 256, 0.1) # d_model
14
+ with pytest.raises(TypeError):
15
+ FeedForwardLayer(64, "256", 0.1) # d_ff
16
+ with pytest.raises(TypeError):
17
+ FeedForwardLayer(64, 256, "0.1") # dropout_rate
18
+
19
+
20
+ def test_ctor_value_checks():
21
+ with pytest.raises(ValueError):
22
+ FeedForwardLayer(0, 256, 0.1)
23
+ with pytest.raises(ValueError):
24
+ FeedForwardLayer(64, 0, 0.1)
25
+ with pytest.raises(ValueError):
26
+ FeedForwardLayer(64, 256, -1.0)
27
+ with pytest.raises(ValueError):
28
+ FeedForwardLayer(64, 256, 1.0) # upper bound excluded
29
+
30
+
31
+ def test_ctor_happy_path_defaults_and_attrs():
32
+ ffn = FeedForwardLayer(64, 256) # default dropout=0.1
33
+ assert ffn.d_model == 64 and ffn.d_ff == 256
34
+ assert isinstance(ffn.dropout, torch.nn.Dropout)
35
+ assert abs(ffn.dropout.p - 0.1) < 1e-9
36
+
37
+
38
+ # =======================
39
+ # Forward: input validation
40
+ # =======================
41
+
42
+
43
+ def test_forward_type_and_rank_checks():
44
+ ffn = FeedForwardLayer(32, 64, 0.1)
45
+ with pytest.raises(TypeError):
46
+ ffn("not a tensor")
47
+ with pytest.raises(ValueError):
48
+ ffn(torch.randn(5, 32)) # rank 2
49
+ with pytest.raises(ValueError):
50
+ ffn(torch.randn(2, 3, 16)) # D mismatch (expects 32)
51
+
52
+
53
+ # =======================
54
+ # Forward: shapes, device, dtype, zero-length
55
+ # =======================
56
+
57
+
58
+ @pytest.mark.parametrize("B,S,D", [(1, 1, 16), (2, 7, 32), (3, 0, 24)])
59
+ def test_forward_shapes_device_dtype_and_zero_len(B, S, D):
60
+ device = (
61
+ torch.device(f"cuda:{torch.cuda.current_device()}")
62
+ if torch.cuda.is_available()
63
+ else torch.device("cpu")
64
+ )
65
+ ffn = FeedForwardLayer(D, D * 2, 0.2).to(device)
66
+
67
+ x = torch.randn(B, S, D, device=device, dtype=torch.float32)
68
+ y = ffn(x)
69
+ assert y.shape == (B, S, D)
70
+ assert y.device == device
71
+ assert y.dtype == x.dtype
72
+
73
+
74
+ # =======================
75
+ # Forward: gradient flow
76
+ # =======================
77
+
78
+
79
+ def test_gradients_flow():
80
+ D, H = 32, 64
81
+ ffn = FeedForwardLayer(D, H, 0.1)
82
+ x = torch.randn(2, 5, D, requires_grad=True)
83
+ y = ffn(x)
84
+ loss = y.pow(2).mean()
85
+ loss.backward()
86
+ assert x.grad is not None and torch.isfinite(x.grad).all()
87
+
88
+
89
+ # =======================
90
+ # Dropout behavior
91
+ # =======================
92
+
93
+
94
+ def test_dropout_zero_equals_no_dropout_path():
95
+ D, H = 16, 32
96
+ # Build two modules: one with p=0.0, one with p>0 but switched to eval()
97
+ ffn0 = FeedForwardLayer(D, H, 0.0)
98
+ ffnx = FeedForwardLayer(D, H, 0.5).eval() # eval disables dropout
99
+
100
+ ffnx.load_state_dict(ffn0.state_dict(), strict=False)
101
+
102
+ x = torch.randn(2, 4, D)
103
+ y0 = ffn0(x)
104
+ yx = ffnx(x)
105
+ # With dropout disabled both should be equal (same weights distrib not identical, but same computation tree)
106
+ assert torch.allclose(y0, yx, atol=1e-6, rtol=1e-6)
107
+
108
+
109
+ def test_dropout_training_changes_output_vs_eval():
110
+ D, H = 32, 64
111
+ ffn = FeedForwardLayer(D, H, 0.5)
112
+ x = torch.randn(3, 6, D)
113
+
114
+ torch.manual_seed(123)
115
+ ffn.train()
116
+ y_train = ffn(x)
117
+
118
+ torch.manual_seed(123)
119
+ ffn.eval()
120
+ y_eval = ffn(x)
121
+
122
+ # Same seed but eval disables dropout -> outputs should differ
123
+ assert not torch.allclose(y_train, y_eval)
124
+
125
+
126
+ def test_dropout_is_noop_on_zero_length():
127
+ D, H = 32, 64
128
+ ffn = FeedForwardLayer(D, H, 0.7).train()
129
+ x = torch.randn(2, 0, D) # zero-length sequence
130
+ y = ffn(x)
131
+ assert y.shape == (2, 0, D) # no crash; shape preserved
tests/units/modules/test_lm_head.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.modules.lm_head import LMHead
5
+
6
+ # -----------------------
7
+ # Constructor checks
8
+ # -----------------------
9
+
10
+
11
+ def test_ctor_type_and_value_checks():
12
+ with pytest.raises(TypeError):
13
+ LMHead("64", 10)
14
+ with pytest.raises(TypeError):
15
+ LMHead(64, "10")
16
+ with pytest.raises(ValueError):
17
+ LMHead(0, 10)
18
+ with pytest.raises(ValueError):
19
+ LMHead(64, 0)
20
+
21
+
22
+ def test_ctor_happy_path():
23
+ lm = LMHead(32, 1000)
24
+ assert lm.d_model == 32
25
+ assert lm.vocab_size == 1000
26
+ assert lm.fc.weight.shape == (1000, 32)
27
+
28
+
29
+ # -----------------------
30
+ # Forward checks (3D only)
31
+ # -----------------------
32
+
33
+
34
+ def test_forward_type_and_shape_errors():
35
+ lm = LMHead(16, 50)
36
+ with pytest.raises(TypeError):
37
+ lm("not a tensor")
38
+ with pytest.raises(ValueError):
39
+ lm(torch.randn(2, 3, 4, 5)) # rank 4 not supported
40
+ with pytest.raises(ValueError):
41
+ lm(torch.randn(5, 16)) # rank 2 not supported
42
+ with pytest.raises(ValueError):
43
+ lm(torch.randn(2, 8, 15)) # last dim != d_model
44
+
45
+
46
+ def test_forward_happy_path_and_zero_len():
47
+ device = (
48
+ torch.device(f"cuda:{torch.cuda.current_device()}")
49
+ if torch.cuda.is_available()
50
+ else torch.device("cpu")
51
+ )
52
+ lm = LMHead(24, 101).to(device)
53
+
54
+ # Non-empty
55
+ x = torch.randn(3, 7, 24, device=device, dtype=torch.float32)
56
+ y = lm(x)
57
+ assert y.shape == (3, 7, 101)
58
+ assert y.device == device and y.dtype == torch.float32
59
+
60
+ # Zero-length sequence allowed
61
+ x0 = torch.randn(2, 0, 24, device=device, dtype=torch.float32)
62
+ y0 = lm(x0)
63
+ assert y0.shape == (2, 0, 101)
64
+
65
+
66
+ def test_gradients_flow():
67
+ lm = LMHead(8, 40)
68
+ x = torch.randn(3, 6, 8, requires_grad=True)
69
+ y = lm(x)
70
+ loss = y.pow(2).mean()
71
+ loss.backward()
72
+ assert x.grad is not None
73
+ assert torch.isfinite(x.grad).all()
tests/units/test_transformer.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from transformer.configs import BasicEncDecCfg
5
+ from transformer.transformer import BasicEncoderDecoderTransformer
6
+
7
+
8
+ def _dev():
9
+ return (
10
+ torch.device(f"cuda:{torch.cuda.current_device()}")
11
+ if torch.cuda.is_available()
12
+ else torch.device("cpu")
13
+ )
14
+
15
+
16
+ def _tiny_cfg(**over):
17
+ # Small, fast config for tests; zero-length safe via max_seq_len >= 0
18
+ base = dict(
19
+ vocab_size=32,
20
+ d_model=16,
21
+ d_ff=32,
22
+ num_heads=4,
23
+ num_layers=2,
24
+ max_seq_len=32,
25
+ pad_id=0,
26
+ bos_id=1,
27
+ eos_id=2,
28
+ dropout_rate=0.1,
29
+ )
30
+ base.update(over)
31
+ return BasicEncDecCfg(**base)
32
+
33
+
34
+ # -----------------------
35
+ # Constructor & config validation
36
+ # -----------------------
37
+
38
+
39
+ def test_ctor_cfg_type_and_values():
40
+ with pytest.raises(TypeError):
41
+ BasicEncoderDecoderTransformer(cfg="not a cfg")
42
+ # invalid vocab / dims / indices
43
+ bads = [
44
+ dict(vocab_size=0),
45
+ dict(d_model=0),
46
+ dict(max_seq_len=-1),
47
+ dict(pad_id=-1),
48
+ dict(pad_id=100), # out of range
49
+ dict(bos_id=999),
50
+ dict(eos_id=999),
51
+ ]
52
+ for upd in bads:
53
+ with pytest.raises((TypeError, ValueError)):
54
+ BasicEncoderDecoderTransformer(_tiny_cfg(**upd))
55
+
56
+ # happy path
57
+ model = BasicEncoderDecoderTransformer(_tiny_cfg())
58
+ # weight tying sanity: same storage
59
+ assert model.embed.token_embed.weight.data_ptr() == model.lm_head.fc.weight.data_ptr()
60
+
61
+
62
+ # -----------------------
63
+ # Encode/Decode/Forward — shapes, dtypes, devices, zero-length
64
+ # -----------------------
65
+
66
+
67
+ @pytest.mark.parametrize("Sx,Sy", [(5, 6), (0, 1)])
68
+ def test_forward_pipeline_shapes_and_types(Sx, Sy):
69
+ device = _dev()
70
+ cfg = _tiny_cfg(max_seq_len=16)
71
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
72
+
73
+ B = 2
74
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
75
+ tgt = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long, device=device)
76
+
77
+ # padding masks (boolean), broadcastable; allow None when S == 0
78
+ src_mask = torch.zeros(B, 1, 1, Sx, dtype=torch.bool, device=device) if Sx > 0 else None
79
+ tgt_mask = torch.zeros(B, 1, 1, Sy, dtype=torch.bool, device=device) if Sy > 0 else None
80
+
81
+ logits = model(src, tgt, src_mask, tgt_mask)
82
+ assert logits.shape == (B, Sy, cfg.vocab_size)
83
+ assert logits.device == device
84
+ assert logits.dtype == torch.get_default_dtype()
85
+
86
+ # encode / decode individually
87
+ mem = model.encode(src, src_mask)
88
+ assert mem.shape == (B, Sx, cfg.d_model)
89
+ out = model.decode(mem, tgt, src_mask, tgt_mask)
90
+ assert out.shape == (B, Sy, cfg.d_model)
91
+
92
+
93
+ def test_forward_dtype_checks_and_shape_errors():
94
+ cfg = _tiny_cfg()
95
+ model = BasicEncoderDecoderTransformer(cfg)
96
+
97
+ B, Sx, Sy = 2, 4, 5
98
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long)
99
+ tgt = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long)
100
+
101
+ with pytest.raises(TypeError):
102
+ model("not a tensor", tgt, None, None)
103
+ with pytest.raises(TypeError):
104
+ model(src, "not a tensor", None, None)
105
+ with pytest.raises(ValueError):
106
+ model(src.unsqueeze(0), tgt, None, None) # rank 3 src_ids
107
+ with pytest.raises(ValueError):
108
+ model(src, tgt.unsqueeze(-1), None, None) # rank 3 tgt_ids
109
+ with pytest.raises(TypeError):
110
+ model(src.float(), tgt, None, None) # wrong dtype
111
+ with pytest.raises(TypeError):
112
+ model(src, tgt.int(), None, None) # wrong dtype
113
+ with pytest.raises(TypeError):
114
+ model(src, tgt, src_padding_mask="not a tensor", tgt_padding_mask=None)
115
+ with pytest.raises(TypeError):
116
+ model(src, tgt, src_padding_mask=None, tgt_padding_mask="not a tensor")
117
+
118
+
119
+ def test_encode_decode_errors_and_messages():
120
+ cfg = _tiny_cfg()
121
+ model = BasicEncoderDecoderTransformer(cfg)
122
+
123
+ src = torch.randint(0, cfg.vocab_size, (2, 4))
124
+ with pytest.raises(ValueError):
125
+ model.encode(src.unsqueeze(-1), None) # rank 3 not allowed
126
+ with pytest.raises(TypeError):
127
+ model.encode(src.float(), None) # dtype must be long
128
+
129
+ mem = torch.randn(2, 4, cfg.d_model)
130
+ tgt = torch.randint(0, cfg.vocab_size, (2, 5))
131
+ with pytest.raises(ValueError):
132
+ model.decode(mem.unsqueeze(0), tgt, None, None) # mem rank 4
133
+ with pytest.raises(ValueError):
134
+ model.decode(mem, tgt.unsqueeze(-1), None, None) # tgt rank 3
135
+ with pytest.raises(TypeError):
136
+ model.decode(mem, tgt.float(), None, None) # tgt dtype must be long
137
+
138
+
139
+ def test_exceeding_max_seq_len_raises():
140
+ cfg = _tiny_cfg(max_seq_len=4)
141
+ model = BasicEncoderDecoderTransformer(cfg)
142
+ # src longer than max -> PositionalEmbedding should raise
143
+ src = torch.randint(0, cfg.vocab_size, (2, 5), dtype=torch.long)
144
+ with pytest.raises(ValueError):
145
+ model.encode(src, None)
146
+
147
+
148
+ # -----------------------
149
+ # Gradients & weight tying
150
+ # -----------------------
151
+
152
+
153
+ def test_backward_through_full_forward_and_tied_weights():
154
+ device = _dev()
155
+ cfg = _tiny_cfg()
156
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
157
+
158
+ B, Sx, Sy = 2, 6, 5
159
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
160
+ tgt = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long, device=device)
161
+ src_mask = torch.zeros(B, 1, 1, Sx, dtype=torch.bool, device=device)
162
+ tgt_mask = torch.zeros(B, 1, 1, Sy, dtype=torch.bool, device=device)
163
+
164
+ logits = model(src, tgt, src_mask, tgt_mask) # (B, Sy, V)
165
+ # Dummy labels (language modeling): predict tgt itself; ignore pads via mask
166
+ loss = logits.pow(2).mean()
167
+ loss.backward()
168
+
169
+ # Tied weights should have gradient (one shared tensor)
170
+ tied_weight = model.lm_head.fc.weight
171
+ assert tied_weight.grad is not None
172
+ assert torch.isfinite(tied_weight.grad).all()
173
+
174
+
175
+ # -----------------------
176
+ # Generate API
177
+ # -----------------------
178
+
179
+
180
+ def test_generate_arg_checks_and_output_shapes():
181
+ device = _dev()
182
+ cfg = _tiny_cfg()
183
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
184
+
185
+ B, Sx = 2, 4
186
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
187
+ src_mask = torch.zeros(B, 1, 1, Sx, dtype=torch.bool, device=device)
188
+
189
+ # type/value errors
190
+ with pytest.raises(TypeError):
191
+ model.generate("not", src_mask)
192
+ with pytest.raises(ValueError):
193
+ model.generate(src.unsqueeze(-1), src_mask)
194
+ with pytest.raises(TypeError):
195
+ model.generate(src.float(), src_mask)
196
+ with pytest.raises(TypeError):
197
+ model.generate(src, "not a tensor")
198
+ with pytest.raises(TypeError):
199
+ model.generate(src, src_mask, max_new_tokens="10")
200
+ with pytest.raises(ValueError):
201
+ model.generate(src, src_mask, max_new_tokens=-1)
202
+ with pytest.raises(TypeError):
203
+ model.generate(src, src_mask, temperature="1.0")
204
+ with pytest.raises(ValueError):
205
+ model.generate(src, src_mask, temperature=0.0)
206
+ with pytest.raises(TypeError):
207
+ model.generate(src, src_mask, top_k="5")
208
+ with pytest.raises(ValueError):
209
+ model.generate(src, src_mask, top_k=0)
210
+ with pytest.raises(TypeError):
211
+ model.generate(src, src_mask, top_p="0.9")
212
+ with pytest.raises(ValueError):
213
+ model.generate(src, src_mask, top_p=0.0)
214
+ with pytest.raises(ValueError):
215
+ model.generate(src, src_mask, top_p=1.1)
216
+
217
+ # happy path: shapes & dtypes; content is stochastic so we don't assert exact ids
218
+ out = model.generate(src, src_mask, max_new_tokens=7, temperature=1.0, top_k=None, top_p=None)
219
+ # Should start with BOS and append new tokens; total length = 1 + new tokens (or earlier if EOS)
220
+ assert out.shape[0] == B and out.dim() == 2
221
+ assert out.dtype == torch.long and out.device == device
222
+ assert out.size(1) >= 1 and out.size(1) <= 1 + 7 # early stop on EOS allowed
223
+
224
+ # Ensure BOS is at position 0 for every sequence
225
+ assert torch.all(out[:, 0] == cfg.bos_id)
226
+
227
+
228
+ def test_generate_zero_new_tokens_returns_only_bos():
229
+ device = _dev()
230
+ cfg = _tiny_cfg()
231
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
232
+
233
+ src = torch.randint(0, cfg.vocab_size, (2, 3), dtype=torch.long, device=device)
234
+ out = model.generate(src, None, max_new_tokens=0)
235
+ assert out.shape == (2, 1) # just the BOS token
236
+
237
+
238
+ # -----------------------
239
+ # Mask broadcast sanity (boolean & additive are handled in utils; we just pass through)
240
+ # -----------------------
241
+
242
+
243
+ def test_forward_accepts_boolean_padding_masks():
244
+ device = _dev()
245
+ cfg = _tiny_cfg()
246
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
247
+
248
+ B, Sx, Sy = 2, 5, 6
249
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
250
+ tgt = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long, device=device)
251
+
252
+ src_pad_bool = torch.zeros(B, 1, 1, Sx, dtype=torch.bool, device=device)
253
+ tgt_pad_bool = torch.zeros(B, 1, 1, Sy, dtype=torch.bool, device=device)
254
+
255
+ logits = model(src, tgt, src_pad_bool, tgt_pad_bool)
256
+ assert logits.shape == (B, Sy, cfg.vocab_size)
257
+
258
+
259
+ # -----------------------
260
+ # Weight tying and optimizer step
261
+ # -----------------------
262
+
263
+
264
+ def test_weight_tying_and_optimizer_step_changes_weights_once():
265
+ device = _dev()
266
+ cfg = _tiny_cfg()
267
+ model = BasicEncoderDecoderTransformer(cfg).to(device)
268
+
269
+ # Same storage pointer means truly tied
270
+ assert model.embed.token_embed.weight.data_ptr() == model.lm_head.fc.weight.data_ptr()
271
+
272
+ opt = torch.optim.SGD(model.parameters(), lr=0.01)
273
+ B, Sx, Sy = 2, 5, 4
274
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
275
+ tgt = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long, device=device)
276
+ logits = model(src, tgt, None, None) # (B, Sy, V)
277
+
278
+ # Simple loss to get gradients flowing
279
+ loss = logits.pow(2).mean()
280
+ loss.backward()
281
+
282
+ # The tied tensor should have a gradient and be finite
283
+ tied = model.lm_head.fc.weight
284
+ assert tied.grad is not None and torch.isfinite(tied.grad).all()
285
+
286
+ # Save copy before step and ensure it changes after step (once, not twice)
287
+ before = tied.detach().clone()
288
+ opt.step()
289
+ after = tied.detach().clone()
290
+
291
+ assert not torch.allclose(before, after)
292
+ # Still tied after step (same storage)
293
+ assert model.embed.token_embed.weight.data_ptr() == model.lm_head.fc.weight.data_ptr()
294
+
295
+
296
+ # -----------------------
297
+ # Autoregressive invariants: causal mask honored
298
+ # -----------------------
299
+
300
+
301
+ def test_decode_invariance_to_future_tokens():
302
+ """
303
+ Sanity-check causal masking: logits at position t must not depend on tokens at > t.
304
+ Construct two target sequences that are identical up to t and differ after; compare
305
+ decoder outputs at step t.
306
+ """
307
+ device = _dev()
308
+ cfg = _tiny_cfg()
309
+ model = BasicEncoderDecoderTransformer(cfg).to(device).eval()
310
+
311
+ B, Sx, Sy = 1, 4, 5
312
+ src = torch.randint(0, cfg.vocab_size, (B, Sx), dtype=torch.long, device=device)
313
+ mem = model.encode(src, None)
314
+
315
+ # Construct y1 and y2 identical up to t=3, different afterwards
316
+ y1 = torch.randint(0, cfg.vocab_size, (B, Sy), dtype=torch.long, device=device)
317
+ y2 = y1.clone()
318
+ t = 3
319
+ if Sy > t + 1:
320
+ y2[:, t + 1 :] = (y1[:, t + 1 :] + 1) % cfg.vocab_size
321
+
322
+ out1 = model.decode(mem, y1, None, None) # (B, Sy, D)
323
+ out2 = model.decode(mem, y2, None, None)
324
+
325
+ # Compare hidden states up to and including position t
326
+ assert torch.allclose(out1[:, : t + 1], out2[:, : t + 1], atol=1e-5, rtol=1e-5)
tests/units/test_utils.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import pytest
4
+ import torch
5
+ import torch.nn.functional as F
6
+
7
+ from transformer.utils import (
8
+ calculate_attention,
9
+ combine_masks,
10
+ create_causal_mask,
11
+ join_heads,
12
+ sample_from_logits,
13
+ shift_right,
14
+ sinusoidal_positional_encoding,
15
+ split_heads,
16
+ )
17
+
18
+ ###___split_heads___###
19
+
20
+
21
+ def test_happy_path_shape_dtype_device_cpu():
22
+ x = torch.randn(2, 10, 16) # B=2, S=10, D=16
23
+ out = split_heads(x, num_heads=4)
24
+ assert out.shape == (2, 4, 10, 4)
25
+ assert out.dtype == x.dtype
26
+ assert out.device == x.device
27
+
28
+
29
+ def test_smallest_valid():
30
+ x = torch.randn(1, 1, 4)
31
+ out = split_heads(x, num_heads=2)
32
+ assert out.shape == (1, 2, 1, 2)
33
+
34
+
35
+ def test_zero_length_seq_allowed():
36
+ x = torch.randn(2, 0, 8)
37
+ out = split_heads(x, num_heads=2)
38
+ assert out.shape == (2, 2, 0, 4)
39
+
40
+
41
+ def test_non_3d_input_raises_value_error():
42
+ x2 = torch.randn(10, 16)
43
+ with pytest.raises(ValueError) as ei2:
44
+ split_heads(x2, 4)
45
+ assert "(B, S, D)" in str(ei2.value)
46
+
47
+ x4 = torch.randn(2, 3, 4, 5)
48
+ with pytest.raises(ValueError) as ei4:
49
+ split_heads(x4, 4)
50
+ assert "(B, S, D)" in str(ei4.value)
51
+
52
+
53
+ def test_invalid_num_heads_type_raises_type_error():
54
+ x = torch.randn(2, 10, 16)
55
+ with pytest.raises(TypeError):
56
+ split_heads(x, 4.0) # float
57
+ with pytest.raises(TypeError):
58
+ split_heads(x, torch.tensor(4)) # Tensor
59
+
60
+
61
+ def test_num_heads_leq_zero_raises_value_error():
62
+ x = torch.randn(2, 10, 16)
63
+ with pytest.raises(ValueError):
64
+ split_heads(x, 0)
65
+ with pytest.raises(ValueError):
66
+ split_heads(x, -2)
67
+
68
+
69
+ def test_non_divisible_d_model_raises_value_error_message_has_values():
70
+ x = torch.randn(2, 5, 10)
71
+ with pytest.raises(ValueError) as e:
72
+ split_heads(x, 3)
73
+ msg = str(e.value)
74
+ assert "d_model (10)" in msg and "num_heads (3)" in msg
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "B,S,D,H",
79
+ [
80
+ (1, 1, 8, 1),
81
+ (2, 3, 12, 3),
82
+ (4, 7, 32, 8),
83
+ (3, 0, 24, 6),
84
+ ],
85
+ )
86
+ def test_invariants_random_valid(B, S, D, H):
87
+ x = torch.randn(B, S, D)
88
+ out = split_heads(x, H)
89
+ assert out.shape[0] == B
90
+ assert out.shape[2] == S
91
+ assert (H * (D // H)) == D
92
+ assert x.numel() == out.numel()
93
+
94
+
95
+ def test_grad_propagates():
96
+ x = torch.randn(2, 10, 16, requires_grad=True)
97
+ out = split_heads(x, 4)
98
+ # Do a simple scalar reduction to make grad non-trivial
99
+ loss = out.square().mean()
100
+ loss.backward()
101
+ assert x.grad is not None
102
+ assert x.grad.shape == x.shape
103
+
104
+
105
+ def test_non_contiguous_input():
106
+ # Create a non-contiguous view by transposing and slicing
107
+ base = torch.randn(10, 2, 16)
108
+ x = base.transpose(0, 1)[:, :8, :] # shape (2, 8, 16), likely non-contiguous
109
+ assert not x.is_contiguous()
110
+ out = split_heads(x, 4)
111
+ assert out.shape == (2, 4, 8, 4)
112
+
113
+
114
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
115
+ def test_device_preserved_cuda():
116
+ x = torch.randn(2, 10, 16, device="cuda")
117
+ out = split_heads(x, 4)
118
+ assert out.device.type == "cuda"
119
+
120
+
121
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
122
+ def test_dtype_half_bfloat_on_gpu():
123
+ x_half = torch.randn(2, 10, 16, device="cuda", dtype=torch.float16)
124
+ out_half = split_heads(x_half, 4)
125
+ assert out_half.dtype == torch.float16
126
+
127
+ # bfloat16 may not be available on all GPUs; guard with try
128
+ try:
129
+ x_bf16 = torch.randn(2, 10, 16, device="cuda", dtype=torch.bfloat16)
130
+ out_bf16 = split_heads(x_bf16, 4)
131
+ assert out_bf16.dtype == torch.bfloat16
132
+ except RuntimeError:
133
+ pytest.skip("bfloat16 not supported on this device")
134
+
135
+
136
+ ###___calculate_attention___###
137
+
138
+
139
+ # ----------------------------
140
+ # Helpers
141
+ # ----------------------------
142
+ def _rand(B=2, H=3, Sq=4, Sk=5, D=8, device="cpu", dtype=torch.float32):
143
+ q = torch.randn(B, H, Sq, D, device=device, dtype=dtype)
144
+ k = torch.randn(B, H, Sk, D, device=device, dtype=dtype)
145
+ v = torch.randn(B, H, Sk, D, device=device, dtype=dtype)
146
+ return q, k, v
147
+
148
+
149
+ # ----------------------------
150
+ # A. Basic correctness
151
+ # ----------------------------
152
+ def test_basic_shapes_and_row_sums():
153
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
154
+ q, k, v = _rand(B, H, Sq, Sk, D)
155
+ attn, probs = calculate_attention(q, k, v, mask=None, return_probs=True)
156
+ assert attn.shape == (B, H, Sq, D)
157
+ assert probs.shape == (B, H, Sq, Sk)
158
+ # softmax rows sum to 1
159
+ assert torch.allclose(probs.sum(-1), torch.ones(B, H, Sq), atol=1e-6)
160
+
161
+
162
+ def test_identity_prefers_diagonal():
163
+ # q == k == v as orthogonal-ish basis to encourage diagonal peak
164
+ Sq = Sk = D = 4
165
+ base = torch.eye(D).view(1, 1, Sk, D).expand(1, 1, Sk, D).contiguous()
166
+ q = base.clone()
167
+ k = base.clone()
168
+ v = base.clone()
169
+ _, probs = calculate_attention(q, k, v, mask=None, return_probs=True)
170
+ assert torch.equal(probs[0, 0].argmax(-1), torch.arange(Sq))
171
+
172
+
173
+ # ----------------------------
174
+ # B. Mask behavior (boolean & causal)
175
+ # ----------------------------
176
+ def test_padding_mask_boolean_last_two_keys():
177
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
178
+ q, k, v = _rand(B, H, Sq, Sk, D)
179
+ mask = torch.zeros(B, H, Sq, Sk, dtype=torch.bool)
180
+ mask[:, :, :, -2:] = True # mask last two keys
181
+ _, probs = calculate_attention(q, k, v, mask=mask, return_probs=True)
182
+ assert (probs[:, :, :, -2:] < 1e-6).all()
183
+ # After zeroing masked entries, rows renormalize to ~1
184
+ assert torch.allclose(probs.masked_fill(mask, 0).sum(-1), torch.ones(B, H, Sq), atol=1e-6)
185
+
186
+
187
+ def test_causal_mask_upper_triangle_zero():
188
+ B, H, S, D = 2, 3, 6, 8
189
+ q, k, v = _rand(B, H, S, S, D)
190
+ causal = torch.ones(B, H, S, S, dtype=torch.bool).triu(1) # True above diagonal
191
+ _, probs = calculate_attention(q, k, v, mask=causal, return_probs=True)
192
+ assert (probs.triu(1) < 1e-6).all()
193
+
194
+
195
+ def test_fully_masked_row_zero_probs_and_output():
196
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
197
+ q, k, v = _rand(B, H, Sq, Sk, D)
198
+ mask = torch.zeros(B, H, Sq, Sk, dtype=torch.bool)
199
+ mask[:, :, 1, :] = True # fully mask a query row
200
+ attn, probs = calculate_attention(q, k, v, mask=mask, return_probs=True)
201
+ assert torch.allclose(probs[:, :, 1, :], torch.zeros(B, H, Sk))
202
+ assert torch.allclose(attn[:, :, 1, :], torch.zeros(B, H, D))
203
+
204
+
205
+ # ----------------------------
206
+ # C. Numerical stability
207
+ # ----------------------------
208
+ def test_extreme_logits_no_nans_or_infs():
209
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
210
+ q, k, v = _rand(B, H, Sq, Sk, D)
211
+ q = q * 1000
212
+ k = k * 1000
213
+ attn, probs = calculate_attention(q, k, v, mask=None, return_probs=True)
214
+ assert not (torch.isnan(probs).any() or torch.isinf(probs).any())
215
+ assert attn.shape == (B, H, Sq, D)
216
+
217
+
218
+ def test_half_precision_close_to_fp32_or_skip():
219
+ if not torch.cuda.is_available():
220
+ pytest.skip("CUDA not available")
221
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
222
+ q32, k32, v32 = _rand(B, H, Sq, Sk, D, device="cuda", dtype=torch.float32)
223
+ q16, k16, v16 = q32.half(), k32.half(), v32.half()
224
+ a16 = calculate_attention(q16, k16, v16, mask=None)
225
+ a32 = calculate_attention(q32, k32, v32, mask=None)
226
+ # looser tolerance for fp16
227
+ assert torch.allclose(a16.float(), a32.float(), atol=5e-2, rtol=5e-2)
228
+
229
+
230
+ # ----------------------------
231
+ # D. Edge cases
232
+ # ----------------------------
233
+ def test_zero_length_sequences():
234
+ B, H, D = 2, 3, 8
235
+ # Sq==0
236
+ q, k, v = _rand(B, H, 0, 5, D)
237
+ a, p = calculate_attention(q, k, v, mask=None, return_probs=True)
238
+ assert a.shape == (B, H, 0, D) and p.shape == (B, H, 0, 5)
239
+ # Sk==0
240
+ q, k, v = _rand(B, H, 4, 0, D)
241
+ a, p = calculate_attention(q, k, v, mask=None, return_probs=True)
242
+ assert a.shape == (B, H, 4, D) and p.shape == (B, H, 4, 0)
243
+ assert torch.allclose(a, torch.zeros_like(a))
244
+
245
+
246
+ def test_singleton_softmax_is_one():
247
+ q = torch.randn(1, 1, 1, 1)
248
+ k = torch.randn(1, 1, 1, 1)
249
+ v = torch.randn(1, 1, 1, 1)
250
+ _, p = calculate_attention(q, k, v, mask=None, return_probs=True)
251
+ assert torch.allclose(p, torch.ones_like(p), atol=1e-6)
252
+
253
+
254
+ # ----------------------------
255
+ # E. Autograd & determinism
256
+ # ----------------------------
257
+ def test_gradients_flow_no_inplace_breakage():
258
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
259
+ q = torch.randn(B, H, Sq, D, requires_grad=True)
260
+ k = torch.randn(B, H, Sk, D, requires_grad=True)
261
+ v = torch.randn(B, H, Sk, D, requires_grad=True)
262
+ out = calculate_attention(q, k, v, mask=None)
263
+ loss = out.pow(2).sum()
264
+ loss.backward()
265
+ for t in (q, k, v):
266
+ assert t.grad is not None and torch.isfinite(t.grad).all()
267
+
268
+
269
+ def test_deterministic_best_effort_same_seed():
270
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
271
+ q, k, v = _rand(B, H, Sq, Sk, D)
272
+ torch.manual_seed(42)
273
+ a1 = calculate_attention(q, k, v, mask=None, deterministic=True)
274
+ torch.manual_seed(42)
275
+ a2 = calculate_attention(q, k, v, mask=None, deterministic=True)
276
+ assert torch.allclose(a1, a2)
277
+
278
+
279
+ # ----------------------------
280
+ # F. Parity with PyTorch SDPA (when available)
281
+ # ----------------------------
282
+ def test_sdpa_parity_if_available():
283
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
284
+ q, k, v = _rand(B, H, Sq, Sk, D)
285
+ try:
286
+ sdpa = F.scaled_dot_product_attention(
287
+ q, k, v, dropout_p=0.0, attn_mask=None, is_causal=False
288
+ )
289
+ manual = calculate_attention(q, k, v, mask=None)
290
+ assert torch.allclose(sdpa, manual, atol=1e-5, rtol=1e-4)
291
+ except Exception:
292
+ # Older PyTorch: skip
293
+ pass
294
+
295
+
296
+ # ----------------------------
297
+ # G. Performance smoke (sanity only)
298
+ # ----------------------------
299
+ def test_perf_smoke_runs_reasonably():
300
+ # Not asserting timing; just ensure no OOM or pathological slowdowns
301
+ q, k, v = _rand(B=1, H=8, Sq=1024, Sk=1024, D=64)
302
+ _ = calculate_attention(q, k, v, mask=None, return_probs=False)
303
+ q, k, v = _rand(B=2, H=8, Sq=1536, Sk=1536, D=64)
304
+ _ = calculate_attention(q, k, v, mask=None, return_probs=False)
305
+
306
+
307
+ # ----------------------------
308
+ # H. Device behavior
309
+ # ----------------------------
310
+ def test_mask_on_cpu_qkv_on_gpu_autofix_or_skip():
311
+ if not torch.cuda.is_available():
312
+ pytest.skip("CUDA not available")
313
+
314
+ prev = torch.are_deterministic_algorithms_enabled()
315
+ try:
316
+ # Disable determinism *only for this test*
317
+ if prev:
318
+ torch.use_deterministic_algorithms(False)
319
+
320
+ q, k, v = _rand(device="cuda")
321
+ mask = torch.zeros(q.shape[0], 1, 1, k.shape[2], dtype=torch.bool) # CPU
322
+ attn, probs = calculate_attention(q, k, v, mask, return_probs=True)
323
+ assert attn.device.type == "cuda"
324
+ assert probs.device.type == "cuda"
325
+ finally:
326
+ # restore whatever the global setting was
327
+ torch.use_deterministic_algorithms(prev)
328
+
329
+
330
+ def test_qkv_device_mismatch_raises_clear_error():
331
+ if not torch.cuda.is_available():
332
+ pytest.skip("CUDA not available")
333
+
334
+ prev = torch.are_deterministic_algorithms_enabled()
335
+ try:
336
+ if prev:
337
+ torch.use_deterministic_algorithms(False)
338
+
339
+ q, k, v = _rand(device="cuda")
340
+ k = k.cpu() # force mismatch
341
+ with pytest.raises(RuntimeError) as ei:
342
+ _ = calculate_attention(q, k, v, mask=None)
343
+ msg = str(ei.value)
344
+ assert (
345
+ "q/k/v must be on the same device" in msg # our explicit check
346
+ or "Expected all tensors to be on the same device" in msg # PyTorch matmul
347
+ )
348
+ finally:
349
+ torch.use_deterministic_algorithms(prev)
350
+
351
+
352
+ # ----------------------------
353
+ # I. Mask broadcastability
354
+ # ----------------------------
355
+ def test_boolean_mask_broadcast_variants_equivalent():
356
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
357
+ q, k, v = _rand(B, H, Sq, Sk, D)
358
+ m_exact = torch.zeros(B, H, Sq, Sk, dtype=torch.bool)
359
+ m_B1_1Sk = torch.zeros(B, 1, 1, Sk, dtype=torch.bool)
360
+ m_11SqSk = torch.zeros(1, 1, Sq, Sk, dtype=torch.bool)
361
+
362
+ a_exact, p_exact = calculate_attention(q, k, v, m_exact, return_probs=True)
363
+ a1, p1 = calculate_attention(q, k, v, m_B1_1Sk, return_probs=True)
364
+ a2, p2 = calculate_attention(q, k, v, m_11SqSk, return_probs=True)
365
+
366
+ assert torch.allclose(a1, a_exact, atol=1e-6, rtol=1e-5)
367
+ assert torch.allclose(p1, p_exact, atol=1e-6, rtol=1e-5)
368
+ assert torch.allclose(a2, a_exact, atol=1e-6, rtol=1e-5)
369
+ assert torch.allclose(p2, p_exact, atol=1e-6, rtol=1e-5)
370
+
371
+
372
+ def test_additive_mask_broadcast_variants_equivalent():
373
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
374
+ q, k, v = _rand(B, H, Sq, Sk, D)
375
+ m_exact = torch.zeros(B, H, Sq, Sk, dtype=torch.float32)
376
+ m_111Sk = torch.zeros(1, 1, 1, Sk, dtype=torch.float32)
377
+ m_B1Sq1 = torch.zeros(B, 1, Sq, 1, dtype=torch.float32)
378
+
379
+ a_exact, p_exact = calculate_attention(q, k, v, m_exact, return_probs=True)
380
+ a1, p1 = calculate_attention(q, k, v, m_111Sk, return_probs=True)
381
+ a2, p2 = calculate_attention(q, k, v, m_B1Sq1, return_probs=True)
382
+
383
+ assert torch.allclose(a1, a_exact, atol=1e-6, rtol=1e-5)
384
+ assert torch.allclose(p1, p_exact, atol=1e-6, rtol=1e-5)
385
+ assert torch.allclose(a2, a_exact, atol=1e-6, rtol=1e-5)
386
+ assert torch.allclose(p2, p_exact, atol=1e-6, rtol=1e-5)
387
+
388
+
389
+ def test_non_broadcastable_mask_raises_value_error():
390
+ B, H, Sq, Sk, D = 2, 3, 4, 5, 8
391
+ q, k, v = _rand(B, H, Sq, Sk, D)
392
+ bad = torch.zeros(
393
+ B, H, Sq, dtype=torch.bool
394
+ ) # missing last dim => not broadcastable to (B,H,Sq,Sk)
395
+ with pytest.raises(ValueError) as ei:
396
+ _ = calculate_attention(q, k, v, bad)
397
+ assert "not broadcastable" in str(ei.value)
398
+
399
+
400
+ ###___join_heads___###
401
+
402
+
403
+ def test_join_heads_type_error():
404
+ with pytest.raises(TypeError):
405
+ join_heads([1, 2, 3]) # not a tensor
406
+
407
+
408
+ def test_join_heads_dim_error():
409
+ with pytest.raises(ValueError):
410
+ join_heads(torch.randn(2, 3, 4)) # only 3D
411
+
412
+
413
+ def test_join_heads_shape_values():
414
+ x = torch.randn(2, 0, 4, 5) # zero heads
415
+ y = join_heads(x)
416
+ assert y.shape == (2, 4, 0)
417
+
418
+
419
+ def test_join_heads_roundtrip():
420
+ B, H, T, Dh = 2, 3, 5, 7
421
+ x = torch.randn(B, H, T, Dh)
422
+ y = join_heads(x)
423
+ assert y.shape == (B, T, H * Dh)
424
+ # Check element mapping correctness
425
+ for b in range(B):
426
+ for h in range(H):
427
+ for t in range(T):
428
+ for d in range(Dh):
429
+ assert torch.allclose(y[b, t, h * Dh + d], x[b, h, t, d])
430
+
431
+
432
+ ###___sinusoidal_positional_encoding___###
433
+
434
+
435
+ @pytest.mark.parametrize("seq_len,dim", [(0, 1), (1, 1), (1, 2), (7, 4), (17, 33)])
436
+ def test_shapes_and_dtypes(seq_len, dim):
437
+ # Try a few dtypes; fp16 on CPU can be quirky, so we gate it on CUDA.
438
+ dtypes = [torch.float32, torch.bfloat16]
439
+ if torch.cuda.is_available():
440
+ dtypes.append(torch.float16)
441
+
442
+ device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
443
+ for dtype in dtypes:
444
+ pe = sinusoidal_positional_encoding(seq_len, dim, dtype=dtype, device=device)
445
+ assert pe.shape == (seq_len, dim)
446
+ assert pe.dtype == dtype
447
+ # Returned tensor should be a constant table (no gradients)
448
+ assert pe.requires_grad is False
449
+
450
+
451
+ def test_values_match_reference_small_case():
452
+ # Small deterministic comparison against the textbook formula.
453
+ seq_len, dim = 4, 6
454
+ base = 10_000.0
455
+ pe = sinusoidal_positional_encoding(seq_len, dim, dtype=torch.float32, device="cpu", base=base)
456
+
457
+ ref = torch.zeros_like(pe)
458
+ # Fill sin/cos pairs explicitly
459
+ for pos in range(seq_len):
460
+ for i in range(0, dim // 2):
461
+ denom = base ** (2 * i / dim)
462
+ ref[pos, 2 * i] = math.sin(pos / denom)
463
+ ref[pos, 2 * i + 1] = math.cos(pos / denom)
464
+
465
+ # Compare only the complete sin/cos pairs (even columns and their cos twins)
466
+ paired = (dim // 2) * 2
467
+ assert torch.allclose(pe[:, :paired], ref[:, :paired], atol=1e-6, rtol=1e-6)
468
+
469
+
470
+ @pytest.mark.parametrize("dim", [1, 3, 8])
471
+ def test_odd_dim_last_column_zero(dim):
472
+ seq_len = 5
473
+ pe = sinusoidal_positional_encoding(seq_len, dim)
474
+ if dim % 2 == 1:
475
+ assert torch.allclose(
476
+ pe[:, -1],
477
+ torch.zeros(seq_len, dtype=pe.dtype, device=pe.device),
478
+ )
479
+
480
+
481
+ def test_offset_windowing_equivalence():
482
+ # pe[pos] with offset should match a slice of a longer table without offset
483
+ seq_len, dim, offset = 8, 6, 5
484
+ base_long = sinusoidal_positional_encoding(seq_len + offset, dim)
485
+ win = sinusoidal_positional_encoding(seq_len, dim, offset=offset)
486
+ assert torch.allclose(win, base_long[offset : offset + seq_len])
487
+
488
+
489
+ def test_determinism():
490
+ a = sinusoidal_positional_encoding(32, 64)
491
+ b = sinusoidal_positional_encoding(32, 64)
492
+ assert torch.allclose(a, b)
493
+
494
+
495
+ def test_invalid_inputs():
496
+ with pytest.raises(ValueError):
497
+ sinusoidal_positional_encoding(-1, 8)
498
+ with pytest.raises(ValueError):
499
+ sinusoidal_positional_encoding(1, 0)
500
+
501
+
502
+ @pytest.mark.parametrize("base", [1_000.0, 10_000.0, 100_000.0])
503
+ def test_different_bases_change_values(base):
504
+ seq_len, dim = 6, 8
505
+ a = sinusoidal_positional_encoding(seq_len, dim, base=base)
506
+ b = sinusoidal_positional_encoding(seq_len, dim, base=base * 10)
507
+ # Different bases should not produce identical tables
508
+ assert not torch.allclose(a, b)
509
+
510
+
511
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for strict fp16 checks")
512
+ def test_cuda_half_precision_support():
513
+ seq_len, dim = 12, 16
514
+ pe = sinusoidal_positional_encoding(seq_len, dim, dtype=torch.float16, device="cuda")
515
+ assert pe.device.type == "cuda"
516
+ assert pe.dtype == torch.float16
517
+
518
+
519
+ ###___combine_masks___###
520
+
521
+
522
+ def test_both_none_returns_none():
523
+ assert combine_masks(None, None) is None
524
+
525
+
526
+ def test_one_none_returns_other():
527
+ m = torch.tensor([[True, False]])
528
+ assert torch.equal(combine_masks(m, None), m)
529
+ assert torch.equal(combine_masks(None, m), m)
530
+
531
+
532
+ def test_or_combination():
533
+ m1 = torch.tensor([[True, False]])
534
+ m2 = torch.tensor([[False, True]])
535
+ expected = torch.tensor([[True, True]])
536
+ out = combine_masks(m1, m2)
537
+ assert torch.equal(out, expected)
538
+
539
+
540
+ def test_shape_mismatch_error():
541
+ m1 = torch.ones(2, 2, dtype=torch.bool)
542
+ m2 = torch.ones(3, 3, dtype=torch.bool)
543
+ with pytest.raises(ValueError):
544
+ combine_masks(m1, m2)
545
+
546
+
547
+ def test_broadcastable_masks():
548
+ m1 = torch.tensor([[True, False, True]])
549
+ m2 = torch.tensor([[False]]) # broadcast across
550
+ out = combine_masks(m1, m2)
551
+ assert torch.equal(out, m1 | m2)
552
+
553
+
554
+ def test_dtype_conversion():
555
+ # Non-bool masks still should work if they are 0/1 ints
556
+ m1 = torch.tensor([[1, 0]], dtype=torch.int32)
557
+ m2 = torch.tensor([[0, 1]], dtype=torch.int32)
558
+ out = combine_masks(m1.bool(), m2.bool())
559
+ expected = torch.tensor([[True, True]])
560
+ assert torch.equal(out, expected)
561
+
562
+
563
+ ###___shift_right___###
564
+
565
+
566
+ def test_basic_shift_single_batch():
567
+ labels = torch.tensor([[5, 6, 7]], dtype=torch.long)
568
+ out = shift_right(labels, bos_id=1, pad_id=0)
569
+ assert torch.equal(out, torch.tensor([[1, 5, 6]], dtype=torch.long))
570
+
571
+
572
+ def test_basic_shift_vector_input():
573
+ labels = torch.tensor([10, 11, 12], dtype=torch.long)
574
+ out = shift_right(labels, bos_id=2, pad_id=0)
575
+ assert out.shape == (1, 3)
576
+ assert torch.equal(out, torch.tensor([[2, 10, 11]], dtype=torch.long))
577
+
578
+
579
+ def test_batch_shift():
580
+ labels = torch.tensor([[5, 6, 7], [8, 9, 10]], dtype=torch.long)
581
+ out = shift_right(labels, bos_id=3, pad_id=0)
582
+ expected = torch.tensor([[3, 5, 6], [3, 8, 9]], dtype=torch.long)
583
+ assert torch.equal(out, expected)
584
+
585
+
586
+ def test_ignores_are_padded_after_shift():
587
+ # -100 in labels must never appear in inputs; becomes PAD after shifting.
588
+ labels = torch.tensor([[5, -100, 7, -100]], dtype=torch.long)
589
+ out = shift_right(labels, bos_id=4, pad_id=99)
590
+ # positions 2 and 4 in inputs come from -100 -> PAD(99)
591
+ expected = torch.tensor([[4, 5, 99, 7]], dtype=torch.long)
592
+ assert torch.equal(out, expected)
593
+ assert (out == -100).sum() == 0
594
+
595
+
596
+ def test_custom_pad_and_bos_ids():
597
+ labels = torch.tensor([[42, 43, -100, 45]], dtype=torch.long)
598
+ out = shift_right(labels, bos_id=7, pad_id=3)
599
+ expected = torch.tensor([[7, 42, 43, 3]], dtype=torch.long)
600
+ assert torch.equal(out, expected)
601
+
602
+
603
+ def test_zero_length_raises():
604
+ with pytest.raises(ValueError):
605
+ shift_right(torch.zeros((2, 0), dtype=torch.long))
606
+
607
+
608
+ def test_wrong_rank_raises():
609
+ with pytest.raises(ValueError):
610
+ shift_right(torch.zeros((2, 3, 4), dtype=torch.long))
611
+
612
+
613
+ def test_dtype_device_preserved():
614
+ device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
615
+ labels = torch.tensor([[1, 2, 3]], device=device)
616
+ out = shift_right(labels, bos_id=9, pad_id=0)
617
+ assert out.device == device
618
+ assert out.dtype == torch.long
619
+ assert torch.equal(out, torch.tensor([[9, 1, 2]], device=device, dtype=torch.long))
620
+
621
+
622
+ def test_no_inplace_on_labels():
623
+ labels = torch.tensor([[5, 6, -100]], dtype=torch.long)
624
+ labels_clone = labels.clone()
625
+ _ = shift_right(labels, bos_id=1, pad_id=0)
626
+ assert torch.equal(labels, labels_clone)
627
+
628
+
629
+ ###___sample_from_logits___###
630
+
631
+
632
+ def test_greedy_equals_argmax():
633
+ torch.manual_seed(0)
634
+ logits = torch.randn(4, 7)
635
+ ids = sample_from_logits(logits) # greedy
636
+ ref = torch.argmax(F.softmax(logits, dim=-1), dim=-1)
637
+ assert torch.equal(ids, ref)
638
+
639
+
640
+ def test_temperature_effect_no_sampling():
641
+ # Greedy with temperature should leave argmax unchanged (monotonic transform)
642
+ logits = torch.tensor([[0.1, 1.2, 0.3]], dtype=torch.float32)
643
+ ids_t1 = sample_from_logits(logits, do_sample=False, temperature=1.0)
644
+ ids_t05 = sample_from_logits(logits, do_sample=False, temperature=0.5)
645
+ assert torch.equal(ids_t1, ids_t05) # argmax unchanged
646
+
647
+
648
+ def test_top_k_limits_candidates():
649
+ logits = torch.tensor([[1.0, 0.9, 0.1, -1.0]], dtype=torch.float32)
650
+ # with top_k=1 greedy must select the max (index 0)
651
+ ids = sample_from_logits(logits, do_sample=False, top_k=1)
652
+ assert torch.equal(ids, torch.tensor([0]))
653
+ # with sampling, and top_k=1, the only candidate is index 0
654
+ gen = torch.Generator().manual_seed(123)
655
+ ids_s = sample_from_logits(logits, do_sample=True, top_k=1, rng=gen)
656
+ assert torch.equal(ids_s, torch.tensor([0]))
657
+
658
+
659
+ def test_top_p_nucleus_filters_tail():
660
+ logits = torch.tensor([[5.0, 4.0, 3.0, -5.0]], dtype=torch.float32) # probs heavily skewed
661
+ ids = sample_from_logits(logits, do_sample=False, top_p=0.6) # should keep a minimal head
662
+ assert ids.item() in (0, 1) # greediest is 0 anyway; check no crash
663
+
664
+
665
+ def test_min_tokens_to_keep_safety():
666
+ logits = torch.tensor([[0.0, 0.0, 0.0, 0.0]], dtype=torch.float32)
667
+ # Even with very small top_p, we keep at least 1 token
668
+ ids = sample_from_logits(logits, do_sample=False, top_p=0.01, min_tokens_to_keep=1)
669
+ assert ids.numel() == 1
670
+
671
+
672
+ def test_allow_and_deny_lists():
673
+ logits = torch.tensor([[0.1, 2.0, 0.3, 3.0]], dtype=torch.float32) # best is idx=3
674
+ # Allow only {0,2} → best among those is 2
675
+ ids = sample_from_logits(logits, do_sample=False, allowed_tokens=[0, 2])
676
+ assert torch.equal(ids, torch.tensor([2]))
677
+ # Deny {3} → next best is 1
678
+ ids = sample_from_logits(logits, do_sample=False, disallowed_tokens=[3])
679
+ assert torch.equal(ids, torch.tensor([1]))
680
+
681
+
682
+ def test_rng_reproducibility_sampling():
683
+ logits = torch.tensor([[1.0, 1.0, 1.0, 1.0]], dtype=torch.float32) # uniform
684
+ g1 = torch.Generator().manual_seed(42)
685
+ g2 = torch.Generator().manual_seed(42)
686
+ s1 = sample_from_logits(logits, do_sample=True, rng=g1)
687
+ s2 = sample_from_logits(logits, do_sample=True, rng=g2)
688
+ assert torch.equal(s1, s2)
689
+
690
+
691
+ def test_device_is_preserved_cpu_cuda():
692
+ target_device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
693
+ logits = torch.randn(3, 5, device=target_device)
694
+ out = sample_from_logits(logits) # greedy by default
695
+ assert out.device == target_device
696
+ assert out.dtype == torch.long
697
+
698
+
699
+ def test_handles_higher_rank_logits_flattening():
700
+ # [B, T, V] -> flattened internally; here we just ensure no crash and correct shape
701
+ logits = torch.randn(2, 3, 7)
702
+ out = sample_from_logits(logits) # returns [B*T]
703
+ assert out.shape == (2 * 3,)
704
+
705
+
706
+ ###___create_causal_mask___###
707
+
708
+
709
+ def test_mask_shape_and_dtype_cpu():
710
+ L = 5
711
+ mask = create_causal_mask(L)
712
+ assert mask.shape == (1, 1, L, L)
713
+ assert mask.dtype == torch.bool
714
+ assert mask.device.type == "cpu"
715
+
716
+
717
+ def test_mask_upper_triangle():
718
+ L = 4
719
+ mask = create_causal_mask(L)
720
+ # For causal mask: mask[i,j] == True if j > i
721
+ mat = mask[0, 0].to(torch.int)
722
+ expected = torch.tensor(
723
+ [
724
+ [0, 1, 1, 1],
725
+ [0, 0, 1, 1],
726
+ [0, 0, 0, 1],
727
+ [0, 0, 0, 0],
728
+ ]
729
+ )
730
+ assert torch.equal(mat, expected)
731
+
732
+
733
+ @pytest.mark.parametrize("dtype", [torch.bool, torch.float32])
734
+ def test_mask_dtype(dtype):
735
+ L = 3
736
+ mask = create_causal_mask(L, dtype=dtype)
737
+ assert mask.dtype == dtype
738
+
739
+
740
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
741
+ def test_device_cuda():
742
+ L = 6
743
+ mask = create_causal_mask(L, device=torch.device("cuda"))
744
+ assert mask.device.type == "cuda"
745
+
746
+
747
+ def test_invalid_length_raises():
748
+ with pytest.raises(ValueError):
749
+ create_causal_mask(0)