from __future__ import annotations from pathlib import Path from typing import Any, Literal import yaml from pydantic import BaseModel, Field, field_validator, model_validator class RewardConfig(BaseModel): quality_weight: float = 1.0 cost_weight: float = 0.0 latency_weight: float = 0.0 cost_scale_usd: float = 0.01 latency_scale_seconds: float = 30.0 judge_model: str | None = None judge_system_prompt: str = ( "Score the candidate answer from 0 to 1. Return only one decimal number." ) @field_validator("cost_scale_usd", "latency_scale_seconds") @classmethod def positive_scales(cls, value: float) -> float: if value <= 0: raise ValueError("reward scales must be positive") return value class WorkerConfig(BaseModel): id: str provider: Literal["openrouter", "openai_compatible", "mock"] = "openrouter" model: str description: str = "" base_url: str | None = None api_key_env: str | None = None api_key: str | None = Field(default=None, repr=False) system_prompt: str = "You are a helpful expert. Follow the user's requested output format." temperature: float = 0.1 max_tokens: int = 1024 extra_body: dict[str, Any] = Field(default_factory=dict) mock_domains: list[str] = Field(default_factory=list) mock_specialist_accuracy: float = 1.0 mock_general_accuracy: float = 0.0 class WorkerPoolConfig(BaseModel): project_name: str = "benchgen-fugu-lite" base_url: str = "https://openrouter.ai/api/v1" api_key_env: str = "OPENROUTER_API_KEY" concurrency: int = 4 timeout_seconds: float = 120.0 max_retries: int = 3 workers: list[WorkerConfig] reward: RewardConfig = Field(default_factory=RewardConfig) @model_validator(mode="after") def unique_workers(self) -> WorkerPoolConfig: ids = [worker.id for worker in self.workers] if not ids: raise ValueError("at least one worker is required") if len(ids) != len(set(ids)): raise ValueError("worker IDs must be unique") return self @property def worker_ids(self) -> list[str]: return [worker.id for worker in self.workers] @property def worker_descriptions(self) -> dict[str, str]: return {worker.id: worker.description for worker in self.workers} class ModelConfig(BaseModel): base_model: str = "Qwen/Qwen3-0.6B" max_length: int = 512 pooling: Literal["last_token", "mean"] = "last_token" dropout: float = 0.05 freeze_backbone: bool = True dtype: Literal["auto", "float32", "float16", "bfloat16"] = "auto" class SFTTrainingConfig(BaseModel): seed: int = 42 epochs: int = 5 batch_size: int = 8 gradient_accumulation_steps: int = 1 learning_rate: float = 1e-3 weight_decay: float = 0.01 max_grad_norm: float = 1.0 soft_target_temperature: float = 0.15 log_every: int = 10 class RLTrainingConfig(BaseModel): seed: int = 43 epochs: int = 3 batch_size: int = 8 gradient_accumulation_steps: int = 1 learning_rate: float = 2e-4 weight_decay: float = 0.0 max_grad_norm: float = 1.0 estimator: Literal["reinforce", "expected_reward"] = "reinforce" rollouts_per_prompt: int = 8 entropy_coefficient: float = 0.01 normalize_advantage: bool = False log_every: int = 10 class ESTrainingConfig(BaseModel): seed: int = 44 generations: int = 30 sigma: float = 0.03 population_size: int | None = 16 objective: Literal["greedy_utility", "expected_utility"] = "greedy_utility" class SFTConfig(BaseModel): model: ModelConfig = Field(default_factory=ModelConfig) training: SFTTrainingConfig = Field(default_factory=SFTTrainingConfig) class RLConfig(BaseModel): model: ModelConfig = Field(default_factory=ModelConfig) training: RLTrainingConfig = Field(default_factory=RLTrainingConfig) class ESConfig(BaseModel): model: ModelConfig = Field(default_factory=ModelConfig) training: ESTrainingConfig = Field(default_factory=ESTrainingConfig) def load_yaml(path: str | Path) -> dict[str, Any]: with Path(path).open("r", encoding="utf-8") as handle: data = yaml.safe_load(handle) if not isinstance(data, dict): raise TypeError(f"Expected a YAML object in {path}") return data def load_worker_pool(path: str | Path) -> WorkerPoolConfig: return WorkerPoolConfig.model_validate(load_yaml(path)) def load_sft_config(path: str | Path) -> SFTConfig: return SFTConfig.model_validate(load_yaml(path)) def load_rl_config(path: str | Path) -> RLConfig: return RLConfig.model_validate(load_yaml(path)) def load_es_config(path: str | Path) -> ESConfig: return ESConfig.model_validate(load_yaml(path))