|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import tempfile |
| 4 | +import unittest |
| 5 | +from pathlib import Path |
| 6 | +from unittest.mock import MagicMock |
| 7 | + |
| 8 | +# Ensure interface.app is imported first to populate shared mixin globals |
| 9 | +from interface import app as _interface_app # noqa: F401 |
| 10 | +from interface.core.project_state import ProjectStateMixin |
| 11 | +from interface.core.project_state_apply import ProjectStateApplyMixin |
| 12 | + |
| 13 | +from engine.config import DatasetConfig |
| 14 | +from engine.data import Document |
| 15 | +from engine.dataset_corpus import _StreamingCorpusBuilder |
| 16 | +from engine.dataset_mixture import ( |
| 17 | + MAX_REPETITIVE_UNIT_RATIO, |
| 18 | + MIN_UNIQUE_UNITS_FOR_DIVERSITY, |
| 19 | + _filter_repetitive_documents, |
| 20 | +) |
| 21 | +from engine.tokenizer import ( |
| 22 | + EOS_TOKEN, |
| 23 | + encode_file_to_bin, |
| 24 | + load_token_memmap, |
| 25 | + token_dtype_for_vocab, |
| 26 | + train_tokenizer, |
| 27 | +) |
| 28 | +from engine.training import TokenDataset |
| 29 | + |
| 30 | + |
| 31 | +class DiversityFilterTests(unittest.TestCase): |
| 32 | + """Tests for low-diversity filtering behavior.""" |
| 33 | + |
| 34 | + def test_diversity_filter_accepts_large_code_and_books(self) -> None: |
| 35 | + """Legitimate files with repeated syntax but many unique units must be accepted.""" |
| 36 | + # Simulate a 1MB-style code file: common boilerplate repeated 10 times, |
| 37 | + # but 150 completely unique function definitions and logic blocks. |
| 38 | + boilerplate = [ |
| 39 | + "// Standard project license header and notice block", |
| 40 | + "import std.collections.unordered_map;", |
| 41 | + "import std.concurrent.atomic_ref;", |
| 42 | + "if (error_code != 0) { return error_code; }", |
| 43 | + ] |
| 44 | + unique_lines = [ |
| 45 | + f"void process_record_item_id_{i}(int param_{i}) {{ execute_job({i}); }}" |
| 46 | + for i in range(150) |
| 47 | + ] |
| 48 | + lines = unique_lines + (boilerplate * 10) |
| 49 | + content = "\n".join(lines) |
| 50 | + doc = Document(path=Path("code_sample.cpp"), text=content, kind="code") |
| 51 | + |
| 52 | + accepted, report = _filter_repetitive_documents([doc]) |
| 53 | + self.assertEqual(len(accepted), 1) |
| 54 | + self.assertEqual(report["removed_documents"], 0) |
| 55 | + |
| 56 | + def test_diversity_filter_rejects_synthetic_padding(self) -> None: |
| 57 | + """Tiny templates repeated over and over to pad corpus size must be excluded.""" |
| 58 | + template_sentences = [ |
| 59 | + "The quick brown fox jumps over the lazy dog repeatedly.", |
| 60 | + "Synthetic data generation fills space without any real knowledge.", |
| 61 | + "This template unit repeats across the document to pad character length.", |
| 62 | + ] |
| 63 | + text = " ".join(template_sentences * 40) |
| 64 | + doc = Document(path=Path("synthetic_padding.txt"), text=text, kind="prose") |
| 65 | + |
| 66 | + accepted, report = _filter_repetitive_documents([doc]) |
| 67 | + self.assertEqual(len(accepted), 0) |
| 68 | + self.assertEqual(report["removed_documents"], 1) |
| 69 | + |
| 70 | + def test_streaming_corpus_builder_respects_filter_toggle(self) -> None: |
| 71 | + """When filter_low_diversity is False, repetitive files are not excluded.""" |
| 72 | + template = "This template repeats indefinitely without variation for testing." |
| 73 | + text = " ".join([template] * 80) |
| 74 | + doc = Document(path=Path("repetitive.txt"), text=text, kind="prose") |
| 75 | + |
| 76 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 77 | + corpus_path = Path(temp_dir) / "corpus.txt" |
| 78 | + builder = _StreamingCorpusBuilder( |
| 79 | + corpus_path=corpus_path, |
| 80 | + code_training_mode=False, |
| 81 | + generate_instruction_samples=False, |
| 82 | + reasoning_sample_mode="scaffold", |
| 83 | + filter_low_diversity=False, |
| 84 | + ) |
| 85 | + builder.submit(doc) |
| 86 | + self.assertEqual(builder.stats.low_diversity_removed, 0) |
| 87 | + self.assertEqual(builder.stats.accepted_document_count, 1) |
| 88 | + builder.close() |
| 89 | + |
| 90 | + |
| 91 | +class EarlyStoppingPatiencePersistenceTests(unittest.TestCase): |
| 92 | + """Tests for early stopping patience persistence in project state.""" |
| 93 | + |
| 94 | + def test_early_stopping_patience_in_default_state(self) -> None: |
| 95 | + window = MagicMock() |
| 96 | + window.device.currentText.return_value = "cpu" |
| 97 | + window.use_amp_default = False |
| 98 | + state = ProjectStateMixin._default_project_state(window) |
| 99 | + |
| 100 | + self.assertIn("early_stopping", state["training"]) |
| 101 | + self.assertTrue(state["training"]["early_stopping"]) |
| 102 | + self.assertIn("early_stopping_patience", state["training"]) |
| 103 | + self.assertEqual(state["training"]["early_stopping_patience"], 3) |
| 104 | + |
| 105 | + def test_early_stopping_patience_snapshot_and_apply(self) -> None: |
| 106 | + window = MagicMock() |
| 107 | + window.theme_name = "dark" |
| 108 | + window.persisted_training_process = {} |
| 109 | + window.early_stopping.isChecked.return_value = True |
| 110 | + window.early_stopping_patience.value.return_value = 7 |
| 111 | + |
| 112 | + snap = ProjectStateMixin._project_state_dict(window, "test", Path(".")) |
| 113 | + self.assertEqual(snap["training"]["early_stopping_patience"], 7) |
| 114 | + |
| 115 | + # Apply should restore patience value |
| 116 | + test_state = { |
| 117 | + "training": { |
| 118 | + "early_stopping": True, |
| 119 | + "early_stopping_patience": 5, |
| 120 | + } |
| 121 | + } |
| 122 | + ProjectStateApplyMixin._apply_project_state(window, test_state) |
| 123 | + window.early_stopping_patience.setValue.assert_called_with(5) |
| 124 | + |
| 125 | + |
| 126 | +class TokenizerCorpusEncodingTests(unittest.TestCase): |
| 127 | + """Tests that tokenizing multi-line text does not corrupt lines with <bos>/<eos>.""" |
| 128 | + |
| 129 | + def test_encode_file_to_bin_no_per_line_special_tokens(self) -> None: |
| 130 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 131 | + temp_path = Path(temp_dir) |
| 132 | + corpus_file = temp_path / "corpus.txt" |
| 133 | + # Two documents, each with two lines, separated by <eos> |
| 134 | + corpus_text = ( |
| 135 | + f"Line one of document alpha\n" |
| 136 | + f"Line two of document alpha\n" |
| 137 | + f"{EOS_TOKEN}\n" |
| 138 | + f"Line one of document beta\n" |
| 139 | + f"Line two of document beta\n" |
| 140 | + f"{EOS_TOKEN}\n" |
| 141 | + ) |
| 142 | + corpus_file.write_text(corpus_text, encoding="utf-8") |
| 143 | + tok_path = temp_path / "tokenizer.json" |
| 144 | + tok = train_tokenizer(corpus_file, tok_path, vocab_size=60) |
| 145 | + eos_id = tok.token_to_id(EOS_TOKEN) |
| 146 | + bos_id = tok.token_to_id("<bos>") |
| 147 | + |
| 148 | + bin_file = temp_path / "tokens.bin" |
| 149 | + dtype = token_dtype_for_vocab(60) |
| 150 | + encode_file_to_bin(tok, corpus_file, bin_file, dtype=dtype) |
| 151 | + |
| 152 | + tokens = list(load_token_memmap(bin_file, dtype=dtype)) |
| 153 | + |
| 154 | + # eos_id should only appear at the document boundaries (2 times total) |
| 155 | + eos_indices = [i for i, t in enumerate(tokens) if t == eos_id] |
| 156 | + self.assertEqual(len(eos_indices), 2) |
| 157 | + |
| 158 | + # bos_id should NOT appear before every line |
| 159 | + bos_indices = [i for i, t in enumerate(tokens) if t == bos_id] |
| 160 | + self.assertEqual(len(bos_indices), 0) |
| 161 | + |
| 162 | + |
| 163 | +class ValidationLoaderStrideTests(unittest.TestCase): |
| 164 | + """Tests for non-overlapping validation loader stride.""" |
| 165 | + |
| 166 | + def test_validation_stride_equals_context_length(self) -> None: |
| 167 | + context_length = 64 |
| 168 | + # 257 tokens -> 4 non-overlapping windows of length 64 with stride 64 |
| 169 | + tokens = list(range(257)) |
| 170 | + val_dataset = TokenDataset(tokens, context_length=context_length, stride=context_length) |
| 171 | + self.assertEqual(len(val_dataset), 4) |
| 172 | + |
| 173 | + # First window covers tokens 0..63 (input) -> 1..64 (target) |
| 174 | + x0, y0 = val_dataset[0] |
| 175 | + self.assertEqual(x0.tolist(), list(range(0, 64))) |
| 176 | + self.assertEqual(y0.tolist(), list(range(1, 65))) |
| 177 | + |
| 178 | + # Second window covers tokens 64..127 (input) -> 65..128 (target) |
| 179 | + x1, y1 = val_dataset[1] |
| 180 | + self.assertEqual(x1.tolist(), list(range(64, 128))) |
| 181 | + self.assertEqual(y1.tolist(), list(range(65, 129))) |
| 182 | + |
| 183 | + |
| 184 | +if __name__ == "__main__": |
| 185 | + unittest.main() |
0 commit comments