fluxloop-cli 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fluxloop-cli might be problematic. Click here for more details.

fluxloop_cli/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.1"
6
6
 
7
7
  from .main import app
8
8
 
@@ -1,5 +1,5 @@
1
1
  """CLI commands."""
2
2
 
3
- from . import config, generate, init, parse, run, status
3
+ from . import config, generate, init, parse, run, status, record
4
4
 
5
- __all__ = ["config", "generate", "init", "parse", "run", "status"]
5
+ __all__ = ["config", "generate", "init", "parse", "run", "status", "record"]
@@ -15,6 +15,7 @@ from rich.table import Table
15
15
  from ..config_loader import load_experiment_config
16
16
  from ..templates import create_env_file, create_gitignore, create_sample_agent
17
17
  from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
18
+ from ..config_schema import CONFIG_SECTION_FILENAMES
18
19
  from ..project_paths import (
19
20
  resolve_config_path,
20
21
  resolve_env_path,
@@ -228,7 +229,12 @@ def set_llm(
228
229
  api_key: str = typer.Argument(..., help="API key or token for the provider"),
229
230
  model: Optional[str] = typer.Option(None, "--model", "-m", help="Default model to use"),
230
231
  overwrite_env: bool = typer.Option(False, "--overwrite-env", help="Overwrite existing key in .env"),
231
- config_file: Path = typer.Option(DEFAULT_CONFIG_PATH, "--file", "-f", help="Configuration file to update"),
232
+ config_file: Path = typer.Option(
233
+ Path(CONFIG_SECTION_FILENAMES["input"]),
234
+ "--file",
235
+ "-f",
236
+ help="Configuration file to update",
237
+ ),
232
238
  env_file: Path = typer.Option(Path(".env"), "--env-file", help="Path to environment file"),
233
239
  project: Optional[str] = typer.Option(None, "--project", help="Project name under the FluxLoop root"),
234
240
  root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
@@ -330,7 +336,11 @@ def validate(
330
336
  raise typer.Exit(1)
331
337
 
332
338
  try:
333
- config = load_experiment_config(config_file)
339
+ config = load_experiment_config(
340
+ resolved_path,
341
+ project=project,
342
+ root=root,
343
+ )
334
344
 
335
345
  # Show validation results
336
346
  console.print("[green]✓[/green] Configuration is valid!\n")
@@ -1,10 +1,9 @@
1
1
  """Generate command for producing input datasets."""
2
2
 
3
- import json
4
3
  import os
5
4
  import sys
6
5
  from pathlib import Path
7
- from typing import Any, Dict, List, Optional
6
+ from typing import Dict, List, Optional
8
7
 
9
8
  import typer
10
9
  from rich.console import Console
@@ -93,11 +92,6 @@ def inputs(
93
92
  "--llm-api-key",
94
93
  help="API key for LLM provider (falls back to FLUXLOOP_LLM_API_KEY)",
95
94
  ),
96
- from_recording: Optional[Path] = typer.Option(
97
- None,
98
- "--from-recording",
99
- help="Use a recorded call (JSONL) as template for generated inputs",
100
- ),
101
95
  ):
102
96
  """Generate input variations for review before running experiments."""
103
97
  resolved_config = resolve_config_path(config_file, project, root)
@@ -136,6 +130,8 @@ def inputs(
136
130
  try:
137
131
  config = load_experiment_config(
138
132
  resolved_config,
133
+ project=project,
134
+ root=root,
139
135
  require_inputs_file=require_inputs_file,
140
136
  )
141
137
  except Exception as exc:
@@ -201,20 +197,8 @@ def inputs(
201
197
  llm_api_key_override=llm_api_key,
202
198
  )
203
199
 
204
- recording_template = None
205
- if from_recording:
206
- try:
207
- recording_template = _load_recording_template(from_recording, config)
208
- console.print(
209
- "📝 Generating inputs from recording"
210
- f"\n → Base content: [cyan]{recording_template['base_content'][:60]}[/cyan]"
211
- )
212
- except Exception as exc:
213
- console.print(f"[red]Failed to load recording template:[/red] {exc}")
214
- raise typer.Exit(1)
215
-
216
200
  try:
217
- result = generate_inputs(config, settings, recording_template=recording_template)
201
+ result = generate_inputs(config, settings)
218
202
  except Exception as exc:
219
203
  console.print(f"[red]Generation failed:[/red] {exc}")
220
204
  if "--debug" in sys.argv:
@@ -238,67 +222,3 @@ def inputs(
238
222
  f"\n🧠 Mode: [magenta]{result.metadata.get('generation_mode', 'deterministic')}[/magenta]"
239
223
  f"\n🎯 Strategies: [cyan]{', '.join(strategy for strategy in strategies_used)}[/cyan]"
240
224
  )
241
-
242
-
243
- def _load_recording_template(path: Path, config: "ExperimentConfig") -> Dict[str, object]:
244
- target_path = path
245
- if not target_path.is_absolute():
246
- source_dir = config.get_source_dir()
247
- if source_dir:
248
- target_path = (source_dir / target_path).resolve()
249
- else:
250
- target_path = target_path.resolve()
251
-
252
- if not target_path.exists():
253
- raise FileNotFoundError(f"Recording file not found: {target_path}")
254
-
255
- with target_path.open("r", encoding="utf-8") as handle:
256
- first_line = handle.readline()
257
-
258
- if not first_line:
259
- raise ValueError(f"Recording file is empty: {target_path}")
260
-
261
- try:
262
- record = json.loads(first_line)
263
- except json.JSONDecodeError as exc:
264
- raise ValueError(f"Invalid JSON in recording file {target_path}: {exc}")
265
-
266
- kwargs = record.get("kwargs", {})
267
- base_content = _extract_content(kwargs)
268
- if not base_content:
269
- sample = json.dumps(kwargs, indent=2)[:500]
270
- raise ValueError(
271
- "Unable to locate textual content in recording. "
272
- "Expected keys such as data.content, input, input_text, message, query, text, or content." \
273
- f"\nRecording snapshot:\n{sample}"
274
- )
275
-
276
- return {
277
- "base_content": base_content,
278
- "full_kwargs": kwargs,
279
- "target": record.get("target"),
280
- }
281
-
282
-
283
- def _extract_content(kwargs: Dict[str, Any]) -> Optional[str]:
284
- content_paths = [
285
- "data.content",
286
- "input",
287
- "input_text",
288
- "message",
289
- "query",
290
- "text",
291
- "content",
292
- ]
293
-
294
- for path in content_paths:
295
- try:
296
- current: Any = kwargs
297
- for part in path.split('.'):
298
- current = current[part]
299
- if isinstance(current, str) and current:
300
- return current
301
- except (KeyError, TypeError):
302
- continue
303
-
304
- return None
@@ -11,13 +11,21 @@ from rich.prompt import Confirm
11
11
  from rich.tree import Tree
12
12
 
13
13
  from ..templates import (
14
- create_experiment_config,
14
+ create_project_config,
15
+ create_input_config,
16
+ create_simulation_config,
17
+ create_evaluation_config,
15
18
  create_sample_agent,
16
19
  create_gitignore,
17
20
  create_env_file,
18
21
  )
19
22
  from ..project_paths import resolve_root_dir, resolve_project_dir
20
- from ..constants import DEFAULT_ROOT_DIR_NAME
23
+ from ..constants import (
24
+ DEFAULT_ROOT_DIR_NAME,
25
+ CONFIG_DIRECTORY_NAME,
26
+ CONFIG_SECTION_FILENAMES,
27
+ CONFIG_SECTION_ORDER,
28
+ )
21
29
 
22
30
  app = typer.Typer()
23
31
  console = Console()
@@ -51,7 +59,7 @@ def project(
51
59
  Initialize a new FluxLoop project.
52
60
 
53
61
  This command creates:
54
- - setting.yaml: Default experiment configuration
62
+ - configs/: Separated configuration files (project/input/simulation/evaluation)
55
63
  - .env: Environment variables template
56
64
  - examples/: Sample agent code (optional)
57
65
  """
@@ -89,14 +97,20 @@ def project(
89
97
  raise typer.Abort()
90
98
 
91
99
  # Check for existing files
92
- config_file = project_path / "setting.yaml"
100
+ config_dir = project_path / CONFIG_DIRECTORY_NAME
101
+ section_paths = {
102
+ key: config_dir / CONFIG_SECTION_FILENAMES[key]
103
+ for key in CONFIG_SECTION_FILENAMES
104
+ }
93
105
  env_file = project_path / ".env"
94
106
  gitignore_file = project_path / ".gitignore"
95
107
 
96
108
  if not force:
97
109
  existing_files = []
98
- if config_file.exists():
99
- existing_files.append("setting.yaml")
110
+ for key in CONFIG_SECTION_ORDER:
111
+ path = section_paths[key]
112
+ if path.exists():
113
+ existing_files.append(path.relative_to(project_path).as_posix())
100
114
  if env_file.exists():
101
115
  existing_files.append(".env")
102
116
 
@@ -107,10 +121,21 @@ def project(
107
121
  if not Confirm.ask("Overwrite existing files?", default=False):
108
122
  raise typer.Abort()
109
123
 
110
- # Create configuration file
111
- console.print("📝 Creating experiment configuration...")
112
- config_content = create_experiment_config(project_name)
113
- config_file.write_text(config_content)
124
+ # Create configuration files
125
+ console.print("📝 Creating configuration files...")
126
+ config_dir.mkdir(exist_ok=True)
127
+
128
+ section_writers = {
129
+ "project": lambda: create_project_config(project_name),
130
+ "input": create_input_config,
131
+ "simulation": lambda: create_simulation_config(project_name),
132
+ "evaluation": create_evaluation_config,
133
+ }
134
+
135
+ for key in CONFIG_SECTION_ORDER:
136
+ content = section_writers[key]() # type: ignore[operator]
137
+ section_path = section_paths[key]
138
+ section_path.write_text(content)
114
139
 
115
140
  # Ensure root .env exists, create project override template
116
141
  root_env_file = root_dir / ".env"
@@ -145,7 +170,9 @@ def project(
145
170
  console.print("\n[bold green]✓ Project initialized successfully![/bold green]\n")
146
171
 
147
172
  tree = Tree(f"[bold]{project_name}/[/bold]")
148
- tree.add("📄 setting.yaml")
173
+ configs_node = tree.add(f"📁 {CONFIG_DIRECTORY_NAME}/")
174
+ for key in CONFIG_SECTION_ORDER:
175
+ configs_node.add(f"📄 {CONFIG_SECTION_FILENAMES[key]}")
149
176
  tree.add("🔐 .env")
150
177
  tree.add("📄 .gitignore")
151
178
  tree.add("📁 recordings/")
@@ -158,7 +185,7 @@ def project(
158
185
 
159
186
  # Show next steps
160
187
  console.print("\n[bold]Next steps:[/bold]")
161
- console.print("1. Edit [cyan]setting.yaml[/cyan] to configure your experiment")
188
+ console.print("1. Review configs in [cyan]configs/[/cyan] (project/input/simulation/evaluation)")
162
189
  console.print("2. Set up environment variables in [cyan].env[/cyan]")
163
190
  if with_example:
164
191
  console.print("3. Try running: [green]fluxloop run experiment[/green]")
@@ -0,0 +1,150 @@
1
+ """Record command for managing argument recording mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Dict, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from ..constants import DEFAULT_ROOT_DIR_NAME
13
+ from ..project_paths import (
14
+ resolve_env_path,
15
+ resolve_project_dir,
16
+ resolve_config_section_path,
17
+ )
18
+ from ..config_schema import CONFIG_SECTION_FILENAMES
19
+
20
+
21
+ app = typer.Typer()
22
+ console = Console()
23
+
24
+
25
+ def _load_env(env_path: Path) -> Dict[str, str]:
26
+ values: Dict[str, str] = {}
27
+ if env_path.exists():
28
+ for line in env_path.read_text().splitlines():
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ key, value = line.split("=", 1)
33
+ values[key.strip()] = value.strip()
34
+ return values
35
+
36
+
37
+ def _write_env(env_path: Path, values: Dict[str, str]) -> None:
38
+ env_path.write_text("\n".join(f"{k}={v}" for k, v in values.items()) + "\n")
39
+
40
+
41
+ def _update_simulation(recording_enabled: bool, project: Optional[str], root: Path) -> None:
42
+ try:
43
+ simulation_path = resolve_config_section_path("simulation", project, root)
44
+ except KeyError:
45
+ return
46
+
47
+ data: Dict[str, object] = {}
48
+ if simulation_path.exists():
49
+ import yaml
50
+
51
+ data = yaml.safe_load(simulation_path.read_text()) or {}
52
+
53
+ replay = data.setdefault("replay_args", {})
54
+ replay["enabled"] = recording_enabled
55
+
56
+ import yaml
57
+
58
+ simulation_path.parent.mkdir(parents=True, exist_ok=True)
59
+ simulation_path.write_text(
60
+ yaml.dump(data, sort_keys=False, default_flow_style=False),
61
+ encoding="utf-8",
62
+ )
63
+
64
+
65
+ @app.command()
66
+ def enable(
67
+ project: Optional[str] = typer.Option(None, "--project", help="Project name"),
68
+ root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
69
+ recording_file: Path = typer.Option(
70
+ Path("recordings/args_recording.jsonl"),
71
+ "--file",
72
+ help="Recording file path relative to project",
73
+ ),
74
+ ):
75
+ """Enable recording mode by updating .env and simulation config."""
76
+
77
+ env_path = resolve_env_path(Path(".env"), project, root)
78
+ env_values = _load_env(env_path)
79
+ env_values["FLUXLOOP_RECORD_ARGS"] = "true"
80
+ env_values["FLUXLOOP_RECORDING_FILE"] = str(recording_file)
81
+
82
+ env_path.parent.mkdir(parents=True, exist_ok=True)
83
+ _write_env(env_path, env_values)
84
+
85
+ _update_simulation(True, project, root)
86
+
87
+ console.print(
88
+ f"[green]✓[/green] Recording enabled. Edit {env_path} or configs/simulation.yaml to adjust settings."
89
+ )
90
+
91
+
92
+ @app.command()
93
+ def disable(
94
+ project: Optional[str] = typer.Option(None, "--project", help="Project name"),
95
+ root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
96
+ ):
97
+ """Disable recording mode and reset settings."""
98
+
99
+ env_path = resolve_env_path(Path(".env"), project, root)
100
+ env_values = _load_env(env_path)
101
+ env_values["FLUXLOOP_RECORD_ARGS"] = "false"
102
+ env_path.parent.mkdir(parents=True, exist_ok=True)
103
+ _write_env(env_path, env_values)
104
+
105
+ _update_simulation(False, project, root)
106
+
107
+ console.print("[green]✓[/green] Recording disabled.")
108
+
109
+
110
+ @app.command()
111
+ def status(
112
+ project: Optional[str] = typer.Option(None, "--project", help="Project name"),
113
+ root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
114
+ ):
115
+ """Display current recording status."""
116
+
117
+ env_path = resolve_env_path(Path(".env"), project, root)
118
+ env_values = _load_env(env_path)
119
+ record_args = env_values.get("FLUXLOOP_RECORD_ARGS", "false").lower() == "true"
120
+ recording_file = env_values.get("FLUXLOOP_RECORDING_FILE", "(default)")
121
+
122
+ simulation_path = resolve_config_section_path("simulation", project, root)
123
+ simulation_exists = simulation_path.exists()
124
+
125
+ table = Table(title="Recording Status", show_header=False)
126
+ table.add_column("Property", style="cyan")
127
+ table.add_column("Value")
128
+
129
+ table.add_row("Mode", "ENABLED" if record_args else "disabled")
130
+ table.add_row("Recording File", recording_file)
131
+ table.add_row(".env Path", str(env_path))
132
+ table.add_row("Simulation Config", str(simulation_path) if simulation_exists else "(missing)")
133
+
134
+ console.print(table)
135
+
136
+ project_dir = (
137
+ resolve_project_dir(project, root)
138
+ if project
139
+ else Path.cwd()
140
+ )
141
+ recordings_dir = project_dir / Path(recording_file).parent
142
+
143
+ if recordings_dir.exists():
144
+ console.print(
145
+ f"\n[dim]Recording directory:[/dim] {recordings_dir}\n"
146
+ )
147
+ else:
148
+ console.print(
149
+ f"\n[yellow]Recording directory does not exist yet:[/yellow] {recordings_dir}\n"
150
+ )
@@ -94,7 +94,11 @@ def experiment(
94
94
  console.print(f"📋 Loading configuration from: [cyan]{resolved_config}[/cyan]")
95
95
 
96
96
  try:
97
- config = load_experiment_config(resolved_config)
97
+ config = load_experiment_config(
98
+ resolved_config,
99
+ project=project,
100
+ root=root,
101
+ )
98
102
  except Exception as e:
99
103
  console.print(f"[red]Error loading configuration:[/red] {e}")
100
104
  raise typer.Exit(1)
@@ -11,7 +11,13 @@ from rich.table import Table
11
11
  from rich.panel import Panel
12
12
 
13
13
  from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
14
- from ..project_paths import resolve_config_path, resolve_root_dir, resolve_project_relative
14
+ from ..config_schema import CONFIG_SECTION_FILENAMES, CONFIG_REQUIRED_KEYS
15
+ from ..project_paths import (
16
+ resolve_config_path,
17
+ resolve_config_directory,
18
+ resolve_root_dir,
19
+ resolve_project_relative,
20
+ )
15
21
 
16
22
  app = typer.Typer()
17
23
  console = Console()
@@ -90,17 +96,40 @@ def check(
90
96
 
91
97
  # Check for configuration file
92
98
  resolved_config = resolve_config_path(DEFAULT_CONFIG_PATH, project, root)
93
- if resolved_config.exists():
99
+ config_dir = resolve_config_directory(project, root)
100
+
101
+ missing_sections = [
102
+ CONFIG_SECTION_FILENAMES[key]
103
+ for key in CONFIG_SECTION_FILENAMES
104
+ if not (config_dir / CONFIG_SECTION_FILENAMES[key]).exists()
105
+ ]
106
+
107
+ if resolved_config.exists() and not missing_sections:
94
108
  status_table.add_row(
95
109
  "Config",
96
110
  "[green]✓ Found[/green]",
97
- str(resolved_config)
111
+ str(config_dir)
98
112
  )
99
113
  else:
114
+ detail_parts = []
115
+ if not config_dir.exists():
116
+ detail_parts.append(f"Missing directory: {config_dir}")
117
+ if missing_sections:
118
+ required_missing = [
119
+ name for name in missing_sections
120
+ if _section_key_from_filename(name) in CONFIG_REQUIRED_KEYS
121
+ ]
122
+ if required_missing:
123
+ detail_parts.append(
124
+ "Missing sections: " + ", ".join(required_missing)
125
+ )
126
+ if not detail_parts:
127
+ detail_parts.append("Run: fluxloop init project")
128
+
100
129
  status_table.add_row(
101
130
  "Config",
102
- "[yellow]- Not found[/yellow]",
103
- "Run: fluxloop init project"
131
+ "[yellow]- Incomplete[/yellow]",
132
+ "\n".join(detail_parts)
104
133
  )
105
134
 
106
135
  # Check environment
@@ -133,6 +162,13 @@ def check(
133
162
  console.print(f" • {rec}")
134
163
 
135
164
 
165
+ def _section_key_from_filename(filename: str) -> Optional[str]:
166
+ for key, value in CONFIG_SECTION_FILENAMES.items():
167
+ if value == filename:
168
+ return key
169
+ return None
170
+
171
+
136
172
  @app.command()
137
173
  def experiments(
138
174
  output_dir: Path = typer.Option(
@@ -4,12 +4,20 @@ Configuration loader for experiments.
4
4
 
5
5
  import sys
6
6
  from pathlib import Path
7
- from typing import Any, Dict, List
7
+ from typing import Any, Dict, Optional
8
8
 
9
9
  import yaml
10
10
  from pydantic import ValidationError
11
11
 
12
12
  from .project_paths import resolve_config_path
13
+ from .config_schema import (
14
+ CONFIG_SECTION_FILENAMES,
15
+ CONFIG_SECTION_ORDER,
16
+ CONFIG_REQUIRED_KEYS,
17
+ iter_section_paths,
18
+ is_legacy_config,
19
+ )
20
+ from .constants import CONFIG_DIRECTORY_NAME
13
21
 
14
22
  # Add shared schemas to path
15
23
  sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "shared"))
@@ -20,33 +28,63 @@ from fluxloop.schemas import ExperimentConfig
20
28
  def load_experiment_config(
21
29
  config_file: Path,
22
30
  *,
31
+ project: Optional[str] = None,
32
+ root: Optional[Path] = None,
23
33
  require_inputs_file: bool = True,
24
34
  ) -> ExperimentConfig:
25
35
  """
26
36
  Load and validate experiment configuration from YAML file.
27
37
  """
28
- resolved_path = resolve_config_path(config_file, project=None, root=None)
29
- if not resolved_path.exists():
30
- raise FileNotFoundError(f"Configuration file not found: {config_file}")
31
-
32
- # Load YAML
33
- with open(resolved_path) as f:
34
- data = yaml.safe_load(f)
35
-
36
- if not data:
37
- raise ValueError("Configuration file is empty")
38
+ resolved_path = resolve_config_path(config_file, project, root)
39
+
40
+ structure, project_root, config_dir = _detect_config_context(resolved_path)
41
+
42
+ if structure == "legacy":
43
+ if not resolved_path.exists():
44
+ raise FileNotFoundError(f"Configuration file not found: {resolved_path}")
45
+
46
+ data = _load_yaml_mapping(resolved_path)
47
+ source_dir = resolved_path.parent
48
+ else:
49
+ # Multi-section configuration
50
+ merged: Dict[str, Any] = {}
51
+ missing_required = []
52
+
53
+ for section_path in iter_section_paths(project_root):
54
+ if not section_path.exists():
55
+ key = section_path.name
56
+ logical_key = _section_key_from_filename(section_path.name)
57
+ if logical_key in CONFIG_REQUIRED_KEYS:
58
+ missing_required.append(section_path.name)
59
+ continue
60
+
61
+ section_data = _load_yaml_mapping(section_path)
62
+ _deep_merge(merged, section_data)
63
+
64
+ if missing_required:
65
+ raise FileNotFoundError(
66
+ "Missing required configuration sections: "
67
+ + ", ".join(missing_required)
68
+ )
69
+
70
+ if not merged:
71
+ raise ValueError(
72
+ f"No configuration data found in {config_dir}"
73
+ )
74
+
75
+ data = merged
76
+ source_dir = project_root
38
77
 
39
78
  # Validate and create config object
40
79
  try:
41
80
  config = ExperimentConfig(**data)
42
- config.set_source_dir(resolved_path.parent)
81
+ config.set_source_dir(source_dir)
43
82
  resolved_input_count = _resolve_input_count(
44
83
  config,
45
84
  require_inputs_file=require_inputs_file,
46
85
  )
47
86
  config.set_resolved_input_count(resolved_input_count)
48
87
  except ValidationError as e:
49
- # Format validation errors nicely
50
88
  errors = []
51
89
  for error in e.errors():
52
90
  loc = ".".join(str(x) for x in error["loc"])
@@ -54,7 +92,7 @@ def load_experiment_config(
54
92
  errors.append(f" - {loc}: {msg}")
55
93
 
56
94
  raise ValueError(
57
- f"Invalid configuration:\n" + "\n".join(errors)
95
+ "Invalid configuration:\n" + "\n".join(errors)
58
96
  )
59
97
 
60
98
  return config
@@ -157,3 +195,54 @@ def merge_config_overrides(
157
195
 
158
196
  # Create new config
159
197
  return ExperimentConfig(**data)
198
+
199
+
200
+ def _load_yaml_mapping(path: Path) -> Dict[str, Any]:
201
+ if not path.exists():
202
+ return {}
203
+
204
+ with open(path, "r", encoding="utf-8") as f:
205
+ payload = yaml.safe_load(f)
206
+
207
+ if payload is None:
208
+ return {}
209
+
210
+ if not isinstance(payload, dict):
211
+ raise ValueError(f"Configuration file must contain a mapping: {path}")
212
+
213
+ return payload
214
+
215
+
216
+ def _deep_merge(target: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, Any]:
217
+ for key, value in incoming.items():
218
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
219
+ _deep_merge(target[key], value)
220
+ else:
221
+ target[key] = value
222
+ return target
223
+
224
+
225
+ def _detect_config_context(resolved_path: Path) -> tuple[str, Path, Path]:
226
+ """Determine whether the path points to legacy or multi-section config."""
227
+
228
+ if resolved_path.is_dir():
229
+ if resolved_path.name == CONFIG_DIRECTORY_NAME:
230
+ return "multi", resolved_path.parent, resolved_path
231
+ if (resolved_path / CONFIG_DIRECTORY_NAME).exists():
232
+ return "multi", resolved_path, resolved_path / CONFIG_DIRECTORY_NAME
233
+ return "legacy", resolved_path, resolved_path
234
+
235
+ if resolved_path.parent.name == CONFIG_DIRECTORY_NAME:
236
+ return "multi", resolved_path.parent.parent, resolved_path.parent
237
+
238
+ if is_legacy_config(resolved_path.name):
239
+ return "legacy", resolved_path.parent, resolved_path.parent
240
+
241
+ return "legacy", resolved_path.parent, resolved_path.parent
242
+
243
+
244
+ def _section_key_from_filename(filename: str) -> Optional[str]:
245
+ for key, value in CONFIG_SECTION_FILENAMES.items():
246
+ if value == filename:
247
+ return key
248
+ return None