opencomb 0.2.0__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.
- opencomb/__init__.py +23 -0
- opencomb/cli.py +323 -0
- opencomb/combinatorial.py +169 -0
- opencomb/combiner.py +232 -0
- opencomb/prompt.py +149 -0
- opencomb/recipe.py +165 -0
- opencomb/template.py +68 -0
- opencomb-0.2.0.dist-info/METADATA +253 -0
- opencomb-0.2.0.dist-info/RECORD +12 -0
- opencomb-0.2.0.dist-info/WHEEL +4 -0
- opencomb-0.2.0.dist-info/entry_points.txt +2 -0
- opencomb-0.2.0.dist-info/licenses/LICENSE +21 -0
opencomb/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenComb – Smart Combiner for Code, Configs, Prompts, Templates, Recipes
|
|
3
|
+
and Combinatorial Generation.
|
|
4
|
+
|
|
5
|
+
A practical toolkit for developers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from opencomb.combiner import CodeCombiner, ConfigMerger
|
|
9
|
+
from opencomb.combinatorial import CombinatorialGenerator
|
|
10
|
+
from opencomb.prompt import PromptCombiner
|
|
11
|
+
from opencomb.recipe import RecipeRunner
|
|
12
|
+
from opencomb.template import TemplateRenderer
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CodeCombiner",
|
|
17
|
+
"ConfigMerger",
|
|
18
|
+
"CombinatorialGenerator",
|
|
19
|
+
"PromptCombiner",
|
|
20
|
+
"TemplateRenderer",
|
|
21
|
+
"RecipeRunner",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
opencomb/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""OpenComb command-line interface – powerful and beautiful."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
|
|
17
|
+
from opencomb import __version__
|
|
18
|
+
from opencomb.combiner import CodeCombiner, ConfigMerger
|
|
19
|
+
from opencomb.combinatorial import CombinatorialGenerator
|
|
20
|
+
from opencomb.prompt import PromptCombiner
|
|
21
|
+
from opencomb.recipe import RecipeRunner
|
|
22
|
+
from opencomb.template import TemplateRenderer
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="opencomb",
|
|
26
|
+
help="OpenComb – Smart Combiner for Code, Configs, Prompts, Templates, Recipes & more",
|
|
27
|
+
add_completion=True,
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
rich_markup_mode="rich",
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version_callback(value: bool) -> None:
|
|
35
|
+
if value:
|
|
36
|
+
console.print(f"[bold cyan]OpenComb[/] version [green]{__version__}[/]")
|
|
37
|
+
raise typer.Exit()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback()
|
|
41
|
+
def main(
|
|
42
|
+
version: Optional[bool] = typer.Option(
|
|
43
|
+
None,
|
|
44
|
+
"--version",
|
|
45
|
+
"-V",
|
|
46
|
+
callback=version_callback,
|
|
47
|
+
is_eager=True,
|
|
48
|
+
help="Show version and exit.",
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""OpenComb – combine code, merge configs, build prompts, render templates, run recipes."""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("combine")
|
|
56
|
+
def combine_cmd(
|
|
57
|
+
files: list[Path] = typer.Argument(..., help="Python files to combine"),
|
|
58
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result to file"),
|
|
59
|
+
no_headers: bool = typer.Option(False, "--no-headers", help="Do not add source headers"),
|
|
60
|
+
no_dedupe: bool = typer.Option(False, "--no-dedupe", help="Do not deduplicate imports"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Combine multiple Python source files into one clean module."""
|
|
63
|
+
combiner = CodeCombiner()
|
|
64
|
+
try:
|
|
65
|
+
result = combiner.combine_files(
|
|
66
|
+
files,
|
|
67
|
+
add_headers=not no_headers,
|
|
68
|
+
deduplicate_imports=not no_dedupe,
|
|
69
|
+
)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
console.print(f"[red]Error:[/] {e}")
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
if output:
|
|
75
|
+
output.write_text(result, encoding="utf-8")
|
|
76
|
+
console.print(f"[green]✓[/] Combined {len(files)} files → [cyan]{output}[/]")
|
|
77
|
+
else:
|
|
78
|
+
syntax = Syntax(result, "python", theme="monokai", line_numbers=True)
|
|
79
|
+
console.print(Panel(syntax, title="Combined Code", border_style="cyan"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("merge")
|
|
83
|
+
def merge_cmd(
|
|
84
|
+
files: list[Path] = typer.Argument(..., help="Config files to merge (later override earlier)"),
|
|
85
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write merged config"),
|
|
86
|
+
strategy: str = typer.Option("deep", "--strategy", "-s", help="deep or shallow"),
|
|
87
|
+
format: Optional[str] = typer.Option(None, "--format", "-f", help="yaml | json | toml"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Intelligently merge YAML / JSON / TOML configuration files."""
|
|
90
|
+
merger = ConfigMerger()
|
|
91
|
+
try:
|
|
92
|
+
result = merger.merge_files(files, strategy=strategy)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
console.print(f"[red]Error:[/] {e}")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
if output:
|
|
98
|
+
try:
|
|
99
|
+
merger.save(result, output, format=format)
|
|
100
|
+
console.print(f"[green]✓[/] Merged {len(files)} configs → [cyan]{output}[/]")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
console.print(f"[red]Error saving:[/] {e}")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
else:
|
|
105
|
+
text = yaml.dump(result, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
106
|
+
syntax = Syntax(text, "yaml", theme="monokai", line_numbers=True)
|
|
107
|
+
console.print(Panel(syntax, title="Merged Config", border_style="green"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("generate")
|
|
111
|
+
def generate_cmd(
|
|
112
|
+
params: Optional[Path] = typer.Option(None, "--params", "-p", help="YAML/JSON params file"),
|
|
113
|
+
method: str = typer.Option("cartesian", "--method", "-m", help="cartesian | pairwise | sample"),
|
|
114
|
+
limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Max combinations"),
|
|
115
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write as JSON Lines"),
|
|
116
|
+
seed: Optional[int] = typer.Option(None, "--seed", help="Random seed"),
|
|
117
|
+
report: bool = typer.Option(False, "--report", help="Generate Markdown report"),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Generate parameter combinations (cartesian / pairwise / sample)."""
|
|
120
|
+
if params is None:
|
|
121
|
+
console.print("[yellow]No --params given.[/] Using demo parameters.\n")
|
|
122
|
+
parameters = {
|
|
123
|
+
"learning_rate": [0.001, 0.01, 0.1],
|
|
124
|
+
"batch_size": [16, 32],
|
|
125
|
+
"optimizer": ["adam", "sgd"],
|
|
126
|
+
}
|
|
127
|
+
else:
|
|
128
|
+
text = params.read_text(encoding="utf-8")
|
|
129
|
+
if params.suffix.lower() in (".yaml", ".yml"):
|
|
130
|
+
parameters = yaml.safe_load(text)
|
|
131
|
+
else:
|
|
132
|
+
parameters = json.loads(text)
|
|
133
|
+
|
|
134
|
+
if not isinstance(parameters, dict):
|
|
135
|
+
console.print("[red]Parameters must be a mapping of name → list[/]")
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
gen = CombinatorialGenerator(seed=seed)
|
|
139
|
+
|
|
140
|
+
if method == "cartesian":
|
|
141
|
+
combos = gen.cartesian(parameters, limit=limit)
|
|
142
|
+
elif method == "pairwise":
|
|
143
|
+
combos = gen.pairwise(parameters, limit=limit)
|
|
144
|
+
elif method == "sample":
|
|
145
|
+
combos = gen.sample(parameters, n=limit or 10)
|
|
146
|
+
else:
|
|
147
|
+
console.print(f"[red]Unknown method:[/] {method}")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
if output:
|
|
151
|
+
with output.open("w", encoding="utf-8") as f:
|
|
152
|
+
for c in combos:
|
|
153
|
+
f.write(json.dumps(c, ensure_ascii=False) + "\n")
|
|
154
|
+
console.print(f"[green]✓[/] Generated [cyan]{len(combos)}[/] combinations → [cyan]{output}[/]")
|
|
155
|
+
else:
|
|
156
|
+
table = Table(title=f"Generated Combinations ({method})", show_header=True)
|
|
157
|
+
if combos:
|
|
158
|
+
for key in combos[0].keys():
|
|
159
|
+
table.add_column(str(key), style="cyan")
|
|
160
|
+
for combo in combos[:40]:
|
|
161
|
+
table.add_row(*[str(v) for v in combo.values()])
|
|
162
|
+
if len(combos) > 40:
|
|
163
|
+
console.print(f"[dim]… and {len(combos) - 40} more[/]")
|
|
164
|
+
console.print(table)
|
|
165
|
+
console.print(f"\n[bold]Total:[/] {len(combos)} combinations")
|
|
166
|
+
|
|
167
|
+
if report and combos:
|
|
168
|
+
md_lines = [
|
|
169
|
+
f"# Combination Report ({method})",
|
|
170
|
+
f"\nTotal combinations: **{len(combos)}**\n",
|
|
171
|
+
"| # | " + " | ".join(combos[0].keys()) + " |",
|
|
172
|
+
"|---|" + "|".join(["---"] * len(combos[0])) + "|",
|
|
173
|
+
]
|
|
174
|
+
for i, c in enumerate(combos, 1):
|
|
175
|
+
md_lines.append(f"| {i} | " + " | ".join(str(v) for v in c.values()) + " |")
|
|
176
|
+
report_path = Path("opencomb_report.md")
|
|
177
|
+
report_path.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
|
|
178
|
+
console.print(f"[green]✓[/] Markdown report → [cyan]{report_path}[/]")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command("prompt")
|
|
182
|
+
def prompt_cmd(
|
|
183
|
+
system: Optional[list[Path]] = typer.Option(None, "--system", "-s", help="System prompt file(s)"),
|
|
184
|
+
instruction: Optional[Path] = typer.Option(None, "--instruction", "-i", help="Instruction file"),
|
|
185
|
+
context: Optional[list[Path]] = typer.Option(None, "--context", "-c", help="Context file(s)"),
|
|
186
|
+
examples: Optional[Path] = typer.Option(None, "--examples", "-e", help="YAML examples file"),
|
|
187
|
+
user: Optional[Path] = typer.Option(None, "--user", "-u", help="User query file"),
|
|
188
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write final prompt"),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Build a structured LLM prompt from components."""
|
|
191
|
+
combiner = PromptCombiner()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
result = combiner.from_files(
|
|
195
|
+
system_files=system,
|
|
196
|
+
instruction_file=instruction,
|
|
197
|
+
context_files=context,
|
|
198
|
+
examples_file=examples,
|
|
199
|
+
user_file=user,
|
|
200
|
+
)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
console.print(f"[red]Error:[/] {e}")
|
|
203
|
+
raise typer.Exit(1)
|
|
204
|
+
|
|
205
|
+
if not result.strip():
|
|
206
|
+
console.print("[yellow]Nothing to combine. Provide at least one component.[/]")
|
|
207
|
+
raise typer.Exit(1)
|
|
208
|
+
|
|
209
|
+
if output:
|
|
210
|
+
output.write_text(result, encoding="utf-8")
|
|
211
|
+
console.print(f"[green]✓[/] Prompt written → [cyan]{output}[/]")
|
|
212
|
+
else:
|
|
213
|
+
console.print(Panel(Markdown(result), title="Combined Prompt", border_style="magenta"))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("template")
|
|
217
|
+
def template_cmd(
|
|
218
|
+
templates: list[Path] = typer.Argument(..., help="Jinja2 template file(s)"),
|
|
219
|
+
data: Optional[Path] = typer.Option(None, "--data", "-d", help="YAML/JSON data file"),
|
|
220
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write rendered result"),
|
|
221
|
+
strict: bool = typer.Option(True, "--strict/--no-strict", help="Strict undefined variables"),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Render one or more Jinja2 templates with data."""
|
|
224
|
+
context: dict = {}
|
|
225
|
+
if data:
|
|
226
|
+
text = data.read_text(encoding="utf-8")
|
|
227
|
+
if data.suffix.lower() in (".yaml", ".yml"):
|
|
228
|
+
context = yaml.safe_load(text) or {}
|
|
229
|
+
else:
|
|
230
|
+
context = json.loads(text)
|
|
231
|
+
|
|
232
|
+
renderer = TemplateRenderer(strict=strict)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
if len(templates) == 1:
|
|
236
|
+
content = templates[0].read_text(encoding="utf-8")
|
|
237
|
+
result = renderer.render_string(content, **context)
|
|
238
|
+
else:
|
|
239
|
+
result = renderer.combine_and_render(templates, data=context)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
console.print(f"[red]Template error:[/] {e}")
|
|
242
|
+
raise typer.Exit(1)
|
|
243
|
+
|
|
244
|
+
if output:
|
|
245
|
+
output.write_text(result, encoding="utf-8")
|
|
246
|
+
console.print(f"[green]✓[/] Rendered → [cyan]{output}[/]")
|
|
247
|
+
else:
|
|
248
|
+
syntax = Syntax(result, "text", theme="monokai", line_numbers=True)
|
|
249
|
+
console.print(Panel(syntax, title="Rendered Template", border_style="blue"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command("recipe")
|
|
253
|
+
def recipe_cmd(
|
|
254
|
+
recipe_file: Path = typer.Argument(..., help="Path to recipe YAML"),
|
|
255
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done"),
|
|
256
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show intermediate results"),
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Run a declarative OpenComb recipe (combine + merge + template + prompt + generate)."""
|
|
259
|
+
runner = RecipeRunner(base_dir=recipe_file.parent)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
results = runner.run(recipe_file, dry_run=dry_run)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
console.print(f"[red]Recipe error:[/] {e}")
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
|
|
267
|
+
name = results.get("_recipe", "recipe")
|
|
268
|
+
console.print(f"[green]✓[/] Recipe [cyan]{name}[/] executed successfully")
|
|
269
|
+
|
|
270
|
+
if dry_run:
|
|
271
|
+
console.print("[yellow]Dry-run mode – no files written[/]")
|
|
272
|
+
|
|
273
|
+
table = Table(title="Recipe Steps", show_header=True)
|
|
274
|
+
table.add_column("Step", style="cyan")
|
|
275
|
+
table.add_column("Status")
|
|
276
|
+
table.add_column("Output")
|
|
277
|
+
|
|
278
|
+
for key in ("combine", "merge", "template", "prompt", "generate"):
|
|
279
|
+
if key in results:
|
|
280
|
+
out_key = f"{key}_output"
|
|
281
|
+
out = results.get(out_key, "—")
|
|
282
|
+
table.add_row(key, "[green]done[/]", str(out))
|
|
283
|
+
|
|
284
|
+
console.print(table)
|
|
285
|
+
|
|
286
|
+
if verbose:
|
|
287
|
+
for key in ("combine", "merge", "template", "prompt"):
|
|
288
|
+
if key in results and isinstance(results[key], str):
|
|
289
|
+
console.print(Panel(
|
|
290
|
+
Syntax(results[key][:2000], "text", theme="monokai"),
|
|
291
|
+
title=f"{key} preview",
|
|
292
|
+
border_style="dim",
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command("info")
|
|
297
|
+
def info_cmd() -> None:
|
|
298
|
+
"""Show information about OpenComb."""
|
|
299
|
+
console.print(
|
|
300
|
+
Panel.fit(
|
|
301
|
+
f"""[bold cyan]OpenComb[/] v{__version__}
|
|
302
|
+
|
|
303
|
+
Smart Combiner for developers – code, configs, prompts, templates & recipes.
|
|
304
|
+
|
|
305
|
+
[bold]Commands:[/]
|
|
306
|
+
[cyan]combine[/] Combine multiple Python files into one
|
|
307
|
+
[cyan]merge[/] Deep-merge YAML / JSON / TOML configs
|
|
308
|
+
[cyan]generate[/] Generate combinatorial parameter sets
|
|
309
|
+
[cyan]prompt[/] Build structured LLM prompts
|
|
310
|
+
[cyan]template[/] Render Jinja2 templates
|
|
311
|
+
[cyan]recipe[/] Run a full declarative recipe
|
|
312
|
+
[cyan]info[/] Show this information
|
|
313
|
+
|
|
314
|
+
[bold]Repository:[/] https://github.com/SlabyLol/OpenComb
|
|
315
|
+
""",
|
|
316
|
+
title="OpenComb",
|
|
317
|
+
border_style="cyan",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
app()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Combinatorial generation utilities – useful for testing and experiments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import random
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CombinatorialGenerator:
|
|
11
|
+
"""
|
|
12
|
+
Generate combinations of parameters.
|
|
13
|
+
|
|
14
|
+
Supports full cartesian product and efficient pairwise (all-pairs) testing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, seed: int | None = None):
|
|
18
|
+
self.rng = random.Random(seed)
|
|
19
|
+
|
|
20
|
+
def cartesian(
|
|
21
|
+
self,
|
|
22
|
+
parameters: dict[str, list[Any]],
|
|
23
|
+
*,
|
|
24
|
+
limit: int | None = None,
|
|
25
|
+
) -> list[dict[str, Any]]:
|
|
26
|
+
"""
|
|
27
|
+
Generate the full cartesian product of parameter values.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
parameters: Mapping of parameter name → list of possible values.
|
|
31
|
+
limit: Optional maximum number of combinations to return.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of dictionaries, each representing one combination.
|
|
35
|
+
"""
|
|
36
|
+
if not parameters:
|
|
37
|
+
return [{}]
|
|
38
|
+
|
|
39
|
+
keys = list(parameters.keys())
|
|
40
|
+
value_lists = [parameters[k] for k in keys]
|
|
41
|
+
|
|
42
|
+
combos = []
|
|
43
|
+
for values in itertools.product(*value_lists):
|
|
44
|
+
combos.append(dict(zip(keys, values)))
|
|
45
|
+
if limit is not None and len(combos) >= limit:
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
return combos
|
|
49
|
+
|
|
50
|
+
def pairwise(
|
|
51
|
+
self,
|
|
52
|
+
parameters: dict[str, list[Any]],
|
|
53
|
+
*,
|
|
54
|
+
limit: int | None = None,
|
|
55
|
+
) -> list[dict[str, Any]]:
|
|
56
|
+
"""
|
|
57
|
+
Generate a (near) minimal set of combinations that cover all pairwise
|
|
58
|
+
interactions between parameters. Very useful for efficient testing.
|
|
59
|
+
|
|
60
|
+
This is a simple greedy implementation – good enough for most practical cases.
|
|
61
|
+
"""
|
|
62
|
+
if not parameters:
|
|
63
|
+
return [{}]
|
|
64
|
+
|
|
65
|
+
keys = list(parameters.keys())
|
|
66
|
+
if len(keys) == 1:
|
|
67
|
+
return [{keys[0]: v} for v in parameters[keys[0]]]
|
|
68
|
+
|
|
69
|
+
# Generate all required pairs
|
|
70
|
+
required_pairs: set[tuple[tuple[str, Any], tuple[str, Any]]] = set()
|
|
71
|
+
for i, k1 in enumerate(keys):
|
|
72
|
+
for k2 in keys[i + 1 :]:
|
|
73
|
+
for v1 in parameters[k1]:
|
|
74
|
+
for v2 in parameters[k2]:
|
|
75
|
+
required_pairs.add(((k1, v1), (k2, v2)))
|
|
76
|
+
|
|
77
|
+
# Greedy covering
|
|
78
|
+
uncovered = required_pairs.copy()
|
|
79
|
+
result: list[dict[str, Any]] = []
|
|
80
|
+
|
|
81
|
+
# Start with a few random full combinations to seed
|
|
82
|
+
for _ in range(min(5, len(list(itertools.product(*[parameters[k] for k in keys]))))):
|
|
83
|
+
combo = {k: self.rng.choice(parameters[k]) for k in keys}
|
|
84
|
+
result.append(combo)
|
|
85
|
+
self._cover(combo, uncovered)
|
|
86
|
+
|
|
87
|
+
# Keep adding combinations that cover the most remaining pairs
|
|
88
|
+
max_iterations = 1000
|
|
89
|
+
iteration = 0
|
|
90
|
+
while uncovered and iteration < max_iterations:
|
|
91
|
+
iteration += 1
|
|
92
|
+
best_combo = None
|
|
93
|
+
best_cover = 0
|
|
94
|
+
|
|
95
|
+
# Try a limited number of candidates
|
|
96
|
+
for _ in range(50):
|
|
97
|
+
candidate = {k: self.rng.choice(parameters[k]) for k in keys}
|
|
98
|
+
cover_count = self._count_cover(candidate, uncovered)
|
|
99
|
+
if cover_count > best_cover:
|
|
100
|
+
best_cover = cover_count
|
|
101
|
+
best_combo = candidate
|
|
102
|
+
|
|
103
|
+
if best_combo is None or best_cover == 0:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
result.append(best_combo)
|
|
107
|
+
self._cover(best_combo, uncovered)
|
|
108
|
+
|
|
109
|
+
if limit is not None and len(result) >= limit:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def sample(
|
|
115
|
+
self,
|
|
116
|
+
parameters: dict[str, list[Any]],
|
|
117
|
+
n: int,
|
|
118
|
+
) -> list[dict[str, Any]]:
|
|
119
|
+
"""Randomly sample n combinations (with replacement if necessary)."""
|
|
120
|
+
if not parameters:
|
|
121
|
+
return [{}] * n
|
|
122
|
+
|
|
123
|
+
keys = list(parameters.keys())
|
|
124
|
+
result = []
|
|
125
|
+
for _ in range(n):
|
|
126
|
+
result.append({k: self.rng.choice(parameters[k]) for k in keys})
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def iterate(
|
|
130
|
+
self,
|
|
131
|
+
parameters: dict[str, list[Any]],
|
|
132
|
+
method: str = "cartesian",
|
|
133
|
+
) -> Iterator[dict[str, Any]]:
|
|
134
|
+
"""Lazy iterator over combinations."""
|
|
135
|
+
if method == "cartesian":
|
|
136
|
+
keys = list(parameters.keys())
|
|
137
|
+
value_lists = [parameters[k] for k in keys]
|
|
138
|
+
for values in itertools.product(*value_lists):
|
|
139
|
+
yield dict(zip(keys, values))
|
|
140
|
+
else:
|
|
141
|
+
for combo in self.pairwise(parameters):
|
|
142
|
+
yield combo
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _cover(
|
|
146
|
+
combo: dict[str, Any],
|
|
147
|
+
uncovered: set[tuple[tuple[str, Any], tuple[str, Any]]],
|
|
148
|
+
) -> None:
|
|
149
|
+
keys = list(combo.keys())
|
|
150
|
+
for i, k1 in enumerate(keys):
|
|
151
|
+
for k2 in keys[i + 1 :]:
|
|
152
|
+
pair = ((k1, combo[k1]), (k2, combo[k2]))
|
|
153
|
+
uncovered.discard(pair)
|
|
154
|
+
# also the reverse order just in case
|
|
155
|
+
uncovered.discard(((k2, combo[k2]), (k1, combo[k1])))
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _count_cover(
|
|
159
|
+
combo: dict[str, Any],
|
|
160
|
+
uncovered: set[tuple[tuple[str, Any], tuple[str, Any]]],
|
|
161
|
+
) -> int:
|
|
162
|
+
count = 0
|
|
163
|
+
keys = list(combo.keys())
|
|
164
|
+
for i, k1 in enumerate(keys):
|
|
165
|
+
for k2 in keys[i + 1 :]:
|
|
166
|
+
pair = ((k1, combo[k1]), (k2, combo[k2]))
|
|
167
|
+
if pair in uncovered or ((k2, combo[k2]), (k1, combo[k1])) in uncovered:
|
|
168
|
+
count += 1
|
|
169
|
+
return count
|