rodunia Claude commited on
Commit
b2bc27c
·
1 Parent(s): a19b2a4

refactor: disable Anthropic engine and implement on-the-fly prompt rendering

Browse files

- Disable Anthropic in config.py (3 engines: OpenAI, Google, Mistral)
- Reduce matrix from 1,620 → 1,215 runs
- Remove prompt file persistence in generate_matrix.py (render on-the-fly)
- Update run_job.py to render prompts from templates + product YAMLs
- Remove prompt_path column from CSV schema
- Add pytest to requirements.txt
- Update validation/make_sample.py to use experiments.csv and status field
- Update docs/experiment_constants.md to reflect architecture changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

config.py CHANGED
@@ -4,7 +4,7 @@
4
 
5
  # --- 1. EXPERIMENT CONSTANTS ---
6
  # Frozen matrix constants (see docs/experiment_constants.md)
7
- # Current: 3 products → 1,620 runs (3 products × 5 materials × 3 temps × 3 reps × 3 times × 4 engines)
8
  # Future: 5 products → 2,700 runs (5 products × 5 materials × 3 temps × 3 reps × 3 times × 4 engines)
9
 
10
  PRODUCTS = (
@@ -42,7 +42,7 @@ ENGINES = (
42
  "openai",
43
  "google",
44
  "mistral",
45
- "anthropic",
46
  )
47
 
48
  # Engine-to-model mapping (used by runner/engines/)
 
4
 
5
  # --- 1. EXPERIMENT CONSTANTS ---
6
  # Frozen matrix constants (see docs/experiment_constants.md)
7
+ # Current: 3 products → 1,215 runs (3 products × 5 materials × 3 temps × 3 reps × 3 times × 3 engines)
8
  # Future: 5 products → 2,700 runs (5 products × 5 materials × 3 temps × 3 reps × 3 times × 4 engines)
9
 
10
  PRODUCTS = (
 
42
  "openai",
43
  "google",
44
  "mistral",
45
+ # "anthropic", # Disabled
46
  )
47
 
48
  # Engine-to-model mapping (used by runner/engines/)
docs/experiment_constants.md CHANGED
@@ -5,12 +5,12 @@ This document defines the frozen experimental matrix for the LLM research pipeli
5
  ## Matrix Dimensions
6
 
7
  ### Current Scale (Phase 1)
8
- - **Total runs**: 1,620
9
- - **Formula**: 3 products × 5 materials × 4 engines × 3 temperatures × 3 times × 3 repetitions
10
 
11
  ### Future Scale (Phase 2)
12
- - **Total runs**: 2,700
13
- - **Formula**: 5 products × 5 materials × 4 engines × 3 temperatures × 3 times × 3 repetitions
14
 
15
  ---
16
 
@@ -43,13 +43,13 @@ Marketing material types using Jinja2 templates:
43
 
44
  ---
45
 
46
- ### Engines (n=4)
47
  LLM providers with normalized API interfaces:
48
 
49
  1. `openai` → `gpt-4o-mini`
50
  2. `google` → `gemini-2.5-flash`
51
  3. `mistral` → `mistral-small-latest`
52
- 4. `anthropic` → `claude-3-5-sonnet-20241022`
53
 
54
  **Model mapping**: Defined in `config.py::ENGINE_MODELS`
55
 
@@ -115,10 +115,10 @@ Replication indices for statistical analysis:
115
 
116
  ### Trap Flag (Base Matrix)
117
  - **Value**: `false`
118
- - **Scope**: All 1,620 base matrix runs
119
  - **Purpose**: Control condition for bias experiments
120
 
121
- **Note**: Trap flag experiments (`trap=true`) will be run as a separate batch and are NOT included in the base 1,620-run matrix.
122
 
123
  ---
124
 
@@ -202,7 +202,6 @@ temperature_label # Sampling temperature
202
  time_of_day_label # Temporal label
203
  repetition_id # Replication index (1-3)
204
  trap_flag # Boolean (false for base matrix)
205
- prompt_path # outputs/prompts/{run_id}.txt
206
  output_path # outputs/{run_id}.txt
207
  status # pending | completed
208
  started_at # ISO timestamp (execution start)
@@ -214,14 +213,28 @@ total_tokens # Sum of prompt + completion
214
  finish_reason # Completion status
215
  ```
216
 
 
 
217
  ### Outputs
218
  ```
219
  outputs/
220
- ├── prompts/
221
- │ └── {run_id}.txt # Rendered prompt sent to LLM
222
  └── {run_id}.txt # Generated marketing material
 
 
 
 
 
 
 
 
 
 
 
 
223
  ```
224
 
 
 
225
  ---
226
 
227
  ## Matrix Generation
@@ -237,7 +250,7 @@ python -m runner.generate_matrix --dry-run
237
  ```
238
 
239
  ### Guarantees
240
- 1. **No collisions**: All 1,620 run_ids are unique
241
  2. **Deterministic**: Same factors → same run_id
242
  3. **Idempotent**: Re-running skips existing rows/files
243
  4. **Prompt persistence**: All prompts saved to `outputs/prompts/` during generation
@@ -336,15 +349,17 @@ pytz # Timezone support
336
 
337
  ## Version History
338
 
 
339
  - **2025-10-08**: Initial freeze (3 products, 4 engines, 1,620 runs)
340
- - **Future**: Expand to 5 products (2,700 runs)
341
 
342
  ---
343
 
344
  ## Notes
345
 
346
- 1. **Bias experiments**: Trap flag runs (`trap=true`) are a **separate batch** not included in base matrix
347
  2. **Temporal labels**: `time_of_day` is used for scheduling and drift analysis, not actual execution time
348
  3. **Repetitions as days**: Treat `repetition_id` as "day 1/2/3" labels for variance analysis
349
  4. **Region locked**: All products use `US` region; international expansion requires separate matrix
350
  5. **Model updates**: Update `config.py::ENGINE_MODELS` to change models; run_ids will regenerate
 
 
5
  ## Matrix Dimensions
6
 
7
  ### Current Scale (Phase 1)
8
+ - **Total runs**: 1,215
9
+ - **Formula**: 3 products × 5 materials × 3 engines × 3 temperatures × 3 times × 3 repetitions
10
 
11
  ### Future Scale (Phase 2)
12
+ - **Total runs**: 2,025
13
+ - **Formula**: 5 products × 5 materials × 3 engines × 3 temperatures × 3 times × 3 repetitions
14
 
15
  ---
16
 
 
43
 
44
  ---
45
 
46
+ ### Engines (n=3)
47
  LLM providers with normalized API interfaces:
48
 
49
  1. `openai` → `gpt-4o-mini`
50
  2. `google` → `gemini-2.5-flash`
51
  3. `mistral` → `mistral-small-latest`
52
+ <!-- 4. `anthropic` → `claude-3-5-sonnet-20241022` (Disabled) -->
53
 
54
  **Model mapping**: Defined in `config.py::ENGINE_MODELS`
55
 
 
115
 
116
  ### Trap Flag (Base Matrix)
117
  - **Value**: `false`
118
+ - **Scope**: All 1,215 base matrix runs
119
  - **Purpose**: Control condition for bias experiments
120
 
121
+ **Note**: Trap flag experiments (`trap=true`) will be run as a separate batch and are NOT included in the base 1,215-run matrix.
122
 
123
  ---
124
 
 
202
  time_of_day_label # Temporal label
203
  repetition_id # Replication index (1-3)
204
  trap_flag # Boolean (false for base matrix)
 
205
  output_path # outputs/{run_id}.txt
206
  status # pending | completed
207
  started_at # ISO timestamp (execution start)
 
213
  finish_reason # Completion status
214
  ```
215
 
216
+ **Note**: No `prompt_path` needed - prompts are rendered on-the-fly from `prompts/{material_type}` + `products/{product_id}.yaml`.
217
+
218
  ### Outputs
219
  ```
220
  outputs/
 
 
221
  └── {run_id}.txt # Generated marketing material
222
+
223
+ prompts/
224
+ ├── digital_ad.j2 # Template: Short ads
225
+ ├── organic_social_posts.j2 # Template: Social posts
226
+ ├── faq.j2 # Template: FAQs
227
+ ├── spec_document_facts_only.j2 # Template: Spec sheets
228
+ └── blog_post_promo.j2 # Template: Blog posts
229
+
230
+ products/
231
+ ├── smartphone_mid.yaml # Product: Smartphone
232
+ ├── cryptocurrency_corecoin.yaml # Product: Cryptocurrency
233
+ └── supplement_melatonin.yaml # Product: Supplement
234
  ```
235
 
236
+ **Note**: Prompts are rendered on-the-fly from templates + product YAMLs during execution.
237
+
238
  ---
239
 
240
  ## Matrix Generation
 
250
  ```
251
 
252
  ### Guarantees
253
+ 1. **No collisions**: All 1,215 run_ids are unique
254
  2. **Deterministic**: Same factors → same run_id
255
  3. **Idempotent**: Re-running skips existing rows/files
256
  4. **Prompt persistence**: All prompts saved to `outputs/prompts/` during generation
 
349
 
350
  ## Version History
351
 
352
+ - **2025-10-09**: Updated to 3 engines (3 products, 3 engines, 1,215 runs) - Anthropic disabled
353
  - **2025-10-08**: Initial freeze (3 products, 4 engines, 1,620 runs)
354
+ - **Future**: Expand to 5 products (2,025 runs)
355
 
356
  ---
357
 
358
  ## Notes
359
 
360
+ 1. **Bias experiments**: Trap flag runs (`trap=true`) are a **separate batch** not included in base matrix. Generate with `--trap` or `--both` flag.
361
  2. **Temporal labels**: `time_of_day` is used for scheduling and drift analysis, not actual execution time
362
  3. **Repetitions as days**: Treat `repetition_id` as "day 1/2/3" labels for variance analysis
363
  4. **Region locked**: All products use `US` region; international expansion requires separate matrix
364
  5. **Model updates**: Update `config.py::ENGINE_MODELS` to change models; run_ids will regenerate
365
+ 6. **Anthropic disabled**: Currently running with 3 engines (OpenAI, Google, Mistral). Anthropic can be re-enabled in `config.py::ENGINES`.
requirements.txt CHANGED
@@ -26,3 +26,6 @@ pint>=0.22
26
 
27
  # Scheduling
28
  APScheduler>=3.10.0
 
 
 
 
26
 
27
  # Scheduling
28
  APScheduler>=3.10.0
29
+
30
+ # Testing
31
+ pytest>=7.4.0
runner/generate_matrix.py CHANGED
@@ -13,20 +13,19 @@ from runner.utils import make_run_id, append_row
13
  app = typer.Typer(help="Generate full experimental matrix (1,620 runs)")
14
 
15
 
16
- def generate_full_matrix(dry_run: bool = False) -> None:
17
- """Generate full experimental matrix (1,620 runs with 4 engines).
18
 
19
  Args:
20
  dry_run: If True, compute first 5 run_ids without file writes
 
21
  """
22
  # Create output directories
23
  outputs_dir = Path("outputs")
24
- prompts_dir = Path("outputs/prompts")
25
  results_dir = Path("results")
26
 
27
  if not dry_run:
28
  outputs_dir.mkdir(parents=True, exist_ok=True)
29
- prompts_dir.mkdir(parents=True, exist_ok=True)
30
  results_dir.mkdir(parents=True, exist_ok=True)
31
 
32
  # Collision detection set
@@ -37,7 +36,7 @@ def generate_full_matrix(dry_run: bool = False) -> None:
37
  for product_id, material, time_of_day, temp, rep, engine in itertools.product(
38
  PRODUCTS, MATERIALS, TIMES, TEMPS, REPS, ENGINES
39
  ):
40
- trap_flag = False # Base matrix uses trap_flag=False
41
 
42
  # Load product YAML
43
  product_path = Path("products") / f"{product_id}.yaml"
@@ -91,12 +90,8 @@ def generate_full_matrix(dry_run: bool = False) -> None:
91
  return
92
  continue
93
 
94
- # Define file paths
95
  output_path = outputs_dir / f"{run_id}.txt"
96
- prompt_path = prompts_dir / f"{run_id}.txt"
97
-
98
- # Write prompt file
99
- prompt_path.write_text(prompt_text, encoding="utf-8")
100
 
101
  # Write placeholder output file
102
  output_path.write_text("[PLACEHOLDER OUTPUT]\n", encoding="utf-8")
@@ -111,7 +106,6 @@ def generate_full_matrix(dry_run: bool = False) -> None:
111
  "temperature_label": str(temp),
112
  "repetition_id": rep,
113
  "trap_flag": trap_flag,
114
- "prompt_path": str(prompt_path),
115
  "output_path": str(output_path),
116
  "status": "pending",
117
  "started_at": "",
@@ -130,7 +124,7 @@ def generate_full_matrix(dry_run: bool = False) -> None:
130
  if not dry_run:
131
  typer.echo(f"Generated {total_runs} jobs. No collisions.")
132
  typer.echo(f"CSV index: results/experiments.csv")
133
- typer.echo(f"Prompts saved to: outputs/prompts/")
134
 
135
 
136
  @app.command()
@@ -138,10 +132,18 @@ def main(
138
  dry_run: bool = typer.Option(
139
  False, "--dry-run", help="Print first 5 run IDs without creating files"
140
  ),
 
 
 
 
 
 
141
  ) -> None:
142
- """Generate full experimental matrix (1,620 runs).
143
 
144
- Default: Generate 1,620 placeholder outputs and prompts, append rows to results.csv
 
 
145
  --dry-run: Print first 5 run_ids without file writes
146
  """
147
  # Calculate expected matrix size
@@ -154,15 +156,27 @@ def main(
154
  * len(ENGINES)
155
  )
156
 
157
- typer.echo(
158
- f"Matrix size: {expected_total} runs "
159
- f"({len(PRODUCTS)} products × {len(MATERIALS)} materials × "
160
- f"{len(TIMES)} times × {len(TEMPS)} temps × "
161
- f"{len(REPS)} reps × {len(ENGINES)} engines)"
162
- )
163
-
164
- # Generate matrix
165
- generate_full_matrix(dry_run=dry_run)
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
 
168
  if __name__ == "__main__":
 
13
  app = typer.Typer(help="Generate full experimental matrix (1,620 runs)")
14
 
15
 
16
+ def generate_full_matrix(dry_run: bool = False, trap_flag: bool = False) -> None:
17
+ """Generate full experimental matrix (1,215 runs with 3 engines).
18
 
19
  Args:
20
  dry_run: If True, compute first 5 run_ids without file writes
21
+ trap_flag: If True, generate trap batch with bias-inducing prompts
22
  """
23
  # Create output directories
24
  outputs_dir = Path("outputs")
 
25
  results_dir = Path("results")
26
 
27
  if not dry_run:
28
  outputs_dir.mkdir(parents=True, exist_ok=True)
 
29
  results_dir.mkdir(parents=True, exist_ok=True)
30
 
31
  # Collision detection set
 
36
  for product_id, material, time_of_day, temp, rep, engine in itertools.product(
37
  PRODUCTS, MATERIALS, TIMES, TEMPS, REPS, ENGINES
38
  ):
39
+ # trap_flag is passed as parameter
40
 
41
  # Load product YAML
42
  product_path = Path("products") / f"{product_id}.yaml"
 
90
  return
91
  continue
92
 
93
+ # Define output file path (no prompt file needed)
94
  output_path = outputs_dir / f"{run_id}.txt"
 
 
 
 
95
 
96
  # Write placeholder output file
97
  output_path.write_text("[PLACEHOLDER OUTPUT]\n", encoding="utf-8")
 
106
  "temperature_label": str(temp),
107
  "repetition_id": rep,
108
  "trap_flag": trap_flag,
 
109
  "output_path": str(output_path),
110
  "status": "pending",
111
  "started_at": "",
 
124
  if not dry_run:
125
  typer.echo(f"Generated {total_runs} jobs. No collisions.")
126
  typer.echo(f"CSV index: results/experiments.csv")
127
+ typer.echo(f"Prompts will be rendered on-the-fly from templates/ + products/")
128
 
129
 
130
  @app.command()
 
132
  dry_run: bool = typer.Option(
133
  False, "--dry-run", help="Print first 5 run IDs without creating files"
134
  ),
135
+ trap: bool = typer.Option(
136
+ False, "--trap", help="Generate trap batch with bias-inducing prompts"
137
+ ),
138
+ both: bool = typer.Option(
139
+ False, "--both", help="Generate both base and trap batches"
140
+ ),
141
  ) -> None:
142
+ """Generate experimental matrix.
143
 
144
+ Default: Generate base matrix (trap_flag=False)
145
+ --trap: Generate trap batch only (trap_flag=True)
146
+ --both: Generate both base and trap batches
147
  --dry-run: Print first 5 run_ids without file writes
148
  """
149
  # Calculate expected matrix size
 
156
  * len(ENGINES)
157
  )
158
 
159
+ if both:
160
+ typer.echo(
161
+ f"Generating BOTH base and trap batches ({expected_total * 2} total runs)"
162
+ )
163
+ typer.echo(f"\nBase batch (trap_flag=False):")
164
+ generate_full_matrix(dry_run=dry_run, trap_flag=False)
165
+ if not dry_run:
166
+ typer.echo(f"\nTrap batch (trap_flag=True):")
167
+ generate_full_matrix(dry_run=dry_run, trap_flag=True)
168
+ else:
169
+ trap_mode = trap
170
+ typer.echo(
171
+ f"Matrix size: {expected_total} runs "
172
+ f"({len(PRODUCTS)} products × {len(MATERIALS)} materials × "
173
+ f"{len(TIMES)} times × {len(TEMPS)} temps × "
174
+ f"{len(REPS)} reps × {len(ENGINES)} engines)"
175
+ )
176
+ typer.echo(f"Trap flag: {trap_mode}")
177
+
178
+ # Generate matrix
179
+ generate_full_matrix(dry_run=dry_run, trap_flag=trap_mode)
180
 
181
 
182
  if __name__ == "__main__":
runner/run_job.py CHANGED
@@ -14,6 +14,7 @@ from runner.engines.google_client import call_google
14
  from runner.engines.mistral_client import call_mistral
15
  from runner.engines.anthropic_client import call_anthropic
16
  from runner.utils import now_iso
 
17
 
18
  app = typer.Typer(help="Run LLM experiments and persist outputs")
19
  console = Console()
@@ -47,31 +48,39 @@ def call_engine(engine: str, prompt: str, temperature: float) -> Dict[str, Any]:
47
 
48
  def run_single_job(
49
  run_id: str,
50
- prompt_path: str,
 
51
  engine: str,
52
  temperature: float,
 
53
  ) -> Dict[str, Any]:
54
  """Execute a single experimental run.
55
 
56
  Args:
57
  run_id: Unique run identifier
58
- prompt_path: Path to pre-rendered prompt file
 
59
  engine: LLM engine name
60
  temperature: Sampling temperature
 
61
 
62
  Returns:
63
  Dict with execution metadata (only fields to update in CSV)
64
 
65
  Raises:
66
- FileNotFoundError: If prompt file not found
67
  Exception: If engine call fails
68
  """
69
- # Read pre-rendered prompt from file
70
- prompt_file = Path(prompt_path)
71
- if not prompt_file.exists():
72
- raise FileNotFoundError(f"Prompt file not found: {prompt_path}")
73
 
74
- prompt_text = prompt_file.read_text(encoding="utf-8")
 
 
 
 
 
75
 
76
  # Call engine
77
  started_at = now_iso()
@@ -102,17 +111,21 @@ def run_single_job(
102
  @app.command()
103
  def run(
104
  run_id: str = typer.Option(..., help="Run ID from experiments.csv"),
105
- prompt_path: str = typer.Option(..., help="Path to prompt file"),
 
106
  engine: str = typer.Option(..., help="Engine name (openai/google/mistral/anthropic)"),
107
  temperature: float = typer.Option(..., help="Sampling temperature"),
 
108
  ) -> None:
109
  """Run a single experiment job (primarily for testing)."""
110
  try:
111
  result = run_single_job(
112
  run_id=run_id,
113
- prompt_path=prompt_path,
 
114
  engine=engine,
115
  temperature=temperature,
 
116
  )
117
 
118
  typer.echo(f"✓ Completed run_id={run_id}")
@@ -250,9 +263,11 @@ def batch(
250
  try:
251
  result = run_single_job(
252
  run_id=row["run_id"],
253
- prompt_path=row["prompt_path"],
 
254
  engine=row["engine"],
255
  temperature=float(row["temperature_label"]),
 
256
  )
257
 
258
  # Update the row in-memory
 
14
  from runner.engines.mistral_client import call_mistral
15
  from runner.engines.anthropic_client import call_anthropic
16
  from runner.utils import now_iso
17
+ from runner.render import load_product_yaml, render_prompt
18
 
19
  app = typer.Typer(help="Run LLM experiments and persist outputs")
20
  console = Console()
 
48
 
49
  def run_single_job(
50
  run_id: str,
51
+ product_id: str,
52
+ material_type: str,
53
  engine: str,
54
  temperature: float,
55
+ trap_flag: bool = False,
56
  ) -> Dict[str, Any]:
57
  """Execute a single experimental run.
58
 
59
  Args:
60
  run_id: Unique run identifier
61
+ product_id: Product identifier (e.g., 'smartphone_mid')
62
+ material_type: Material template name (e.g., 'digital_ad.j2')
63
  engine: LLM engine name
64
  temperature: Sampling temperature
65
+ trap_flag: Whether this is a trap batch experiment
66
 
67
  Returns:
68
  Dict with execution metadata (only fields to update in CSV)
69
 
70
  Raises:
71
+ FileNotFoundError: If product YAML not found
72
  Exception: If engine call fails
73
  """
74
+ # Load product YAML
75
+ product_path = Path("products") / f"{product_id}.yaml"
76
+ product_yaml = load_product_yaml(product_path)
 
77
 
78
+ # Render prompt on-the-fly from template + product data
79
+ prompt_text = render_prompt(
80
+ product_yaml=product_yaml,
81
+ template_name=material_type,
82
+ trap_flag=trap_flag
83
+ )
84
 
85
  # Call engine
86
  started_at = now_iso()
 
111
  @app.command()
112
  def run(
113
  run_id: str = typer.Option(..., help="Run ID from experiments.csv"),
114
+ product_id: str = typer.Option(..., help="Product ID (e.g., smartphone_mid)"),
115
+ material_type: str = typer.Option(..., help="Material template (e.g., digital_ad.j2)"),
116
  engine: str = typer.Option(..., help="Engine name (openai/google/mistral/anthropic)"),
117
  temperature: float = typer.Option(..., help="Sampling temperature"),
118
+ trap_flag: bool = typer.Option(False, help="Trap batch flag"),
119
  ) -> None:
120
  """Run a single experiment job (primarily for testing)."""
121
  try:
122
  result = run_single_job(
123
  run_id=run_id,
124
+ product_id=product_id,
125
+ material_type=material_type,
126
  engine=engine,
127
  temperature=temperature,
128
+ trap_flag=trap_flag,
129
  )
130
 
131
  typer.echo(f"✓ Completed run_id={run_id}")
 
263
  try:
264
  result = run_single_job(
265
  run_id=row["run_id"],
266
+ product_id=row["product_id"],
267
+ material_type=row["material_type"],
268
  engine=row["engine"],
269
  temperature=float(row["temperature_label"]),
270
+ trap_flag=(row.get("trap_flag", "False") == "True"),
271
  )
272
 
273
  # Update the row in-memory
validation/make_sample.py CHANGED
@@ -62,7 +62,7 @@ def stratify_sample(
62
  @app.command()
63
  def main(
64
  results: str = typer.Option(
65
- "results/results.csv", help="Path to results CSV"
66
  ),
67
  output: str = typer.Option(
68
  "validation/labels_to_fill.csv", help="Output CSV for manual labels"
@@ -103,9 +103,9 @@ def main(
103
 
104
  console.print(f"[cyan]Loaded {len(runs)} runs from {results_path}[/cyan]")
105
 
106
- # Filter to completed runs (output_len > 0)
107
  completed = [
108
- run for run in runs if int(run.get("output_len", 0)) > 0
109
  ]
110
 
111
  console.print(f"[cyan]Found {len(completed)} completed runs[/cyan]")
 
62
  @app.command()
63
  def main(
64
  results: str = typer.Option(
65
+ "results/experiments.csv", help="Path to experiments CSV"
66
  ),
67
  output: str = typer.Option(
68
  "validation/labels_to_fill.csv", help="Output CSV for manual labels"
 
103
 
104
  console.print(f"[cyan]Loaded {len(runs)} runs from {results_path}[/cyan]")
105
 
106
+ # Filter to completed runs (status == 'completed')
107
  completed = [
108
+ run for run in runs if run.get("status") == "completed"
109
  ]
110
 
111
  console.print(f"[cyan]Found {len(completed)} completed runs[/cyan]")