diff --git a/client_tools/client_generator.py b/client_tools/client_generator.py deleted file mode 100644 index bc2a52885..000000000 --- a/client_tools/client_generator.py +++ /dev/null @@ -1,918 +0,0 @@ -# Copyright (c) ModelScope Contributors. All rights reserved. -import ast -from pathlib import Path -from typing import Dict, List, Set, Tuple - -AUTO_GEN_WARNING = """# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ -""" - - -def generate_processors(): - """Generate client wrappers for all classes with @remote_function methods.""" - - # Module mapping: module_name -> directory in src/twinkle - module_mapping = { - 'dataloader': 'dataloader', - 'dataset': 'dataset', - 'processor': 'processor', - 'reward': 'reward', - 'template': 'template', - 'weight_loader': 'weight_loader', - } - - # Map module names to processor types in the server - processor_type_mapping = { - 'dataloader': 'dataloader', - 'dataset': 'dataset', - 'processor': 'processor', - 'reward': 'reward', - 'template': 'template', - 'weight_loader': 'weight_loader', - } - - # Get the project root directory - project_root = Path(__file__).parent.parent - src_twinkle_path = project_root / 'src' / 'twinkle' - src_client_path = project_root / 'src' / 'twinkle_client' - - def get_method_signature(func_node: ast.FunctionDef) -> str: - """Extract method signature from AST node.""" - args = [] - - # Regular arguments - for i, arg in enumerate(func_node.args.args): - if arg.arg == 'self': - continue - - # Get argument name - arg_str = arg.arg - - # Get type annotation if available - if arg.annotation: - try: - arg_str += f': {ast.unparse(arg.annotation)}' - except: - pass - - # Get default value if available - defaults_offset = len(func_node.args.args) - len(func_node.args.defaults) - if i >= defaults_offset: - default_idx = i - defaults_offset - try: - default_val = ast.unparse(func_node.args.defaults[default_idx]) - arg_str += f' = {default_val}' - except: - pass - - args.append(arg_str) - - # *args - if func_node.args.vararg: - vararg_str = f'*{func_node.args.vararg.arg}' - if func_node.args.vararg.annotation: - try: - vararg_str += f': {ast.unparse(func_node.args.vararg.annotation)}' - except: - pass - args.append(vararg_str) - - # **kwargs - if func_node.args.kwarg: - kwarg_str = f'**{func_node.args.kwarg.arg}' - if func_node.args.kwarg.annotation: - try: - kwarg_str += f': {ast.unparse(func_node.args.kwarg.annotation)}' - except: - pass - args.append(kwarg_str) - - return ', '.join(args) - - def extract_typing_imports(signatures: List[str]) -> Set[str]: - """Extract required typing imports from signatures.""" - typing_patterns = { - 'Union[': 'Union', - 'Optional[': 'Optional', - 'List[': 'List', - 'Dict[': 'Dict', - 'Tuple[': 'Tuple', - 'Type[': 'Type', - 'Any': 'Any', - 'Callable': 'Callable', - 'Literal[': 'Literal', - 'Required[': 'Required', - 'Set[': 'Set', - 'TypedDict': 'TypedDict', - } - - all_text = ' '.join(signatures) - return {name for pattern, name in typing_patterns.items() if pattern in all_text} - - def extract_twinkle_imports(signatures: List[str]) -> Set[str]: - """Extract required twinkle imports from signatures.""" - twinkle_patterns = { - 'InputFeature': ['from twinkle.data_format import InputFeature'], - 'Trajectory': ['from twinkle.data_format import Trajectory'], - 'DataFilter': ['from twinkle.preprocessor import DataFilter'], - 'Preprocessor': ['from twinkle.preprocessor import Preprocessor'], - 'DatasetMeta': ['from twinkle.dataset import DatasetMeta'], - 'Dataset': ['from twinkle.dataset import Dataset'], - 'DeviceMesh': ['from twinkle import DeviceMesh'], - 'Template': ['from twinkle.template import Template'], - 'template.Template': ['from twinkle.template import Template', 'from twinkle import template'], - 'processor.InputProcessor': - ['from twinkle.processor import InputProcessor', 'from twinkle import processor'], - 'InputProcessor': ['from twinkle.processor import InputProcessor'], - } - - all_text = ' '.join(signatures) - imports = set() - for pattern, stmts in twinkle_patterns.items(): - if pattern in all_text: - imports.update(stmts) - - return imports - - def parse_params_from_signature(signature: str) -> List[str]: - """Parse parameter names from signature, handling nested brackets.""" - params = [] - current = '' - depth = 0 - - for char in signature + ',': - if char in '[(': - depth += 1 - elif char in '])': - depth -= 1 - - if char == ',' and depth == 0: - name = current.split(':')[0].split('=')[0].strip() - if name and name != 'self' and not name.startswith('*'): - params.append(name) - current = '' - else: - current += char - - return params - - def find_classes_with_remote_methods(file_path: Path) -> List[Tuple[str, str, List[Tuple[str, str]]]]: - """Find all classes that have @remote_function decorated methods.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - tree = ast.parse(f.read(), filename=str(file_path)) - except Exception as e: - print(f'Error parsing {file_path}: {e}') - return [] - - def has_remote_decorator(func: ast.FunctionDef) -> bool: - for dec in func.decorator_list: - if isinstance(dec, ast.Name) and dec.id == 'remote_function': - return True - if isinstance(dec, ast.Call): - func_node = dec.func - if isinstance(func_node, ast.Name) and func_node.id == 'remote_function': - return True - if isinstance(func_node, ast.Attribute) and func_node.attr == 'remote_function': - return True - return False - - def is_public_or_dunder(name: str) -> bool: - return (name.startswith('__') and name.endswith('__')) or not name.startswith('_') - - def get_base_name(node: ast.ClassDef) -> str: - if not node.bases: - return 'object' - base = node.bases[0] - if isinstance(base, ast.Name): - return base.id - if isinstance(base, ast.Attribute): - return base.attr - return 'object' - - classes_found = [] - for node in ast.walk(tree): - if not isinstance(node, ast.ClassDef): - continue - - methods = [ - (item.name, get_method_signature(item)) for item in node.body - if isinstance(item, ast.FunctionDef) and has_remote_decorator(item) and is_public_or_dunder(item.name) - ] - - # Extract __init__ signature separately (it may not have @remote_function) - init_signature = '' - for item in node.body: - if isinstance(item, ast.FunctionDef) and item.name == '__init__': - init_signature = get_method_signature(item) - break - - if methods: - classes_found.append((node.name, get_base_name(node), methods, init_signature)) - - return classes_found - - def generate_client_class(class_name: str, - base_class_name: str, - methods: List[Tuple[str, str]], - module_name: str, - processor_type: str, - source_filename: str, - has_base_file: bool, - init_signature: str = '') -> str: - """Generate client wrapper class code.""" - - def build_imports() -> Tuple[List[str], str]: - # Include both method signatures and __init__ signature for import detection. - # Use the timeout-injected signature for ``encode`` so the ``Optional`` - # import introduced by ``_inject_timeout`` is detected. - signatures = [] - for name, sig in methods: - if name == 'encode' and class_name in ('Dataset', 'LazyDataset'): - signatures.append(_inject_timeout(sig)) - else: - signatures.append(sig) - if init_signature: - signatures.append(init_signature) - - typing_imports = extract_typing_imports(signatures) - twinkle_imports = extract_twinkle_imports(signatures) - - lines = [] - if typing_imports: - lines.append(f"from typing import {', '.join(sorted(typing_imports))}") - lines.extend([ - 'from twinkle_client.http import http_post', - ]) - lines.extend(sorted(twinkle_imports)) - - if source_filename == 'base': - inheritance = 'object' - elif base_class_name == 'IterableDataset': - lines.append('from torch.utils.data import IterableDataset') - inheritance = 'IterableDataset' - elif has_base_file and base_class_name != 'object': - lines.append(f'from .base import {base_class_name}') - inheritance = base_class_name - else: - inheritance = 'object' - - lines.append('') - return lines, inheritance - - def _inject_timeout(signature: str) -> str: - """Insert `timeout: Optional[int] = 600` before any **kwargs in the signature.""" - if 'timeout' in signature: - return signature - if ', **' in signature: - pre, post = signature.rsplit(', **', 1) - return f'{pre}, timeout: Optional[int] = 600, **{post}' - if signature.startswith('**'): - return f'timeout: Optional[int] = 600, {signature}' - if signature: - return f'{signature}, timeout: Optional[int] = 600' - return 'timeout: Optional[int] = 600' - - def build_method(name: str, signature: str) -> str: - param_names = parse_params_from_signature(signature) - kwargs_dict = '{' + ', '.join(f"'{p}': {p}" for p in param_names) + '}' if param_names else '{}' - wants_timeout = name == 'encode' and class_name in ('Dataset', 'LazyDataset') - effective_sig = _inject_timeout(signature) if wants_timeout else signature - sig_part = f', {effective_sig}' if effective_sig else '' - if 'kwargs' in sig_part: - extra_args = '\n **kwargs' - else: - extra_args = '' - ret = 'self' if name == '__iter__' else 'response.json()["result"]' - timeout_kwarg = ',\n timeout=timeout' if wants_timeout else '' - - code = f''' - def {name}(self{sig_part}): - response = http_post( - url=f'{{self.server_url}}/call', - json_data={{ - 'processor_id': self.processor_id, - 'function': '{name}', - **{kwargs_dict},{extra_args} - }}{timeout_kwarg} - ) - response.raise_for_status() - return {ret} - ''' - if name == '__iter__': - code += ''' - def __next__(self): - response = http_post( - url=f'{self.server_url}/call', - json_data={ - 'processor_id': self.processor_id, - 'function': '__next__', - } - ) - response.raise_for_status() - return response.json()["result"] - ''' - return code - - import_lines, inheritance = build_imports() - - # Build __init__ method with actual signature - if init_signature: - # Extract parameter names from signature (excluding **kwargs) - param_names = parse_params_from_signature(init_signature) - init_params = f'self, {init_signature}' if init_signature else 'self' - - # Check if signature has **kwargs - has_kwargs = '**' in init_signature - - # Extract the **kwargs name if present - kwargs_name = None - if has_kwargs: - # Find the **kwargs parameter name - for part in init_signature.split(','): - part = part.strip() - if part.startswith('**'): - # Extract name after **, before : or end - kwargs_name = part[2:].split(':')[0].strip() - break - - # Build kwargs dict for HTTP request - if param_names: - kwargs_items = ', '.join([f"'{p}': {p}" for p in param_names]) - if has_kwargs and kwargs_name: - # Include both named params and **kwargs - kwargs_dict = f'{{{kwargs_items}}}, **{kwargs_name}' - else: - kwargs_dict = f'{{{kwargs_items}}}' - else: - if has_kwargs and kwargs_name: - kwargs_dict = kwargs_name - else: - kwargs_dict = '{}' - else: - # Fallback to **kwargs if no __init__ found - init_params = 'self, **kwargs' - kwargs_dict = 'kwargs' - - class_template = f'''{AUTO_GEN_WARNING} -{chr(10).join(import_lines)} -class {class_name}({inheritance}): - """Client wrapper for {class_name} that calls server HTTP endpoints.""" - - def __init__({init_params}): - from twinkle_client.http import get_base_url - - self.server_url = f'{{get_base_url()}}/processor/twinkle' - response = http_post( - url=f'{{self.server_url}}/create', - json_data={{ - 'processor_type': '{processor_type}', - 'class_type': '{class_name}', - **{kwargs_dict} - }} - ) - response.raise_for_status() - self.processor_id = response.json()['processor_id'] - - ''' - - method_codes = [build_method(name, sig) for name, sig in methods] - - return class_template + '\n'.join(method_codes) - - def scan_modules(src_twinkle_path: Path, module_mapping: Dict[str, str]) -> Dict: - """Scan all modules for classes with @remote_function methods.""" - print('Scanning src/twinkle modules for classes with @remote_function methods...') - - module_files = {} - for module_name, module_dir in module_mapping.items(): - module_path = src_twinkle_path / module_dir - if not module_path.exists(): - continue - - print(f' Scanning {module_name}...') - for py_file in module_path.glob('*.py'): - if py_file.name.startswith('_'): - continue - - if classes := find_classes_with_remote_methods(py_file): - module_files.setdefault(module_name, {}).setdefault(py_file.stem, []).extend(classes) - - return module_files - - def write_client_files(module_files: Dict, src_client_path: Path, processor_type_mapping: Dict[str, str]) -> None: - """Generate and write client files.""" - print('\nGenerating client classes...') - - for module_name, source_files in module_files.items(): - client_module_path = src_client_path / module_name - client_module_path.mkdir(parents=True, exist_ok=True) - - processor_type = processor_type_mapping.get(module_name, module_name) - has_base_file = 'base' in source_files - - for source_filename, classes in source_files.items(): - client_file = client_module_path / f'{source_filename}.py' - print(f' Writing {client_file}...') - - code = '\n\n'.join( - generate_client_class(class_name, base_class_name, methods, module_name, processor_type, - source_filename, has_base_file, init_signature) - for class_name, base_class_name, methods, init_signature in classes) - client_file.write_text(code, encoding='utf-8') - - def write_init_files(module_files: Dict, src_client_path: Path) -> None: - """Generate __init__.py files for each module.""" - print('\nGenerating __init__.py files...') - - for module_name, source_files in module_files.items(): - init_file = src_client_path / module_name / '__init__.py' - print(f' Writing {init_file}...') - - init_lines = [ - f'from .{source_filename} import {class_name}' - for source_filename, classes in sorted(source_files.items()) for class_name, _, _, _ in classes - ] - init_content = AUTO_GEN_WARNING + '\n'.join(sorted(init_lines)) + '\n' - init_file.write_text(init_content, encoding='utf-8') - - module_files = scan_modules(src_twinkle_path, module_mapping) - write_client_files(module_files, src_client_path, processor_type_mapping) - write_init_files(module_files, src_client_path) - print('\nProcessor client generation complete!') - return module_files - - -def generate_models(): - """Generate client wrapper for Model management.""" - from pathlib import Path - - project_root = Path(__file__).parent.parent - src_client_path = project_root / 'src' / 'twinkle_client' - client_module_path = src_client_path / 'model' - client_module_path.mkdir(parents=True, exist_ok=True) - - model_code = AUTO_GEN_WARNING + '''from typing import Any, Dict, Optional -from pathlib import Path -import time -from twinkle_client.http import http_get, http_post -from twinkle_client.types.model import ( - CalculateLossResponse, - CalculateMetricResponse, - ClipGradNormResponse, - ForwardBackwardResponse, - ForwardResponse, - GetStateDictResponse, - GetTrainConfigsResponse, - SaveResponse, - TrainingProgressResponse, -) - - -class MultiLoraTransformersModel: - """Client wrapper for TwinkleModel that calls server HTTP endpoints. - - This client manages adapters and sends training/inference requests to the model server. - The server-side session (managed by TwinkleClient) keeps the model alive. - """ - - def __init__(self, model_id: str, **kwargs): - """Initialize model client.""" - from twinkle_client.http import get_base_url - self.server_url = get_base_url() - - if '://' in model_id: - model_id = model_id.split('://')[1] - self.model_id = model_id - self.server_url = f'{self.server_url}/model/{model_id}/twinkle' - self.adapter_name = None - response = http_post( - url=f'{self.server_url}/create', - ) - response.raise_for_status() - - def add_adapter_to_model(self, adapter_name: str, config: Dict[str, Any], **kwargs) -> None: - """Add a new adapter to the model.""" - save_dir = kwargs.get('save_dir') - if save_dir: - kwargs['save_dir'] = Path(save_dir).expanduser().resolve().as_posix() - response = http_post( - url=f'{self.server_url}/add_adapter_to_model', - json_data={'adapter_name': adapter_name, 'config': config, **kwargs} - ) - response.raise_for_status() - self.adapter_name = adapter_name - - def forward(self, inputs: Any, **kwargs) -> ForwardResponse: - """Execute forward pass on the model.""" - response = http_post( - url=f'{self.server_url}/forward', - json_data={'inputs': inputs, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return ForwardResponse(**response.json()) - - def forward_only(self, inputs: Any, **kwargs) -> ForwardResponse: - """Execute forward pass without gradient computation.""" - response = http_post( - url=f'{self.server_url}/forward_only', - json_data={'inputs': inputs, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return ForwardResponse(**response.json()) - - def calculate_loss(self, **kwargs) -> CalculateLossResponse: - """Calculate loss from model outputs.""" - response = http_post( - url=f'{self.server_url}/calculate_loss', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return CalculateLossResponse(**response.json()) - - def get_train_configs(self, **kwargs) -> GetTrainConfigsResponse: - """Get training configs.""" - response = http_post( - url=f'{self.server_url}/get_train_configs', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return GetTrainConfigsResponse(**response.json()) - - def backward(self, **kwargs) -> None: - """Execute backward pass.""" - response = http_post( - url=f'{self.server_url}/backward', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def forward_backward(self, inputs: Any, **kwargs) -> ForwardBackwardResponse: - """Execute combined forward and backward pass.""" - response = http_post( - url=f'{self.server_url}/forward_backward', - json_data={'inputs': inputs, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return ForwardBackwardResponse(**response.json()) - - def step(self, **kwargs) -> None: - """Execute optimizer step.""" - response = http_post( - url=f'{self.server_url}/step', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def zero_grad(self, **kwargs) -> None: - """Zero out gradients.""" - response = http_post( - url=f'{self.server_url}/zero_grad', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def lr_step(self, **kwargs) -> None: - """Execute learning rate scheduler step.""" - response = http_post( - url=f'{self.server_url}/lr_step', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def clip_grad_norm(self, max_grad_norm: float = 1.0, norm_type: int = 2, **kwargs) -> ClipGradNormResponse: - """Clip gradient norm.""" - response = http_post( - url=f'{self.server_url}/clip_grad_norm', - json_data={'max_grad_norm': max_grad_norm, 'norm_type': norm_type, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return ClipGradNormResponse(**response.json()) - - def clip_grad_and_step(self, max_grad_norm: float = 1.0, norm_type: int = 2, **kwargs) -> None: - """Clip gradient norm and execute optimizer step in one call.""" - response = http_post( - url=f'{self.server_url}/clip_grad_and_step', - json_data={'max_grad_norm': max_grad_norm, 'norm_type': norm_type, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def set_loss(self, loss_cls: str, **kwargs) -> None: - """Set the loss function.""" - response = http_post( - url=f'{self.server_url}/set_loss', - json_data={'loss_cls': loss_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def set_optimizer(self, optimizer_cls: str, **kwargs) -> None: - """Set the optimizer.""" - response = http_post( - url=f'{self.server_url}/set_optimizer', - json_data={'optimizer_cls': optimizer_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def set_lr_scheduler(self, scheduler_cls: str, **kwargs) -> None: - """Set the learning rate scheduler.""" - response = http_post( - url=f'{self.server_url}/set_lr_scheduler', - json_data={'scheduler_cls': scheduler_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def save(self, name: str, **kwargs) -> SaveResponse: - """Save model checkpoint.""" - response = http_post( - url=f'{self.server_url}/save', - json_data={'name': name, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return SaveResponse(**response.json()) - - def load(self, name: str, **kwargs) -> None: - """Load model checkpoint.""" - response = http_post( - url=f'{self.server_url}/load', - json_data={'name': name, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def resume_from_checkpoint(self, name: str, *, resume_only_model: bool = False, **kwargs) -> Dict[str, Any]: - response = http_post( - url=f'{self.server_url}/resume_from_checkpoint', - json_data={'name': name, 'adapter_name': self.adapter_name, - 'resume_only_model': resume_only_model, **kwargs} - ) - response.raise_for_status() - return TrainingProgressResponse(**response.json()).result - - def apply_patch(self, patch_cls: str, **kwargs) -> None: - """Apply a patch to the model.""" - response = http_post( - url=f'{self.server_url}/apply_patch', - json_data={'patch_cls': patch_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def add_metric(self, metric_cls: str, is_training: Optional[bool] = None, **kwargs) -> None: - """Add a metric to the model.""" - response = http_post( - url=f'{self.server_url}/add_metric', - json_data={'metric_cls': metric_cls, 'is_training': is_training, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def set_template(self, template_cls: str, **kwargs) -> None: - """Set the template for data processing.""" - response = http_post( - url=f'{self.server_url}/set_template', - json_data={'template_cls': template_cls, 'adapter_name': self.adapter_name, 'model_id': self.model_id, **kwargs} - ) - response.raise_for_status() - - def set_processor(self, processor_cls: str, **kwargs) -> None: - """Set the input processor.""" - response = http_post( - url=f'{self.server_url}/set_processor', - json_data={'processor_cls': processor_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - - def calculate_metric(self, is_training: bool = True, **kwargs) -> CalculateMetricResponse: - """Calculate metrics from model outputs.""" - response = http_post( - url=f'{self.server_url}/calculate_metric', - json_data={'is_training': is_training, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return CalculateMetricResponse(**response.json()) - - def get_state_dict(self, **kwargs) -> GetStateDictResponse: - """Get model state dictionary.""" - response = http_post( - url=f'{self.server_url}/get_state_dict', - json_data={'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() - return GetStateDictResponse(**response.json()) - - def upload_to_hub( - self, - checkpoint_dir: str, - hub_model_id: str, - hub_token: Optional[str] = None, - async_upload: bool = True, - poll_interval: float = 5.0, - ) -> None: - """Upload model checkpoint to hub. - - Submits the upload task to the server and polls for completion. - Blocks until the upload finishes or raises on failure. - - Args: - checkpoint_dir: The directory path of the checkpoint to upload. - hub_model_id: The hub model id. - hub_token: The hub token (optional). - async_upload: Deprecated, has no effect. The server always runs the - upload in the background and the client polls for completion. - poll_interval: Seconds between status poll requests (default: 5). - """ - response = http_post( - url=f'{self.server_url}/upload_to_hub', - json_data={ - 'checkpoint_dir': checkpoint_dir, - 'hub_model_id': hub_model_id, - 'hub_token': hub_token, - } - ) - response.raise_for_status() - request_id = response.json().get('request_id') - if not request_id: - return - - print(f'[upload_to_hub] Upload started (task {request_id}), waiting for completion...') - while True: - status_resp = http_get(url=f'{self.server_url}/upload_status/{request_id}') - status_resp.raise_for_status() - data = status_resp.json() - status = data.get('status', 'unknown') - if status == 'completed': - print(f'[upload_to_hub] Upload completed successfully.') - return - elif status == 'failed': - error = data.get('error', 'Unknown error') - raise RuntimeError(f'[upload_to_hub] Upload failed: {error}') - else: - print(f'[upload_to_hub] Status: {status}...') - time.sleep(poll_interval) -''' - - # Write the model client file - client_file = client_module_path / 'multi_lora_transformers.py' - print(f'Generating {client_file}...') - with open(client_file, 'w', encoding='utf-8') as f: - f.write(model_code) - - # Create/overwrite __init__.py - init_file = client_module_path / '__init__.py' - init_content = AUTO_GEN_WARNING + 'from .multi_lora_transformers import MultiLoraTransformersModel\n' - print(f'Writing {init_file}...') - with open(init_file, 'w', encoding='utf-8') as f: - f.write(init_content) - - print('Model client generation complete!') - - -def generate_samplers(): - """Generate client wrapper for Sampler management.""" - from pathlib import Path - - project_root = Path(__file__).parent.parent - src_client_path = project_root / 'src' / 'twinkle_client' - client_module_path = src_client_path / 'sampler' - client_module_path.mkdir(parents=True, exist_ok=True) - - sampler_code = AUTO_GEN_WARNING + '''from typing import Any, Dict, List, Optional, Union -from twinkle_client.http import http_post -from twinkle_client.types.sampler import AddAdapterResponse, SampleResponseModel, SetTemplateResponse -from peft import PeftConfig -from twinkle.data_format import Trajectory, InputFeature - - -# Intentionally does NOT subclass ``twinkle.sampler.base.Sampler``: importing -# that base pulls ``twinkle.sampler.__init__`` → ``VLLMEngine`` → torch + zmq, -# which the mock / CPU-only client environments don't have. -class vLLMSampler: - """Client wrapper for Sampler that calls server HTTP endpoints. - - This client manages sampling operations and adapter synchronization with the sampler server. - The server-side session (managed by TwinkleClient) keeps the sampler alive. - """ - - def __init__(self, model_id: str, **kwargs): - """Create the sampler instance on server.""" - from twinkle_client.http import get_base_url - self.server_url = get_base_url() - - self.adapter_name = None - if '://' in model_id: - model_id = model_id.split('://')[1] - self.model_id = model_id - self.server_url = f'{self.server_url}/sampler/{model_id}/twinkle' - response = http_post( - url=f'{self.server_url}/create', - json_data=kwargs - ) - response.raise_for_status() - - def add_adapter_to_sampler(self, adapter_name: str, config: PeftConfig, **kwargs) -> AddAdapterResponse: - """Add a new adapter to the sampler.""" - if isinstance(config, PeftConfig): - config = config.__dict__ - response = http_post( - url=f'{self.server_url}/add_adapter_to_sampler', - json_data={'adapter_name': adapter_name, 'config': config, **kwargs} - ) - response.raise_for_status() - self.adapter_name = adapter_name - return AddAdapterResponse(**response.json()) - - def sample( - self, - inputs: Union[List[Trajectory], List[InputFeature]], - sampling_params: Optional[Dict[str, Any]] = None, - adapter_name: str = '', - adapter_uri: Optional[str] = None, - num_samples: int = 1, - ) -> List[SampleResponseModel]: - """Sample from the model. - - Args: - inputs: List of Trajectory or InputFeature to sample from. - sampling_params: Sampling parameters dict. - adapter_name: Adapter name for LoRA inference. - adapter_uri: Adapter URI (twinkle:// path or local path) for LoRA inference. - num_samples: Number of completions to generate per prompt. - - Returns: - SampleResponseModel with 'sequences' list, each containing tokens, logprobs, stop_reason. - """ - json_data = { - 'inputs': inputs, - 'sampling_params': sampling_params, - 'adapter_name': adapter_name, - 'num_samples': num_samples, - } - if adapter_uri is not None: - json_data['adapter_uri'] = adapter_uri - - response = http_post( - url=f'{self.server_url}/sample', - json_data=json_data - ) - response.raise_for_status() - return [SampleResponseModel(**r) for r in response.json()['samples']] - - def set_template(self, template_cls: str, adapter_name: str = '', **kwargs) -> SetTemplateResponse: - """Set the template for encoding trajectories.""" - response = http_post( - url=f'{self.server_url}/set_template', - json_data={'template_cls': template_cls, 'adapter_name': adapter_name, **kwargs} - ) - response.raise_for_status() - return SetTemplateResponse(**response.json()) - - def apply_patch(self, patch_cls: str, **kwargs) -> None: - """Apply a patch to the model.""" - response = http_post( - url=f'{self.server_url}/apply_patch', - json_data={'patch_cls': patch_cls, 'adapter_name': self.adapter_name, **kwargs} - ) - response.raise_for_status() -''' - - # Write the sampler client file - client_file = client_module_path / 'vllm_sampler.py' - print(f'Generating {client_file}...') - with open(client_file, 'w', encoding='utf-8') as f: - f.write(sampler_code) - - # Create/overwrite __init__.py - init_file = client_module_path / '__init__.py' - init_content = AUTO_GEN_WARNING + 'from .vllm_sampler import vLLMSampler\n' - print(f'Writing {init_file}...') - with open(init_file, 'w', encoding='utf-8') as f: - f.write(init_content) - - print('Sampler client generation complete!') - - -if __name__ == '__main__': - print('Starting client code generation...\n') - print('=' * 60) - - # Generate processor-based clients - print('\n[1/3] Generating processor-based clients...') - generate_processors() - - # Generate model client - print('\n' + '=' * 60) - print('\n[2/3] Generating model client...') - generate_models() - - # Generate sampler client - print('\n' + '=' * 60) - print('\n[3/3] Generating sampler client...') - generate_samplers() - - print('\n' + '=' * 60) - print('\n✓ All client code generation complete!\n') diff --git "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Embedding\350\256\255\347\273\203.md" "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Embedding\350\256\255\347\273\203.md" index 94ea86ebe..f5df4e9f4 100644 --- "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Embedding\350\256\255\347\273\203.md" +++ "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Embedding\350\256\255\347\273\203.md" @@ -106,6 +106,72 @@ for step, batch in enumerate(dataloader): --- +## 通过 Twinkle Client 训练 + +除了直接使用裸库 `TransformersModel`,你也可以通过 `twinkle_client` 的 `MultiLoraTransformersModel` 以 HTTP 方式驱动服务端完成 Embedding 训练。协议层(`/twinkle/*`)已经具备完成 Embedding 训练所需的全部能力,**无需引入任何新的高层封装函数**(例如 `setup_embedding_training`),只需按正确顺序调用现有方法即可。 + +### 完整调用顺序 + +client 化的 Embedding 训练遵循与裸库一致的调用顺序: + +``` +set_processor('InputProcessor') + -> set_loss('InfonceLoss', ...) + -> add_metric('EmbeddingMetric', is_training=True) + -> loop: forward_backward(inputs=mb, task='embedding') + clip_grad_and_step(...) + -> calculate_metric(is_training=True) +``` + +与裸库不同的是,client 侧传入的是**类名字符串**(如 `'InfonceLoss'`、`'EmbeddingMetric'`、`'InputProcessor'`),由服务端解析为对应的核心库类。 + +### 示例 + +```python +from peft import LoraConfig +from twinkle_client import init_twinkle_client +from twinkle_client.model import MultiLoraTransformersModel + +# --- Connect to the running Twinkle server --- +init_twinkle_client(base_url='http://127.0.0.1:8000', api_key='EMPTY_TOKEN') + +# --- Build the client model with a LoRA adapter for embedding --- +model = MultiLoraTransformersModel(model_id='ms://Qwen/Qwen3.5-4B') +model.add_adapter_to_model('emb_adapter', LoraConfig(target_modules='all-linear')) +model.set_template('Qwen3_5Template') + +# --- Configure the embedding-training pipeline (order matters) --- +# NOTE: pass class names as strings; the server resolves them to core-lib classes. +model.set_processor('InputProcessor') +model.set_loss('InfonceLoss', temperature=0.07, use_batch=True, hard_negatives=None) +model.add_metric('EmbeddingMetric', is_training=True) + +# --- Training loop --- +for step, mb in enumerate(minibatches): + # `task='embedding'` is forwarded through the /twinkle/* protocol as an extra + # kwarg and selects the embedding pooling + InfoNCE loss path on the server. + model.forward_backward(inputs=mb, task='embedding') + model.clip_grad_and_step(max_grad_norm=1.0) + +# --- Read back embedding metrics (pos_sim / neg_sim / loss) --- +metric = model.calculate_metric(is_training=True) +``` + +### 关于 `TransformersEmbeddingPatch` 的自动应用 + +Embedding 训练依赖 `TransformersEmbeddingPatch` 把 causal LM 的 `lm_head` 替换为 identity 输出,从而得到 per-token hidden states 用于池化。**你不需要显式调用 `apply_patch(TransformersEmbeddingPatch())`**:当你在 `forward_backward`(或 `forward_only`)中传入 `task='embedding'` 时,服务端的 `_resolve_task_context` 会在该次 `forward` 调用期间自动应用该 patch,并在调用结束后自动回滚。 + +这意味着: + +- 同一个 adapter 在一次 `forward_backward(task='embedding')` 之后,紧接着调用不带 `task` 参数的 `forward_only` 仍会返回正常的词表维度 `logits`,不会残留上一次 embedding 任务的 identity hidden states。 +- 你在 client 侧真正需要显式调用的前置步骤只有三个:`set_processor('InputProcessor')`、`set_loss('InfonceLoss', ...)` 与 `add_metric('EmbeddingMetric', is_training=True)`。 + +### 说明 + +- 本节不引入任何新的高层封装函数,仅说明现有 `MultiLoraTransformersModel` 方法的正确调用顺序。 +- 各参数(`temperature`、`use_batch`、`hard_negatives` 等)的含义与取值建议参见上文「关键参数」;`calculate_metric` 返回的指标含义参见下文「监控指标」。 + +--- + ## 监控指标 `EmbeddingMetric` 报告关键训练信号: diff --git "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/Tinker\345\205\274\345\256\271\345\256\242\346\210\267\347\253\257.md" "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/Tinker\345\205\274\345\256\271\345\256\242\346\210\267\347\253\257.md" index a1f7e064f..2bc16c171 100644 --- "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/Tinker\345\205\274\345\256\271\345\256\242\346\210\267\347\253\257.md" +++ "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/Tinker\345\205\274\345\256\271\345\256\242\346\210\267\347\253\257.md" @@ -195,3 +195,68 @@ rest_client = service_client.create_rest_client() rest_client.publish_checkpoint_from_tinker_path(save_result.path).result() print("Published checkpoint to ModelScope Hub") ``` + +## 多轮工具调用(间接方案) + +Tinker 兼容客户端本身不提供多轮 agentic rollout 编排能力。如果你在用 Tinker Client 训练/管理 checkpoint,同时又需要多轮工具调用能力(**仅用于生成或评测**),可以在完全不改动 Tinker SDK 代码的前提下,额外使用一个标准的 OpenAI 兼容客户端指向 Gateway 的 `/chat/completions` 端点,并复用 `twinkle_agentic.rollout.APIMultiTurnRollout` 与 `ToolManager`。 + +这是一条纯增量的使用路径:它复用的是 Twinkle Server 上一个独立于 Tinker 协议命名空间的、已经存在并跑通的 OpenAI 兼容端点,因此不涉及对 Tinker SDK 源码或 `patch_tinker.py` 中现有 monkeypatch 范围的任何修改。 + +```python +import os +from twinkle.data_format.sampling import SamplingParams +from twinkle_agentic.protocol.openai import OpenAI +from twinkle_agentic.rollout import APIMultiTurnRollout +from twinkle_agentic.tools.tool_manager import ToolManager +# from your_project.tools import CalculatorTool, SearchTool + +# An OpenAI-compatible client pointing at the Gateway route, +# NOT at tinker's base_url. This is a plain additive usage of an +# already-existing endpoint; the tinker SDK is untouched. +api = OpenAI( + model='Qwen/Qwen3.5-4B', + api_key=os.environ.get('MODELSCOPE_TOKEN', 'EMPTY_API_KEY'), + base_url='http://localhost:8000/api/v1', # Gateway /chat/completions route +) + +# Register your tools; ToolManager is reused directly from twinkle_agentic. +tool_manager = ToolManager([ + # CalculatorTool(), + # SearchTool(), +]) + +rollout = APIMultiTurnRollout( + api=api, + tool_manager=tool_manager, + sampling_params=SamplingParams(temperature=0.7, max_tokens=512), + max_turns=6, + concurrency=8, +) + +trajectories_in = [ + {'messages': [{'role': 'user', 'content': 'What is 12 * 34?'}]}, +] +trajectories_out = rollout(trajectories_in) + +# trajectories_out[i]['messages'] contains the full conversation, including +# assistant tool_calls and tool-response turns. +# trajectories_out[i]['stop_reason'] is one of 'stop' | 'length' | 'max_turns' | 'api_error'. +for traj in trajectories_out: + for message in traj['messages']: + print(message['role'], ':', message.get('content')) +``` + +### 能力边界:不可用于 RL 训练 + +该间接方案返回的 Trajectory **不包含** `logprobs` 字段(端到端验证已确认走 Gateway `/chat/completions` 的 `APIMultiTurnRollout` 产出的 trajectory 中不存在 `logprobs`,也没有 `input_ids` 等训练所需字段)。因此该路径**仅适合生成数据、离线评测等场景,不能直接喂给依赖 token 级对齐信息的 RL 训练(如 GRPO)**。 + +如果多轮 rollout 的产出需要直接用于 RL 训练(需要 token 级 `logprobs` 与 `labels` 对齐),请使用 Twinkle 客户端侧的 `ClientMultiTurnRollout`(通过 `/twinkle/sample` 返回 `new_input_feature`/`logprobs`),详见《Twinkle 客户端》文档。 + +### 为什么 Tinker 客户端不支持 embedding 训练与可训练多轮 rollout + +这两项能力无法通过 Tinker 兼容客户端提供,根本原因在于 Tinker 协议的数据类型层面缺少所需结构: + +- **Embedding 训练**:`tinker.types.Datum` 不具备对比学习所需的样本分组结构(anchor/positive 成对分组),无法承载 embedding 训练的输入语义。 +- **可训练多轮 rollout**:`tinker.types.SampledSequence` 不具备 `new_input_feature` 字段,无法在多轮之间传递并累积 token 级对齐信息,因此产出无法用于训练。 + +由于 Tinker 是第三方 SDK、不可修改其源码,即使想补齐这些字段也需要改动 SDK 本身(不允许)。因此上述能力仅在 Twinkle 客户端侧支持,Tinker 兼容客户端只提供上文所述的"仅生成/评测"间接多轮方案。 diff --git a/src/twinkle/server/sampler/backends/mock_sampler.py b/src/twinkle/server/sampler/backends/mock_sampler.py index ec5c86f97..f04accb4f 100644 --- a/src/twinkle/server/sampler/backends/mock_sampler.py +++ b/src/twinkle/server/sampler/backends/mock_sampler.py @@ -14,7 +14,7 @@ import numpy as np import time -from typing import Any +from typing import Any, Iterable from twinkle import remote_class, remote_function from twinkle.data_format import SampledSequence, SampleResponse, SamplingParams @@ -23,16 +23,61 @@ logger = get_logger() +# Recognised ctor / per-call knobs that tune multi-turn control flow. Kept in a +# set so the ctor can log only *genuinely* unknown kwargs (a real backend +# signature drift) while still accepting these opt-in multi-turn knobs. +_MULTI_TURN_KNOBS = ('stop_reason', 'tool_call_text', 'tool_call_turns') + @remote_class() class MockSampler: - """Deterministic mock sampler for CPU-only testing.""" + """Deterministic mock sampler for CPU-only testing. + + Beyond the base sampler surface, this backend exposes a few opt-in knobs so + a local (CPU-only) multi-turn rollout e2e can be driven without a GPU: + + - ``stop_reason`` (default ``'length'``): the ``SampledSequence.stop_reason`` + emitted for every sampled sequence. ``'length'`` terminates a multi-turn + loop immediately; ``'stop'`` lets the loop proceed to tool-call parsing. + - ``tool_call_text`` (default ``None``): when set, this text is emitted as + ``SampledSequence.decoded`` on the configured turns so the rollout's + ``template.parse_tool_call`` can produce a tool call and exercise the + "sample -> tool -> bridge -> sample" control flow. + - ``tool_call_turns`` (default ``(1,)`` when ``tool_call_text`` is set): + the 1-based turn indices on which ``tool_call_text`` is injected. A turn + corresponds to one ``sample()`` invocation (one multi-turn round). - def __init__(self, model_id: str, *, seed: int = 0, vocab_size: int = 32, **kwargs: Any) -> None: + All three knobs may be supplied at construction time or overridden per call + via ``sample()`` keyword arguments or matching attributes on + ``sampling_params``. When none are configured the backend keeps its previous + behaviour exactly (``stop_reason='length'``, ``decoded=None``), so existing + callers are unaffected. Outputs stay deterministic and CPU-only. + """ + + def __init__( + self, + model_id: str, + *, + seed: int = 0, + vocab_size: int = 32, + stop_reason: str = 'length', + tool_call_text: str | None = None, + tool_call_turns: Iterable[int] | None = None, + **kwargs: Any, + ) -> None: self.model_id = model_id self._seed = int(seed) self._vocab_size = int(vocab_size) self._adapters: dict[str, Any] = {} + # Multi-turn control-flow knobs (see class docstring). + self._stop_reason = str(stop_reason) + self._tool_call_text = tool_call_text + self._tool_call_turns = self._normalize_turns(tool_call_turns, tool_call_text) + # Per-round counter: incremented once per ``sample()`` call so that + # ``tool_call_turns`` can address individual multi-turn rounds. Only + # consulted when tool-call injection is active, so the default path + # stays fully stateless and deterministic. + self._round = 0 # Surface (rather than silently swallow) extra ctor kwargs: a real # backend signature drift then shows up as a visible DEBUG warning in # the mock e2e instead of being discarded without trace. @@ -64,9 +109,19 @@ def sample( raise ValueError(f'max_tokens must be >= 1, got {max_tokens!r} ' '(set sampling_params.max_tokens to a positive integer)') + # Resolve per-call multi-turn knobs (precedence: explicit sample() + # kwargs > sampling_params attributes > ctor defaults) and advance the + # round counter that ``tool_call_turns`` addresses. + stop_reason = self._resolve_stop_reason(sampling_params, kwargs) + tool_call_text = self._resolve_tool_call_text(sampling_params, kwargs) + tool_call_turns = self._resolve_tool_call_turns(sampling_params, kwargs, tool_call_text) + self._round += 1 + inject_tool_call = tool_call_text is not None and self._round in tool_call_turns + decoded = tool_call_text if inject_tool_call else None + normalized = self._normalize_inputs(inputs) responses: list[SampleResponse] = [] - for prompt_idx, _ in enumerate(normalized): + for prompt_idx, pif in enumerate(normalized): sequences: list[SampledSequence] = [] for sample_idx in range(num_samples): seed = stable_seed(self.model_id, adapter_name, self._seed, prompt_idx, sample_idx) @@ -78,11 +133,14 @@ def sample( # mock returns top-1 with the chosen token. The tinker handler # flattens this to a single chosen-token logprob. logprobs = [[(tok, float(lp))] for tok, lp in zip(tokens, logprobs_per_token)] - sequences.append(SampledSequence( - stop_reason='length', - tokens=tokens, - logprobs=logprobs, - )) + sequences.append( + SampledSequence( + stop_reason=stop_reason, + tokens=tokens, + logprobs=logprobs, + decoded=decoded, + new_input_feature=self._build_new_input_feature(pif, tokens), + )) responses.append(SampleResponse(sequences=sequences)) return responses @@ -150,3 +208,77 @@ def _resolve_max_tokens(params: SamplingParams | None) -> int | None: if params is None: return None return getattr(params, 'max_tokens', None) + + @staticmethod + def _normalize_turns(turns: Iterable[int] | None, tool_call_text: str | None) -> frozenset[int]: + """Normalise the ``tool_call_turns`` config into a ``frozenset[int]``. + + When ``tool_call_text`` is set but no turns are given, default to + ``{1}`` — inject the tool call exactly once, on the first round. + """ + if turns is None: + return frozenset({1}) if tool_call_text is not None else frozenset() + return frozenset(int(t) for t in turns) + + def _resolve_stop_reason(self, params: SamplingParams | None, kwargs: dict[str, Any]) -> str: + if 'stop_reason' in kwargs and kwargs['stop_reason'] is not None: + return str(kwargs['stop_reason']) + override = getattr(params, 'stop_reason', None) + if override is not None: + return str(override) + return self._stop_reason + + def _resolve_tool_call_text(self, params: SamplingParams | None, kwargs: dict[str, Any]) -> str | None: + if 'tool_call_text' in kwargs: + return kwargs['tool_call_text'] + override = getattr(params, 'tool_call_text', None) + if override is not None: + return override + return self._tool_call_text + + def _resolve_tool_call_turns( + self, + params: SamplingParams | None, + kwargs: dict[str, Any], + tool_call_text: str | None, + ) -> frozenset[int]: + if 'tool_call_turns' in kwargs and kwargs['tool_call_turns'] is not None: + return self._normalize_turns(kwargs['tool_call_turns'], tool_call_text) + override = getattr(params, 'tool_call_turns', None) + if override is not None: + return self._normalize_turns(override, tool_call_text) + # Fall back to the ctor-configured turns, but keep the "default to {1} + # when text is injected via a per-call override" behaviour consistent. + if tool_call_text is not None and not self._tool_call_turns: + return frozenset({1}) + return self._tool_call_turns + + @staticmethod + def _build_new_input_feature(pif: Any, tokens: list[int]) -> dict[str, Any]: + """Deterministically append the freshly sampled ``tokens`` to ``pif``. + + Produces a plain-dict ``InputFeature`` that carries the running context + for the next multi-turn round: ``input_ids`` is the prior prompt plus + this round's sampled tokens, and ``labels`` marks the sampled tokens as + trainable (their own ids) while prior/context positions stay ``-100``. + This mirrors the shape a real sampler's ``concat_input_feature`` yields, + which the multi-turn rollout relies on (it reads + ``new_input_feature.input_ids`` and counts trainable ``labels``). + + The mock does not depend on a template, so the append is a simple, + deterministic list concatenation performed entirely on the CPU. + """ + feat: dict[str, Any] = dict(pif) if isinstance(pif, dict) else {} + prev_ids = list(feat.get('input_ids') or []) + prev_labels = feat.get('labels') + if prev_labels is not None and len(prev_labels) == len(prev_ids): + labels = list(prev_labels) + else: + # No (or misaligned) prior labels: treat the entire prior context as + # non-trainable so only this round's sampled tokens count. + labels = [-100] * len(prev_ids) + + feat['input_ids'] = prev_ids + list(tokens) + feat['labels'] = labels + list(tokens) + feat['length'] = len(feat['input_ids']) + return feat diff --git a/src/twinkle_agentic/rollout/__init__.py b/src/twinkle_agentic/rollout/__init__.py index 52c0fb12b..67c589e06 100644 --- a/src/twinkle_agentic/rollout/__init__.py +++ b/src/twinkle_agentic/rollout/__init__.py @@ -1,6 +1,7 @@ # Copyright (c) ModelScope Contributors. All rights reserved. from .api_multi_turn import APIMultiTurnRollout from .base import Rollout +from .bridge import extend_with_bridge from .multi_turn import MultiTurnRollout from .multi_turn_condense import MultiTurnCondenseRollout @@ -9,4 +10,5 @@ 'MultiTurnCondenseRollout', 'MultiTurnRollout', 'Rollout', + 'extend_with_bridge', ] diff --git a/src/twinkle_agentic/rollout/bridge.py b/src/twinkle_agentic/rollout/bridge.py new file mode 100644 index 000000000..2663f9ed1 --- /dev/null +++ b/src/twinkle_agentic/rollout/bridge.py @@ -0,0 +1,156 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Shared, pure bridge-token stitching logic for multi-turn rollouts. + +This module hosts :func:`extend_with_bridge`, a ``self``-free function that +appends tool messages and the next generation prompt to a running +``InputFeature`` (``pif``) as ``-100`` "bridge" tokens. It is shared between +the core-library ``MultiTurnRollout`` and the client-side rollout so the two +paths cannot drift. + +The logic was lifted verbatim from ``MultiTurnRollout._extend_with_bridge`` and +``MultiTurnRollout._append_bridge_tokens``; every ``self.template`` access was +rewritten to use the ``template`` parameter. No Ray decorators +(``@remote_function`` / ``@remote_class``) are applied here. +""" +import numpy as np +from typing import Any, Dict, List, Optional + +from twinkle.template.base import Template + + +def _to_plain(obj: Any) -> Any: + """Recursively convert numpy arrays/scalars to plain Python lists/numbers. + + Mirrors ``vllm_sampler._convert_ndarray_to_list`` but lives locally so we + do not depend on a private symbol. + """ + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.bool_): + return bool(obj) + if isinstance(obj, dict): + return {k: _to_plain(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + conv = [_to_plain(x) for x in obj] + return type(obj)(conv) if isinstance(obj, tuple) else conv + return obj + + +def extend_with_bridge( + pif: Dict[str, Any], + tool_messages: List[Dict[str, Any]], + template: Template, +) -> Optional[Dict[str, Any]]: + """Append tool messages and the next generation prompt as -100 bridge. + + Strategy: compute the bridge ENTIRELY in template space. Render + ``messages_before`` and ``messages_before + tool_messages`` with the + same chat template and take ``s_after[len(s_before):]`` as the delta. + + We deliberately do NOT diff against ``tokenizer.decode(pif.input_ids)`` + because raw vLLM output and canonical template rendering differ in + whitespace (e.g. Qwen inserts ``\\n\\n`` between assistant content and + a ```` block, while the model generates only ``\\n``). Such + cosmetic divergences would break a ``startswith`` alignment but do not + affect training correctness: history tokens stay in ``pif.input_ids`` + verbatim; only the newly appended bridge is tokenized from the + canonical template output. + + Returns ``None`` when the trajectory exceeds ``max_length`` and the + template's truncation strategy is ``'delete'``. + """ + tokenizer = template.tokenizer + + messages_before = list(pif.get('messages') or []) + messages_after = messages_before + list(tool_messages) + + enable_thinking = getattr(template, 'enable_thinking', False) + s_before = tokenizer.apply_chat_template( + messages_before, tokenize=False, add_generation_prompt=False, enable_thinking=enable_thinking) + s_after = tokenizer.apply_chat_template( + messages_after, tokenize=False, add_generation_prompt=True, enable_thinking=enable_thinking) + + if not s_after.startswith(s_before): + raise RuntimeError('Canonical chat_template output for messages_after is not a ' + 'prefix-extension of messages_before; cannot compute bridge ' + 'delta. This indicates the template is non-monotonic in the ' + 'message list (e.g. reorders / rewrites earlier turns).\n' + f's_before tail: {s_before[-80:]!r}\n' + f's_after at same offset: ' + f'{s_after[max(0, len(s_before) - 80):len(s_before) + 80]!r}') + bridge_text = s_after[len(s_before):] + if not bridge_text: + raise RuntimeError('Bridge text computation returned empty string; ' + 'tool turn would add no tokens (template misconfiguration?).') + + bridge_ids = tokenizer.encode(bridge_text, add_special_tokens=False) + if not bridge_ids: + raise RuntimeError(f'Bridge text tokenised to empty id list: {bridge_text!r}') + + new_pif = _append_bridge_tokens(pif, bridge_ids, template) + if new_pif is None: + # Trajectory exceeds max_length and strategy is 'delete' + return None + new_pif['messages'] = messages_after + return new_pif + + +def _append_bridge_tokens( + pif: Dict[str, Any], + bridge_ids: List[int], + template: Template, +) -> Optional[Dict[str, Any]]: + """Append bridge tokens with labels = -100. + + Mirrors the unroll-append-reroll pattern of + :meth:`Template.concat_input_feature` so that ``labels`` semantics + stay consistent with the sampler-produced pif. + + Shallow copy is deliberately used: every mutation below is a + top-level key reassignment, never an in-place change to nested + tensors. Multimodal payloads (``images``, ``pixel_values``, + ``image_grid_thw`` ...) are shared by reference so we avoid + re-copying image buffers every turn. + """ + result = dict(pif) + + input_ids = list(result['input_ids']) + labels = list(result.get('labels') or []) + # labels arrive in output/shifted order (post _roll_labels). Unroll by + # one position (shift right by 1) to get back to input order. + if labels: + if len(labels) != len(input_ids): + raise RuntimeError(f'labels length ({len(labels)}) != input_ids length ' + f'({len(input_ids)}); cannot safely append bridge tokens.') + labels = labels[-1:] + labels[:-1] + else: + labels = [-100] * len(input_ids) + + input_ids = input_ids + list(bridge_ids) + labels = labels + [-100] * len(bridge_ids) + + result['input_ids'] = input_ids + result['labels'] = labels + + if 'mm_token_type_ids' in result: + import torch + mm = result['mm_token_type_ids'] + if not isinstance(mm, torch.Tensor): + mm = torch.as_tensor(mm) + # Pad along the last (sequence) dim — handles 1D [T] and 2D [1, T] uniformly. + leading_shape = mm.shape[:-1] + pad = torch.zeros((*leading_shape, len(bridge_ids)), dtype=mm.dtype, device=mm.device) + result['mm_token_type_ids'] = torch.cat([mm, pad], dim=-1) + + # Replay the post pipeline: refresh attention_mask / position_ids / + # length and re-roll labels back into output/shifted order. + refreshed_list = template._invoke_post_pipeline([result]) + if not refreshed_list: + # truncation_strategy='delete': trajectory exceeds max_length + return None + result.update(refreshed_list[0]) + return _to_plain(result) diff --git a/src/twinkle_agentic/rollout/multi_turn.py b/src/twinkle_agentic/rollout/multi_turn.py index 3b4035eda..c3c82dd58 100644 --- a/src/twinkle_agentic/rollout/multi_turn.py +++ b/src/twinkle_agentic/rollout/multi_turn.py @@ -12,6 +12,7 @@ from twinkle.template.base import Template from twinkle_agentic.tools.tool_manager import ToolManager from .base import Rollout +from .bridge import extend_with_bridge def _to_plain(obj: Any) -> Any: @@ -214,7 +215,7 @@ def __call__(self, trajectories: List[Trajectory], **kwargs) -> List[Trajectory] # outstanding tool turns. Done serially: bridge computation is # a cheap decode-diff-encode on python strings / token lists. for global_idx, tool_messages in pending_bridges: - extended = self._extend_with_bridge(pifs[global_idx], tool_messages) + extended = extend_with_bridge(pifs[global_idx], tool_messages, self.template) if extended is None: # Trajectory exceeded max_length, mark as done (deleted) truncated[global_idx] = True @@ -403,114 +404,3 @@ def _unwrap_response_list(resps, expected: int) -> List[SampleResponse]: if not r.sequences: raise RuntimeError(f'SampleResponse at batch index {i} has no sequences') return resps - - def _extend_with_bridge( - self, - pif: Dict[str, Any], - tool_messages: List[Dict[str, Any]], - ) -> Dict[str, Any]: - """Append tool messages and the next generation prompt as -100 bridge. - - Strategy: compute the bridge ENTIRELY in template space. Render - ``messages_before`` and ``messages_before + tool_messages`` with the - same chat template and take ``s_after[len(s_before):]`` as the delta. - - We deliberately do NOT diff against ``tokenizer.decode(pif.input_ids)`` - because raw vLLM output and canonical template rendering differ in - whitespace (e.g. Qwen inserts ``\\n\\n`` between assistant content and - a ```` block, while the model generates only ``\\n``). Such - cosmetic divergences would break a ``startswith`` alignment but do not - affect training correctness: history tokens stay in ``pif.input_ids`` - verbatim; only the newly appended bridge is tokenized from the - canonical template output. - """ - tokenizer = self.template.tokenizer - - messages_before = list(pif.get('messages') or []) - messages_after = messages_before + list(tool_messages) - - enable_thinking = getattr(self.template, 'enable_thinking', False) - s_before = tokenizer.apply_chat_template( - messages_before, tokenize=False, add_generation_prompt=False, enable_thinking=enable_thinking) - s_after = tokenizer.apply_chat_template( - messages_after, tokenize=False, add_generation_prompt=True, enable_thinking=enable_thinking) - - if not s_after.startswith(s_before): - raise RuntimeError('Canonical chat_template output for messages_after is not a ' - 'prefix-extension of messages_before; cannot compute bridge ' - 'delta. This indicates the template is non-monotonic in the ' - 'message list (e.g. reorders / rewrites earlier turns).\n' - f's_before tail: {s_before[-80:]!r}\n' - f's_after at same offset: ' - f'{s_after[max(0, len(s_before) - 80):len(s_before) + 80]!r}') - bridge_text = s_after[len(s_before):] - if not bridge_text: - raise RuntimeError('Bridge text computation returned empty string; ' - 'tool turn would add no tokens (template misconfiguration?).') - - bridge_ids = tokenizer.encode(bridge_text, add_special_tokens=False) - if not bridge_ids: - raise RuntimeError(f'Bridge text tokenised to empty id list: {bridge_text!r}') - - new_pif = self._append_bridge_tokens(pif, bridge_ids) - if new_pif is None: - # Trajectory exceeds max_length and strategy is 'delete' - return None - new_pif['messages'] = messages_after - return new_pif - - def _append_bridge_tokens( - self, - pif: Dict[str, Any], - bridge_ids: List[int], - ) -> Dict[str, Any]: - """Append bridge tokens with labels = -100. - - Mirrors the unroll-append-reroll pattern of - :meth:`Template.concat_input_feature` so that ``labels`` semantics - stay consistent with the sampler-produced pif. - - Shallow copy is deliberately used: every mutation below is a - top-level key reassignment, never an in-place change to nested - tensors. Multimodal payloads (``images``, ``pixel_values``, - ``image_grid_thw`` ...) are shared by reference so we avoid - re-copying image buffers every turn. - """ - result = dict(pif) - - input_ids = list(result['input_ids']) - labels = list(result.get('labels') or []) - # labels arrive in output/shifted order (post _roll_labels). Unroll by - # one position (shift right by 1) to get back to input order. - if labels: - if len(labels) != len(input_ids): - raise RuntimeError(f'labels length ({len(labels)}) != input_ids length ' - f'({len(input_ids)}); cannot safely append bridge tokens.') - labels = labels[-1:] + labels[:-1] - else: - labels = [-100] * len(input_ids) - - input_ids = input_ids + list(bridge_ids) - labels = labels + [-100] * len(bridge_ids) - - result['input_ids'] = input_ids - result['labels'] = labels - - if 'mm_token_type_ids' in result: - import torch - mm = result['mm_token_type_ids'] - if not isinstance(mm, torch.Tensor): - mm = torch.as_tensor(mm) - # Pad along the last (sequence) dim — handles 1D [T] and 2D [1, T] uniformly. - leading_shape = mm.shape[:-1] - pad = torch.zeros((*leading_shape, len(bridge_ids)), dtype=mm.dtype, device=mm.device) - result['mm_token_type_ids'] = torch.cat([mm, pad], dim=-1) - - # Replay the post pipeline: refresh attention_mask / position_ids / - # length and re-roll labels back into output/shifted order. - refreshed_list = self.template._invoke_post_pipeline([result]) - if not refreshed_list: - # truncation_strategy='delete': trajectory exceeds max_length - return None - result.update(refreshed_list[0]) - return _to_plain(result) diff --git a/src/twinkle_client/dataloader/__init__.py b/src/twinkle_client/dataloader/__init__.py index 341d0b776..b94ed5b8d 100644 --- a/src/twinkle_client/dataloader/__init__.py +++ b/src/twinkle_client/dataloader/__init__.py @@ -1,11 +1 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from .dataloader import DataLoader diff --git a/src/twinkle_client/dataloader/dataloader.py b/src/twinkle_client/dataloader/dataloader.py index 4164940dd..86e2bbf42 100644 --- a/src/twinkle_client/dataloader/dataloader.py +++ b/src/twinkle_client/dataloader/dataloader.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Callable, Type, Union from twinkle_client.http import http_post diff --git a/src/twinkle_client/dataset/__init__.py b/src/twinkle_client/dataset/__init__.py index ad90b90aa..ba37b1fe5 100644 --- a/src/twinkle_client/dataset/__init__.py +++ b/src/twinkle_client/dataset/__init__.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from .base import Dataset from .iterable_dataset import IterableDataset from .iterable_packing_dataset import IterablePackingDataset diff --git a/src/twinkle_client/dataset/base.py b/src/twinkle_client/dataset/base.py index 845b11cb8..bec2d4309 100644 --- a/src/twinkle_client/dataset/base.py +++ b/src/twinkle_client/dataset/base.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Any, Callable, Dict, Optional, Type, Union from twinkle_client.http import http_post diff --git a/src/twinkle_client/dataset/iterable_dataset.py b/src/twinkle_client/dataset/iterable_dataset.py index 95ba9e447..0646ea33b 100644 --- a/src/twinkle_client/dataset/iterable_dataset.py +++ b/src/twinkle_client/dataset/iterable_dataset.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from twinkle_client.http import http_post from twinkle.dataset import Dataset diff --git a/src/twinkle_client/dataset/iterable_packing_dataset.py b/src/twinkle_client/dataset/iterable_packing_dataset.py index f774090f2..f4b6b5d1e 100644 --- a/src/twinkle_client/dataset/iterable_packing_dataset.py +++ b/src/twinkle_client/dataset/iterable_packing_dataset.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Type, Union from twinkle_client.http import http_post diff --git a/src/twinkle_client/dataset/lazy_dataset.py b/src/twinkle_client/dataset/lazy_dataset.py index c3b70c85f..7bf49c70e 100644 --- a/src/twinkle_client/dataset/lazy_dataset.py +++ b/src/twinkle_client/dataset/lazy_dataset.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Any, Callable, Dict, Optional, Type, Union from twinkle_client.http import http_post diff --git a/src/twinkle_client/dataset/packing_dataset.py b/src/twinkle_client/dataset/packing_dataset.py index 1f7ff066d..855777674 100644 --- a/src/twinkle_client/dataset/packing_dataset.py +++ b/src/twinkle_client/dataset/packing_dataset.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from twinkle_client.http import http_post from twinkle.dataset import Dataset diff --git a/src/twinkle_client/model/__init__.py b/src/twinkle_client/model/__init__.py index 507cc4cbe..94e3538af 100644 --- a/src/twinkle_client/model/__init__.py +++ b/src/twinkle_client/model/__init__.py @@ -1,11 +1 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from .multi_lora_transformers import MultiLoraTransformersModel diff --git a/src/twinkle_client/model/multi_lora_transformers.py b/src/twinkle_client/model/multi_lora_transformers.py index 2508bbded..c628c353b 100644 --- a/src/twinkle_client/model/multi_lora_transformers.py +++ b/src/twinkle_client/model/multi_lora_transformers.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Any, Dict, Optional from pathlib import Path import time diff --git a/src/twinkle_client/processor/__init__.py b/src/twinkle_client/processor/__init__.py index 1f8acd8fc..677da196c 100644 --- a/src/twinkle_client/processor/__init__.py +++ b/src/twinkle_client/processor/__init__.py @@ -1,11 +1 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from .base import InputProcessor diff --git a/src/twinkle_client/processor/base.py b/src/twinkle_client/processor/base.py index 048ace5e3..377865274 100644 --- a/src/twinkle_client/processor/base.py +++ b/src/twinkle_client/processor/base.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import List, Literal, Optional, Union from twinkle_client.http import http_post diff --git a/src/twinkle_client/rollout/__init__.py b/src/twinkle_client/rollout/__init__.py new file mode 100644 index 000000000..66e393392 --- /dev/null +++ b/src/twinkle_client/rollout/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +from .multi_turn import ClientMultiTurnRollout + +__all__ = ['ClientMultiTurnRollout'] diff --git a/src/twinkle_client/rollout/multi_turn.py b/src/twinkle_client/rollout/multi_turn.py new file mode 100644 index 000000000..cd42bafd9 --- /dev/null +++ b/src/twinkle_client/rollout/multi_turn.py @@ -0,0 +1,289 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Client-side multi-turn agentic rollout orchestration. + +This module hosts :class:`ClientMultiTurnRollout`, a hand-maintained multi-turn +rollout orchestrator whose algorithmic structure mirrors +``twinkle_agentic.rollout.multi_turn.MultiTurnRollout`` but issues sampling over +HTTP via ``twinkle_client.sampler.vLLMSampler.sample()`` instead of holding a +Ray actor handle. + +Design notes: + * It deliberately does NOT subclass ``MultiTurnRollout``. That class is + decorated with ``@remote_class`` / ``@remote_function`` for Ray remote + dispatch and assumes ``self.sampler`` is a Ray actor handle, which does + not match the HTTP-client semantics here. + * The ``tool_manager`` type is reused directly from + ``twinkle_agentic.tools.tool_manager.ToolManager`` (imported, not copied). + * Bridge-token stitching is reused from + ``twinkle_agentic.rollout.bridge.extend_with_bridge`` (see task 8.2). +""" +import dataclasses +from typing import Any, Dict, List, Optional + +from twinkle.data_format import Trajectory +from twinkle.data_format.sampling import SamplingParams +from twinkle.template.base import Template +from twinkle_agentic.rollout.bridge import extend_with_bridge +from twinkle_agentic.tools.tool_manager import ToolManager +from twinkle_client.sampler import vLLMSampler + + +class ClientMultiTurnRollout: + """Agentic multi-turn rollout with tool use, driven over HTTP. + + Mirrors the per-trajectory state machine of + ``twinkle_agentic.rollout.multi_turn.MultiTurnRollout`` but issues sampling + via ``vLLMSampler.sample()`` (an HTTP call to ``/twinkle/sample``) rather + than a Ray actor call. + """ + + def __init__( + self, + sampler: vLLMSampler, + template: Template, + tool_manager: Optional[ToolManager] = None, + sampling_params: Optional[SamplingParams] = None, + max_turns: int = 6, + max_trajectory_tokens: Optional[int] = None, + ): + # Validation aligned with MultiTurnRollout.__init__. + if template is None: + raise ValueError('ClientMultiTurnRollout requires a local Template instance') + if max_turns < 1: + raise ValueError(f'max_turns must be >= 1, got {max_turns}') + if max_trajectory_tokens is not None and max_trajectory_tokens < 1: + raise ValueError(f'max_trajectory_tokens must be >= 1 or None, got ' + f'{max_trajectory_tokens}') + + self.sampler = sampler + self.template = template + self.tool_manager = tool_manager + self.sampling_params = sampling_params or SamplingParams() + self.max_turns = max_turns + self.max_trajectory_tokens = max_trajectory_tokens + + if self.sampling_params.num_samples != 1: + raise ValueError(f'ClientMultiTurnRollout currently supports num_samples=1 only, ' + f'got {self.sampling_params.num_samples}') + + def __call__(self, trajectories: List[Trajectory], **kwargs) -> List[Trajectory]: + """Run the batched multi-turn rollout state machine over HTTP. + + Structurally mirrors ``MultiTurnRollout.__call__`` but issues each + round's sampling through ``vLLMSampler.sample()`` (an HTTP POST to + ``/twinkle/sample``) rather than a Ray actor call. Every round makes a + SINGLE batched HTTP call for all currently-live trajectories so the + sampler can run them in parallel; finished trajectories are parked and + excluded from later batches. + + Returns a ``List[Trajectory]`` of the same length and order as the + input, each augmented with ``messages`` / ``logprobs`` / ``turns`` / + ``stop_reason`` / ``truncated`` fields. + + Exception handling and boundary truncation contract: + * ``new_input_feature`` missing / lacking ``input_ids`` -> RuntimeError + carrying both the batch index and the trajectory index. + * per-round ``len(seq.logprobs) != len(seq.tokens)`` -> RuntimeError + carrying the specific counts. + * final per-trajectory ``len(all_logprobs[i]) != count(labels != -100)`` + -> RuntimeError (protects downstream GRPO old_logps alignment). + * ``vLLMSampler.sample()`` network/timeout errors propagate unchanged + (never wrapped or swallowed). + * tool_calls produced with no ``tool_manager`` -> ValueError. + * ``max_turns == 1`` with a first-round tool call -> the trajectory is + marked ``truncated=True, stop_reason='max_turns'`` and sampling stops. + """ + if isinstance(trajectories, dict): + raise TypeError('ClientMultiTurnRollout.__call__ expects a List[Trajectory]; ' + 'wrap a single trajectory as [trajectory].') + trajectories = list(trajectories) + n = len(trajectories) + if n == 0: + return [] + + sampling_params = self._as_sampling_params_dict( + kwargs.get('sampling_params', self.sampling_params)) + tool_managers = self._resolve_tool_managers( + kwargs.get('tool_manager', self.tool_manager), n) + + # 1. Encode each trajectory once; ``pifs[i]`` is the live per-turn + # state for trajectory ``i``. + pifs: List[Dict[str, Any]] = [] + for traj in trajectories: + pif = self.template.encode(traj, add_generation_prompt=True) + pif.setdefault('messages', list(traj.get('messages', []))) + pifs.append(pif) + + all_logprobs: List[List[Any]] = [[] for _ in range(n)] + stop_reasons: List[Optional[str]] = [None] * n + turns: List[int] = [0] * n + truncated: List[bool] = [False] * n + done: List[bool] = [False] * n + + for _ in range(self.max_turns): + active = [i for i in range(n) if not done[i]] + if not active: + break + + # 2. One batched HTTP sample call for all currently-live + # trajectories. No device_mesh / min_batch_size padding: an HTTP + # client has no Ray DP ranks to align against. + # + # Passthrough contract: ``vLLMSampler.sample()`` may raise + # network / timeout / HTTP errors (e.g. requests exceptions). We + # deliberately do NOT wrap this call in try/except -- such errors + # propagate unchanged to the caller so ret/backoff policy stays an + # upstream concern (retry/backoff) and failures are never + # silently swallowed. + batch_pifs = [pifs[i] for i in active] + resps = self.sampler.sample(batch_pifs, sampling_params=sampling_params) + + pending_bridges: List[tuple] = [] # (global_idx, tool_messages) + for local_idx, global_idx in enumerate(active): + turns[global_idx] += 1 + seq = resps[local_idx].sequences[0] + + # ``new_input_feature`` is the running pif for the next round; + # the /twinkle/sample response contract guarantees it is set and + # carries ``input_ids``. A missing feature makes the next round + # impossible, so raise a batch/trajectory-indexed RuntimeError. + if seq.new_input_feature is None or 'input_ids' not in seq.new_input_feature: + raise RuntimeError( + f'Sampler returned a sequence without new_input_feature.input_ids at ' + f'batch index {local_idx} (trajectory {global_idx}); ' + f'cannot continue multi-turn.') + + pifs[global_idx] = dict(seq.new_input_feature) + # Per-round logprobs/token alignment guard: each sampled token + # must carry exactly one logprob entry. Mirrors the core-lib + # ``len(seq.logprobs) != len(seq.tokens)`` semantic so client and + # Ray paths cannot drift on this invariant. + if seq.logprobs is not None: + if len(seq.logprobs) != len(seq.tokens): + raise RuntimeError( + f'logprobs length ({len(seq.logprobs)}) does not match sampled ' + f'token count ({len(seq.tokens)}) at turn {turns[global_idx]} ' + f'(trajectory {global_idx})') + all_logprobs[global_idx].extend(seq.logprobs) + stop_reasons[global_idx] = seq.stop_reason + + # 3. Termination conditions. + if seq.stop_reason == 'length': + done[global_idx] = True + continue + + # 3a. Sequence-length cap. + if (self.max_trajectory_tokens is not None and len( + pifs[global_idx].get('input_ids') or []) >= self.max_trajectory_tokens): + truncated[global_idx] = True + done[global_idx] = True + continue + + # 3b. Parse tool calls from the freshly sampled assistant turn. + _msgs = pifs[global_idx].get('messages') or [] + _last_msg = _msgs[-1] if _msgs else None + tool_calls = (_last_msg.get('tool_calls') if isinstance(_last_msg, dict) else None) + if not tool_calls: + tool_calls = self.template.parse_tool_call(seq.decoded or '') + if not tool_calls: + done[global_idx] = True + continue + + # 3c. Hit the turn cap while still wanting to call a tool: force + # truncation. Also covers the ``max_turns == 1`` edge (task + # 8.3), where the very first sampled turn trips this branch. + if turns[global_idx] >= self.max_turns: + truncated[global_idx] = True + stop_reasons[global_idx] = 'max_turns' + done[global_idx] = True + continue + + # 4. Dispatch tools for this trajectory via its ToolManager. + tool_manager = tool_managers[global_idx] + if tool_manager is None: + raise ValueError( + f'trajectory {global_idx} produced tool_calls but no tool_manager ' + f'was provided (at construction time or as a per-call kwarg).') + tool_messages = [{ + 'role': 'tool', + 'content': tool_manager(tc), + } for tc in tool_calls] + pending_bridges.append((global_idx, tool_messages)) + + # Stitch bridge tokens (tool turns + next generation prompt) for + # every trajectory with outstanding tool turns. Reuses the shared + # pure function so client and core-lib paths cannot drift. + for global_idx, tool_messages in pending_bridges: + extended = extend_with_bridge(pifs[global_idx], tool_messages, self.template) + if extended is None: + # Trajectory exceeded max_length (truncation strategy 'delete'). + truncated[global_idx] = True + done[global_idx] = True + else: + pifs[global_idx] = extended + + # 4b. Final logprobs/labels alignment guard. For every trajectory that + # collected logprobs, the total logprob count must equal the number + # of trainable positions (labels != -100) in the final pif. This is + # the same invariant grpo._pad_and_align_to_batch relies on; a + # mismatch would silently corrupt GRPO old_logps alignment, so we + # fail loudly with the specific numbers. + for i in range(n): + if not all_logprobs[i]: + continue + labels_i = pifs[i].get('labels') or [] + trainable_i = sum(1 for label in labels_i if label != -100) + if len(all_logprobs[i]) != trainable_i: + raise RuntimeError(f'logprobs/labels misaligned for trajectory {i}: ' + f'{len(all_logprobs[i])} logprobs vs {trainable_i} ' + f'trainable labels (labels != -100). This invariant is ' + f'required by grpo._pad_and_align_to_batch; a mismatch ' + f'would silently corrupt GRPO old_logps alignment.') + + # 5. Merge pif fields into each trajectory dict at TOP LEVEL, preserving + # input length and order. + outs: List[Trajectory] = [] + for i, traj in enumerate(trajectories): + out = dict(traj) + out.update(pifs[i]) + out['messages'] = list(pifs[i].get('messages') or out.get('messages', [])) + out['logprobs'] = all_logprobs[i] if all_logprobs[i] else None + out['turns'] = turns[i] + out['stop_reason'] = stop_reasons[i] + out['truncated'] = truncated[i] + outs.append(out) + return outs + + # ------------------------------------------------------------------ private + + @staticmethod + def _as_sampling_params_dict(sampling_params) -> Optional[Dict[str, Any]]: + """Coerce ``sampling_params`` into the ``Optional[Dict]`` that + ``vLLMSampler.sample()`` expects. + + ``self.sampling_params`` is a core-lib :class:`SamplingParams` dataclass, + while the HTTP sampler wants a plain dict. A per-call kwarg override may + be either a dataclass or an already-built dict. + """ + if sampling_params is None: + return None + if isinstance(sampling_params, dict): + return sampling_params + if dataclasses.is_dataclass(sampling_params): + return dataclasses.asdict(sampling_params) + return sampling_params + + @staticmethod + def _resolve_tool_managers(arg, n: int) -> List[Optional[ToolManager]]: + """Broadcast a single ``ToolManager`` or validate a per-trajectory list. + + Unlike the core-lib rollout, ``None`` is tolerated here and broadcast as + ``[None] * n``; the ValueError is raised lazily at the tool-dispatch site + only when a trajectory actually produces tool_calls (requirement 3.10). + """ + if isinstance(arg, list): + if len(arg) != n: + raise ValueError(f'per-call tool_manager list length ({len(arg)}) does ' + f'not match number of trajectories ({n})') + return list(arg) + return [arg] * n diff --git a/src/twinkle_client/sampler/__init__.py b/src/twinkle_client/sampler/__init__.py index 724d41ef7..06b961b3c 100644 --- a/src/twinkle_client/sampler/__init__.py +++ b/src/twinkle_client/sampler/__init__.py @@ -1,11 +1 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from .vllm_sampler import vLLMSampler diff --git a/src/twinkle_client/sampler/vllm_sampler.py b/src/twinkle_client/sampler/vllm_sampler.py index 610163b5c..270da0674 100644 --- a/src/twinkle_client/sampler/vllm_sampler.py +++ b/src/twinkle_client/sampler/vllm_sampler.py @@ -1,13 +1,3 @@ -# ============================================================================ -# WARNING: AUTO-GENERATED FILE - DO NOT MODIFY MANUALLY! -# ============================================================================ -# This file is automatically generated by client_tools/client_generator.py -# Any manual changes will be overwritten when the generator runs again. -# -# To update this file: -# 1. Modify the source files in src/twinkle/ -# 2. Run: python client_tools/client_generator.py -# ============================================================================ from typing import Any, Dict, List, Optional, Union from twinkle_client.http import http_post from twinkle_client.types.sampler import AddAdapterResponse, SampleResponseModel, SetTemplateResponse diff --git a/tests/server/gateway/test_api_multi_turn_rollout_no_logprobs.py b/tests/server/gateway/test_api_multi_turn_rollout_no_logprobs.py new file mode 100644 index 000000000..76b68cdf0 --- /dev/null +++ b/tests/server/gateway/test_api_multi_turn_rollout_no_logprobs.py @@ -0,0 +1,261 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""GPU-gated E2E: ``APIMultiTurnRollout`` over the Gateway ``/chat/completions`` +endpoint proves the returned Trajectory carries NO ``logprobs`` field. + +Purpose +------- +This is the "generation-only, not trainable" counterpart to the trainable +``ClientMultiTurnRollout`` path validated in task 9.2. It drives the SAME +tool-calling scenario, but through the OpenAI-compatible route: + + OpenAI client -> Gateway POST /chat/completions -> /twinkle/sample + +and asserts that the resulting Trajectory does **not** contain a ``logprobs`` +field (or that it is ``None``/absent). The OpenAI chat-completions protocol only +surfaces assistant ``content`` / ``tool_calls`` / ``finish_reason`` — it never +carries the token-level ``logprobs`` + ``new_input_feature`` alignment info that +GRPO training requires. This test locks in the accuracy of that limitation +statement (Requirement 5.2 / 5.5): the Gateway indirection is fine for +generation/evaluation but cannot feed token-aligned RL training. + +What is "real" here +------------------- + * REAL (GPU): the running Twinkle server (gateway + real vLLM sampler for + ``Qwen/Qwen3.5-4B``), the ``/chat/completions`` -> ``/twinkle/sample`` hop, + and the ``openai`` pip client issuing actual HTTP requests. + * ``APIMultiTurnRollout`` + a small ``ToolManager`` (calculator tool) drive + the multi-turn control flow client-side. + +============================================================================== +GPU + CONDA ENVIRONMENT REQUIREMENT (MANDATORY) +============================================================================== +This case requires a GPU and an already-running GPU server (see +tests/server/start_e2e_server.py). It is gated behind ``TWINKLE_TEST_GPU_E2E=1`` +and SKIPS cleanly on machines without a GPU (e.g. local dev). Run on GPU CI in +the ``twinkle`` conda env: + + TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest \ + tests/server/gateway/test_api_multi_turn_rollout_no_logprobs.py -v + +Locally (no GPU / variable unset) it collects and skips without error: + + conda run -n twinkle pytest \ + tests/server/gateway/test_api_multi_turn_rollout_no_logprobs.py -v +============================================================================== +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict + +# Ensure project root is importable for both pytest and direct execution. +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import pytest + +# Reuse the shared GPU-e2e gate and server coordinates. +from tests.server.integration.e2e_helpers import ( + API_KEY, + BASE_MODEL, + BASE_URL, + log, + wait_for_server, +) +from tests.server.test_embedding_e2e import gpu_e2e_enabled + +# The gateway (OpenAI-compatible) routes are mounted under the ``server`` app's +# route prefix ``/api/v1`` (see tests/server/config/server_config_4b_e2e.yaml). +# The ``openai`` client appends ``/chat/completions`` to this base URL. +GATEWAY_BASE_URL = f'{BASE_URL}/api/v1' + +# Model/adapter name routed by the gateway. The e2e config serves the base model +# ``Qwen/Qwen3.5-4B`` directly. +GATEWAY_MODEL = BASE_MODEL + + +# ═══════════════════════════════════════════════════════════════════════════ +# Tool scenario (mirrors the 9.2 tool-calling scenario): a small calculator. +# ═══════════════════════════════════════════════════════════════════════════ +TOOL_NAME = 'calculator' + + +def _make_tool_manager(): + """Build a ToolManager with a single deterministic calculator tool.""" + from twinkle_agentic.tools.base import Tool + from twinkle_agentic.tools.tool_manager import ToolManager + + class CalculatorTool(Tool): + """Adds/subtracts/multiplies two integers; returns the result as text.""" + + def __call__(self, tool_name: str, arguments: Dict[str, Any]) -> str: + a = int(arguments.get('a', 0)) + b = int(arguments.get('b', 0)) + op = arguments.get('operation', 'add') + if op == 'add': + return str(a + b) + if op == 'subtract': + return str(a - b) + if op == 'multiply': + return str(a * b) + return f'Error: unknown operation {op!r}' + + def tool_info(self): + return { + 'type': 'function', + 'function': { + 'name': TOOL_NAME, + 'description': 'Compute a + b, a - b, or a * b for two integers.', + 'parameters': { + 'type': 'object', + 'properties': { + 'a': {'type': 'integer', 'description': 'first operand'}, + 'b': {'type': 'integer', 'description': 'second operand'}, + 'operation': { + 'type': 'string', + 'enum': ['add', 'subtract', 'multiply'], + 'description': 'the arithmetic operation', + }, + }, + 'required': ['a', 'b', 'operation'], + }, + }, + } + + mgr = ToolManager({}) + mgr.register(CalculatorTool()) + return mgr + + +def _make_trajectory() -> Dict[str, Any]: + """A single math trajectory that nudges the model toward the calculator tool.""" + return { + 'messages': [ + { + 'role': 'system', + 'content': ('You are a precise assistant. When a calculation is needed, ' + 'call the calculator tool instead of computing yourself.'), + }, + {'role': 'user', 'content': 'What is 123 multiplied by 7? Use the calculator tool.'}, + ], + } + + +def _build_rollout(max_turns: int = 3): + """Construct an APIMultiTurnRollout wired to the Gateway /chat/completions.""" + from twinkle.data_format.sampling import SamplingParams + from twinkle_agentic.protocol.openai import OpenAI + from twinkle_agentic.rollout import APIMultiTurnRollout + + api = OpenAI(model=GATEWAY_MODEL, api_key=API_KEY, base_url=GATEWAY_BASE_URL) + # temperature=0 keeps the control flow as reproducible as the server allows. + sampling_params = SamplingParams(num_samples=1, temperature=0.0, max_tokens=256) + return APIMultiTurnRollout( + api=api, + tool_manager=_make_tool_manager(), + sampling_params=sampling_params, + max_turns=max_turns, + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Fixtures +# ═══════════════════════════════════════════════════════════════════════════ +@pytest.fixture(scope='module') +def gpu_gateway_ready(): + """Ensure a GPU server is up before running; skip cleanly when GPU e2e is off.""" + if not gpu_e2e_enabled(): + pytest.skip('Set TWINKLE_TEST_GPU_E2E=1 to run the Gateway APIMultiTurnRollout ' + 'no-logprobs E2E (requires a running GPU server)') + wait_for_server() + yield + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 9.3 — APIMultiTurnRollout via Gateway: Trajectory carries NO logprobs. +# ═══════════════════════════════════════════════════════════════════════════ +def test_api_multi_turn_rollout_trajectory_has_no_logprobs(gpu_gateway_ready): + """The Gateway /chat/completions path yields Trajectories without logprobs. + + Runs the same tool-calling scenario as task 9.2 but through + ``APIMultiTurnRollout`` + an OpenAI-compatible client pointed at the Gateway. + Asserts the returned Trajectory does NOT expose a ``logprobs`` field (absent + or ``None``), confirming the "generation-only, not trainable" limitation is + accurate: token-level alignment info never crosses the OpenAI protocol. + + GPU-gated (TWINKLE_TEST_GPU_E2E=1 + running GPU server); skipped locally. + + Validates: Requirements 5.2, 5.5, 7.2 + """ + rollout = _build_rollout(max_turns=3) + trajectories = [_make_trajectory()] + + outs = rollout(trajectories) + + # Same length/order contract the multi-turn rollouts guarantee. + assert len(outs) == 1 + out = outs[0] + log(f'[api-rollout] stop_reason={out.get("stop_reason")} turns={out.get("turns")}') + + # Control flow terminated within the APIMultiTurnRollout stop-reason vocabulary. + assert out.get('stop_reason') in {'stop', 'length', 'max_turns', 'api_error'}, out.get('stop_reason') + assert isinstance(out.get('turns'), int) and out['turns'] >= 1 + + # A functional run should not have surfaced an API error. + assert out.get('stop_reason') != 'api_error', f"unexpected api_error: {out.get('error')!r}" + + # Core assertion (Requirement 5.2 / 5.5): NO trainable token-level logprobs. + assert out.get('logprobs') is None, ( + f'Gateway APIMultiTurnRollout Trajectory unexpectedly carries logprobs: ' + f'{out.get("logprobs")!r}. The OpenAI /chat/completions path is ' + f'generation-only and must not expose token-level logprobs.') + + # The per-message conversation also must not smuggle token-level logprobs + # into any assistant turn (only content / tool_calls / finish_reason are set). + for msg in out.get('messages', []): + assert 'logprobs' not in msg, ( + f'assistant/tool message unexpectedly carries logprobs: {msg!r}') + + +def test_api_multi_turn_rollout_tool_call_scenario_shape(gpu_gateway_ready): + """The tool-calling scenario produces a well-formed multi-turn conversation. + + Complements the no-logprobs assertion by checking the conversation shape: + messages accumulate across turns and, when the model invokes the calculator, + a matching ``role='tool'`` message is stitched back in. This mirrors the 9.2 + scenario so the two paths are compared on the same workload. Tool invocation + itself is model-dependent, so it is asserted conditionally. + + Validates: Requirements 5.2, 5.5, 7.2 + """ + rollout = _build_rollout(max_turns=3) + + outs = rollout([_make_trajectory()]) + out = outs[0] + + messages = out.get('messages', []) + # The full conversation includes at least the seed system+user turns plus one + # assistant reply. + assert len(messages) >= 3, messages + assert messages[0]['role'] == 'system' + assert messages[1]['role'] == 'user' + assert any(m['role'] == 'assistant' for m in messages) + + # If the model emitted a tool call, a paired tool result must follow it, and + # the calculator's deterministic answer (123 * 7 = 861) should appear. + assistant_with_tool = next( + (m for m in messages if m['role'] == 'assistant' and m.get('tool_calls')), None) + if assistant_with_tool is not None: + tool_msgs = [m for m in messages if m['role'] == 'tool'] + assert tool_msgs, 'assistant emitted tool_calls but no tool result was stitched back' + # Whichever calculator call was made, its numeric result is plain text + # (no logprobs), reinforcing the generation-only contract. + for tm in tool_msgs: + assert isinstance(tm.get('content'), str) + assert 'logprobs' not in tm + + # Regardless of tool invocation, the no-logprobs invariant still holds. + assert out.get('logprobs') is None diff --git a/tests/server/integration/test_client_multi_turn_rollout_e2e.py b/tests/server/integration/test_client_multi_turn_rollout_e2e.py new file mode 100644 index 000000000..b063f3ac7 --- /dev/null +++ b/tests/server/integration/test_client_multi_turn_rollout_e2e.py @@ -0,0 +1,597 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Behavioural-alignment E2E: ``ClientMultiTurnRollout`` vs bare ``MultiTurnRollout``. + +This module validates that the client-side, HTTP-driven +:class:`twinkle_client.rollout.multi_turn.ClientMultiTurnRollout` produces the +SAME multi-turn control flow as the bare-library, Ray-actor-driven +:class:`twinkle_agentic.rollout.multi_turn.MultiTurnRollout` when both are pointed +at the *same real sampler weights* (Qwen3.5-4B) with the same prompt, the same +tools, and greedy decoding (``temperature=0``). + +Why this must be GPU-gated +--------------------------- +Real numeric semantics (actual token sampling + per-token ``logprobs`` on real +model weights) cannot be reproduced by the CPU-only mock sampler used in the +local mock E2E (task 9.1). Two things in particular are only observable with a +real sampler and are therefore asserted here under GPU gating: + + * The two rollout paths agree on the ``messages`` structure and on *when* + tool calls are triggered (greedy decoding makes the control flow + deterministic across both transports, even though we allow token-level + non-determinism in principle). + * ``logprobs`` are actually populated, so the strict invariant + ``len(logprobs) == count(labels != -100)`` can be checked per trajectory on + both paths (the mock path cannot exercise real logprobs numerics). + +Topology on GPU CI +------------------ + * CLIENT path: connects to an ALREADY-RUNNING Twinkle e2e server (start it + first with ``tests/server/start_e2e_server.py``, which serves + ``tests/server/config/server_config_4b_e2e.yaml`` including the + ``sampler-Qwen3.5-4B`` application). Sampling goes over HTTP through + :class:`twinkle_client.sampler.vLLMSampler`. This mirrors the convention of + the GPU cases in ``tests/server/test_embedding_e2e.py``. + * BARE path: builds its OWN bare-library Ray-actor + :class:`twinkle.sampler.vLLMSampler` (``remote_group='sampler'`` + + ``DeviceMesh``) in-process, mirroring the standalone GPU sampler tests + (``tests/sampler/test_weight_sync.py``, ``tests/sampler/align_swift.py``) + and the multi-turn cookbook (``cookbook/rl/multi_turn/multi_turn_grpo.py``). + This is what the task means by "directly holding the corresponding Ray actor + sampler". The runner must therefore provision enough GPUs for BOTH the + server's sampler and this in-test bare sampler. + +============================================================================== +CONDA ENVIRONMENT REQUIREMENT (MANDATORY) — GPU REQUIRED +============================================================================== +This whole file is gated behind ``TWINKLE_TEST_GPU_E2E=1`` and is skipped +automatically on machines without a GPU (so it collects/skips cleanly during +local, CPU-only development). Run it inside the ``twinkle`` conda env on a GPU +host, with the e2e server already running: + + # 1) start the server (separate shell) + conda run -n twinkle python tests/server/start_e2e_server.py + + # 2) run the alignment test + TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest \ + tests/server/integration/test_client_multi_turn_rollout_e2e.py -v +============================================================================== +""" +from __future__ import annotations + +import copy +import json +import os +import sys +from typing import Any, Dict, List, Optional + +# Ensure project root is importable for both pytest and direct execution. +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import pytest + +from twinkle.data_format.sampling import SamplingParams +from twinkle_agentic.tools.base import Tool +from twinkle_agentic.tools.tool_manager import ToolManager + +# Reuse the shared GPU e2e helpers (running-server URL, session init, model id). +from tests.server.integration.e2e_helpers import ( + MODEL_ID, + init_twinkle_client_session, + log, + wait_for_server, +) + + +# ═══════════════════════════════════════════════════════════════════════════ +# GPU gating +# +# Mirrors the ``gpu_e2e_enabled`` gate used by tests/server/test_embedding_e2e.py. +# Defined locally (not imported) so this file collects/skips cleanly even if the +# embedding e2e module is being edited concurrently. +# ═══════════════════════════════════════════════════════════════════════════ +def gpu_e2e_enabled() -> bool: + """Return True only when GPU e2e tests are explicitly enabled.""" + return os.environ.get('TWINKLE_TEST_GPU_E2E', '0') == '1' + + +# ═══════════════════════════════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════════════════════════════ + +# Greedy decoding (temperature=0) makes both transports produce the SAME token +# stream for the SAME weights, so the multi-turn control flow is deterministic +# and directly comparable. ``logprobs=1`` forces per-token logprobs so the +# strict logprobs/trainable-label alignment can be asserted on both paths. +GREEDY_MAX_TOKENS = 128 +GREEDY_SEED = 0 + +# 3-6 rounds as required by the task. +ALIGN_MAX_TURNS = 6 + +# Template used locally by BOTH rollouts for encode / parse_tool_call / bridge. +TEMPLATE_CLS = 'Qwen3_5Template' + + +# ═══════════════════════════════════════════════════════════════════════════ +# A tiny ToolManager with 1-2 simple, deterministic tools (echo + calculator). +# ═══════════════════════════════════════════════════════════════════════════ +class EchoTool(Tool): + """Echoes its arguments back as a JSON string (deterministic).""" + + NAME = 'echo' + + def __call__(self, tool_name: str, arguments: Dict[str, Any]) -> str: + return f'echo[{tool_name}]:{json.dumps(arguments, sort_keys=True, ensure_ascii=False)}' + + def tool_info(self): + return { + 'type': 'function', + 'function': { + 'name': self.NAME, + 'description': 'Echo the given text back verbatim.', + 'parameters': { + 'type': 'object', + 'properties': {'text': {'type': 'string', 'description': 'Text to echo.'}}, + 'required': ['text'], + }, + }, + } + + +class CalculatorTool(Tool): + """Evaluates ``a b`` for the four basic operators (deterministic).""" + + NAME = 'calculator' + _OPS = {'add': lambda a, b: a + b, 'sub': lambda a, b: a - b, + 'mul': lambda a, b: a * b, 'div': lambda a, b: (a / b if b else float('inf'))} + + def __call__(self, tool_name: str, arguments: Dict[str, Any]) -> str: + op = str(arguments.get('op', 'add')) + a = float(arguments.get('a', 0)) + b = float(arguments.get('b', 0)) + fn = self._OPS.get(op, self._OPS['add']) + return json.dumps({'result': fn(a, b)}, ensure_ascii=False) + + def tool_info(self): + return { + 'type': 'function', + 'function': { + 'name': self.NAME, + 'description': 'Compute a basic arithmetic operation on two numbers.', + 'parameters': { + 'type': 'object', + 'properties': { + 'op': {'type': 'string', 'enum': ['add', 'sub', 'mul', 'div']}, + 'a': {'type': 'number'}, + 'b': {'type': 'number'}, + }, + 'required': ['op', 'a', 'b'], + }, + }, + } + + +def make_tool_manager() -> ToolManager: + """Build a small ToolManager holding the echo + calculator tools.""" + mgr = ToolManager({}) + mgr.register(EchoTool()) + mgr.register(CalculatorTool()) + return mgr + + +def tool_schema() -> List[Dict[str, Any]]: + """Tool schema advertised to the model in the trajectory ``tools`` field.""" + return [EchoTool().tool_info(), CalculatorTool().tool_info()] + + +SYSTEM_PROMPT = ( + 'You are a helpful assistant with access to tools. When a computation is ' + 'needed, call the `calculator` tool with the appropriate operator and ' + 'operands. When asked to repeat text, call the `echo` tool. Prefer using a ' + 'tool over answering directly, then give a short final answer.') + + +def build_alignment_trajectories() -> List[Dict[str, Any]]: + """Build a small, fixed batch of tool-inviting trajectories. + + Each trajectory carries a hidden ``_tid`` so output order can be checked, a + system prompt describing the tools, and the ``tools`` schema so the template + renders tool definitions into the prompt. + """ + users = [ + 'Please compute 21 plus 34 using your tools, then tell me the result.', + 'Use a tool to echo the exact text "ping", then confirm what you echoed.', + ] + trajectories: List[Dict[str, Any]] = [] + for tid, content in enumerate(users): + trajectories.append({ + 'messages': [ + {'role': 'system', 'content': SYSTEM_PROMPT}, + {'role': 'user', 'content': content}, + ], + 'tools': tool_schema(), + '_tid': tid, + }) + return trajectories + + +# ═══════════════════════════════════════════════════════════════════════════ +# Pure comparison helpers (collection-safe: unit-tested locally without a GPU). +# ═══════════════════════════════════════════════════════════════════════════ +def count_trainable_labels(labels: Optional[List[int]]) -> int: + """Number of trainable positions (``label != -100``) in a labels list.""" + return sum(1 for label in (labels or []) if label != -100) + + +def message_role_signature(messages: List[Dict[str, Any]]) -> List[str]: + """Ordered list of message roles — captures turn structure & tool timing. + + The presence and position of ``tool`` role messages encodes exactly when a + tool call was triggered and answered, which is the control-flow signal we + compare across the two transports. + """ + return [str(m.get('role')) for m in (messages or [])] + + +def tool_turn_indices(messages: List[Dict[str, Any]]) -> List[int]: + """Assistant-turn indices (0-based over assistant messages) that were + immediately followed by a tool response. + + This makes "when tool_calls fired" explicit and independent of textual + content: assistant turn ``k`` is a tool-calling turn iff the next message is + a ``tool`` message. + """ + roles = message_role_signature(messages) + indices: List[int] = [] + assistant_idx = -1 + for i, role in enumerate(roles): + if role == 'assistant': + assistant_idx += 1 + if i + 1 < len(roles) and roles[i + 1] == 'tool': + indices.append(assistant_idx) + return indices + + +def control_flow_fingerprint(traj: Dict[str, Any]) -> Dict[str, Any]: + """Extract the transport-independent control-flow fingerprint of an output. + + Deliberately EXCLUDES token ids / raw logprob values (which may differ) and + keeps only the control-flow-relevant fields the task requires to match: + role signature, tool-call timing, stop_reason, turns, truncated. + """ + return { + 'roles': message_role_signature(traj.get('messages') or []), + 'tool_turns': tool_turn_indices(traj.get('messages') or []), + 'stop_reason': traj.get('stop_reason'), + 'turns': traj.get('turns'), + 'truncated': bool(traj.get('truncated')), + } + + +def assert_logprobs_align_with_labels(traj: Dict[str, Any], label: str) -> None: + """Assert ``len(logprobs) == count(labels != -100)`` for a rollout output. + + Only meaningful when logprobs were requested/collected; a ``None``/empty + logprobs list means "not trainable" and is skipped (the rollout itself + already guards the non-empty case, this re-asserts it at the e2e boundary). + """ + logprobs = traj.get('logprobs') + if not logprobs: + return + trainable = count_trainable_labels(traj.get('labels')) + assert len(logprobs) == trainable, ( + f'[{label}] logprobs({len(logprobs)}) != trainable labels({trainable}) ' + f'(labels != -100)') + + +# Allowed stop reasons (Property 4 in the design doc / task 8.4). +_ALLOWED_STOP_REASONS = {'length', 'stop', 'max_turns'} + + +def assert_properties_3_4_6_7(outs: List[Dict[str, Any]], n_inputs: int, max_turns: int, label: str) -> None: + """Re-assert the multi-turn Properties 3/4/6/7 on a rollout output list. + + * Property 3 — output length & order preserved (via hidden ``_tid``). + * Property 4 — stop_reason within ``{'length','stop','max_turns'}``. + * Property 6 — actual turns never exceed max_turns. + * Property 7 — a ``max_turns`` truncation implies ``truncated=True``. + """ + # Property 3: same length and order. + assert len(outs) == n_inputs, f'[{label}] expected {n_inputs} outputs, got {len(outs)}' + for i, out in enumerate(outs): + assert out.get('_tid') == i, f'[{label}] output order mismatch at index {i}' + # Property 4: stop_reason value range. + assert out.get('stop_reason') in _ALLOWED_STOP_REASONS, \ + f"[{label}] stop_reason={out.get('stop_reason')!r} not in {_ALLOWED_STOP_REASONS}" + # Property 6: turns bounded by max_turns. + assert out.get('turns') is not None and out['turns'] <= max_turns, \ + f"[{label}] turns({out.get('turns')}) > max_turns({max_turns})" + # Property 7: max_turns truncation implies truncated=True. + if out.get('stop_reason') == 'max_turns': + assert out.get('truncated') is True, \ + f'[{label}] stop_reason==max_turns but truncated is not True' + + +# ═══════════════════════════════════════════════════════════════════════════ +# GPU-only sampler / rollout builders (never called at collection time). +# ═══════════════════════════════════════════════════════════════════════════ +def _build_local_template(): + """Build the real Qwen3.5 template used locally by BOTH rollouts.""" + from twinkle.template import Qwen3_5Template + template = Qwen3_5Template(MODEL_ID, max_length=8192, enable_thinking=False) + # Multi-turn bridge stitching does not support 'split'. + template.truncation_strategy = 'delete' + return template + + +def _greedy_sampling_params() -> SamplingParams: + """Greedy, deterministic sampling params shared by both paths.""" + return SamplingParams( + max_tokens=GREEDY_MAX_TOKENS, + temperature=0.0, + num_samples=1, + logprobs=1, + seed=GREEDY_SEED, + ) + + +def _build_client_rollout(): + """Build a ClientMultiTurnRollout wired to the running e2e server (HTTP).""" + from twinkle_client.sampler import vLLMSampler as ClientVLLMSampler + from twinkle_client.rollout.multi_turn import ClientMultiTurnRollout + + sampler = ClientVLLMSampler(model_id=MODEL_ID) + sampler.set_template(TEMPLATE_CLS, model_id=MODEL_ID, enable_thinking=False) + + return ClientMultiTurnRollout( + sampler=sampler, + template=_build_local_template(), + tool_manager=make_tool_manager(), + sampling_params=_greedy_sampling_params(), + max_turns=ALIGN_MAX_TURNS, + ) + + +def _build_bare_rollout(): + """Build a bare-library MultiTurnRollout holding its own Ray-actor sampler. + + Mirrors the standalone GPU sampler tests / multi-turn cookbook: initialise + Twinkle in Ray mode with a dedicated ``sampler`` device group and construct + a Ray-actor :class:`twinkle.sampler.vLLMSampler` (``remote_group='sampler'``). + """ + import twinkle + from twinkle import DeviceGroup, DeviceMesh + from twinkle.sampler import vLLMSampler as RayVLLMSampler + from twinkle_agentic.rollout.multi_turn import MultiTurnRollout + + sampler_gpus = int(os.environ.get('TWINKLE_ALIGN_SAMPLER_GPUS', '1')) + twinkle.initialize( + mode='ray', + nproc_per_node=sampler_gpus, + groups=[DeviceGroup(name='sampler', ranks=list(range(sampler_gpus)), device_type='GPU')], + lazy_collect=False, + ) + sampler_mesh = DeviceMesh.from_sizes(world_size=sampler_gpus, dp_size=sampler_gpus) + sampler = RayVLLMSampler( + model_id=MODEL_ID, + engine_args={ + 'gpu_memory_utilization': 0.5, + 'max_model_len': 4096, + 'enable_lora': True, + }, + device_mesh=sampler_mesh, + remote_group='sampler', + ) + sampler.set_template(TEMPLATE_CLS, model_id=MODEL_ID, enable_thinking=False) + + return MultiTurnRollout( + sampler=sampler, + template=_build_local_template(), + tool_manager=make_tool_manager(), + sampling_params=_greedy_sampling_params(), + max_turns=ALIGN_MAX_TURNS, + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# GPU fixture: build both rollouts once for the module (skips without GPU). +# ═══════════════════════════════════════════════════════════════════════════ +@pytest.fixture(scope='module') +def aligned_rollouts(): + """Yield ``(client_rollout, bare_rollout)`` built against the same weights. + + Skipped automatically unless ``TWINKLE_TEST_GPU_E2E=1``. Requires the e2e + server to be already running (client path) and enough GPUs for the in-test + bare Ray-actor sampler. + """ + if not gpu_e2e_enabled(): + pytest.skip('Set TWINKLE_TEST_GPU_E2E=1 to run the real-sampler multi-turn ' + 'alignment E2E (requires a running server + GPU).') + + wait_for_server() + init_twinkle_client_session() + + log('Building client (HTTP) rollout...') + client_rollout = _build_client_rollout() + log('Building bare-library (Ray actor) rollout...') + bare_rollout = _build_bare_rollout() + + yield client_rollout, bare_rollout + + +# ═══════════════════════════════════════════════════════════════════════════ +# GPU test: the two paths agree on control flow, and logprobs/labels align. +# ═══════════════════════════════════════════════════════════════════════════ +def test_client_and_bare_multi_turn_control_flow_aligned(aligned_rollouts): + """ClientMultiTurnRollout and bare MultiTurnRollout agree on control flow. + + Both paths run the SAME prompt / tools / greedy sampling against the SAME + real Qwen3.5-4B weights for 3-6 rounds. We assert: + + * Both satisfy Properties 3/4/6/7 (length/order, stop_reason range, + turns <= max_turns, max_turns => truncated). + * The transport-independent control-flow fingerprint (message role + signature, tool-call timing, stop_reason, turns, truncated) is IDENTICAL + between the two paths for every trajectory. Token-level sampling + non-determinism is allowed, but the control flow must match. + * On each path, ``len(logprobs) == count(labels != -100)`` for every + trajectory that collected logprobs (real-sampler numeric alignment). + + Validates: Requirements 3.12, 7.2 + """ + client_rollout, bare_rollout = aligned_rollouts + trajectories = build_alignment_trajectories() + n = len(trajectories) + + client_outs = client_rollout(copy.deepcopy(trajectories)) + bare_outs = bare_rollout(copy.deepcopy(trajectories)) + + # Per-path structural properties (3/4/6/7). + assert_properties_3_4_6_7(client_outs, n, ALIGN_MAX_TURNS, label='client') + assert_properties_3_4_6_7(bare_outs, n, ALIGN_MAX_TURNS, label='bare') + + # Per-path real-sampler logprobs/labels alignment (strict equality). + for out in client_outs: + assert_logprobs_align_with_labels(out, label='client') + for out in bare_outs: + assert_logprobs_align_with_labels(out, label='bare') + + # Cross-path control-flow equality (allowing token-level non-determinism). + for i in range(n): + client_fp = control_flow_fingerprint(client_outs[i]) + bare_fp = control_flow_fingerprint(bare_outs[i]) + assert client_fp == bare_fp, ( + f'control-flow mismatch for trajectory {i}:\n' + f' client={client_fp}\n bare ={bare_fp}') + + # The two paths must also agree on the exact trainable-label count, which + # (with greedy decoding on identical weights) is the strongest available + # cross-path numeric-alignment signal short of bit-identical logprobs. + client_trainable = count_trainable_labels(client_outs[i].get('labels')) + bare_trainable = count_trainable_labels(bare_outs[i].get('labels')) + assert client_trainable == bare_trainable, ( + f'trainable-label count mismatch for trajectory {i}: ' + f'client={client_trainable} vs bare={bare_trainable}') + + +# ═══════════════════════════════════════════════════════════════════════════ +# Collection-safe unit tests for the pure comparison helpers (run locally, no +# GPU). These prove the module imports and the fingerprint logic behaves, so the +# file is meaningful even when the GPU test above is skipped. +# ═══════════════════════════════════════════════════════════════════════════ +def test_gpu_gate_reflects_env_flag(): + """The GPU gate reflects TWINKLE_TEST_GPU_E2E without side effects.""" + assert gpu_e2e_enabled() == (os.environ.get('TWINKLE_TEST_GPU_E2E', '0') == '1') + + +def test_count_trainable_labels(): + assert count_trainable_labels(None) == 0 + assert count_trainable_labels([]) == 0 + assert count_trainable_labels([-100, -100]) == 0 + assert count_trainable_labels([-100, 5, 7, -100, 9]) == 3 + + +def test_message_role_signature_and_tool_turns(): + messages = [ + {'role': 'system', 'content': 's'}, + {'role': 'user', 'content': 'u'}, + {'role': 'assistant', 'content': 'call'}, # assistant turn 0 -> tool + {'role': 'tool', 'content': 't'}, + {'role': 'assistant', 'content': 'final'}, # assistant turn 1 -> no tool + ] + assert message_role_signature(messages) == ['system', 'user', 'assistant', 'tool', 'assistant'] + # Only the first assistant turn is immediately followed by a tool response. + assert tool_turn_indices(messages) == [0] + + +def test_control_flow_fingerprint_ignores_tokens_and_logprob_values(): + """Two outputs with identical control flow but different tokens/logprobs + produce an identical fingerprint (token-level non-determinism allowed).""" + base_messages = [ + {'role': 'user', 'content': 'q'}, + {'role': 'assistant', 'content': 'call'}, + {'role': 'tool', 'content': 't'}, + {'role': 'assistant', 'content': 'final'}, + ] + a = { + 'messages': base_messages, + 'stop_reason': 'stop', 'turns': 2, 'truncated': False, + 'labels': [-100, 1, 2], 'logprobs': [[(1, -0.1)], [(2, -0.2)]], + } + b = { + # Same roles/timing, DIFFERENT token content and logprob values. + 'messages': [dict(m) for m in base_messages], + 'stop_reason': 'stop', 'turns': 2, 'truncated': False, + 'labels': [-100, 9, 8], 'logprobs': [[(9, -1.0)], [(8, -2.0)]], + } + assert control_flow_fingerprint(a) == control_flow_fingerprint(b) + + +def test_assert_logprobs_align_with_labels_pass_and_fail(): + # Passing case: 2 logprob entries, 2 trainable labels. + ok = {'labels': [-100, 3, 4], 'logprobs': [[(3, -0.1)], [(4, -0.2)]]} + assert_logprobs_align_with_labels(ok, label='unit') # no raise + + # Empty/None logprobs is a no-op (not trainable). + assert_logprobs_align_with_labels({'labels': [-100, 3], 'logprobs': None}, label='unit') + + # Mismatch raises. + bad = {'labels': [-100, 3, 4], 'logprobs': [[(3, -0.1)]]} + with pytest.raises(AssertionError): + assert_logprobs_align_with_labels(bad, label='unit') + + +def test_assert_properties_3_4_6_7_detects_violations(): + good = [ + {'_tid': 0, 'stop_reason': 'stop', 'turns': 1, 'truncated': False}, + {'_tid': 1, 'stop_reason': 'max_turns', 'turns': 3, 'truncated': True}, + ] + assert_properties_3_4_6_7(good, n_inputs=2, max_turns=3, label='unit') # no raise + + # Property 4 violation: unknown stop_reason. + with pytest.raises(AssertionError): + assert_properties_3_4_6_7( + [{'_tid': 0, 'stop_reason': 'weird', 'turns': 1, 'truncated': False}], + n_inputs=1, max_turns=3, label='unit') + + # Property 6 violation: turns exceed max_turns. + with pytest.raises(AssertionError): + assert_properties_3_4_6_7( + [{'_tid': 0, 'stop_reason': 'stop', 'turns': 5, 'truncated': False}], + n_inputs=1, max_turns=3, label='unit') + + # Property 7 violation: max_turns but not truncated. + with pytest.raises(AssertionError): + assert_properties_3_4_6_7( + [{'_tid': 0, 'stop_reason': 'max_turns', 'turns': 3, 'truncated': False}], + n_inputs=1, max_turns=3, label='unit') + + # Property 3 violation: order/length mismatch. + with pytest.raises(AssertionError): + assert_properties_3_4_6_7( + [{'_tid': 1, 'stop_reason': 'stop', 'turns': 1, 'truncated': False}], + n_inputs=1, max_turns=3, label='unit') + + +def test_build_alignment_trajectories_shape(): + trajs = build_alignment_trajectories() + assert len(trajs) == 2 + for i, traj in enumerate(trajs): + assert traj['_tid'] == i + assert traj['messages'][0]['role'] == 'system' + assert traj['messages'][1]['role'] == 'user' + assert isinstance(traj['tools'], list) and traj['tools'] + names = {t['function']['name'] for t in traj['tools']} + assert names == {'echo', 'calculator'} + + +def test_tool_manager_dispatch_is_deterministic(): + """The echo/calculator tools dispatch deterministically via ToolManager.""" + mgr = make_tool_manager() + calc_call = {'type': 'function', 'function': {'name': 'calculator', + 'arguments': {'op': 'add', 'a': 21, 'b': 34}}} + echo_call = {'type': 'function', 'function': {'name': 'echo', 'arguments': {'text': 'ping'}}} + assert json.loads(mgr(calc_call))['result'] == 55 + assert mgr(echo_call) == 'echo[echo]:{"text": "ping"}' diff --git a/tests/server/integration/test_client_multi_turn_rollout_mock_e2e.py b/tests/server/integration/test_client_multi_turn_rollout_mock_e2e.py new file mode 100644 index 000000000..39ddb7b58 --- /dev/null +++ b/tests/server/integration/test_client_multi_turn_rollout_mock_e2e.py @@ -0,0 +1,577 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Local CPU-only multi-turn control-flow E2E for ``ClientMultiTurnRollout``. + +This test boots a REAL CPU-only Twinkle server (Ray Serve, in-process) whose +sampler backend is the enhanced :class:`MockSampler` (task 8.6), then drives +:class:`twinkle_client.rollout.multi_turn.ClientMultiTurnRollout` over ACTUAL +HTTP through :class:`twinkle_client.sampler.vLLMSampler`. No GPU is required. + +What is "real" vs "test double" here +------------------------------------ + * REAL: the HTTP server (gateway + sampler apps on Ray Serve), the + ``/twinkle/sample`` protocol hop, ``vLLMSampler.sample()`` and the + enhanced ``MockSampler`` producing ``new_input_feature`` / configurable + ``stop_reason`` / configurable tool-call text over the wire. + * TEST DOUBLE (client-local only): a lightweight char-level Template used by + the client to ``encode`` trajectories and ``parse_tool_call`` the sampled + text, plus a small ``ToolManager`` with an echo tool. The Template never + crosses the network; encoding, tool parsing and bridge stitching are + client-side concerns. A real HF Template would require a downloaded + tokenizer (network/model weights) which is unavailable in a local offline + CPU environment, so a deterministic char-level double is used instead — + exactly the established pattern from + ``tests/twinkle_client/test_client_multi_turn_rollout.py`` (task 8.4). + +Why the sampler knobs are set at CONSTRUCTION time +-------------------------------------------------- +The multi-turn knobs (``stop_reason`` / ``tool_call_text`` / ``tool_call_turns``) +CANNOT be injected per-call through the HTTP layer: the ``/twinkle/sample`` +handler rebuilds ``sampling_params`` via ``SamplingParams.from_dict`` which +filters the payload down to the known dataclass fields, dropping any extra +knobs before they reach ``MockSampler.sample``. Setting them per call would +require server-side protocol changes beyond this task's scope. Therefore each +behaviour is realised as a SEPARATE sampler app whose ``MockSampler`` is +constructed with the desired knobs: + + * ``mock-tool`` — ``stop_reason='stop'`` + a tool call injected on EVERY + round (``tool_call_turns`` covers a wide range). This deterministically + exercises the "sample -> tool -> bridge -> sample -> ... -> max_turns + truncation" control flow regardless of the sampler's monotonic per-call + round counter (so the module-scoped server can be reused across cases). + * ``mock-stop`` — ``stop_reason='stop'`` with NO tool call, i.e. natural + single-turn termination with ``stop_reason == 'stop'``. + * ``mock-length`` — ``stop_reason='length'``, i.e. immediate termination with + ``stop_reason == 'length'``. + +============================================================================== +CONDA ENVIRONMENT REQUIREMENT (MANDATORY) — NO GPU REQUIRED +============================================================================== +ALL cases in this file are CPU-only and MUST run inside the ``twinkle`` conda env: + + conda run -n twinkle pytest \ + tests/server/integration/test_client_multi_turn_rollout_mock_e2e.py -v +============================================================================== +""" +from __future__ import annotations + +import copy +import json +import os +import re +import subprocess +import sys +import time +from typing import Any, Dict, List, Optional + +# Ensure project root is importable for both pytest and direct execution. +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import pytest + +from twinkle.data_format.sampling import SamplingParams +from twinkle_agentic.tools.base import Tool +from twinkle_agentic.tools.tool_manager import ToolManager +from twinkle_client.rollout.multi_turn import ClientMultiTurnRollout + +# ═══════════════════════════════════════════════════════════════════════════ +# Sampler model ids (one app per multi-turn behaviour — see module docstring). +# ═══════════════════════════════════════════════════════════════════════════ +MODEL_TOOL = 'mock-tool' # stop_reason='stop' + tool call on every round +MODEL_STOP = 'mock-stop' # stop_reason='stop', no tool call +MODEL_LENGTH = 'mock-length' # stop_reason='length' + +# Tool call text the mock emits as SampledSequence.decoded on injected rounds. +# Must match the client Template's parse_tool_call (Qwen {...}). +TOOL_NAME = 'echo' +TOOL_CALL_TEXT = '' + json.dumps({'name': TOOL_NAME, 'arguments': {'text': 'hi'}}) + '' + +# Wide range so "inject a tool call on every round" holds even as the mock's +# monotonic per-call round counter advances across reused test cases. +_ALWAYS_INJECT_TURNS = list(range(1, 201)) + +# Sampling params carried on every round. ``max_tokens`` MUST be set: the mock +# rejects max_tokens < 1, and the rollout's default SamplingParams leaves it None. +SAMPLE_MAX_TOKENS = 4 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Client-local test doubles: char-level tokenizer + Template + echo tool. +# (Mirrors tests/twinkle_client/test_client_multi_turn_rollout.py — task 8.4.) +# ═══════════════════════════════════════════════════════════════════════════ +class _FakeTokenizer: + """Char-level tokenizer with atomic special tokens. + + Guarantees ``decode(encode(s)) == s`` so ``extend_with_bridge``'s + template-space delta computation is deterministic. + """ + SPECIALS = ('<|im_start|>', '<|im_end|>') + + def __init__(self) -> None: + self._s2i: Dict[str, int] = {} + self._i2s: Dict[int, str] = {} + for s in self.SPECIALS: + self._add(s) + + def _add(self, tok: str) -> int: + if tok not in self._s2i: + i = len(self._s2i) + self._s2i[tok] = i + self._i2s[i] = tok + return self._s2i[tok] + + def encode(self, text: str, add_special_tokens: bool = False) -> List[int]: + ids: List[int] = [] + i = 0 + while i < len(text): + matched = False + for sp in self.SPECIALS: + if text.startswith(sp, i): + ids.append(self._add(sp)) + i += len(sp) + matched = True + break + if not matched: + ids.append(self._add(text[i])) + i += 1 + return ids + + def decode(self, ids: List[int], skip_special_tokens: bool = False) -> str: + specials = set(self.SPECIALS) + toks = [self._i2s[int(i)] for i in ids] + if skip_special_tokens: + toks = [t for t in toks if t not in specials] + return ''.join(toks) + + def apply_chat_template( + self, + messages: List[Dict[str, Any]], + tokenize: bool = False, + add_generation_prompt: bool = False, + **_, + ): + s = '' + for m in messages: + s += f"<|im_start|>{m['role']}\n{m['content']}<|im_end|>\n" + if add_generation_prompt: + s += '<|im_start|>assistant\n' + return self.encode(s) if tokenize else s + + +class _FakeTemplate: + """Minimal Template mirroring the parts ClientMultiTurnRollout touches. + + A client-local test double (never crosses the network). Implements exactly + ``encode`` / ``parse_tool_call`` / ``_invoke_post_pipeline`` / + ``enable_thinking`` / ``tokenizer`` — everything ``extend_with_bridge`` and + the rollout need. + """ + model_id = 'qwen-fake' + truncation_strategy = 'right' + enable_thinking = False + + def __init__(self, tokenizer: _FakeTokenizer) -> None: + self.tokenizer = tokenizer + + def encode(self, trajectory: Dict[str, Any], add_generation_prompt: bool = False) -> Dict[str, Any]: + messages = trajectory.get('messages', []) + s = self.tokenizer.apply_chat_template( + messages, tokenize=False, add_generation_prompt=add_generation_prompt) + input_ids = self.tokenizer.encode(s, add_special_tokens=False) + pif: Dict[str, Any] = dict(trajectory) # preserve top-level fields (incl. _tid) + pif['input_ids'] = input_ids + pif['labels'] = [-100] * len(input_ids) # inference mode + return self._invoke_post_pipeline([pif])[0] + + def _invoke_post_pipeline(self, inputs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out = [] + for pif in inputs: + pif = dict(pif) + input_ids = list(pif['input_ids']) + labels = list(pif.get('labels') or []) + if labels: + if len(labels) != len(input_ids): + raise RuntimeError(f'FakeTemplate post_pipeline: labels({len(labels)}) ' + f'!= input_ids({len(input_ids)})') + labels = labels[1:] + labels[:1] # np.roll(labels, -1): shift LEFT by 1 + pif['input_ids'] = input_ids + pif['labels'] = labels + pif['attention_mask'] = [1] * len(input_ids) + pif['position_ids'] = list(range(len(input_ids))) + pif['length'] = len(input_ids) + out.append(pif) + return out + + def parse_tool_call(self, decoded: str) -> List[Dict[str, Any]]: + matches = re.findall(r'\s*([\s\S]*?)\s*', decoded or '') + results: List[Dict[str, Any]] = [] + for m in matches: + try: + d = json.loads(m) + except json.JSONDecodeError: + continue + name = d.get('name') or d.get('tool_name') + if not name: + continue + results.append({ + 'type': 'function', + 'function': {'name': name, 'arguments': d.get('arguments', {})}, + }) + return results + + +class EchoTool(Tool): + """Echoes its arguments as a JSON string.""" + + def __init__(self, name: str = TOOL_NAME): + self._name = name + + def __call__(self, tool_name: str, arguments: Dict[str, Any]) -> str: + return f'echo[{tool_name}]:{json.dumps(arguments, sort_keys=True)}' + + def tool_info(self): + return { + 'type': 'function', + 'function': {'name': self._name, 'description': 'echo test tool', 'parameters': {}}, + } + + +def _make_tool_manager() -> ToolManager: + mgr = ToolManager({}) + mgr.register(EchoTool(TOOL_NAME)) + return mgr + + +def _make_template() -> _FakeTemplate: + return _FakeTemplate(_FakeTokenizer()) + + +def _make_trajectories(n: int) -> List[Dict[str, Any]]: + """Build ``n`` single-user-message trajectories tagged with a hidden _tid.""" + return [{'messages': [{'role': 'user', 'content': f'q{tid}'}], '_tid': tid} for tid in range(n)] + + +# ═══════════════════════════════════════════════════════════════════════════ +# CPU-only mock server config. +# +# Built as plain dicts and fed DIRECTLY to the app builders (build_gateway_app / +# build_sampler_app) instead of through ``ServerConfig.model_validate``. The +# typed ``SamplerArgs`` schema uses ``extra='forbid'`` and does NOT declare the +# mock multi-turn knobs (stop_reason / tool_call_text / tool_call_turns), so +# routing them through the validated config would be rejected. The builders +# themselves accept ``**kwargs`` and forward them to the MockSampler ctor, so we +# skip validation and call them directly (this is test-only wiring). +# ═══════════════════════════════════════════════════════════════════════════ +def _sampler_app(name: str, model_id: str, *, extra_args: Dict[str, Any]) -> Dict[str, Any]: + # NOTE: ``queue_config`` is intentionally omitted. When calling the builder + # directly (bypassing ServerConfig validation) a raw dict would NOT be + # coerced into a TaskQueueConfig; leaving it unset lets the sampler app + # construct a default TaskQueueConfig, which is fine for this CPU test. + args: Dict[str, Any] = { + 'sampler_type': 'mock', + 'model_id': model_id, + 'nproc_per_node': 1, + 'device_group': {'name': f'sampler_{model_id}', 'ranks': 1, 'device_type': 'CPU'}, + 'device_mesh': {'device_type': 'CPU', 'dp_size': 1}, + } + args.update(extra_args) + return { + 'name': name, + 'route_prefix': f'/api/v1/sampler/{model_id}', + 'import_path': 'sampler', + 'args': args, + } + + +def _build_applications() -> List[Dict[str, Any]]: + """Return the plain-dict application specs (gateway + 3 sampler apps).""" + return [ + { + 'name': 'server', + 'route_prefix': '/api/v1', + 'import_path': 'server', + 'args': { + 'server_config': {'per_token_model_limit': 3}, + 'supported_models': [MODEL_TOOL, MODEL_STOP, MODEL_LENGTH], + }, + }, + _sampler_app('sampler-mock-tool', MODEL_TOOL, extra_args={ + 'stop_reason': 'stop', + 'tool_call_text': TOOL_CALL_TEXT, + 'tool_call_turns': _ALWAYS_INJECT_TURNS, + }), + _sampler_app('sampler-mock-stop', MODEL_STOP, extra_args={'stop_reason': 'stop'}), + _sampler_app('sampler-mock-length', MODEL_LENGTH, extra_args={'stop_reason': 'length'}), + ] + + +# File-backed persistence path (gateway + samplers run as separate replicas, so +# ``memory`` mode is insufficient — cross-process visibility requires a file). +_PERSISTENCE_FILE = '/tmp/twinkle_state_multi_turn_mock.json' + + +# ═══════════════════════════════════════════════════════════════════════════ +# In-process CPU-only Ray Serve harness (mirrors MockEmbeddingServerHarness). +# ═══════════════════════════════════════════════════════════════════════════ +class MultiTurnMockServerHarness: + """Boots the CPU-only mock multi-turn server in-process via Ray Serve. + + Manages its own local Ray head node (independent of the session-scoped Ray + fixture), starts Ray Serve on a randomized port, and runs the gateway plus + all three sampler apps declared by ``_build_server_config_dict``. No GPU. + """ + + READY_BUDGET_SECONDS = 90.0 + RAY_NODE_CPUS = 8 + + def __init__(self) -> None: + self.host = '127.0.0.1' + self.port = 18900 + (os.getpid() % 700) + self.base_url = f'http://{self.host}:{self.port}' + self._started = False + + @staticmethod + def _run_ray_command(*args: str) -> None: + ray_bin = os.path.join(os.path.dirname(sys.executable), 'ray') + result = subprocess.run( + [ray_bin, *args], check=False, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if result.returncode != 0: + raise RuntimeError(f'ray {" ".join(args)} failed ({result.returncode}):\n{result.stdout}') + + def start(self) -> str: + import ray + from ray import serve + + from twinkle.server.config.persistence import PersistenceConfig + from twinkle.server.gateway import build_gateway_app + from twinkle.server.sampler import build_sampler_app + + # File-backed persistence so gateway + sampler replicas share state. + persistence_env = PersistenceConfig(mode='file', file_path=_PERSISTENCE_FILE).to_env_vars() + for k, v in persistence_env.items(): + os.environ[k] = v + + if ray.is_initialized(): + ray.shutdown() + self._run_ray_command('stop', '--force') + self._run_ray_command( + 'start', '--head', '--port=0', f'--num-cpus={self.RAY_NODE_CPUS}', + '--num-gpus=0', '--include-dashboard=false', '--disable-usage-stats') + ray.init( + address='auto', + runtime_env={'env_vars': persistence_env} if persistence_env else None, + ) + self._started = True + + serve.start(http_options={'host': self.host, 'port': self.port}) + # Call the builders DIRECTLY with plain-dict args (see _build_applications): + # this bypasses the typed SamplerArgs schema (extra='forbid') so the mock + # multi-turn knobs reach the MockSampler ctor. + builders = {'server': build_gateway_app, 'sampler': build_sampler_app} + deploy_options: Dict[str, Any] = {'ray_actor_options': {'num_cpus': 0.1}} + for app_spec in _build_applications(): + builder = builders[app_spec['import_path']] + args = dict(app_spec['args']) + if app_spec['import_path'] == 'server': + args.setdefault('http_options', {'host': self.host, 'port': self.port}) + bound = builder(deploy_options=deploy_options, **args) + serve.run(bound, name=app_spec['name'], route_prefix=app_spec['route_prefix']) + + self._wait_until_healthy(serve, self.READY_BUDGET_SECONDS) + return self.base_url + + def _wait_until_healthy(self, serve_module: Any, timeout: float) -> None: + deadline = time.monotonic() + timeout + last: Dict[str, Any] = {} + while time.monotonic() < deadline: + status = serve_module.status() + last = {name: app.status for name, app in status.applications.items()} + if last and all(s == 'RUNNING' for s in last.values()): + return + time.sleep(0.5) + raise TimeoutError(f'Mock multi-turn server not RUNNING within {timeout}s: {last}') + + def stop(self) -> None: + if not self._started: + return + try: + import ray + from ray import serve + try: + serve.shutdown() + except Exception: + pass + try: + ray.shutdown() + except Exception: + pass + finally: + try: + self._run_ray_command('stop', '--force') + except Exception: + pass + self._started = False + + +# ═══════════════════════════════════════════════════════════════════════════ +# Fixtures +# ═══════════════════════════════════════════════════════════════════════════ +@pytest.fixture(scope='module') +def mock_multi_turn_server(): + """Boot the CPU-only mock multi-turn server ONCE for the module.""" + harness = MultiTurnMockServerHarness() + base_url = harness.start() + try: + from twinkle_client import init_twinkle_client + init_twinkle_client(base_url=base_url, api_key='EMPTY_TOKEN') + yield base_url + finally: + harness.stop() + + +def _make_sampler(model_id: str): + """Create a real vLLMSampler bound to the given mock sampler app.""" + from twinkle_client.sampler import vLLMSampler + return vLLMSampler(model_id=model_id) + + +def _make_rollout(model_id: str, *, tool_manager: Optional[ToolManager], max_turns: int) -> ClientMultiTurnRollout: + return ClientMultiTurnRollout( + sampler=_make_sampler(model_id), + template=_make_template(), + tool_manager=tool_manager, + sampling_params=SamplingParams(max_tokens=SAMPLE_MAX_TOKENS, num_samples=1), + max_turns=max_turns, + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Multi-turn control flow over real HTTP (tool app: tool call on every round). +# +# With ``stop_reason='stop'`` and a tool call injected every round, the loop +# runs "sample -> tool -> bridge -> sample -> ..." until it hits ``max_turns`` +# and force-truncates. Exercises Property 3 (len/order), Property 4 +# ('max_turns' in the allowed set), and Property 6 (turns <= max_turns). +# ═══════════════════════════════════════════════════════════════════════════ +@pytest.mark.parametrize('n_traj,max_turns', [(1, 3), (3, 2), (2, 4)]) +def test_multi_turn_tool_loop_over_http(mock_multi_turn_server, n_traj, max_turns): + """Full multi-turn control flow over real HTTP terminates at max_turns. + + Boots a real CPU server, drives ClientMultiTurnRollout via vLLMSampler HTTP + calls through several "sample -> tool -> bridge -> sample" rounds, and + checks the batch invariants. + + Validates: Requirements 3.2 (Property 3), 3.3 (Property 4), 3.5 (Property 6), 7.1, 7.2 + """ + rollout = _make_rollout(MODEL_TOOL, tool_manager=_make_tool_manager(), max_turns=max_turns) + trajectories = _make_trajectories(n_traj) + + outs = rollout(copy.deepcopy(trajectories)) + + # Property 3: output list is same length and order as the input. + assert len(outs) == n_traj + for i, out in enumerate(outs): + assert out['_tid'] == i, 'output order must match input order' + + for out in outs: + # Property 4: stop_reason is within the allowed set. + assert out['stop_reason'] in {'length', 'stop', 'max_turns'}, out['stop_reason'] + # Property 6: actual turns never exceed the configured max_turns. + assert out['turns'] <= max_turns, f"turns({out['turns']}) > max_turns({max_turns})" + # A tool call on every round means the loop must hit the turn cap. + assert out['stop_reason'] == 'max_turns' + assert out['truncated'] is True + assert out['turns'] == max_turns + # Multi-turn actually stitched tool turns via the shared bridge helper: + # the running context grew past the initial prompt encoding. + assert out['messages'][0]['role'] == 'user' + if max_turns >= 2: + # At least one tool message was appended by extend_with_bridge. + assert any(m['role'] == 'tool' for m in out['messages']) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Property 7: max_turns == 1 with a first-round tool call forces truncation. +# ═══════════════════════════════════════════════════════════════════════════ +def test_property7_max_turns_one_forces_truncation(mock_multi_turn_server): + """max_turns==1 + first-round tool call -> truncated=True, stop_reason='max_turns'. + + Validates: Requirements 3.6 (Property 7), 7.1, 7.2 + """ + rollout = _make_rollout(MODEL_TOOL, tool_manager=_make_tool_manager(), max_turns=1) + trajectories = _make_trajectories(3) + + outs = rollout(copy.deepcopy(trajectories)) + + assert len(outs) == 3 + for i, out in enumerate(outs): + assert out['_tid'] == i + assert out['truncated'] is True + assert out['stop_reason'] == 'max_turns' + assert out['turns'] == 1 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Property 4: natural termination reasons ('stop' and 'length') over real HTTP. +# ═══════════════════════════════════════════════════════════════════════════ +def test_natural_stop_termination_over_http(mock_multi_turn_server): + """No tool call + stop_reason='stop' -> single-turn natural termination. + + Validates: Requirements 3.2 (Property 3), 3.3 (Property 4), 3.5 (Property 6), 7.1, 7.2 + """ + rollout = _make_rollout(MODEL_STOP, tool_manager=_make_tool_manager(), max_turns=4) + trajectories = _make_trajectories(2) + + outs = rollout(copy.deepcopy(trajectories)) + + assert len(outs) == 2 + for i, out in enumerate(outs): + assert out['_tid'] == i + assert out['stop_reason'] == 'stop' + assert out['truncated'] is False + assert out['turns'] == 1 + assert out['turns'] <= 4 + + +def test_length_termination_over_http(mock_multi_turn_server): + """stop_reason='length' -> immediate termination on the first round. + + Validates: Requirements 3.3 (Property 4), 3.5 (Property 6), 7.1, 7.2 + """ + rollout = _make_rollout(MODEL_LENGTH, tool_manager=_make_tool_manager(), max_turns=4) + trajectories = _make_trajectories(2) + + outs = rollout(copy.deepcopy(trajectories)) + + assert len(outs) == 2 + for out in outs: + assert out['stop_reason'] == 'length' + assert out['truncated'] is False + assert out['turns'] == 1 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Exception path (task 8.3): tool_calls produced but no tool_manager -> ValueError. +# +# Note on ``new_input_feature=None``: the enhanced MockSampler ALWAYS populates +# new_input_feature, so that specific 8.3 error path is not reproducible against +# a real mock server and is covered by the unit tests in +# tests/twinkle_client/test_client_multi_turn_rollout.py instead. The +# tool_manager-missing path IS reachable over real HTTP and is asserted here. +# ═══════════════════════════════════════════════════════════════════════════ +def test_tool_calls_without_tool_manager_raises_value_error_over_http(mock_multi_turn_server): + """A tool call with no tool_manager raises ValueError over the real HTTP path. + + Validates: Requirements 3.10, 7.1, 7.2 + """ + rollout = _make_rollout(MODEL_TOOL, tool_manager=None, max_turns=3) + trajectories = _make_trajectories(1) + + with pytest.raises(ValueError) as excinfo: + rollout(copy.deepcopy(trajectories)) + + msg = str(excinfo.value) + assert 'tool_manager' in msg, msg + assert 'trajectory 0' in msg, msg diff --git a/tests/server/sampler/test_mock_sampler.py b/tests/server/sampler/test_mock_sampler.py index ea1c07667..85c467ab3 100644 --- a/tests/server/sampler/test_mock_sampler.py +++ b/tests/server/sampler/test_mock_sampler.py @@ -160,3 +160,63 @@ def test_sample_stream_rejects_bad_max_tokens() -> None: inp = InputFeature(input_ids=[1]) with pytest.raises(ValueError): list(s.sample_stream(inp, SamplingParams(max_tokens=0))) + + +# ---------- Multi-turn contract knobs (task 8.6) -------------------------- # + + +def test_default_new_input_feature_appends_sampled_tokens() -> None: + """Default behaviour now carries a non-empty new_input_feature that appends + the round's sampled tokens onto the input pif's input_ids.""" + s = MockSampler('mid') + inp = InputFeature(input_ids=[1, 2, 3]) + seq = s.sample(inp, SamplingParams(max_tokens=4))[0].sequences[0] + assert seq.new_input_feature is not None + assert 'input_ids' in seq.new_input_feature + assert seq.new_input_feature['input_ids'] == [1, 2, 3] + list(seq.tokens) + # Prior context stays non-trainable; sampled tokens are trainable (their ids). + assert seq.new_input_feature['labels'] == [-100, -100, -100] + list(seq.tokens) + assert seq.new_input_feature['length'] == 3 + len(seq.tokens) + + +def test_default_backward_compatible_stop_reason_and_decoded() -> None: + """With no knobs configured, stop_reason stays 'length' and decoded None.""" + s = MockSampler('mid') + inp = InputFeature(input_ids=[1]) + seq = s.sample(inp, SamplingParams(max_tokens=2))[0].sequences[0] + assert seq.stop_reason == 'length' + assert seq.decoded is None + + +def test_configurable_stop_reason_via_ctor() -> None: + s = MockSampler('mid', stop_reason='stop') + inp = InputFeature(input_ids=[1]) + seq = s.sample(inp, SamplingParams(max_tokens=2))[0].sequences[0] + assert seq.stop_reason == 'stop' + + +def test_stop_reason_per_call_kwarg_overrides_ctor() -> None: + s = MockSampler('mid', stop_reason='stop') + inp = InputFeature(input_ids=[1]) + seq = s.sample(inp, SamplingParams(max_tokens=2), stop_reason='length')[0].sequences[0] + assert seq.stop_reason == 'length' + + +def test_tool_call_text_injected_only_on_configured_turns() -> None: + """tool_call_text is emitted as decoded on turn 1 only (default), driving a + 'sample -> tool -> next round -> terminate' loop.""" + s = MockSampler('mid', stop_reason='stop', tool_call_text='x') + inp = InputFeature(input_ids=[1, 2]) + # Round 1: tool call injected. + seq1 = s.sample(inp, SamplingParams(max_tokens=2))[0].sequences[0] + assert seq1.decoded == 'x' + # Round 2: no injection -> decoded None (loop can terminate on empty parse). + seq2 = s.sample(inp, SamplingParams(max_tokens=2))[0].sequences[0] + assert seq2.decoded is None + + +def test_tool_call_turns_multiple_rounds() -> None: + s = MockSampler('mid', stop_reason='stop', tool_call_text='TC', tool_call_turns=(1, 3)) + inp = InputFeature(input_ids=[1]) + decoded = [s.sample(inp, SamplingParams(max_tokens=1))[0].sequences[0].decoded for _ in range(3)] + assert decoded == ['TC', None, 'TC'] diff --git a/tests/server/test_embedding_e2e.py b/tests/server/test_embedding_e2e.py new file mode 100644 index 000000000..e910ffee1 --- /dev/null +++ b/tests/server/test_embedding_e2e.py @@ -0,0 +1,1139 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Embedding training E2E test scaffolding (Twinkle client path). + +This module provides the reusable scaffolding for validating the embedding +training pipeline end to end through the Twinkle client: + + set_processor('InputProcessor') + -> set_loss('InfonceLoss', temperature=..., use_batch=True) + -> add_metric('EmbeddingMetric', is_training=True) + -> loop: forward_backward(inputs=mb, task='embedding') + clip_grad_and_step(...) + -> calculate_metric(is_training=True) + +It exposes TWO switchable backend entrypoints (fixtures): + + * ``mock_embedding_model`` — local CPU-only entrypoint backed by + ``TwinkleCompatMockModel`` (src/twinkle/server/model/backends/mock_model.py). + Requires NO GPU. Boots an in-process Ray Serve cluster from the CPU-only + mock server config fixture. Consumed by task 4.2 (protocol/link validation). + + * ``gpu_embedding_model`` — real transformers model entrypoint. Gated behind + the ``TWINKLE_TEST_GPU_E2E=1`` environment variable (skipped otherwise) and + connects to an already-running GPU server (see + tests/server/start_e2e_server.py). Consumed by tasks 4.3-4.5 (real numeric + validation). + +Plus two reusable helpers: + + * ``build_synthetic_contrastive_dataset(...)`` — builds a small synthetic + anchor/positive contrastive-learning dataset (even number of samples, with + ``labels = [1, 0, 1, 0, ...]`` anchor/positive semantics) in the embedding + pooling input format consumed by ``InputProcessor``. + * ``run_embedding_training(...)`` — issues the design-document "verification + call sequence" against a client model and returns the observed losses and + the final metric result. + +============================================================================== +CONDA ENVIRONMENT REQUIREMENT (MANDATORY) +============================================================================== +ALL test cases in this file MUST be run inside the ``twinkle`` conda env, e.g.: + + conda run -n twinkle pytest tests/server/test_embedding_e2e.py -v + +The local, CPU-only mock cases (task 4.2) run without a GPU and must pass in +that environment: + + conda run -n twinkle pytest tests/server/test_embedding_e2e.py -v -k mock + +The GPU-gated cases (tasks 4.3-4.5) additionally require ``TWINKLE_TEST_GPU_E2E=1`` +and a running GPU server; they are skipped automatically when the variable is +unset: + + TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest tests/server/test_embedding_e2e.py -v -k gpu +============================================================================== +""" +from __future__ import annotations + +import math +import os +import subprocess +import sys +import time +from typing import Any, Dict, List, Optional + +# Ensure project root is importable for both pytest and direct execution. +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import pytest + +# Reuse the existing GPU e2e helpers (server URL, session init, real model id). +from tests.server.integration.e2e_helpers import ( + BASE_URL, + MODEL_ID, + init_twinkle_client_session, + log, + wait_for_server, +) + +# ═══════════════════════════════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════════════════════════════ + +# Local CPU-only mock backend model id (see tests/server/fixtures/server_config_mock.yaml). +MOCK_MODEL_ID = 'mock-model' + +# InfoNCE temperature used by the verification call sequence. +DEFAULT_TEMPERATURE = 0.07 + +# Default synthetic-dataset shape (kept tiny for CPU speed). +DEFAULT_NUM_PAIRS = 4 +DEFAULT_SEQ_LEN = 8 +DEFAULT_VOCAB_SIZE = 32 + +# Number of training steps used by the default verification loop. +DEFAULT_TRAIN_STEPS = 4 + + +def gpu_e2e_enabled() -> bool: + """Return True when GPU e2e tests are explicitly enabled.""" + return os.environ.get('TWINKLE_TEST_GPU_E2E', '0') == '1' + + +# ═══════════════════════════════════════════════════════════════════════════ +# Synthetic contrastive-learning dataset +# ═══════════════════════════════════════════════════════════════════════════ + +def build_synthetic_contrastive_dataset( + num_pairs: int = DEFAULT_NUM_PAIRS, + *, + seq_len: int = DEFAULT_SEQ_LEN, + vocab_size: int = DEFAULT_VOCAB_SIZE, + seed: int = 0, +) -> List[Dict[str, Any]]: + """Build a small synthetic anchor/positive contrastive dataset. + + Produces ``2 * num_pairs`` samples interleaved as + ``[anchor_0, positive_0, anchor_1, positive_1, ...]`` so that the per-sample + ``labels`` scalar carries the ``[1, 0, 1, 0, ...]`` anchor/positive semantics + expected by embedding pooling (anchor -> 1, positive -> 0). Each sample is a + feature dict in the embedding pooling input format consumed by + ``InputProcessor`` (``input_ids`` + ``attention_mask`` + ``labels``). + + Anchor and its positive within a pair share correlated token content so a + real InfoNCE loss has a meaningful contrastive signal to learn from, while a + fixed ``seed`` keeps the dataset deterministic across runs. + + Args: + num_pairs: Number of anchor/positive pairs (result has ``2*num_pairs`` samples). + seq_len: Token sequence length per sample. + vocab_size: Upper bound (exclusive) for synthetic token ids. + seed: RNG seed for deterministic generation. + + Returns: + A list of feature dicts of length ``2 * num_pairs``. + """ + if num_pairs < 1: + raise ValueError(f'num_pairs must be >= 1, got {num_pairs}') + if seq_len < 1: + raise ValueError(f'seq_len must be >= 1, got {seq_len}') + + import numpy as np + + rng = np.random.default_rng(seed) + samples: List[Dict[str, Any]] = [] + for pair_idx in range(num_pairs): + # Shared "concept" tokens make anchor/positive semantically correlated. + base = rng.integers(low=1, high=vocab_size, size=seq_len) + # Anchor: the base sequence. + anchor_ids = base.copy() + # Positive: perturb a couple of positions so it is similar but not identical. + positive_ids = base.copy() + n_perturb = max(1, seq_len // 4) + perturb_pos = rng.choice(seq_len, size=n_perturb, replace=False) + positive_ids[perturb_pos] = rng.integers(low=1, high=vocab_size, size=n_perturb) + + samples.append(_make_embedding_feature(anchor_ids.tolist(), label=1)) + samples.append(_make_embedding_feature(positive_ids.tolist(), label=0)) + + return samples + + +def _make_embedding_feature(input_ids: List[int], *, label: int) -> Dict[str, Any]: + """Build a single embedding pooling feature dict. + + ``labels`` is a single scalar per sample (``[label]``) — this mirrors the + anchor/positive grouping used by the bare-library embedding example + (cookbook/exp/embedding/train_embedding_full_ddp.py) where anchors carry + ``labels=[1]`` and positives carry ``labels=[0]``. + """ + seq_len = len(input_ids) + return { + 'input_ids': list(input_ids), + 'attention_mask': [1] * seq_len, + 'labels': [int(label)], + } + + +def iter_minibatches(dataset: List[Dict[str, Any]], batch_size: int) -> List[List[Dict[str, Any]]]: + """Split a dataset into contiguous minibatches. + + ``batch_size`` should be even so anchor/positive pairs stay together inside + each minibatch (InfoNCE assumes paired samples within a batch). + """ + if batch_size < 2 or batch_size % 2 != 0: + raise ValueError(f'batch_size must be a positive even number, got {batch_size}') + return [dataset[i:i + batch_size] for i in range(0, len(dataset), batch_size)] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Verification call sequence +# ═══════════════════════════════════════════════════════════════════════════ + +def configure_embedding_adapter( + model: Any, + *, + temperature: float = DEFAULT_TEMPERATURE, + use_batch: bool = True, + hard_negatives: Optional[int] = None, +) -> None: + """Apply the embedding-training configuration steps to a client model. + + Order matches the design document verification call sequence: + set_processor('InputProcessor') + -> set_loss('InfonceLoss', temperature=..., use_batch=True) + -> add_metric('EmbeddingMetric', is_training=True) + + Note: ``TransformersEmbeddingPatch`` is applied/rolled back automatically + inside ``forward`` via ``_resolve_task_context`` when ``task='embedding'`` is + passed, so there is intentionally no ``apply_patch(...)`` call here. + """ + model.set_processor('InputProcessor') + model.set_loss('InfonceLoss', temperature=temperature, use_batch=use_batch, hard_negatives=hard_negatives) + model.add_metric('EmbeddingMetric', is_training=True) + + +def extract_loss(fwd_bwd_response: Any) -> float: + """Best-effort extraction of the scalar loss from a forward_backward response. + + Handles both backend shapes: + * real transformers backend: ``result`` is a ModelOutput/dict with a + ``'loss'`` key (see TransformersModel.forward_backward). + * mock backend: ``result`` is ``[records, loss]`` (see + TwinkleCompatMockModel.forward_backward). + """ + result = getattr(fwd_bwd_response, 'result', fwd_bwd_response) + if isinstance(result, dict) and 'loss' in result: + return float(result['loss']) + if isinstance(result, (list, tuple)) and result and isinstance(result[-1], (int, float)): + return float(result[-1]) + raise AssertionError(f'Could not extract loss from forward_backward response: {result!r}') + + +def run_embedding_training( + model: Any, + minibatches: List[List[Dict[str, Any]]], + *, + temperature: float = DEFAULT_TEMPERATURE, + max_grad_norm: float = 1.0, + configure: bool = True, +) -> Dict[str, Any]: + """Run the design-document embedding-training verification call sequence. + + Steps: + (optional) set_processor -> set_loss -> add_metric + loop over minibatches: forward_backward(task='embedding') + clip_grad_and_step + calculate_metric(is_training=True) + + Args: + model: A configured client model (mock or GPU entrypoint). + minibatches: List of minibatches, each a list of embedding feature dicts. + temperature: InfoNCE temperature (used only when ``configure=True``). + max_grad_norm: Grad clipping threshold passed to ``clip_grad_and_step``. + configure: When True, apply ``configure_embedding_adapter`` first. + + Returns: + Dict with keys ``losses`` (list[float]) and ``metric`` (final metric result). + """ + if configure: + configure_embedding_adapter(model, temperature=temperature) + + losses: List[float] = [] + for step, mb in enumerate(minibatches): + fwd_bwd = model.forward_backward(inputs=mb, task='embedding') + loss = extract_loss(fwd_bwd) + losses.append(loss) + model.clip_grad_and_step(max_grad_norm=max_grad_norm) + log(f'[embedding step {step}] loss={loss:.6f}') + + metric = model.calculate_metric(is_training=True) + metric_result = getattr(metric, 'result', metric) + return {'losses': losses, 'metric': metric_result} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Client model builder (shared by both entrypoints) +# ═══════════════════════════════════════════════════════════════════════════ + +def build_embedding_client_model(model_id: str, *, adapter_name: str = 'emb_adapter') -> Any: + """Create a ``MultiLoraTransformersModel`` with a LoRA adapter for embedding. + + The InfoNCE loss / EmbeddingMetric / processor configuration is applied + separately by ``configure_embedding_adapter`` so that callers can inspect or + reorder the verification call sequence. + """ + from peft import LoraConfig + from twinkle_client.model import MultiLoraTransformersModel + + model = MultiLoraTransformersModel(model_id=model_id) + model.add_adapter_to_model(adapter_name, LoraConfig(target_modules='all-linear')) + model.set_template('Qwen3_5Template') + return model + + +# ═══════════════════════════════════════════════════════════════════════════ +# Local CPU-only mock server harness +# ═══════════════════════════════════════════════════════════════════════════ + +class MockEmbeddingServerHarness: + """Boots the CPU-only mock server in-process via Ray Serve. + + Mirrors the proven boot sequence in + tests/server/integration/test_mock_mode_startup.py: it manages its own local + Ray head node (so it does not depend on the session-scoped Ray fixture), + starts Ray Serve on a randomized port, and runs every application declared in + the CPU-only mock server config fixture (server + mock model + mock sampler). + + No GPU is required. + """ + + READY_BUDGET_SECONDS = 60.0 + RAY_NODE_CPUS = 8 + + def __init__(self) -> None: + self.host = '127.0.0.1' + self.port = 18100 + (os.getpid() % 800) + self.base_url = f'http://{self.host}:{self.port}' + self._started = False + + # ----- Ray lifecycle ------------------------------------------------- # + + @staticmethod + def _run_ray_command(*args: str) -> None: + ray_bin = os.path.join(os.path.dirname(sys.executable), 'ray') + result = subprocess.run( + [ray_bin, *args], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f'ray {" ".join(args)} failed ({result.returncode}):\n{result.stdout}') + + def start(self) -> str: + """Boot Ray + Serve + all mock apps; return the base URL when ready.""" + import ray + from ray import serve + + from tests.server.fixtures import MOCK_SERVER_CONFIG + from twinkle.server.config import ServerConfig + from twinkle.server.gateway import build_gateway_app + from twinkle.server.model import build_model_app + from twinkle.server.sampler import build_sampler_app + + cfg = ServerConfig.from_yaml(MOCK_SERVER_CONFIG) + + persistence_env: Dict[str, str] = {} + if cfg.persistence is not None: + persistence_env = cfg.persistence.to_env_vars() + for k, v in persistence_env.items(): + os.environ[k] = v + + if ray.is_initialized(): + ray.shutdown() + self._run_ray_command('stop', '--force') + self._run_ray_command( + 'start', '--head', '--port=0', f'--num-cpus={self.RAY_NODE_CPUS}', + '--num-gpus=0', '--include-dashboard=false', '--disable-usage-stats') + ray.init( + address='auto', + runtime_env={'env_vars': persistence_env} if persistence_env else None, + ) + self._started = True + + serve.start(http_options={'host': self.host, 'port': self.port}) + builders = { + 'server': build_gateway_app, + 'model': build_model_app, + 'sampler': build_sampler_app, + } + for app_spec in cfg.applications: + builder = builders[app_spec.import_path] + args = {k: v for k, v in dict(app_spec.args).items() if v is not None} + if app_spec.import_path == 'server': + http_opts = cfg.http_options.model_dump() + http_opts['host'] = self.host + http_opts['port'] = self.port + args.setdefault('http_options', http_opts) + deploy_options: Dict[str, Any] = {'ray_actor_options': {'num_cpus': 0.1}} + for raw in app_spec.deployments: + if isinstance(raw, dict): + deploy_options = { + k: v + for k, v in raw.items() if k not in ('name', 'ray_actor_options', 'autoscaling_config') + } + deploy_options['ray_actor_options'] = {'num_cpus': 0.1} + break + bound = builder(deploy_options=deploy_options, **args) + serve.run(bound, name=app_spec.name, route_prefix=app_spec.route_prefix) + + self._wait_until_healthy(serve, self.READY_BUDGET_SECONDS) + return self.base_url + + def _wait_until_healthy(self, serve_module: Any, timeout: float) -> None: + deadline = time.monotonic() + timeout + last: Dict[str, Any] = {} + while time.monotonic() < deadline: + status = serve_module.status() + last = {name: app.status for name, app in status.applications.items()} + if last and all(s == 'RUNNING' for s in last.values()): + return + time.sleep(0.5) + raise TimeoutError(f'Mock server not RUNNING within {timeout}s: {last}') + + def stop(self) -> None: + if not self._started: + return + try: + import ray + from ray import serve + try: + serve.shutdown() + except Exception: + pass + try: + ray.shutdown() + except Exception: + pass + finally: + try: + self._run_ray_command('stop', '--force') + except Exception: + pass + self._started = False + + +# ═══════════════════════════════════════════════════════════════════════════ +# Fixtures: the two switchable backend entrypoints +# ═══════════════════════════════════════════════════════════════════════════ + +@pytest.fixture(scope='module') +def mock_embedding_model(): + """Local CPU-only mock entrypoint (no GPU required). + + Boots the mock server in-process and yields a client model configured with a + LoRA adapter, ready for the embedding verification call sequence. Consumed by + task 4.2. + """ + harness = MockEmbeddingServerHarness() + base_url = harness.start() + try: + from twinkle_client import init_twinkle_client + init_twinkle_client(base_url=base_url, api_key='EMPTY_TOKEN') + model = build_embedding_client_model(MOCK_MODEL_ID) + yield model + finally: + harness.stop() + + +@pytest.fixture(scope='module') +def gpu_embedding_model(): + """GPU real-model entrypoint (gated by TWINKLE_TEST_GPU_E2E=1). + + Connects to an already-running GPU server (see start_e2e_server.py) and + yields a client model configured with a LoRA adapter. Consumed by tasks + 4.3-4.5. Skipped automatically when GPU e2e is not enabled. + """ + if not gpu_e2e_enabled(): + pytest.skip('Set TWINKLE_TEST_GPU_E2E=1 to run real GPU embedding E2E tests (requires running server)') + + wait_for_server() + init_twinkle_client_session() + model = build_embedding_client_model(MODEL_ID) + yield model + + +# ═══════════════════════════════════════════════════════════════════════════ +# Smoke tests — verify the scaffolding collects/imports and helpers behave. +# (Real protocol/numeric assertions live in tasks 4.2-4.5.) +# ═══════════════════════════════════════════════════════════════════════════ + +def test_synthetic_dataset_shape_and_labels(): + """Synthetic dataset is even-length with [1,0,1,0,...] anchor/positive labels.""" + num_pairs = 3 + dataset = build_synthetic_contrastive_dataset(num_pairs, seq_len=6, vocab_size=16, seed=123) + + assert len(dataset) == 2 * num_pairs + labels = [sample['labels'][0] for sample in dataset] + assert labels == [1, 0] * num_pairs + + for sample in dataset: + assert set(sample.keys()) == {'input_ids', 'attention_mask', 'labels'} + assert len(sample['input_ids']) == 6 + assert len(sample['attention_mask']) == 6 + assert all(m == 1 for m in sample['attention_mask']) + + +def test_synthetic_dataset_is_deterministic(): + """Same seed -> identical dataset (keeps mock/GPU comparisons reproducible).""" + a = build_synthetic_contrastive_dataset(2, seq_len=5, seed=7) + b = build_synthetic_contrastive_dataset(2, seq_len=5, seed=7) + assert a == b + + +def test_iter_minibatches_keeps_pairs_together(): + """Minibatching requires an even batch size and preserves order.""" + dataset = build_synthetic_contrastive_dataset(4, seq_len=4, seed=1) + batches = iter_minibatches(dataset, batch_size=4) + assert [len(b) for b in batches] == [4, 4] + assert batches[0] + batches[1] == dataset + + with pytest.raises(ValueError): + iter_minibatches(dataset, batch_size=3) + + +def test_extract_loss_handles_both_backend_shapes(): + """extract_loss understands mock ([records, loss]) and real ({'loss': ...}).""" + + class _Resp: + def __init__(self, result): + self.result = result + + # Mock backend shape: [records, loss] + assert extract_loss(_Resp([[{'logprobs': [0.1]}], 0.42])) == pytest.approx(0.42) + # Real backend shape: dict with 'loss' + assert extract_loss(_Resp({'loss': 1.5, 'logits': None})) == pytest.approx(1.5) + + with pytest.raises(AssertionError): + extract_loss(_Resp({'no_loss_here': 1})) + + +def test_gpu_fixture_gating_env_flag(): + """The GPU gate reflects TWINKLE_TEST_GPU_E2E without side effects.""" + assert gpu_e2e_enabled() == (os.environ.get('TWINKLE_TEST_GPU_E2E', '0') == '1') + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 4.2 — Local CPU protocol/link validation against the mock backend. +# +# These cases boot the CPU-only mock server (module-scoped ``mock_embedding_model`` +# fixture — booted ONCE, never rebooted per example) and drive the full embedding +# verification call sequence over HTTP: +# +# set_processor('InputProcessor') +# -> set_loss('InfonceLoss', temperature=..., use_batch=True) +# -> add_metric('EmbeddingMetric', is_training=True) +# -> forward_backward(inputs=mb, task='embedding') +# -> calculate_metric(is_training=True) +# +# They assert each HTTP request/response hop is well-formed, that ``task='embedding'`` +# is transmitted through the protocol layer to the mock backend without protocol +# errors, and — Property 1 (Validates: Requirements 1.1) — that the loss returned by +# ``forward_backward(task='embedding')`` is a finite number (math.isfinite: not NaN, +# not Inf). Here the mock loss layer validates the numeric-finiteness contract at the +# protocol-link level; real-model numeric semantics live in the GPU-gated tasks 4.3-4.5. +# +# Run locally (no GPU) via: +# conda run -n twinkle pytest tests/server/test_embedding_e2e.py -v -k mock +# ═══════════════════════════════════════════════════════════════════════════ + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + + +def test_mock_embedding_protocol_link_full_sequence(mock_embedding_model): + """Full ordered call sequence runs over HTTP against the mock backend. + + Validates that every hop of the design-document verification call sequence + (set_processor -> set_loss -> add_metric -> forward_backward(task='embedding') + -> calculate_metric) completes without any protocol-layer exception, that + ``task='embedding'`` is accepted through the /twinkle/* protocol and reaches + the mock backend, and — Property 1 — that every returned loss is finite. + + Validates: Requirements 1.1, 1.7, 7.1, 7.2 + """ + model = mock_embedding_model + + dataset = build_synthetic_contrastive_dataset(DEFAULT_NUM_PAIRS, seq_len=DEFAULT_SEQ_LEN, seed=0) + minibatches = iter_minibatches(dataset, batch_size=2 * DEFAULT_NUM_PAIRS) + + result = run_embedding_training(model, minibatches) + + losses = result['losses'] + # One loss per minibatch, and the sequence actually issued forward_backward calls. + assert len(losses) == len(minibatches) + assert losses, 'expected at least one forward_backward loss' + + # Property 1: every embedding forward_backward loss is a finite number. + for step, loss in enumerate(losses): + assert math.isfinite(loss), f'loss at step {step} is not finite: {loss!r}' + + # calculate_metric(is_training=True) returned a well-formed metric mapping. + metric = result['metric'] + assert isinstance(metric, dict), f'expected metric dict, got {type(metric)!r}' + + +def test_mock_embedding_task_embedding_is_passed_through(mock_embedding_model): + """``task='embedding'`` traverses the HTTP protocol layer to the mock backend. + + The mock backend intentionally ignores ``task`` semantically (it only exercises + dispatch), so we assert the protocol contract instead: a ``forward_backward`` + carrying ``task='embedding'`` is accepted end-to-end and returns a well-formed + response whose extracted loss is finite. This confirms the extra kwarg is + transported (via ``ForwardRequest.model_extra``) rather than rejected by the + /twinkle/forward_backward endpoint. + + Validates: Requirements 1.1, 1.7, 7.1, 7.2 + """ + model = mock_embedding_model + configure_embedding_adapter(model) + + dataset = build_synthetic_contrastive_dataset(2, seq_len=DEFAULT_SEQ_LEN, seed=42) + minibatch = dataset # single minibatch of 4 samples (2 pairs) + + response = model.forward_backward(inputs=minibatch, task='embedding') + + # Response is the ForwardBackwardResponse pydantic model with a ``result`` field. + assert hasattr(response, 'result'), f'malformed forward_backward response: {response!r}' + loss = extract_loss(response) + assert math.isfinite(loss), f'forward_backward(task="embedding") loss not finite: {loss!r}' + + +@settings( + max_examples=12, + deadline=None, + # ``mock_embedding_model`` is module-scoped (booted once and reused across all + # generated examples); suppress the health check so hypothesis does not object + # to the shared fixture and — critically — does NOT reboot the server per example. + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given( + num_pairs=st.integers(min_value=1, max_value=4), + seq_len=st.integers(min_value=2, max_value=12), + seed=st.integers(min_value=0, max_value=10_000), +) +def test_mock_embedding_loss_is_finite_property(mock_embedding_model, num_pairs, seq_len, seed): + """Property 1: forward_backward(task='embedding') loss is always finite. + + Varied contrastive dataset shapes (num_pairs, seq_len) and seeds are generated + within a SINGLE module-scoped mock server instance — the server is never + rebooted per example. For every generated minibatch, the loss returned by the + mock backend over the HTTP protocol link must be a finite number + (math.isfinite: not NaN, not Inf). + + Validates: Requirements 1.1 + """ + model = mock_embedding_model + # Idempotent no-op configuration on the mock backend; keeps the case self-contained. + configure_embedding_adapter(model) + + dataset = build_synthetic_contrastive_dataset(num_pairs, seq_len=seq_len, seed=seed) + # 2*num_pairs is a positive even batch size, so every anchor/positive pair stays + # together inside a single minibatch (InfoNCE assumes paired samples per batch). + minibatches = iter_minibatches(dataset, batch_size=2 * num_pairs) + + for step, mb in enumerate(minibatches): + response = model.forward_backward(inputs=mb, task='embedding') + loss = extract_loss(response) + assert math.isfinite(loss), ( + f'non-finite loss {loss!r} at step {step} ' + f'(num_pairs={num_pairs}, seq_len={seq_len}, seed={seed})') + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 4.3 — Single-GPU real-model loss finiteness (Property 1, GPU-gated). +# +# This case exercises the REAL transformers model backend through the Twinkle +# client HTTP path (module-scoped ``gpu_embedding_model`` fixture, gated behind +# TWINKLE_TEST_GPU_E2E=1 and an already-running GPU server; skipped otherwise). +# For a non-empty contrastive minibatch, the real-model +# ``forward_backward(task='embedding')`` loss must be a finite number +# (math.isfinite: not NaN, not Inf). Unlike the mock cases in task 4.2 (which +# validate the numeric-finiteness contract only at the protocol-link level), this +# asserts Property 1 against real InfoNCE numeric semantics on real model weights. +# +# Run on GPU via: +# TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest \ +# tests/server/test_embedding_e2e.py -v -k gpu +# ═══════════════════════════════════════════════════════════════════════════ + + +def test_gpu_embedding_real_model_loss_is_finite(gpu_embedding_model): + """Property 1: real-model forward_backward(task='embedding') loss is finite. + + Drives the design-document embedding verification call sequence + (set_processor -> set_loss('InfonceLoss', ...) -> add_metric('EmbeddingMetric') + -> forward_backward(task='embedding') + clip_grad_and_step) against the REAL + transformers backend and asserts that every loss returned by + ``forward_backward(task='embedding')`` over a non-empty contrastive minibatch + is a finite number (math.isfinite: not NaN, not Inf). + + GPU-gated (TWINKLE_TEST_GPU_E2E=1 + running GPU server): skipped automatically + on machines without a GPU, so this asserts real numeric semantics only on GPU + CI while collecting/skipping cleanly locally. + + Validates: Requirements 1.1 + """ + model = gpu_embedding_model + + dataset = build_synthetic_contrastive_dataset(DEFAULT_NUM_PAIRS, seq_len=DEFAULT_SEQ_LEN, seed=0) + minibatches = iter_minibatches(dataset, batch_size=2 * DEFAULT_NUM_PAIRS) + + # Property 1 requires a non-empty minibatch to actually exercise the loss path. + assert minibatches, 'expected at least one minibatch' + assert all(mb for mb in minibatches), 'expected every minibatch to be non-empty' + + result = run_embedding_training(model, minibatches) + + losses = result['losses'] + # One loss per minibatch, and the sequence actually issued forward_backward calls. + assert len(losses) == len(minibatches) + assert losses, 'expected at least one forward_backward loss' + + # Property 1: every real-model embedding forward_backward loss is finite. + for step, loss in enumerate(losses): + assert math.isfinite(loss), f'real-model loss at step {step} is not finite: {loss!r}' + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 4.4 — Single-GPU: TransformersEmbeddingPatch auto-rollback (Property 2). +# +# GPU-gated (TWINKLE_TEST_GPU_E2E=1). Skipped automatically on this GPU-less dev +# machine, and on any environment where the variable is unset. +# +# Property 2 (Validates: Requirements 1.2): TransformersEmbeddingPatch is applied +# *and rolled back automatically* inside a single forward call via +# ``_resolve_task_context(model, task='embedding')`` (src/twinkle/model/transformers/ +# transformers.py). While the patch is active, ``lm_head`` is swapped for identity +# and a forward hook replaces the model output with per-token hidden states +# (src/twinkle/patch/transformers_emb.py::_output_features_hook), i.e. the "logits" +# would carry the HIDDEN-STATE dimension. After the embedding forward_backward +# returns, the patch MUST be reverted, so the very next ``forward_only`` WITHOUT a +# ``task`` argument (defaults to 'causal_lm') must produce real language-model +# logits whose trailing dimension is the VOCABULARY size — never the leftover +# identity hidden states. +# +# This property depends on the real model's patch/unpatch semantics, which the +# CPU-only mock backend cannot exhibit, so it lives behind the GPU gate. +# +# Run on GPU (with a running GPU server) in the ``twinkle`` conda env: +# TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest tests/server/test_embedding_e2e.py -v -k gpu +# ═══════════════════════════════════════════════════════════════════════════ + + +def _innermost_dim(nested: Any) -> Optional[int]: + """Return the length of the innermost (last-axis) list of a nested list. + + ``forward_only`` returns logits that ``to_cpu_safe_output`` has converted from + a torch tensor of shape ``[B, T, V]`` (or a per-sample list of ``[T, V]``) into + plain nested Python lists. Descending to the innermost list of scalars yields + the trailing dimension ``V`` (the vocabulary size for real logits, or the hidden + size if the embedding patch had leaked). Returns ``None`` when the structure is + not a nested list (e.g. logits were suppressed to ``None``). + """ + cur = nested + while isinstance(cur, list) and cur and isinstance(cur[0], list): + cur = cur[0] + return len(cur) if isinstance(cur, list) else None + + +def _build_causal_sample(seq_len: int = DEFAULT_SEQ_LEN) -> Dict[str, Any]: + """Build a well-formed causal-LM feature (input_ids/attention_mask/labels aligned). + + Distinct from the embedding features (which carry a single scalar ``labels``): + here ``labels`` has the same length as ``input_ids`` so a causal ``forward_only`` + can compute logps without shape mismatch, and we can read back real vocab-dim + logits. Small, deterministic token ids keep it valid for any real tokenizer. + """ + input_ids = [(i % 7) + 1 for i in range(seq_len)] + return { + 'input_ids': list(input_ids), + 'attention_mask': [1] * seq_len, + 'labels': list(input_ids), + } + + +def test_gpu_embedding_patch_auto_rollback_property(gpu_embedding_model): + """Property 2: TransformersEmbeddingPatch auto-rolls back after an embedding step. + + Flow (single real GPU adapter, via the ``gpu_embedding_model`` fixture): + + 1. Configure the adapter for embedding training (set_processor -> set_loss + 'InfonceLoss' -> add_metric 'EmbeddingMetric'). + 2. Establish a baseline: call ``forward_only(return_logits=True)`` WITHOUT a + ``task`` argument BEFORE any embedding step. Since the patch is never + applied outside a ``task='embedding'`` forward, these baseline logits are + genuine vocabulary-dimension logits — the ground-truth "vocab dim". + 3. Run one real ``forward_backward(inputs=mb, task='embedding')``. This applies + TransformersEmbeddingPatch for the duration of that forward and must revert + it on the way out. + 4. Immediately call ``forward_only(return_logits=True)`` again WITHOUT ``task``. + + Assertions: + * The post-embedding logits exist and are 3D nested lists (a leaked patch + would instead make ``forward_only`` fail — the feature hook returns only + ``{'features': ...}`` with no ``logits`` key — or yield the hidden-state + dimension). + * The post-embedding logits' trailing dimension equals the baseline vocabulary + dimension, proving ``lm_head``/the forward hook were restored (not left as + identity emitting hidden states). + + Validates: Requirements 1.2, 7.2 + """ + model = gpu_embedding_model + configure_embedding_adapter(model) + + causal_sample = _build_causal_sample(seq_len=DEFAULT_SEQ_LEN) + + # (2) Baseline vocab-dim logits with the patch NEVER applied. + baseline_resp = model.forward_only(inputs=[causal_sample], return_logits=True) + baseline_result = getattr(baseline_resp, 'result', baseline_resp) + assert isinstance(baseline_result, dict), f'malformed forward_only response: {baseline_result!r}' + baseline_logits = baseline_result.get('logits') + assert baseline_logits is not None, 'baseline forward_only returned no logits (return_logits was set)' + vocab_dim = _innermost_dim(baseline_logits) + assert isinstance(vocab_dim, int) and vocab_dim > 1, ( + f'baseline logits trailing dim is not a valid vocab size: {vocab_dim!r}') + + # (3) One real embedding step: applies + must auto-rollback the patch. + emb_dataset = build_synthetic_contrastive_dataset(2, seq_len=DEFAULT_SEQ_LEN, seed=0) + fwd_bwd = model.forward_backward(inputs=emb_dataset, task='embedding') + emb_loss = extract_loss(fwd_bwd) + assert math.isfinite(emb_loss), f'embedding forward_backward loss not finite: {emb_loss!r}' + + # (4) Post-embedding causal forward_only WITHOUT task: patch must be gone. + post_resp = model.forward_only(inputs=[causal_sample], return_logits=True) + post_result = getattr(post_resp, 'result', post_resp) + assert isinstance(post_result, dict), ( + f'post-embedding forward_only returned malformed result (patch may have leaked): {post_result!r}') + post_logits = post_result.get('logits') + assert post_logits is not None, ( + 'post-embedding forward_only returned no logits — the embedding patch likely ' + 'did NOT roll back (feature hook suppresses the logits key)') + post_dim = _innermost_dim(post_logits) + + # Property 2: trailing dim is the vocab dim (identical to baseline), NOT the + # leftover identity hidden-state dim. + assert post_dim == vocab_dim, ( + f'TransformersEmbeddingPatch did NOT auto-rollback: post-embedding logits ' + f'trailing dim {post_dim!r} != baseline vocab dim {vocab_dim!r} — logits appear ' + f'to reuse identity hidden states from the embedding task') + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 4.5 — Single-GPU: bare-library parity + pos_sim upward trend (GPU-gated). +# +# These cases exercise the REAL transformers model backend and assert real +# numeric semantics that the mock backend cannot express, so they live behind +# the GPU gate (module-scoped ``gpu_embedding_model`` fixture — gated by +# TWINKLE_TEST_GPU_E2E=1 and a running GPU server; skipped automatically +# otherwise). Two properties are validated: +# +# * Requirement 1.5 — Twinkle client HTTP path vs. bare-library path parity: +# the same small synthetic contrastive dataset is trained for the same number +# of steps through (a) the Twinkle client HTTP path (task 4.1 helpers) and +# (b) the bare-library training path used by +# ``cookbook/exp/embedding/train_embedding_full_ddp.py``. The two losses must +# stay within the SAME ORDER OF MAGNITUDE (digit-for-digit equality is NOT +# required). +# +# * Requirement 1.6 — pos_sim upward trend: after several real training steps +# on a dataset with a clear contrastive signal, ``calculate_metric(is_training= +# True)`` must report an increasing anchor-positive cosine similarity +# (``pos_sim``) relative to the untrained baseline. +# +# NOTE on the "bare-library path": the published script +# ``cookbook/exp/embedding/train_embedding_full_ddp.py`` orchestrates an 8-GPU +# online-compression pipeline (Ray, vLLM condenser, real datasets) that cannot be +# executed verbatim inside a single-GPU test. This module instead reproduces the +# script's ESSENTIAL bare-library embedding-training primitives IN-PROCESS with +# the very same core-library classes it uses — +# TransformersModel/MultiLoraTransformersModel +# + set_processor(InputProcessor) +# + set_loss(InfonceLoss, temperature=..., use_batch=True, hard_negatives=None) +# + set_optimizer('AdamW', ...) +# + add_metric(EmbeddingMetric, is_training=True) +# + loop: forward_backward(inputs=mb, task='embedding') + clip_grad_and_step(...) +# + calculate_metric(is_training=True) +# — running on the SAME synthetic dataset and the SAME number of steps as the HTTP +# path. A LoRA adapter is used (instead of full fine-tuning) purely to keep the +# in-process GPU memory footprint tractable next to the running server; the loss +# numerics being compared come from the identical InfoNCE + embedding-pooling code +# path exercised by the bare script. +# +# Run on GPU via: +# TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle pytest \ +# tests/server/test_embedding_e2e.py -v -k gpu +# ═══════════════════════════════════════════════════════════════════════════ + +# Dataset / training shape for the parity + trend cases (kept small for GPU speed +# while still giving InfoNCE a real contrastive signal to learn from). +CMP_NUM_PAIRS = 6 # -> 12 samples +CMP_BATCH_SIZE = 4 # -> 3 minibatches == 3 training steps for BOTH paths +TREND_NUM_PAIRS = 6 # -> 12-sample fixed eval minibatch +TREND_STEPS = 20 # real training steps to expose the pos_sim trend +TREND_LR = 1e-3 # aggressive LR so the trend is visible within few steps + + +def _metric_dict(metric_response: Any) -> Dict[str, Any]: + """Normalize a calculate_metric result to a plain dict. + + Handles the client HTTP shape (``CalculateMetricResponse`` with a ``result`` + attribute) and the bare-library local shape (a plain dict returned directly). + """ + result = getattr(metric_response, 'result', metric_response) + assert isinstance(result, dict), f'expected metric dict, got {type(result)!r}: {result!r}' + return result + + +def _parse_metric_float(metric_response: Any, key: str) -> float: + """Parse a single float metric value (EmbeddingMetric formats values as strings).""" + result = _metric_dict(metric_response) + assert key in result, f'metric {key!r} missing from result: {result!r}' + return float(result[key]) + + +def _order_of_magnitude(value: float) -> int: + """Return floor(log10(|value|)); 0 is treated as magnitude 0.""" + if value == 0: + return 0 + return int(math.floor(math.log10(abs(value)))) + + +def _assert_same_order_of_magnitude(a: float, b: float, *, label: str) -> None: + """Assert two positive losses share the same order of magnitude. + + "Same order of magnitude" is interpreted as a bounded ratio in [0.1, 10] + (equivalently, their base-10 exponents differ by at most 1). Digit-for-digit + equality is intentionally NOT required (Requirement 1.5). + """ + assert math.isfinite(a) and math.isfinite(b), f'[{label}] non-finite losses: {a!r}, {b!r}' + assert a > 0 and b > 0, f'[{label}] expected positive InfoNCE losses, got {a!r}, {b!r}' + ratio = a / b + assert 0.1 <= ratio <= 10.0, ( + f'[{label}] losses differ by more than one order of magnitude: ' + f'client={a:.6f}, bare={b:.6f}, ratio={ratio:.4f} ' + f'(oom client={_order_of_magnitude(a)}, bare={_order_of_magnitude(b)})') + log(f'[{label}] same order of magnitude: client={a:.6f}, bare={b:.6f}, ratio={ratio:.4f}') + + +def run_bare_library_embedding_training( + minibatches: List[List[Dict[str, Any]]], + *, + model_id: str = MODEL_ID, + temperature: float = DEFAULT_TEMPERATURE, + lr: float = 1e-4, + max_grad_norm: float = 1.0, + adapter_name: str = 'bare_emb_adapter', +) -> Dict[str, Any]: + """Run the bare-library embedding-training path IN-PROCESS on a single GPU. + + Reproduces the essential core-library primitives used by + ``cookbook/exp/embedding/train_embedding_full_ddp.py`` (see the module section + comment for why the full multi-GPU script cannot be executed verbatim): + + model = MultiLoraTransformersModel(model_id=...) + model.add_adapter_to_model(adapter, LoraConfig(target_modules='all-linear')) + model.set_processor(InputProcessor) + model.set_loss(InfonceLoss, temperature=..., use_batch=True, hard_negatives=None) + model.set_optimizer('AdamW', lr=...) + model.add_metric(EmbeddingMetric, is_training=True) + for mb in minibatches: + model.forward_backward(inputs=mb, task='embedding') + model.clip_grad_and_step(...) + model.calculate_metric(is_training=True) + + The model runs in ``local`` mode (the default when ``TWINKLE_MODE`` is unset), + so the ``@remote_function`` decorators call straight through and every method + requires an explicit ``adapter_name`` kwarg (unlike the client wrapper which + tracks it internally). + + Args: + minibatches: The SAME minibatches trained by the HTTP path (same order/steps). + model_id: Base model id (defaults to the shared real-model id). + temperature: InfoNCE temperature (matches the client path). + lr: AdamW learning rate. + max_grad_norm: Grad clipping threshold. + adapter_name: LoRA adapter name for the in-process model. + + Returns: + Dict with ``losses`` (list[float]) and ``metric`` (final metric dict). + """ + import gc + + from peft import LoraConfig + + from twinkle.loss import InfonceLoss + from twinkle.metric import EmbeddingMetric + from twinkle.model import MultiLoraTransformersModel + from twinkle.processor import InputProcessor + + model = None + try: + model = MultiLoraTransformersModel(model_id=model_id) + model.add_adapter_to_model(adapter_name, LoraConfig(target_modules='all-linear')) + model.set_processor(InputProcessor, adapter_name=adapter_name) + model.set_loss( + InfonceLoss, temperature=temperature, use_batch=True, hard_negatives=None, + adapter_name=adapter_name) + model.set_optimizer(optimizer_cls='AdamW', lr=lr, adapter_name=adapter_name) + model.add_metric(EmbeddingMetric, is_training=True, adapter_name=adapter_name) + + losses: List[float] = [] + for step, mb in enumerate(minibatches): + outputs = model.forward_backward(inputs=mb, task='embedding', adapter_name=adapter_name) + loss = extract_loss(outputs) + losses.append(loss) + model.clip_grad_and_step(max_grad_norm=max_grad_norm, adapter_name=adapter_name) + log(f'[bare embedding step {step}] loss={loss:.6f}') + + metric = model.calculate_metric(is_training=True, adapter_name=adapter_name) + return {'losses': losses, 'metric': _metric_dict(metric)} + finally: + # Free the in-process model's GPU memory so it does not linger next to the + # running server (the test process holds a full second copy of the weights). + try: + del model + except Exception: + pass + gc.collect() + try: + import torch + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + + +def test_gpu_embedding_loss_matches_bare_library_order_of_magnitude(gpu_embedding_model): + """Requirement 1.5: client HTTP path and bare-library path losses match in magnitude. + + Trains the SAME small synthetic contrastive dataset for the SAME number of + steps through two independent paths: + + * the Twinkle client HTTP path (task 4.1 helpers), against the real GPU + server, and + * the bare-library training path reproduced in-process from + ``cookbook/exp/embedding/train_embedding_full_ddp.py``. + + Both paths use a fresh, untrained LoRA adapter, the same InfoNCE temperature, + the same AdamW learning rate, and the same minibatch sequence, so the observed + losses are directly comparable. The assertion requires only that the mean + losses stay within the SAME ORDER OF MAGNITUDE — digit-for-digit equality is + explicitly NOT required (LoRA weights are randomly initialized independently on + each path, so the two runs are not bit-identical). + + GPU-gated (TWINKLE_TEST_GPU_E2E=1 + running GPU server): skipped automatically + on machines without a GPU, so it collects/skips cleanly locally and asserts + real numeric semantics only on GPU CI. + + Validates: Requirements 1.5, 7.2 + """ + # ``gpu_embedding_model`` guarantees the GPU gate + a live server/session; use a + # dedicated fresh client adapter so the comparison is order-independent of other + # GPU cases that may have already trained the shared fixture adapter. + client_model = build_embedding_client_model(MODEL_ID, adapter_name='emb_cmp_adapter') + + dataset = build_synthetic_contrastive_dataset(CMP_NUM_PAIRS, seq_len=DEFAULT_SEQ_LEN, seed=0) + minibatches = iter_minibatches(dataset, batch_size=CMP_BATCH_SIZE) + assert minibatches, 'expected at least one minibatch' + assert all(mb for mb in minibatches), 'expected every minibatch to be non-empty' + + # ---- Client HTTP path -------------------------------------------------- # + # Configure explicitly (processor + loss + metric) and add an optimizer with a + # matching LR, then train WITHOUT re-configuring inside run_embedding_training. + configure_embedding_adapter(client_model, temperature=DEFAULT_TEMPERATURE) + client_model.set_optimizer('AdamW', lr=1e-4) + client_result = run_embedding_training(client_model, minibatches, configure=False) + client_losses = client_result['losses'] + + # ---- Bare-library path (in-process, same dataset + steps) -------------- # + bare_result = run_bare_library_embedding_training( + minibatches, temperature=DEFAULT_TEMPERATURE, lr=1e-4) + bare_losses = bare_result['losses'] + + # Both paths issued exactly one loss per (identical) minibatch. + assert len(client_losses) == len(minibatches), ( + f'client produced {len(client_losses)} losses for {len(minibatches)} minibatches') + assert len(bare_losses) == len(minibatches), ( + f'bare produced {len(bare_losses)} losses for {len(minibatches)} minibatches') + assert client_losses and bare_losses, 'both paths must produce at least one loss' + + for step, loss in enumerate(client_losses): + assert math.isfinite(loss), f'client loss at step {step} not finite: {loss!r}' + for step, loss in enumerate(bare_losses): + assert math.isfinite(loss), f'bare loss at step {step} not finite: {loss!r}' + + # Same order of magnitude on the mean loss across the identical step sequence. + client_mean = sum(client_losses) / len(client_losses) + bare_mean = sum(bare_losses) / len(bare_losses) + _assert_same_order_of_magnitude(client_mean, bare_mean, label='embedding-loss-parity') + + +def test_gpu_embedding_pos_sim_increases_after_training(gpu_embedding_model): + """Requirement 1.6: pos_sim rises after several real training steps. + + Uses a fresh, untrained LoRA adapter on the real GPU model and repeatedly + trains on a fixed synthetic contrastive minibatch (anchors and their positives + share correlated tokens, so InfoNCE has a clear signal). ``pos_sim`` (the + anchor-positive cosine similarity reported by ``EmbeddingMetric``) is measured + on the SAME fixed minibatch before training and after each step, and must show + an upward trend: the average of the last few measurements exceeds the average + of the first few. + + This is a real numeric-semantics property that the mock backend cannot express + (mock embeddings carry no contrastive signal), hence the GPU gate. + + GPU-gated (TWINKLE_TEST_GPU_E2E=1 + running GPU server): skipped automatically + on machines without a GPU. + + Validates: Requirements 1.6, 7.2 + """ + # Fresh dedicated adapter -> clean untrained baseline, independent of other cases. + model = build_embedding_client_model(MODEL_ID, adapter_name='emb_trend_adapter') + configure_embedding_adapter(model, temperature=DEFAULT_TEMPERATURE) + model.set_optimizer('AdamW', lr=TREND_LR) + + dataset = build_synthetic_contrastive_dataset(TREND_NUM_PAIRS, seq_len=DEFAULT_SEQ_LEN, seed=0) + eval_mb = dataset # single fixed minibatch reused for every measurement + assert eval_mb, 'expected a non-empty eval minibatch' + # Sanity: the dataset must contain anchors (label==1) for pos_sim to be defined. + assert any(sample['labels'][0] == 1 for sample in eval_mb), 'eval minibatch has no anchors' + + pos_sims: List[float] = [] + for step in range(TREND_STEPS + 1): + # Measure pos_sim on the CURRENT (pre-step) weights, then take a train step. + model.forward_backward(inputs=eval_mb, task='embedding') + metric = model.calculate_metric(is_training=True) + pos_sims.append(_parse_metric_float(metric, 'pos_sim')) + model.clip_grad_and_step(max_grad_norm=1.0) + + # Every measurement must be finite and a valid cosine similarity in [-1, 1]. + for step, ps in enumerate(pos_sims): + assert math.isfinite(ps), f'pos_sim at measurement {step} not finite: {ps!r}' + assert -1.0001 <= ps <= 1.0001, f'pos_sim at measurement {step} out of range: {ps!r}' + + # Upward trend: average of the last 3 measurements exceeds the first 3 + # (mirrors the robust first-avg/last-avg comparison used elsewhere in the + # e2e helpers, tolerating per-step noise). + assert len(pos_sims) >= 6, f'need >=6 pos_sim measurements, got {len(pos_sims)}' + first_avg = sum(pos_sims[:3]) / 3 + last_avg = sum(pos_sims[-3:]) / 3 + assert last_avg > first_avg, ( + f'pos_sim did NOT increase after training: ' + f'first_3_avg={first_avg:.4f} >= last_3_avg={last_avg:.4f} ' + f'(full trace: {[round(p, 4) for p in pos_sims]})') + log(f'[embedding pos_sim trend] {first_avg:.4f} -> {last_avg:.4f} ' + f'(baseline={pos_sims[0]:.4f}, final={pos_sims[-1]:.4f})') diff --git a/tests/server/test_embedding_e2e_sp_cp.py b/tests/server/test_embedding_e2e_sp_cp.py new file mode 100644 index 000000000..e849fa5b7 --- /dev/null +++ b/tests/server/test_embedding_e2e_sp_cp.py @@ -0,0 +1,446 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Embedding training E2E validation under SP/CP distributed configs (task 5.1). + +This module reuses the synthetic contrastive dataset and the embedding +verification call sequence from ``tests/server/test_embedding_e2e.py`` and reruns +the embedding training flow under a MULTI-GPU device mesh that enables sequence +parallel (SP, ``ulysses_size > 1``) and/or context parallel (CP, ``cp_size > 1``), +then asserts that the resulting ``pos_sim`` (anchor-positive cosine similarity) +stays within a reasonable tolerance of the single-card baseline. + +The distributed device mesh is constructed with +``DeviceMesh.from_sizes(fsdp_size=..., ulysses_size=..., cp_size=...)`` (see +``src/twinkle/utils/device_mesh.py``) and driven across ranks with +``torch.multiprocessing.spawn``, mirroring the multi-rank harness in +``tests/transformers/test_sequence_parallel_and_cp.py``. Each rank builds a real +``MultiLoraTransformersModel`` bound to that mesh; SP is enabled automatically +inside the model whenever ``device_mesh.ulysses_size > 1``. + +============================================================================== +ENVIRONMENT CONSTRAINTS (MANDATORY — READ BEFORE RUNNING) +============================================================================== +1. MULTI-GPU required. These cases spawn ``world_size`` distributed workers, one + per GPU (SP-only needs >= 2 GPUs; combined CP+SP needs >= 4 GPUs). They are + gated behind ``TWINKLE_TEST_GPU_E2E=1`` AND an actual multi-GPU CUDA runtime, + and are SKIPPED automatically on a machine without enough GPUs (including this + GPU-less dev machine). + +2. ``twinkle`` conda env required. Run every case inside the ``twinkle`` conda + env, e.g.: + + TWINKLE_TEST_GPU_E2E=1 conda run -n twinkle \ + pytest tests/server/test_embedding_e2e_sp_cp.py -v + + On a GPU-less machine the file still collects/imports cleanly and skips: + + conda run -n twinkle pytest tests/server/test_embedding_e2e_sp_cp.py -v +============================================================================== +""" +from __future__ import annotations + +import json +import math +import os +import socket +import sys +import tempfile +from typing import Any, Dict, List, Optional + +# Ensure project root is importable for both pytest and direct execution. +_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import pytest + +# Reuse the task 4.1-4.5 scaffolding (dataset, minibatching, model id, metric +# parsing, loss extraction, GPU gate) so the SP/CP flow exercises the SAME +# synthetic dataset and the SAME verification call sequence as the single-card +# path — the only difference is the distributed device mesh. +from tests.server.test_embedding_e2e import ( + DEFAULT_SEQ_LEN, + DEFAULT_TEMPERATURE, + MODEL_ID, + _parse_metric_float, + build_synthetic_contrastive_dataset, + extract_loss, + gpu_e2e_enabled, + iter_minibatches, +) +from tests.server.integration.e2e_helpers import log + +# ═══════════════════════════════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════════════════════════════ + +# Dataset / training shape (kept small for GPU speed while still giving InfoNCE a +# real contrastive signal). Mirrors the pos_sim-trend shape used in task 4.5. +SP_CP_NUM_PAIRS = 6 # -> 12 samples +SP_CP_BATCH_SIZE = 4 # -> 3 minibatches == 3 training steps +SP_CP_TRAIN_STEPS = 8 # real training steps before measuring pos_sim +SP_CP_LR = 1e-3 # aggressive LR so weights actually move within few steps +SP_CP_SEED = 1234 # identical seed on baseline + distributed runs + +# ``pos_sim`` is a cosine similarity in [-1, 1]. SP/CP pooling is mathematically +# equivalent to the single-card path, so trained values should agree closely; +# a loose absolute tolerance accommodates bf16 + distributed reduction noise. +POS_SIM_ATOL = 5e-2 + +# Distributed configs exercised by the parametrized case. +# +# In the transformers backend, sequence/context parallel is driven by +# ``device_mesh.ulysses_size`` (see TransformersModel._decide_strategy: SP is +# enabled whenever ``ulysses_size > 1``). Ulysses degree is *derived* from the +# data ranks and therefore does NOT multiply the mesh world size — so +# ``from_sizes(fsdp_size=W, ulysses_size=U)`` yields exactly ``W`` ranks with a +# single ulysses group of size ``U``. Whether that group behaves as pure SP or a +# CP (ring-attention) mode is derived internally from ``ulysses_size`` vs. the +# model's attention-head count (see tests/transformers/test_sequence_parallel_and_cp.py), +# which is why both SP and CP are exercised through ``ulysses_size`` here. +# +# Each config is skipped unless its required GPU count is available. +DIST_CONFIGS = [ + # SP: 2 GPUs, ulysses_size=2 (sequence parallel enabled over 2 ranks). + {'name': 'sp2', 'world_size': 2, 'ulysses_size': 2, 'cp_size': None}, + # SP over 4 GPUs: ulysses_size=4 (larger sequence/context-parallel group). + {'name': 'sp4', 'world_size': 4, 'ulysses_size': 4, 'cp_size': None}, +] + + +def _cuda_device_count() -> int: + """Return the CUDA device count, or 0 when torch/CUDA is unavailable.""" + try: + import torch + if torch.cuda.is_available(): + return int(torch.cuda.device_count()) + except Exception: + pass + return 0 + + +def multi_gpu_e2e_enabled(min_devices: int = 2) -> bool: + """True only when GPU e2e is enabled AND enough CUDA devices are present.""" + return gpu_e2e_enabled() and _cuda_device_count() >= min_devices + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('127.0.0.1', 0)) + return int(sock.getsockname()[1]) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Distributed worker: build an SP/CP device mesh, train, report pos_sim. +# +# Runs inside every spawned rank. Must be a module-level function so it is +# picklable by ``torch.multiprocessing.spawn`` (start method: spawn). +# ═══════════════════════════════════════════════════════════════════════════ + + +def _init_distributed(rank: int, world_size: int, port: int): + """Initialize the process group + pin this rank to its CUDA device.""" + import torch + import torch.distributed as dist + + os.environ['RANK'] = str(rank) + os.environ['WORLD_SIZE'] = str(world_size) + os.environ['LOCAL_RANK'] = str(rank) + os.environ['LOCAL_WORLD_SIZE'] = str(world_size) + os.environ['MASTER_ADDR'] = '127.0.0.1' + os.environ['MASTER_PORT'] = str(port) + + torch.cuda.set_device(rank) + device = torch.device(f'cuda:{rank}') + if not dist.is_initialized(): + dist.init_process_group( + backend='nccl', + rank=rank, + world_size=world_size, + init_method=f'tcp://127.0.0.1:{port}', + ) + return device + + +def _seed_everything(seed: int) -> None: + import torch + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def _build_embedding_model_with_mesh( + world_size: int, + ulysses_size: Optional[int], + cp_size: Optional[int], + *, + adapter_name: str, + temperature: float, + lr: float, +): + """Build a real ``MultiLoraTransformersModel`` bound to an SP/CP device mesh. + + Uses the SAME bare-library primitives as the single-card path in task 4.5 + (InputProcessor + InfonceLoss + EmbeddingMetric + AdamW), the only difference + being the distributed ``DeviceMesh`` passed to the constructor. SP is enabled + automatically by the transformers backend whenever ``ulysses_size > 1``. + """ + from peft import LoraConfig + + from twinkle.loss import InfonceLoss + from twinkle.metric import EmbeddingMetric + from twinkle.model import MultiLoraTransformersModel + from twinkle.processor import InputProcessor + from twinkle.utils import DeviceMesh + + # fsdp_size = world_size shards the model across all ranks; ulysses_size + # enables sequence/context parallel derived from those data ranks (it does + # not change the world size). cp_size, when provided, adds an explicit CP + # mesh dimension (and therefore multiplies the world size). + mesh = DeviceMesh.from_sizes( + fsdp_size=world_size, + dp_size=1, + ulysses_size=ulysses_size, + cp_size=cp_size, + device_type='cuda', + ) + + model = MultiLoraTransformersModel(model_id=MODEL_ID, device_mesh=mesh) + model.add_adapter_to_model(adapter_name, LoraConfig(target_modules='all-linear')) + model.set_processor(InputProcessor, adapter_name=adapter_name) + model.set_loss( + InfonceLoss, temperature=temperature, use_batch=True, hard_negatives=None, + adapter_name=adapter_name) + model.set_optimizer(optimizer_cls='AdamW', lr=lr, adapter_name=adapter_name) + model.add_metric(EmbeddingMetric, is_training=True, adapter_name=adapter_name) + return model, adapter_name + + +def _distributed_embedding_pos_sim_worker( + rank: int, + world_size: int, + port: int, + ulysses_size: Optional[int], + cp_size: Optional[int], + seed: int, + result_path: str, +) -> None: + """Spawned worker: run embedding training under the given mesh, emit pos_sim. + + Every rank trains identically (FSDP-sharded, SP/CP-parallel). Rank 0 writes + the final ``pos_sim`` (plus the per-step losses) to ``result_path`` as JSON so + the parent process can compare it against the single-card baseline. + """ + import gc + + import torch + import torch.distributed as dist + + device = _init_distributed(rank, world_size, port) + model = None + try: + _seed_everything(seed) + + # SAME synthetic dataset + minibatch sequence as the single-card path. + dataset = build_synthetic_contrastive_dataset( + SP_CP_NUM_PAIRS, seq_len=DEFAULT_SEQ_LEN, seed=0) + minibatches = iter_minibatches(dataset, batch_size=SP_CP_BATCH_SIZE) + + adapter_name = f'emb_sp_cp_adapter_u{ulysses_size}_c{cp_size}' + model, adapter_name = _build_embedding_model_with_mesh( + world_size, ulysses_size, cp_size, + adapter_name=adapter_name, temperature=DEFAULT_TEMPERATURE, lr=SP_CP_LR) + + losses: List[float] = [] + # Repeat the (short) minibatch sequence until SP_CP_TRAIN_STEPS steps run. + step = 0 + while step < SP_CP_TRAIN_STEPS: + for mb in minibatches: + if step >= SP_CP_TRAIN_STEPS: + break + outputs = model.forward_backward(inputs=mb, task='embedding', adapter_name=adapter_name) + losses.append(extract_loss(outputs)) + model.clip_grad_and_step(max_grad_norm=1.0, adapter_name=adapter_name) + step += 1 + + metric = model.calculate_metric(is_training=True, adapter_name=adapter_name) + pos_sim = _parse_metric_float(metric, 'pos_sim') + + if rank == 0: + with open(result_path, 'w') as f: + json.dump({'pos_sim': pos_sim, 'losses': losses}, f) + log(f'[sp_cp worker u={ulysses_size} c={cp_size}] pos_sim={pos_sim:.6f} ' + f'losses={[round(x, 4) for x in losses]}') + dist.barrier() + finally: + try: + del model + except Exception: + pass + gc.collect() + try: + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + if dist.is_initialized(): + dist.destroy_process_group() + + +def _run_embedding_pos_sim( + world_size: int, + ulysses_size: Optional[int], + cp_size: Optional[int], + *, + seed: int = SP_CP_SEED, +) -> Dict[str, Any]: + """Spawn ``world_size`` workers, run embedding training, return rank-0 result. + + ``world_size == 1`` with ``ulysses_size in (None, 1)`` yields the single-card + baseline (SP/CP disabled). Larger world sizes exercise the SP/CP mesh. The + spawn path is identical for both so the ONLY variable is the device mesh. + """ + import torch.multiprocessing as mp + + port = _find_free_port() + fd, result_path = tempfile.mkstemp(prefix='emb_sp_cp_', suffix='.json') + os.close(fd) + try: + mp.spawn( + _distributed_embedding_pos_sim_worker, + args=(world_size, port, ulysses_size, cp_size, seed, result_path), + nprocs=world_size, + join=True, + ) + with open(result_path) as f: + return json.load(f) + finally: + try: + os.remove(result_path) + except OSError: + pass + + +# ═══════════════════════════════════════════════════════════════════════════ +# Single-card baseline (module-scoped: computed once, reused by every config). +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture(scope='module') +def single_card_pos_sim(): + """Baseline ``pos_sim`` from a single-card (SP/CP-disabled) embedding run. + + Gated behind the multi-GPU e2e requirement so it skips cleanly on machines + without enough GPUs. Runs through the same spawn harness with + ``world_size=1`` / ``ulysses_size=1`` so the baseline and the distributed + runs differ ONLY in the device mesh. + """ + if not multi_gpu_e2e_enabled(min_devices=2): + pytest.skip( + 'Requires TWINKLE_TEST_GPU_E2E=1 and >= 2 CUDA devices ' + '(multi-GPU SP/CP embedding e2e).') + + result = _run_embedding_pos_sim(world_size=1, ulysses_size=1, cp_size=None) + pos_sim = result['pos_sim'] + log(f'[sp_cp baseline] single-card pos_sim={pos_sim:.6f}') + return pos_sim + + +# ═══════════════════════════════════════════════════════════════════════════ +# Task 5.1 — SP/CP pos_sim consistency (multi-GPU, GPU-gated). +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.parametrize('config', DIST_CONFIGS, ids=[c['name'] for c in DIST_CONFIGS]) +def test_gpu_sp_cp_pos_sim_matches_single_card(config, single_card_pos_sim): + """SP/CP pos_sim agrees with the single-card baseline within tolerance. + + Reruns the task 4.1-4.5 embedding verification flow on the SAME synthetic + contrastive dataset under a distributed device mesh built with + ``DeviceMesh.from_sizes(fsdp_size=world_size, ulysses_size=..., cp_size=...)`` + (SP when ``ulysses_size > 1``, CP when ``cp_size > 1``), then asserts the + resulting ``pos_sim`` stays within ``POS_SIM_ATOL`` of the single-card value. + + Multi-GPU + GPU-gated (TWINKLE_TEST_GPU_E2E=1): each config is skipped unless + its required GPU count is available, so the file collects/skips cleanly on a + GPU-less machine and asserts real distributed numeric semantics only on + multi-GPU CI. + + Validates: Requirements 1.3, 7.2 + """ + world_size = int(config['world_size']) + if not multi_gpu_e2e_enabled(min_devices=world_size): + pytest.skip( + f"Config {config['name']!r} needs TWINKLE_TEST_GPU_E2E=1 and " + f'>= {world_size} CUDA devices (have {_cuda_device_count()}).') + + result = _run_embedding_pos_sim( + world_size=world_size, + ulysses_size=config['ulysses_size'], + cp_size=config['cp_size'], + ) + dist_pos_sim = result['pos_sim'] + + # pos_sim is a valid cosine similarity. + assert math.isfinite(dist_pos_sim), f"non-finite pos_sim: {dist_pos_sim!r}" + assert -1.0001 <= dist_pos_sim <= 1.0001, f'pos_sim out of range: {dist_pos_sim!r}' + + delta = abs(dist_pos_sim - single_card_pos_sim) + log(f"[sp_cp {config['name']}] dist_pos_sim={dist_pos_sim:.6f} " + f'baseline={single_card_pos_sim:.6f} delta={delta:.6f}') + assert delta <= POS_SIM_ATOL, ( + f"[{config['name']}] SP/CP pos_sim diverged from single-card baseline: " + f'dist={dist_pos_sim:.6f}, baseline={single_card_pos_sim:.6f}, ' + f'delta={delta:.6f} > atol={POS_SIM_ATOL}') + + +# ═══════════════════════════════════════════════════════════════════════════ +# Local (GPU-less) collection guards — verify the file imports/collects/skips +# cleanly and the mesh-config helpers behave, WITHOUT requiring any GPU. +# ═══════════════════════════════════════════════════════════════════════════ + + +def test_multi_gpu_gate_reflects_env_and_devices(): + """The multi-GPU gate is False unless BOTH the env flag and >=N GPUs exist.""" + expected = gpu_e2e_enabled() and _cuda_device_count() >= 2 + assert multi_gpu_e2e_enabled(min_devices=2) == expected + + +def test_dist_configs_are_well_formed(): + """Every declared SP/CP config enables SP and/or CP with a matching world size.""" + for cfg in DIST_CONFIGS: + assert cfg['world_size'] >= 2, cfg + sp = (cfg['ulysses_size'] or 1) > 1 + cp = (cfg['cp_size'] or 1) > 1 + assert sp or cp, f'config enables neither SP nor CP: {cfg}' + # ulysses/cp parallelism must fit within the world size. + assert (cfg['ulysses_size'] or 1) <= cfg['world_size'], cfg + assert (cfg['cp_size'] or 1) <= cfg['world_size'], cfg + + +def test_device_mesh_from_sizes_builds_sp_cp_mesh(): + """DeviceMesh.from_sizes wires ulysses_size/cp_size into a multi-rank mesh. + + Pure CPU construction (no distributed init, no GPU): confirms the exact + ``from_sizes(fsdp_size=..., ulysses_size=..., cp_size=...)`` usage the workers + rely on. ``ulysses_size`` is derived from the data ranks and does NOT change + the mesh world size (SP degree lives inside the existing ranks), whereas + ``cp_size`` adds an explicit mesh dimension that multiplies the world size. + """ + from twinkle.utils import DeviceMesh + + # SP mesh (2 ranks, ulysses_size=2): ulysses does not inflate world_size. + sp_mesh = DeviceMesh.from_sizes(fsdp_size=2, dp_size=1, ulysses_size=2, device_type='cuda') + assert sp_mesh.world_size == 2 + assert sp_mesh.ulysses_size == 2 + + # SP mesh over 4 ranks (ulysses_size=4): still exactly fsdp_size ranks. + sp4_mesh = DeviceMesh.from_sizes(fsdp_size=4, dp_size=1, ulysses_size=4, device_type='cuda') + assert sp4_mesh.world_size == 4 + assert sp4_mesh.ulysses_size == 4 + + # CP mesh dimension (cp_size=2) multiplies the world size and is recorded as + # an explicit 'cp' dim: fsdp(2) x dp(1) x cp(2) == 4 ranks. + cp_mesh = DeviceMesh.from_sizes(fsdp_size=2, dp_size=1, cp_size=2, device_type='cuda') + assert cp_mesh.world_size == 4 + assert cp_mesh.has_dim('cp') + assert cp_mesh.get_dim_size('cp') == 2 diff --git a/tests/twinkle_agentic/test_bridge.py b/tests/twinkle_agentic/test_bridge.py new file mode 100644 index 000000000..c3c9155ec --- /dev/null +++ b/tests/twinkle_agentic/test_bridge.py @@ -0,0 +1,253 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Unit tests for :func:`twinkle_agentic.rollout.bridge.extend_with_bridge`. + +These tests target the pure, ``self``-free bridge-stitching function directly +(rather than through ``MultiTurnRollout``). They exercise: + + - normal bridge append: tool messages + next generation prompt are + appended as ``-100`` bridge tokens, history is preserved verbatim, and + ``messages`` is updated to ``messages_before + tool_messages``. + - RuntimeError when the template's ``s_after`` rendering is NOT a + prefix-extension of ``s_before`` (non-monotonic template). + - RuntimeError when the computed bridge text is empty (template adds no + tokens for the tool turn). + - RuntimeError when ``labels`` length != ``input_ids`` length in the pif. + +The fakes mirror the char-level FakeTokenizer / FakeTemplate infrastructure in +``test_multi_turn_rollout.py``: ``decode(encode(s)) == s`` for any mix of raw +chars and registered specials, and ``_invoke_post_pipeline`` replays the +label-roll / attention-mask semantics the real Template applies. +""" +from __future__ import annotations + +from typing import Any + +import pytest + +from twinkle_agentic.rollout.bridge import extend_with_bridge + + +# ============================================================================= +# Fakes (mirrors test_multi_turn_rollout.py) +# ============================================================================= +class FakeTokenizer: + """Char-level tokenizer with atomic special tokens. + + Guarantees ``decode(encode(s)) == s`` for any mix of raw chars and + registered specials, which keeps the template-space bridge diff exact. + """ + SPECIALS = ('<|im_start|>', '<|im_end|>') + + def __init__(self) -> None: + self._s2i: dict[str, int] = {} + self._i2s: dict[int, str] = {} + for s in self.SPECIALS: + self._add(s) + + def _add(self, tok: str) -> int: + if tok not in self._s2i: + i = len(self._s2i) + self._s2i[tok] = i + self._i2s[i] = tok + return self._s2i[tok] + + def encode(self, text: str, add_special_tokens: bool = False) -> list[int]: + ids: list[int] = [] + i = 0 + while i < len(text): + matched = False + for sp in self.SPECIALS: + if text.startswith(sp, i): + ids.append(self._add(sp)) + i += len(sp) + matched = True + break + if not matched: + ids.append(self._add(text[i])) + i += 1 + return ids + + def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str: + specials = set(self.SPECIALS) + toks = [self._i2s[int(i)] for i in ids] + if skip_special_tokens: + toks = [t for t in toks if t not in specials] + return ''.join(toks) + + def apply_chat_template( + self, + messages: list[dict[str, Any]], + tokenize: bool = False, + add_generation_prompt: bool = False, + **_, + ): + s = '' + for m in messages: + role = m['role'] + content = m['content'] + s += f'<|im_start|>{role}\n{content}<|im_end|>\n' + if add_generation_prompt: + s += '<|im_start|>assistant\n' + if tokenize: + return self.encode(s) + return s + + +class NonMonotonicTokenizer(FakeTokenizer): + """Renders a length-prefix that changes with message count. + + Because the leading ``[]`` marker differs between ``messages_before`` + and ``messages_after``, ``s_after`` is NOT a prefix-extension of + ``s_before`` — this is the non-monotonic template case that + ``extend_with_bridge`` must reject. + """ + + def apply_chat_template(self, messages, tokenize=False, add_generation_prompt=False, **_): + s = f'[{len(messages)}]' + s += super().apply_chat_template(messages, tokenize=False, add_generation_prompt=add_generation_prompt) + if tokenize: + return self.encode(s) + return s + + +class EmptyBridgeTokenizer(FakeTokenizer): + """Always renders the same constant string regardless of messages. + + ``s_after == s_before`` ⇒ the computed bridge text is empty, which + ``extend_with_bridge`` must reject. + """ + + def apply_chat_template(self, messages, tokenize=False, add_generation_prompt=False, **_): + s = 'CONSTANT' + if tokenize: + return self.encode(s) + return s + + +class FakeTemplate: + """Minimal Template mirroring the parts ``extend_with_bridge`` touches.""" + model_id = 'qwen-fake' + truncation_strategy = 'right' + + def __init__(self, tokenizer: FakeTokenizer) -> None: + self.tokenizer = tokenizer + + def encode(self, trajectory: dict[str, Any], add_generation_prompt: bool = False) -> dict[str, Any]: + messages = trajectory.get('messages', []) + s = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=add_generation_prompt) + input_ids = self.tokenizer.encode(s, add_special_tokens=False) + pif: dict[str, Any] = dict(trajectory) + pif['input_ids'] = input_ids + pif['labels'] = [-100] * len(input_ids) + return self._invoke_post_pipeline([pif])[0] + + def _invoke_post_pipeline(self, inputs: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for pif in inputs: + pif = dict(pif) + input_ids = list(pif['input_ids']) + labels = list(pif.get('labels') or []) + if labels: + if len(labels) != len(input_ids): + raise RuntimeError(f'FakeTemplate post_pipeline: labels({len(labels)}) ' + f'!= input_ids({len(input_ids)})') + # np.roll(labels, -1): shift LEFT by 1 (output/shifted order) + labels = labels[1:] + labels[:1] + pif['input_ids'] = input_ids + pif['labels'] = labels + pif['attention_mask'] = [1] * len(input_ids) + pif['position_ids'] = list(range(len(input_ids))) + pif['length'] = len(input_ids) + out.append(pif) + return out + + +# ============================================================================= +# Helpers +# ============================================================================= +def _make_pif(template: FakeTemplate, messages: list[dict[str, Any]]) -> dict[str, Any]: + """Build a post-pipeline pif for ``messages`` in inference mode.""" + return template.encode({'messages': list(messages)}, add_generation_prompt=True) + + +def _count_trainable(labels: list[int]) -> int: + return sum(1 for label in labels if label != -100) + + +# ============================================================================= +# Tests +# ============================================================================= +def test_normal_bridge_append(): + """Tool messages + generation prompt are appended as -100 bridge tokens. + + The pre-existing history is preserved verbatim (prefix of the new + ``input_ids``), the appended positions are all masked (-100), and + ``messages`` is updated to ``messages_before + tool_messages``. + """ + tokenizer = FakeTokenizer() + template = FakeTemplate(tokenizer) + + messages = [{'role': 'user', 'content': 'What is the weather?'}] + pif = _make_pif(template, messages) + before_ids = list(pif['input_ids']) + before_trainable = _count_trainable(pif['labels']) + + tool_messages = [{'role': 'tool', 'content': 'sunny'}] + new_pif = extend_with_bridge(pif, tool_messages, template) + + assert new_pif is not None + # History preserved verbatim as a prefix. + assert new_pif['input_ids'][:len(before_ids)] == before_ids + # Bridge actually added tokens. + assert len(new_pif['input_ids']) > len(before_ids) + # All newly appended positions are masked (-100 → not trainable). + assert _count_trainable(new_pif['labels']) == before_trainable + # input_ids / labels stay aligned. + assert len(new_pif['input_ids']) == len(new_pif['labels']) + # messages updated to before + tool. + assert new_pif['messages'] == messages + tool_messages + + # The bridge delta equals the template-space difference exactly. + s_before = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) + s_after = tokenizer.apply_chat_template( + messages + tool_messages, tokenize=False, add_generation_prompt=True) + expected_bridge_ids = tokenizer.encode(s_after[len(s_before):], add_special_tokens=False) + assert new_pif['input_ids'][len(before_ids):] == expected_bridge_ids + + +def test_non_prefix_extension_raises(): + """``s_after`` not a prefix-extension of ``s_before`` → RuntimeError.""" + tokenizer = NonMonotonicTokenizer() + template = FakeTemplate(tokenizer) + + messages = [{'role': 'user', 'content': 'hello'}] + pif = _make_pif(template, messages) + + with pytest.raises(RuntimeError, match='prefix-extension'): + extend_with_bridge(pif, [{'role': 'tool', 'content': 'x'}], template) + + +def test_empty_bridge_text_raises(): + """Bridge text computing to empty string → RuntimeError.""" + tokenizer = EmptyBridgeTokenizer() + template = FakeTemplate(tokenizer) + + messages = [{'role': 'user', 'content': 'hello'}] + pif = _make_pif(template, messages) + + with pytest.raises(RuntimeError, match='empty string'): + extend_with_bridge(pif, [{'role': 'tool', 'content': 'x'}], template) + + +def test_labels_length_mismatch_raises(): + """``labels`` length != ``input_ids`` length in the pif → RuntimeError.""" + tokenizer = FakeTokenizer() + template = FakeTemplate(tokenizer) + + messages = [{'role': 'user', 'content': 'hello'}] + pif = _make_pif(template, messages) + # Corrupt the pif: drop one label so lengths disagree. + pif['labels'] = list(pif['labels'])[:-1] + + with pytest.raises(RuntimeError, match='labels length'): + extend_with_bridge(pif, [{'role': 'tool', 'content': 'x'}], template) diff --git a/tests/twinkle_client/__init__.py b/tests/twinkle_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/twinkle_client/test_client_multi_turn_rollout.py b/tests/twinkle_client/test_client_multi_turn_rollout.py new file mode 100644 index 000000000..91dd072bc --- /dev/null +++ b/tests/twinkle_client/test_client_multi_turn_rollout.py @@ -0,0 +1,588 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Property-based tests for +:class:`twinkle_client.rollout.multi_turn.ClientMultiTurnRollout`. + +These tests are 100% CPU-only and do NOT require a GPU or a running server. +They reuse the char-level Fake Tokenizer / Template infrastructure style from +``tests/twinkle_agentic/test_multi_turn_rollout.py`` but adapt the fake sampler +to the ``twinkle_client`` HTTP contract: ``FakeClientSampler.sample()`` mirrors +``vLLMSampler.sample()`` and returns ``List[SampleResponseModel]`` (pydantic, +from ``twinkle_client.types.sampler``) whose ``sequences[0]`` carries a populated +``new_input_feature`` so the multi-turn loop can proceed round after round. + +Properties covered (see design doc "Correctness Properties"): + * Property 3 - output length & order preservation (Validates: Requirements 3.2) + * Property 4 - stop_reason value range (Validates: Requirements 3.3) + * Property 5 - logprobs / trainable-label alignment (Validates: Requirements 3.4) + * Property 6 - actual turns never exceed max_turns (Validates: Requirements 3.5) + * Property 7 - forced truncation at the max_turns edge (Validates: Requirements 3.6) +""" +from __future__ import annotations + +import copy +import json +import re +from collections import defaultdict +from typing import Any, Dict, List, Optional + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from twinkle.data_format.sampling import SamplingParams +from twinkle_agentic.tools.base import Tool +from twinkle_agentic.tools.tool_manager import ToolManager +from twinkle_client.rollout.multi_turn import ClientMultiTurnRollout +from twinkle_client.types.sampler import SampledSequenceModel, SampleResponseModel + + +# ============================================================================= +# Fakes (tokenizer / template mirror the twinkle_agentic test infra) +# ============================================================================= +class FakeTokenizer: + """Char-level tokenizer with atomic special tokens. + + Guarantees ``decode(encode(s)) == s`` for any mix of raw chars and + registered specials, which is what makes ``extend_with_bridge``'s + template-space delta computation deterministic in the test. + """ + SPECIALS = ('<|im_start|>', '<|im_end|>') + + def __init__(self) -> None: + self._s2i: Dict[str, int] = {} + self._i2s: Dict[int, str] = {} + for s in self.SPECIALS: + self._add(s) + + def _add(self, tok: str) -> int: + if tok not in self._s2i: + i = len(self._s2i) + self._s2i[tok] = i + self._i2s[i] = tok + return self._s2i[tok] + + def encode(self, text: str, add_special_tokens: bool = False) -> List[int]: + ids: List[int] = [] + i = 0 + while i < len(text): + matched = False + for sp in self.SPECIALS: + if text.startswith(sp, i): + ids.append(self._add(sp)) + i += len(sp) + matched = True + break + if not matched: + ids.append(self._add(text[i])) + i += 1 + return ids + + def decode(self, ids: List[int], skip_special_tokens: bool = False) -> str: + specials = set(self.SPECIALS) + toks = [self._i2s[int(i)] for i in ids] + if skip_special_tokens: + toks = [t for t in toks if t not in specials] + return ''.join(toks) + + def apply_chat_template( + self, + messages: List[Dict[str, Any]], + tokenize: bool = False, + add_generation_prompt: bool = False, + **_, + ): + s = '' + for m in messages: + role = m['role'] + content = m['content'] + s += f'<|im_start|>{role}\n{content}<|im_end|>\n' + if add_generation_prompt: + s += '<|im_start|>assistant\n' + if tokenize: + return self.encode(s) + return s + + +class FakeTemplate: + """Minimal Template mirroring the parts ClientMultiTurnRollout touches.""" + model_id = 'qwen-fake' + truncation_strategy = 'right' + enable_thinking = False + + def __init__(self, tokenizer: FakeTokenizer) -> None: + self.tokenizer = tokenizer + + def encode(self, trajectory: Dict[str, Any], add_generation_prompt: bool = False) -> Dict[str, Any]: + messages = trajectory.get('messages', []) + s = self.tokenizer.apply_chat_template( + messages, tokenize=False, add_generation_prompt=add_generation_prompt) + input_ids = self.tokenizer.encode(s, add_special_tokens=False) + pif: Dict[str, Any] = dict(trajectory) # preserve top-level fields (incl. _tid) + pif['input_ids'] = input_ids + pif['labels'] = [-100] * len(input_ids) # inference mode + return self._invoke_post_pipeline([pif])[0] + + def _invoke_post_pipeline(self, inputs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out = [] + for pif in inputs: + pif = dict(pif) + input_ids = list(pif['input_ids']) + labels = list(pif.get('labels') or []) + if labels: + if len(labels) != len(input_ids): + raise RuntimeError(f'FakeTemplate post_pipeline: labels({len(labels)}) ' + f'!= input_ids({len(input_ids)})') + # np.roll(labels, -1): shift LEFT by 1 (output/shifted order) + labels = labels[1:] + labels[:1] + pif['input_ids'] = input_ids + pif['labels'] = labels + pif['attention_mask'] = [1] * len(input_ids) + pif['position_ids'] = list(range(len(input_ids))) + pif['length'] = len(input_ids) + out.append(pif) + return out + + def parse_tool_call(self, decoded: str) -> List[Dict[str, Any]]: + matches = re.findall(r'\s*([\s\S]*?)\s*', decoded or '') + results: List[Dict[str, Any]] = [] + for m in matches: + try: + d = json.loads(m) + except json.JSONDecodeError: + continue + name = d.get('name') or d.get('tool_name') + if not name: + continue + results.append({ + 'type': 'function', + 'function': { + 'name': name, + 'arguments': d.get('arguments', {}), + }, + }) + return results + + def concat_input_feature(self, pif: Dict[str, Any], new_tokens: List[int]) -> Dict[str, Any]: + result = copy.deepcopy(pif) + prompt_ids = list(result['input_ids']) + labels = list(result.get('labels') or []) + if labels: + # Unroll (shift RIGHT by 1): reverse the post_pipeline roll + labels = labels[-1:] + labels[:-1] + else: + labels = [-100] * len(prompt_ids) + input_ids = prompt_ids + list(new_tokens) + labels = labels + list(new_tokens) # assistant tokens trainable + result['input_ids'] = input_ids + result['labels'] = labels + result = self._invoke_post_pipeline([result])[0] + response_text = self.tokenizer.decode(new_tokens, skip_special_tokens=True) + messages = list(result.get('messages') or []) + messages.append({'role': 'assistant', 'content': response_text}) + result['messages'] = messages + return result + + +class FakeClientSampler: + """Script-driven sampler mirroring ``vLLMSampler.sample()``. + + Adapts the ``twinkle_agentic`` fake sampler to the client HTTP contract: + ``sample()`` accepts ``(inputs, sampling_params=, ...)`` and returns + ``List[SampleResponseModel]`` (pydantic), each sequence carrying a populated + ``new_input_feature`` so the multi-turn loop can proceed. + + Each trajectory is identified by a hidden ``_tid`` field that survives + ``encode`` / ``concat_input_feature`` / ``extend_with_bridge`` (all preserve + top-level keys), so we can look up its scripted turns regardless of how the + active set shrinks across rounds. + """ + + def __init__(self, template: FakeTemplate, scripts: Dict[int, List[Dict[str, Any]]]) -> None: + self.template = template + self.scripts = scripts + self.turn_counters: Dict[int, int] = defaultdict(int) + self.sample_calls = 0 + + def sample(self, inputs, sampling_params: Optional[Dict[str, Any]] = None, **kwargs): + if isinstance(inputs, dict): + inputs = [inputs] + assert isinstance(inputs, list), f'expects a list, got {type(inputs).__name__}' + # Contract check: the rollout coerces core-lib SamplingParams into a + # plain dict before calling the HTTP sampler. + assert sampling_params is None or isinstance(sampling_params, dict) + + responses: List[SampleResponseModel] = [] + for pif in inputs: + tid = pif['_tid'] + script = self.scripts[tid] + idx = self.turn_counters[tid] + self.turn_counters[tid] += 1 + self.sample_calls += 1 + + if idx < len(script): + turn = script[idx] + else: + # Defensive fallback: terminate cleanly if over-sampled. + turn = {'kind': 'terminal', 'stop_reason': 'stop', 'logprobs': False} + + if turn['kind'] == 'tool': + decoded = _tool_call_text('search', {'q': f't{idx}'}) + stop_reason = 'stop' + else: + decoded = f'final-{idx}' + stop_reason = turn['stop_reason'] + + raw = decoded + '<|im_end|>' + tokens = self.template.tokenizer.encode(raw, add_special_tokens=False) + logprobs = ([[(int(t), -0.1)] for t in tokens] if turn['logprobs'] else None) + new_pif = self.template.concat_input_feature(pif, tokens) + + seq = SampledSequenceModel( + stop_reason=stop_reason, + tokens=tokens, + logprobs=logprobs, + decoded=decoded, + new_input_feature=new_pif, + ) + responses.append(SampleResponseModel(sequences=[seq])) + return responses + + +class EchoTool(Tool): + """Echoes its arguments as a JSON string.""" + + def __init__(self, name: str = 'search'): + self._name = name + + def __call__(self, tool_name: str, arguments: Dict[str, Any]) -> str: + return f'echo[{tool_name}]:{json.dumps(arguments, sort_keys=True)}' + + def tool_info(self): + return { + 'type': 'function', + 'function': { + 'name': self._name, + 'description': 'echo test tool', + 'parameters': {}, + }, + } + + +# ============================================================================= +# Helpers +# ============================================================================= +def _tool_call_text(name: str, arguments: Dict[str, Any]) -> str: + return '' + json.dumps({'name': name, 'arguments': arguments}) + '' + + +def _count_trainable(labels: List[int]) -> int: + return sum(1 for label in labels if label != -100) + + +def _make_tool_manager() -> ToolManager: + mgr = ToolManager({}) + mgr.register(EchoTool('search')) + return mgr + + +def _build_from_scripts(scripts_spec: List[Dict[str, Any]]): + """Turn a list of per-trajectory specs into (rollout inputs, sampler). + + ``scripts_spec[k]`` = {'num_tools': int, 'terminal': 'stop'|'length', + 'logprobs': bool}. Each trajectory's script is + ``num_tools`` tool-call turns followed by one terminal turn. + """ + tokenizer = FakeTokenizer() + template = FakeTemplate(tokenizer) + + scripts: Dict[int, List[Dict[str, Any]]] = {} + trajectories: List[Dict[str, Any]] = [] + for tid, spec in enumerate(scripts_spec): + turns: List[Dict[str, Any]] = [] + for _ in range(spec['num_tools']): + turns.append({'kind': 'tool', 'stop_reason': 'stop', 'logprobs': spec['logprobs']}) + turns.append({'kind': 'terminal', 'stop_reason': spec['terminal'], 'logprobs': spec['logprobs']}) + scripts[tid] = turns + trajectories.append({'messages': [{'role': 'user', 'content': f'q{tid}'}], '_tid': tid}) + + sampler = FakeClientSampler(template, scripts) + return trajectories, sampler, template + + +# ============================================================================= +# Hypothesis strategies +# ============================================================================= +_MAX_TOOLS = 5 + + +def _traj_spec(): + return st.fixed_dictionaries({ + 'num_tools': st.integers(min_value=0, max_value=_MAX_TOOLS), + 'terminal': st.sampled_from(['stop', 'length']), + 'logprobs': st.booleans(), + }) + + +def _batch_specs(min_size: int = 1, max_size: int = 4): + return st.lists(_traj_spec(), min_size=min_size, max_size=max_size) + + +# ============================================================================= +# Property 3: multi-turn output length & order preservation (Req 3.2) +# ============================================================================= +@settings(deadline=None, max_examples=60) +@given(scripts_spec=_batch_specs(min_size=0, max_size=5), max_turns=st.integers(min_value=1, max_value=6)) +def test_property3_output_length_and_order_preserved(scripts_spec, max_turns): + """**Validates: Requirements 3.2** + + ``__call__`` returns a list of the same length as the input, in the exact + same order (verified via the hidden per-trajectory ``_tid`` tag).""" + trajectories, sampler, template = _build_from_scripts(scripts_spec) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=max_turns) + + outs = rollout(copy.deepcopy(trajectories)) + + assert len(outs) == len(trajectories) + for i, out in enumerate(outs): + assert out['_tid'] == i, 'output order must match input order' + + +# ============================================================================= +# Property 4: stop_reason value range (Req 3.3) +# ============================================================================= +@settings(deadline=None, max_examples=60) +@given(scripts_spec=_batch_specs(), max_turns=st.integers(min_value=1, max_value=6)) +def test_property4_stop_reason_value_range(scripts_spec, max_turns): + """**Validates: Requirements 3.3** + + Every returned trajectory's ``stop_reason`` is one of + ``{'length', 'stop', 'max_turns'}``.""" + trajectories, sampler, template = _build_from_scripts(scripts_spec) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=max_turns) + + outs = rollout(copy.deepcopy(trajectories)) + + for out in outs: + assert out['stop_reason'] in {'length', 'stop', 'max_turns'}, out['stop_reason'] + + +# ============================================================================= +# Property 5: logprobs / trainable-label alignment (Req 3.4) +# ============================================================================= +@settings(deadline=None, max_examples=60) +@given(scripts_spec=_batch_specs(), max_turns=st.integers(min_value=1, max_value=6)) +def test_property5_logprobs_align_with_trainable_labels(scripts_spec, max_turns): + """**Validates: Requirements 3.4** + + For every returned trajectory with a non-empty ``logprobs`` list, its length + equals the number of trainable labels (``label != -100``).""" + trajectories, sampler, template = _build_from_scripts(scripts_spec) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=max_turns) + + outs = rollout(copy.deepcopy(trajectories)) + + for out in outs: + logprobs = out.get('logprobs') + if logprobs: + trainable = _count_trainable(out.get('labels') or []) + assert len(logprobs) == trainable, ( + f'logprobs({len(logprobs)}) != trainable labels({trainable})') + + +# ============================================================================= +# Property 6: actual turns never exceed max_turns (Req 3.5) +# ============================================================================= +@settings(deadline=None, max_examples=60) +@given(scripts_spec=_batch_specs(), max_turns=st.integers(min_value=1, max_value=6)) +def test_property6_turns_do_not_exceed_max_turns(scripts_spec, max_turns): + """**Validates: Requirements 3.5** + + Every returned trajectory's ``turns`` count is <= the configured + ``max_turns``.""" + trajectories, sampler, template = _build_from_scripts(scripts_spec) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=max_turns) + + outs = rollout(copy.deepcopy(trajectories)) + + for out in outs: + assert out['turns'] <= max_turns, f"turns({out['turns']}) > max_turns({max_turns})" + + +# ============================================================================= +# Property 7: forced truncation at the max_turns edge (Req 3.6) +# ============================================================================= +@settings(deadline=None, max_examples=60) +@given(logprobs_flags=st.lists(st.booleans(), min_size=1, max_size=5)) +def test_property7_max_turns_one_forces_truncation(logprobs_flags): + """**Validates: Requirements 3.6** + + With ``max_turns == 1`` and a first-round tool_call, every trajectory is + marked ``truncated=True`` and ``stop_reason='max_turns'`` and stops after + exactly one turn.""" + # Every trajectory emits a tool_call on its first (and only allowed) turn. + scripts_spec = [{'num_tools': 3, 'terminal': 'stop', 'logprobs': lp} for lp in logprobs_flags] + trajectories, sampler, template = _build_from_scripts(scripts_spec) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=1) + + outs = rollout(copy.deepcopy(trajectories)) + + assert len(outs) == len(trajectories) + for out in outs: + assert out['truncated'] is True + assert out['stop_reason'] == 'max_turns' + assert out['turns'] == 1 + + +# ============================================================================= +# Deterministic unit tests: exception paths & dependency reuse (non-hypothesis) +# +# These cover the failure/edge contract described in the module docstring of +# ``twinkle_client.rollout.multi_turn`` and requirements 3.7-3.10, 4.3-4.5, 7.1. +# All are CPU-only and reuse the Fake infra above. +# ============================================================================= +class NetworkError(Exception): + """Stand-in for a requests-style transport error (connection reset/timeout).""" + + +class _NullFeatureSampler: + """Sampler that violates the contract by returning ``new_input_feature=None``. + + Mirrors ``vLLMSampler.sample()`` shape (returns ``List[SampleResponseModel]``) + but every sequence lacks ``new_input_feature``, which must make the multi-turn + loop raise a batch/trajectory-indexed ``RuntimeError``. + """ + + def __init__(self, template: FakeTemplate) -> None: + self.template = template + self.sample_calls = 0 + + def sample(self, inputs, sampling_params=None, **kwargs): + if isinstance(inputs, dict): + inputs = [inputs] + self.sample_calls += 1 + responses: List[SampleResponseModel] = [] + for _ in inputs: + seq = SampledSequenceModel( + stop_reason='stop', + tokens=[0, 1], + logprobs=None, + decoded='final', + new_input_feature=None, # contract violation under test + ) + responses.append(SampleResponseModel(sequences=[seq])) + return responses + + +class _NetworkFailingSampler: + """Sampler whose ``sample()`` always raises a network-like exception. + + Used to assert the rollout NEVER wraps or swallows transport errors coming + from ``vLLMSampler.sample()``; they must propagate unchanged. + """ + + def __init__(self, template: FakeTemplate, exc: Exception) -> None: + self.template = template + self._exc = exc + self.sample_calls = 0 + + def sample(self, inputs, sampling_params=None, **kwargs): + self.sample_calls += 1 + raise self._exc + + +def test_missing_new_input_feature_raises_indexed_runtime_error(): + """new_input_feature=None -> RuntimeError naming batch AND trajectory index. + + _Requirements: 3.7_ + """ + trajectories, _script_sampler, template = _build_from_scripts( + [{'num_tools': 0, 'terminal': 'stop', 'logprobs': False}]) + sampler = _NullFeatureSampler(template) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=3) + + with pytest.raises(RuntimeError) as excinfo: + rollout(copy.deepcopy(trajectories)) + + msg = str(excinfo.value) + # Message must carry both the batch index and the trajectory index so the + # failure is localizable in a batched HTTP round. + assert 'batch index 0' in msg, msg + assert 'trajectory 0' in msg, msg + assert 'new_input_feature' in msg, msg + + +def test_tool_calls_without_tool_manager_raises_value_error(): + """tool_calls produced but tool_manager missing -> ValueError. + + _Requirements: 3.10, 4.5_ + """ + # One tool-call turn then a terminal turn; max_turns=2 so the tool-dispatch + # site (not the max_turns truncation edge) is what fails. + trajectories, sampler, template = _build_from_scripts( + [{'num_tools': 1, 'terminal': 'stop', 'logprobs': False}]) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=None, max_turns=2) + + with pytest.raises(ValueError) as excinfo: + rollout(copy.deepcopy(trajectories)) + + msg = str(excinfo.value) + assert 'tool_manager' in msg, msg + assert 'trajectory 0' in msg, msg + + +def test_tool_calls_without_tool_manager_via_per_call_kwarg_raises_value_error(): + """Passing tool_manager=None as a per-call kwarg also raises at dispatch. + + _Requirements: 3.10, 4.5_ + """ + trajectories, sampler, template = _build_from_scripts( + [{'num_tools': 1, 'terminal': 'stop', 'logprobs': False}]) + # Constructed WITH a manager, but the per-call override nulls it out. + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=2) + + with pytest.raises(ValueError): + rollout(copy.deepcopy(trajectories), tool_manager=None) + + +def test_sampler_network_error_propagates_unchanged(): + """vLLMSampler.sample() network error propagates unchanged (not swallowed/wrapped). + + _Requirements: 3.9_ + """ + trajectories, _script_sampler, template = _build_from_scripts( + [{'num_tools': 0, 'terminal': 'stop', 'logprobs': False}]) + sentinel = NetworkError('simulated connection reset by peer') + sampler = _NetworkFailingSampler(template, sentinel) + rollout = ClientMultiTurnRollout( + sampler=sampler, template=template, tool_manager=_make_tool_manager(), max_turns=3) + + with pytest.raises(NetworkError) as excinfo: + rollout(copy.deepcopy(trajectories)) + + # Same exact exception object, neither re-wrapped nor replaced. + assert excinfo.value is sentinel + assert str(excinfo.value) == 'simulated connection reset by peer' + assert sampler.sample_calls == 1 + + +def test_dependencies_are_reused_not_reimplemented(): + """ClientMultiTurnRollout imports (does not copy) ToolManager & extend_with_bridge. + + _Requirements: 4.3, 4.4, 7.1_ + """ + import twinkle_agentic.rollout.bridge as bridge_mod + import twinkle_agentic.tools.tool_manager as tool_manager_mod + import twinkle_client.rollout.multi_turn as m + + # Same object identity => the symbols are imported from the shared core-lib + # modules rather than re-defined locally. + assert m.ToolManager is tool_manager_mod.ToolManager + assert m.extend_with_bridge is bridge_mod.extend_with_bridge