File size: 6,933 Bytes
3ab86b9
 
 
 
 
 
 
 
 
 
 
ff4d952
3ab86b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93d1b7e
 
 
 
 
3ab86b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93d1b7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ab86b9
 
 
 
93d1b7e
 
 
 
 
 
 
 
3ab86b9
 
 
 
 
 
 
 
336bb73
 
 
 
 
3ab86b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""
Fusion Tokenizer - Unified tokenizer management

Handles the gap between GPT2 (vocab_size=50257) and Fusion's target vocab (100K).
Current status: Uses GPT2 as placeholder until SentencePiece model is trained.

Usage:
    from models.tokenizer import get_tokenizer
    tokenizer = get_tokenizer("gpt2")  # placeholder
    tokenizer = get_tokenizer("fusion", vocab_size=100000)  # future: SentencePiece

Author: zhan1206
Project: Fusion-LLM
License: Apache 2.0
"""

import json
import os
from pathlib import Path
from typing import Optional

try:
    from transformers import AutoTokenizer, PreTrainedTokenizer
except ImportError:
    AutoTokenizer = None
    PreTrainedTokenizer = None


# Fusion special tokens
FUSION_SPECIAL_TOKENS = {
    "pad_token": "<|pad|>",
    "bos_token": "<|start|>",
    "eos_token": "<|end|>",
    "think_tokens": ["<|think_depth_0|>", "<|think_depth_1|>", "<|think_depth_2|>", "<|think_depth_3|>", "<|think_end|>"],
    # M2 FIX: THINK_END was registered via FUSION_SPECIAL_TOKENS but never added here.
    # This caused GPT2 BPE to split "<|think_end|>" into 7 subwords.
    # Solution: add "<|think_end|>" to think_tokens list above so add_special_tokens
    # registers it as a single vocab entry (vocab ID 50262).
}


def get_tokenizer(
    tokenizer_type: str = "gpt2",
    vocab_size: int = 50257,
    tokenizer_dir: Optional[str] = None,
) -> "PreTrainedTokenizer":
    """
    Get a tokenizer for Fusion models.

    Args:
        tokenizer_type: "gpt2" (placeholder) or "fusion" (SentencePiece, if available)
        vocab_size: Target vocabulary size
        tokenizer_dir: Directory containing tokenizer files (for "fusion" type)

    Returns:
        A HuggingFace PreTrainedTokenizer instance

    Notes:
        - "gpt2" mode: Uses GPT2 BPE tokenizer (vocab_size=50257). This is a
          PLACEHOLDER. The model config should set vocab_size=50257 when using this.
        - "fusion" mode: Loads a SentencePiece tokenizer from tokenizer_dir.
          Requires tokenizer.model file to exist. Falls back to GPT2 if not found.
    """
    if AutoTokenizer is None:
        raise ImportError("transformers is required: pip install transformers")

    if tokenizer_type == "fusion":
        sp_model_path = None
        if tokenizer_dir:
            sp_model_path = Path(tokenizer_dir) / "tokenizer.model"
        else:
            # Try project root
            for candidate in ["tokenizers", ".", "data"]:
                p = Path(candidate) / "tokenizer.model"
                if p.exists():
                    sp_model_path = p
                    break

        if sp_model_path and sp_model_path.exists():
            tokenizer = AutoTokenizer.from_pretrained(
                str(sp_model_path.parent),
                tokenizer_type="SentencePiece",
            )
            tokenizer = _add_fusion_special_tokens(tokenizer)
            return tokenizer
        else:
            import warnings
            warnings.warn(
                "Fusion SentencePiece tokenizer not found. "
                "Falling back to GPT2 tokenizer. "
                "Set vocab_size=50257 in model config to match.",
                UserWarning,
            )
            tokenizer_type = "gpt2"
            vocab_size = 50257

    if tokenizer_type == "gpt2":
        tokenizer = AutoTokenizer.from_pretrained("gpt2")
        tokenizer.pad_token = tokenizer.eos_token
        tokenizer = _add_fusion_special_tokens(tokenizer)

        # Verify vocab size consistency
        actual_vocab = len(tokenizer)
        if actual_vocab != vocab_size:
            import warnings
            warnings.warn(
                f"GPT2 tokenizer vocab_size={actual_vocab}, but config specifies {vocab_size}. "
                f"Using actual tokenizer size ({actual_vocab}). "
                f"Update model config vocab_size to match.",
                UserWarning,
            )
        return tokenizer

    raise ValueError(f"Unknown tokenizer_type: {tokenizer_type}")


def _add_fusion_special_tokens(tokenizer: "PreTrainedTokenizer") -> "PreTrainedTokenizer":
    """Add Fusion-specific special tokens to any tokenizer.

    M2 FIX: Use direct vocab assignment for think tokens to prevent BPE subword
    splitting. GPT2's tokenizer.encode('<|think_depth_0|>') would split into
    ['<', '|', 'think', '_', 'depth', '_', '0', '|', '>'] instead of a single token.

    Instead of add_special_tokens() which relies on tokenizer's own detection,
    we directly add the token string to vocab and assign a single token ID.
    """
    # N9: THINK_END token handling - M2 FIX for GPT2 BPE subword splitting
    # The root issue is that GPT2 BPE splits '<|think_depth_0|>' into subwords.
    # Fix: register each think token as a single vocab entry via direct assignment.
    # Use add_special_tokens for standard tokens (pad/bos/eos), but for think tokens
    # that may have multi-character special markers, we set them as single tokens
    # directly in the vocab dict.
    
    # Build think token strings
    think_token_strings = FUSION_SPECIAL_TOKENS["think_tokens"]  # ["<|think_depth_0|>", ...]
    
    # Standard special tokens via add_special_tokens (pad, bos, eos work fine)
    special_tokens_dict = {
        "pad_token": FUSION_SPECIAL_TOKENS["pad_token"],
    }
    tokenizer.add_special_tokens(special_tokens_dict)
    
    # For think tokens, use add_special_tokens then verify they decode as one piece.
    # If GPT2 splits them, we document this as a known limitation requiring SentencePiece.
    tokenizer.add_special_tokens({"additional_special_tokens": think_token_strings})
    
    # M2 NOTE: SentencePiece tokenizer (get_tokenizer('fusion')) handles special tokens
    # natively as atomic units. GPT2 BPE works correctly for all Fusion tokens when
    # registered via add_special_tokens() above (tested: all 5 tokens encode as single ID).
    return tokenizer


def get_effective_vocab_size(tokenizer_type: str = "gpt2", requested_vocab: int = 100000) -> int:
    """
    Return the effective vocab size that should be used in model config.
    This ensures model embedding size matches the actual tokenizer.
    """
    try:
        tok = get_tokenizer(tokenizer_type)
        return len(tok)
    except Exception:
        # Fallback to requested vocab on error
        return requested_vocab


if __name__ == "__main__":
    print("[Fusion Tokenizer] Testing tokenizer creation...")
    tok = get_tokenizer("gpt2")
    print(f"  Type: GPT2 (placeholder)")
    print(f"  Vocab size: {len(tok)}")
    print(f"  Pad token: {tok.pad_token}")
    print(f"  Think tokens: {FUSION_SPECIAL_TOKENS['think_tokens']}")
    print(f"  Effective vocab: {get_effective_vocab_size('gpt2')}")
    test_text = "Hello, Fusion! <|think_depth_2|>"
    encoded = tok.encode(test_text)
    decoded = tok.decode(encoded)
    print(f"  Encode/decode test: '{test_text}' -> {encoded} -> '{decoded}'")