"""Tests for pipeline.processors module.""" import pytest import torch from pipeline.processors import ( BaseProcessor, PreTrainProcessor, SFTProcessor, DPOProcessor, ProcessorFactory, ) class DummyTokenizer: def __init__(self): self._special_token_map = {} self._chat_template = None def encode(self, text: str, add_special_tokens: bool = False): return [ord(c) for c in text] def decode(self, tokens, skip_special_tokens=True): return "".join(chr(t) for t in tokens) def token_to_id(self, token: str): return ord(token) def set_chat_template(self, template): self._chat_template = template def apply_chat_template(self, messages, add_generation_prompt=True, tokenize=True): text = "" for m in messages: text += f"<|im▁start|>{m['role']}\n{m['content']}<|im▁end|>\n" if add_generation_prompt: text += "<|im▁start|>assistant\n" return self.encode(text) if tokenize else text class TestBaseProcessor: def test_abstract_class_cannot_be_instantiated(self): with pytest.raises(TypeError): BaseProcessor() class TestPreTrainProcessor: def test_output_keys(self): assert PreTrainProcessor(DummyTokenizer()).output_keys == ["sequence"] def test_process_returns_tensor(self): processor = PreTrainProcessor(DummyTokenizer()) result = processor.process({"text": "hello world"}) assert "sequence" in result assert isinstance(result["sequence"], torch.Tensor) assert result["sequence"].dtype == torch.int32 def test_process_adds_eos(self): result = PreTrainProcessor(DummyTokenizer()).process({"text": "a"}) assert len(result["sequence"]) > 0 class TestSFTProcessor: def test_output_keys(self): keys = SFTProcessor(DummyTokenizer()).output_keys assert "sequence" in keys assert "loss_mask" in keys assert "position_ids" in keys def test_process_returns_all_keys(self): result = SFTProcessor(DummyTokenizer()).process( {"query": "hello", "response": "world"} ) for key in ["sequence", "loss_mask", "position_ids"]: assert key in result assert isinstance(result[key], torch.Tensor) def test_loss_mask_correct_length(self): result = SFTProcessor(DummyTokenizer()).process( {"query": "hi", "response": "bye"} ) assert len(result["sequence"]) == len(result["loss_mask"]) def test_loss_mask_is_bool(self): result = SFTProcessor(DummyTokenizer()).process( {"query": "ab", "response": "cd"} ) assert result["loss_mask"].dtype == torch.bool def test_position_ids_start_from_zero(self): result = SFTProcessor(DummyTokenizer()).process( {"query": "abc", "response": "de"} ) seq_len = len(result["sequence"]) expected = torch.arange(seq_len, dtype=torch.int32) assert torch.equal(result["position_ids"], expected) def test_messages_single_turn(self): result = SFTProcessor(DummyTokenizer()).process({ "messages": [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "bye"}, ] }) for key in ["sequence", "loss_mask", "position_ids"]: assert key in result assert len(result["sequence"]) == len(result["loss_mask"]) def test_messages_loss_on_last_assistant_only(self): result = SFTProcessor(DummyTokenizer()).process({ "messages": [ {"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1"}, {"role": "user", "content": "q2"}, {"role": "assistant", "content": "a2"}, ] }) mask = result["loss_mask"] first_true = mask.tolist().index(True) assert not mask[:first_true].any() assert mask[-1].item() is True def test_messages_with_system_prompt(self): result = SFTProcessor(DummyTokenizer()).process({ "messages": [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}, ] }) assert "sequence" in result def test_messages_empty_raises(self): with pytest.raises(ValueError, match="Messages list is empty"): SFTProcessor(DummyTokenizer()).process({"messages": []}) def test_messages_last_not_assistant_raises(self): with pytest.raises(ValueError, match="Last message must"): SFTProcessor(DummyTokenizer()).process({ "messages": [{"role": "user", "content": "hi"}] }) def test_missing_fields_raises(self): with pytest.raises(KeyError): SFTProcessor(DummyTokenizer()).process({"foo": "bar"}) def test_position_ids_start_from_zero(self): result = SFTProcessor(DummyTokenizer()).process( {"query": "hi", "response": "ok"} ) pos_ids = result["position_ids"] assert pos_ids.dtype == torch.int32 assert len(pos_ids) == len(result["sequence"]) assert pos_ids[0].item() == 0 assert (pos_ids == torch.arange(len(pos_ids))).all() class TestDPOProcessor: def test_output_keys(self): keys = DPOProcessor(DummyTokenizer()).output_keys assert keys == ["chosen", "chosen_mask", "rejected", "rejected_mask"] def test_process_returns_all_keys(self): result = DPOProcessor(DummyTokenizer()).process( {"query": "hello", "chosen": "r1", "rejected": "r2"} ) for key in ["chosen", "chosen_mask", "rejected", "rejected_mask"]: assert key in result assert isinstance(result[key], torch.Tensor) def test_masks_match_lengths(self): result = DPOProcessor(DummyTokenizer()).process( {"query": "test", "chosen": "yes", "rejected": "no"} ) assert len(result["chosen"]) == len(result["chosen_mask"]) assert len(result["rejected"]) == len(result["rejected_mask"]) def test_masks_are_bool(self): result = DPOProcessor(DummyTokenizer()).process( {"query": "test", "chosen": "yes", "rejected": "no"} ) assert result["chosen_mask"].dtype == torch.bool assert result["rejected_mask"].dtype == torch.bool class TestProcessorFactory: def test_create_pre_train_processor(self): assert isinstance( ProcessorFactory.create("pt", DummyTokenizer()), PreTrainProcessor ) def test_create_sft_processor(self): assert isinstance( ProcessorFactory.create("sft", DummyTokenizer()), SFTProcessor ) def test_create_dpo_processor(self): assert isinstance( ProcessorFactory.create("dpo", DummyTokenizer()), DPOProcessor ) def test_create_invalid_processor_raises_error(self): with pytest.raises(ValueError, match="Unknown processor type"): ProcessorFactory.create("invalid", DummyTokenizer()) def test_register_and_create_custom_processor(self): class CustomProcessor(BaseProcessor): def __init__(self, tokenizer=None): self._tokenizer = tokenizer @property def output_keys(self): return ["custom"] def process(self, input_dict): return {"custom": torch.tensor([1, 2, 3])} ProcessorFactory.register("custom")(CustomProcessor) assert isinstance( ProcessorFactory.create("custom", DummyTokenizer()), CustomProcessor )