# utils/config_loader.py import argparse import copy import logging from pathlib import Path from typing import Dict, List, Sequence, Any import yaml logger = logging.getLogger(__name__) _ALWAYS: Sequence[str] = ( # minimal required keys for the pipeline to run at all "experiment_base_dir", "human_profile_path", "num_iterations", "min_word_len_for_analysis", "log_level", "model_id", ) _VLLM: Sequence[str] = ( "vllm_model_id", "vllm_port", "vllm_hf_token", "vllm_cuda_visible_devices", "vllm_gpu_memory_utilization", "vllm_max_model_len", "vllm_dtype", "vllm_extra_args", "vllm_env", ) _GENERATION: Sequence[str] = ( "generation_api_key", "generation_api_base_url", # needed if you do local or remote calls "generation_model_id", "generation_max_new_tokens", "generation_threads", "generation_max_prompts", "generation_hf_dataset_name", "generation_hf_dataset_split", "generation_logging_level", "generation_chat_template_model_id", "generation_param_chunk_size", "generation_param_top_logprobs_count", "generation_param_temperature", "generation_param_top_p", "generation_param_top_k", "generation_param_min_p", "generation_param_timeout", "generation_param_stop_sequences", "generation_ngram_remove_stopwords", "generation_ngram_language", "generation_force_backtrack", "generation_prompt_template", "generation_system_prompt" ) _NGRAM: Sequence[str] = ( "top_k_bigrams", "top_k_trigrams", "dict_bigrams_initial", "dict_bigrams_subsequent", "nodict_bigrams_initial", "nodict_bigrams_subsequent", "dict_trigrams_initial", "dict_trigrams_subsequent", "nodict_trigrams_initial", "nodict_trigrams_subsequent", "extra_ngrams_to_ban", ) _SLOP: Sequence[str] = ( "min_phrase_freq_to_keep", "top_n_initial_slop_ban", "top_n_subsequent_slop_ban", "extra_slop_phrases_to_ban", "banned_slop_phrases_filename", ) _OVERREP: Sequence[str] = ( "top_k_words_for_overrep_analysis", "dict_overrep_initial", "dict_overrep_subsequent", "nodict_overrep_initial", "nodict_overrep_subsequent", ) _FINETUNE: Sequence[str] = ( "finetune_mode", "finetune_ftpo_dataset", "finetune_base_model_id", "finetune_max_seq_length", "finetune_load_in_4bit", "finetune_lora_r", "finetune_lora_alpha", "finetune_lora_dropout", "finetune_weight_decay", "finetune_target_modules", "finetune_gradient_checkpointing", "finetune_chat_template", "finetune_batch_size", "finetune_gradient_accumulation_steps", "finetune_warmup_ratio", "finetune_num_epochs", "finetune_learning_rate", "finetune_auto_learning_rate", "finetune_beta", "finetune_output_dir_suffix", "finetune_save_merged_16bit", "finetune_save_gguf_q8_0", "finetune_max_train_examples", "finetune_cuda_visible_devices", "ftpo_sample_rejected_regularisation_strength", "ftpo_sample_chosen_regularisation_strength", "ftpo_sample_min_chosen_tokens", ) def _deep_update(dst: Dict, src: Dict) -> Dict: """Recursively merge src into dst (src wins).""" for k, v in src.items(): if k in dst and isinstance(v, dict) and isinstance(dst[k], dict): _deep_update(dst[k], v) else: dst[k] = copy.deepcopy(v) return dst def load_pipeline_config(config_path: Path) -> Dict[str, Any]: """Load config from a YAML file, or return empty dict if missing/invalid.""" if config_path and config_path.exists(): try: with config_path.open('r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} logger.info("Loaded configuration from %s", config_path) return data except Exception as e: logger.error("Could not load %s: %s – using empty config", config_path, e) else: logger.info("Config file %s not found – using empty config", config_path) return {} def merge_config_with_cli_args(config: Dict[str, Any], cli_args: argparse.Namespace) -> Dict[str, Any]: """ Merges every possible CLI parameter from your old DEFAULT_CONFIG into 'config' if the user actually provided it (i.e. it's not None). Also merges housekeeping flags (config_file, resume_from_dir, log_level). """ merged = copy.deepcopy(config) # 1. Housekeeping arguments (not originally in DEFAULT_CONFIG, but we keep them if set) if getattr(cli_args, 'config_file', None) is not None: merged['config_file'] = cli_args.config_file if getattr(cli_args, 'resume_from_dir', None) is not None: merged['resume_from_dir'] = cli_args.resume_from_dir if getattr(cli_args, 'log_level', None) is not None: merged['log_level'] = cli_args.log_level # 2. Booleans that map from CLI flags to known keys in config if getattr(cli_args, 'run_finetune', None) is not None: merged['finetune_enabled'] = cli_args.run_finetune if getattr(cli_args, 'manage_vllm', None) is not None: merged['manage_vllm'] = cli_args.manage_vllm if getattr(cli_args, 'generation_step_enabled', None) is not None: merged['generation_step_enabled'] = cli_args.generation_step_enabled if getattr(cli_args, "finetune_cuda_visible_devices", None) is not None: merged["finetune_cuda_visible_devices"] = cli_args.finetune_cuda_visible_devices # 3. All remaining keys from the old DEFAULT_CONFIG _all_groups: Sequence[Sequence[str]] = ( _ALWAYS, _VLLM, _GENERATION, _NGRAM, _SLOP, _OVERREP, _FINETUNE, ) all_config_keys: List[str] = [k for group in _all_groups for k in group] # 4. fallback for per-stage model IDs <-- add this block for key in ( "vllm_model_id", "generation_model_id", "generation_chat_template_model_id", "finetune_base_model_id", ): if not merged.get(key): # None, "", or missing merged[key] = merged.get("model_id") # Overwrite config if user specified a value for key in all_config_keys: cli_val = getattr(cli_args, key, None) if cli_val is not None: merged[key] = cli_val return merged # --------------------------------------------------------------------------- # Validate with partial requirements depending on which features are enabled # --------------------------------------------------------------------------- def _missing(cfg: Dict[str, Any], keys: Sequence[str]) -> List[str]: return [k for k in keys if k not in cfg or cfg[k] is None] def validate_config(cfg: Dict[str, Any]) -> None: """Raise ValueError if any required config is missing based on pipeline flags.""" missing = [] # always missing.extend(_missing(cfg, _ALWAYS)) # vllm if cfg.get("manage_vllm", False): missing.extend(_missing(cfg, _VLLM)) # generation if cfg.get("generation_step_enabled", True): missing.extend(_missing(cfg, _GENERATION)) # n-gram ban if cfg.get("enable_ngram_ban", False): missing.extend(_missing(cfg, _NGRAM)) # slop phrase ban if cfg.get("enable_slop_phrase_ban", False): missing.extend(_missing(cfg, _SLOP)) # over-rep analysis if cfg.get("compute_overrep_words", False): missing.extend(_missing(cfg, _OVERREP)) # finetuning if cfg.get("finetune_enabled", False): missing.extend(_missing(cfg, _FINETUNE)) if missing: raise ValueError( f"Configuration is incomplete; missing these keys: {', '.join(sorted(set(missing)))}" ) logger.info("Configuration validated – all required keys present (for enabled features).") def load_merge_validate(config_path: Path, cli_args: argparse.Namespace) -> Dict[str, Any]: """ 1) Load YAML from config_path, 2) Merge in any CLI flags user typed, 3) Validate that all needed keys for enabled features are present. """ cfg = load_pipeline_config(config_path) cfg = merge_config_with_cli_args(cfg, cli_args) validate_config(cfg) return cfg