agent-attrib 0.1.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.
@@ -0,0 +1,124 @@
1
+ """agent-attrib: multi-factor attribution for agent release changes."""
2
+
3
+ from .model import MetricResult, analyze_runset
4
+ from .schema import Design, Run, RunSet, load_runs
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "plan",
9
+ "analyze",
10
+ "load_runs",
11
+ "RunSet",
12
+ "Run",
13
+ "Design",
14
+ "MetricResult",
15
+ "__version__",
16
+ ]
17
+
18
+
19
+ def plan(
20
+ factors: list[str],
21
+ tasks: int = 30,
22
+ trials: int = 2,
23
+ seed: int = 42,
24
+ resolution: str = "IV",
25
+ sd: float | None = None,
26
+ ) -> "Design":
27
+ """Generate a factorial experiment design.
28
+
29
+ Args:
30
+ factors: list of factor names.
31
+ tasks: number of distinct tasks.
32
+ trials: trials per configuration per task.
33
+ seed: random seed for run-order randomization.
34
+ resolution: minimum design resolution for fractional designs ("IV" or "V").
35
+ sd: assumed SD for MDE calculation on continuous metrics.
36
+
37
+ Returns:
38
+ Design object with run order and summary statistics.
39
+ """
40
+ from .design import build_design, generate_run_order
41
+ from .power import mde_binary, mde_continuous
42
+ from .schema import Design, DesignRow
43
+
44
+ dm_matrix, dm = build_design(factors, tasks=tasks, trials=trials, seed=seed, resolution=resolution)
45
+ task_ids = [f"t{i:04d}" for i in range(tasks)]
46
+ df = generate_run_order(dm_matrix, factors, task_ids, trials, seed)
47
+
48
+ n_configs = dm_matrix.shape[0]
49
+ n_total = n_configs * tasks * trials
50
+
51
+ mde_val = mde_continuous(n_configs, tasks, trials, sd=sd) if sd else mde_binary(n_configs, tasks, trials)
52
+
53
+ rows: list[DesignRow] = []
54
+ for _, row in df.iterrows():
55
+ factor_levels = {f: str(row[f"f_{f}"]) for f in factors}
56
+ rows.append(
57
+ DesignRow(
58
+ config_id=str(row["config_id"]),
59
+ task_id=str(row["task_id"]),
60
+ trial=int(row["trial"]),
61
+ run_order=int(row["run_order"]),
62
+ factors=factor_levels, # type: ignore[arg-type]
63
+ )
64
+ )
65
+
66
+ return Design(
67
+ factors=factors,
68
+ tasks=tasks,
69
+ trials=trials,
70
+ seed=seed,
71
+ design_type=dm.design_type,
72
+ resolution=dm.resolution,
73
+ alias_structure=dm.alias_structure,
74
+ runs=rows,
75
+ n_configs=n_configs,
76
+ n_total=n_total,
77
+ mde=mde_val,
78
+ mde_metric="success (binary, p=0.5)" if sd is None else "continuous",
79
+ )
80
+
81
+
82
+ def analyze(
83
+ runset: "RunSet",
84
+ metrics: list[str] | None = None,
85
+ margin: float = 0.05,
86
+ binary_metrics: list[str] | None = None,
87
+ ) -> "AnalysisResult":
88
+ """Analyze a RunSet and return an AnalysisResult.
89
+
90
+ Args:
91
+ runset: loaded runs from load_runs().
92
+ metrics: metrics to analyze (default: all in runset).
93
+ margin: equivalence margin for EQUIVALENT/INCONCLUSIVE verdicts.
94
+ binary_metrics: metric names to treat as binary (default: ["success"]).
95
+
96
+ Returns:
97
+ AnalysisResult with per-metric results and reporting methods.
98
+ """
99
+ metrics = metrics or runset.metrics
100
+ results = analyze_runset(runset, metrics=metrics, margin=margin, binary_metrics=binary_metrics)
101
+ return AnalysisResult(results)
102
+
103
+
104
+ class AnalysisResult:
105
+ """Container for per-metric analysis results with reporting methods."""
106
+
107
+ def __init__(self, results: list["MetricResult"]) -> None:
108
+ self.results = results
109
+
110
+ def to_markdown(self) -> str:
111
+ from .report import to_markdown
112
+ return to_markdown(self.results)
113
+
114
+ def to_json(self) -> str:
115
+ from .report import to_json
116
+ return to_json(self.results)
117
+
118
+ def print(self) -> None:
119
+ from .report import print_rich
120
+ print_rich(self.results)
121
+
122
+ def __repr__(self) -> str:
123
+ metrics = [r.metric for r in self.results]
124
+ return f"AnalysisResult(metrics={metrics})"
agent_attrib/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """Typer CLI: plan, analyze, demo, version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ app = typer.Typer(
12
+ name="agent-attrib",
13
+ help="Multi-factor attribution for agent release changes.",
14
+ no_args_is_help=True,
15
+ )
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def plan(
21
+ factors: Annotated[
22
+ str,
23
+ typer.Option("--factors", "-f", help="Comma-separated factor names"),
24
+ ],
25
+ tasks: Annotated[int, typer.Option("--tasks", "-t", help="Number of tasks")] = 30,
26
+ trials: Annotated[
27
+ int, typer.Option("--trials", help="Trials per config per task")
28
+ ] = 2,
29
+ output: Annotated[
30
+ Path | None,
31
+ typer.Option("--output", "-o", help="Write design CSV to this path"),
32
+ ] = None,
33
+ seed: Annotated[int, typer.Option("--seed", help="Random seed")] = 42,
34
+ resolution: Annotated[
35
+ str,
36
+ typer.Option("--resolution", help="Minimum resolution for fractional designs (IV or V)"),
37
+ ] = "IV",
38
+ sd: Annotated[
39
+ float | None,
40
+ typer.Option("--sd", help="Assumed SD for MDE (continuous metrics)"),
41
+ ] = None,
42
+ pilot: Annotated[
43
+ Path | None,
44
+ typer.Option("--pilot", help="Pilot runs JSONL for SD estimation"),
45
+ ] = None,
46
+ ) -> None:
47
+ """Generate a factorial experiment design."""
48
+ from .design import build_design, generate_run_order
49
+ from .power import mde_binary, mde_continuous
50
+ from .schema import Design, DesignRow, load_runs
51
+
52
+ factor_list = [f.strip() for f in factors.split(",") if f.strip()]
53
+ if not factor_list:
54
+ console.print("[red]Error: --factors must specify at least one factor[/red]")
55
+ raise typer.Exit(1)
56
+
57
+ try:
58
+ dm_matrix, dm = build_design(factor_list, tasks=tasks, trials=trials, seed=seed, resolution=resolution)
59
+ except ValueError as e:
60
+ console.print(f"[red]Error: {e}[/red]")
61
+ raise typer.Exit(1) from None
62
+
63
+ task_ids = [f"t{i:04d}" for i in range(tasks)]
64
+ df = generate_run_order(dm_matrix, factor_list, task_ids, trials, seed)
65
+
66
+ n_configs = dm_matrix.shape[0]
67
+ n_total = n_configs * tasks * trials
68
+
69
+ # MDE calculation
70
+ mde_val: float | None = None
71
+ mde_metric_name = "success (binary, p=0.5)"
72
+ if pilot is not None:
73
+ try:
74
+ rs = load_runs(pilot)
75
+ import numpy as np
76
+ vals = [r.metrics.get("success", 0) for r in rs.runs]
77
+ sd_est = float(np.std(vals))
78
+ mde_val = mde_continuous(n_configs, tasks, trials, sd=sd_est)
79
+ mde_metric_name = "success (from pilot)"
80
+ except Exception as e:
81
+ console.print(f"[yellow]Warning: could not read pilot file: {e}[/yellow]")
82
+ elif sd is not None:
83
+ mde_val = mde_continuous(n_configs, tasks, trials, sd=sd)
84
+ mde_metric_name = f"continuous metric (SD={sd})"
85
+ else:
86
+ mde_val = mde_binary(n_configs, tasks, trials)
87
+
88
+ rows: list[DesignRow] = []
89
+ for _, row in df.iterrows():
90
+ factor_levels = {f: str(row[f"f_{f}"]) for f in factor_list}
91
+ rows.append(
92
+ DesignRow(
93
+ config_id=str(row["config_id"]),
94
+ task_id=str(row["task_id"]),
95
+ trial=int(row["trial"]),
96
+ run_order=int(row["run_order"]),
97
+ factors=factor_levels, # type: ignore[arg-type]
98
+ )
99
+ )
100
+
101
+ design = Design(
102
+ factors=factor_list,
103
+ tasks=tasks,
104
+ trials=trials,
105
+ seed=seed,
106
+ design_type=dm.design_type,
107
+ resolution=dm.resolution,
108
+ alias_structure=dm.alias_structure,
109
+ runs=rows,
110
+ n_configs=n_configs,
111
+ n_total=n_total,
112
+ mde=mde_val,
113
+ mde_metric=mde_metric_name,
114
+ )
115
+
116
+ console.print(design.summary())
117
+
118
+ if output is not None:
119
+ design.to_csv(output)
120
+ console.print(f"\nDesign written to [cyan]{output}[/cyan]")
121
+
122
+
123
+ @app.command()
124
+ def analyze(
125
+ runs_file: Annotated[Path, typer.Argument(help="JSONL or CSV runs file")],
126
+ metric: Annotated[
127
+ list[str] | None,
128
+ typer.Option("--metric", "-m", help="Metric to analyze (repeatable)"),
129
+ ] = None,
130
+ margin: Annotated[
131
+ float,
132
+ typer.Option("--margin", help="Equivalence margin for verdict"),
133
+ ] = 0.05,
134
+ output: Annotated[
135
+ Path | None,
136
+ typer.Option("--output", "-o", help="Write markdown report to this path"),
137
+ ] = None,
138
+ json_output: Annotated[
139
+ bool, typer.Option("--json", help="Output JSON instead of markdown")
140
+ ] = False,
141
+ binary: Annotated[
142
+ list[str] | None,
143
+ typer.Option("--binary", help="Metric names to treat as binary (default: success)"),
144
+ ] = None,
145
+ ) -> None:
146
+ """Analyze agent runs and report effect attributions."""
147
+ from .model import analyze_runset
148
+ from .report import print_rich, to_json, to_markdown
149
+ from .schema import load_runs
150
+
151
+ try:
152
+ runset = load_runs(runs_file)
153
+ except Exception as e:
154
+ console.print(f"[red]Error loading runs: {e}[/red]")
155
+ raise typer.Exit(1) from None
156
+
157
+ metrics_to_analyze = metric or runset.metrics
158
+ binary_metrics = binary or ["success"]
159
+
160
+ try:
161
+ results = analyze_runset(
162
+ runset,
163
+ metrics=metrics_to_analyze,
164
+ margin=margin,
165
+ binary_metrics=binary_metrics,
166
+ )
167
+ except Exception as e:
168
+ console.print(f"[red]Analysis error: {e}[/red]")
169
+ raise typer.Exit(1) from None
170
+
171
+ if json_output:
172
+ from .report import to_json
173
+ text = to_json(results)
174
+ if output:
175
+ output.write_text(text)
176
+ console.print(f"JSON report written to [cyan]{output}[/cyan]")
177
+ else:
178
+ print(text)
179
+ elif output:
180
+ md = to_markdown(results)
181
+ output.write_text(md)
182
+ console.print(f"Markdown report written to [cyan]{output}[/cyan]")
183
+ else:
184
+ print_rich(results, console=console)
185
+
186
+
187
+ @app.command()
188
+ def demo(
189
+ tasks: Annotated[int, typer.Option("--tasks", "-t")] = 50,
190
+ seed: Annotated[int, typer.Option("--seed")] = 7,
191
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
192
+ ) -> None:
193
+ """Run a zero-cost demo with synthetic runs and planted effects."""
194
+ from .model import analyze_runset
195
+ from .report import print_rich, to_markdown
196
+ from .simulate import simulate_multi_metric
197
+
198
+ console.print("[bold cyan]agent-attrib demo[/bold cyan]")
199
+ console.print(
200
+ "Generating synthetic runs with planted effects:\n"
201
+ " prompt: +0.15 on success\n"
202
+ " model: +0.08 on success, −20% on cost\n"
203
+ " retriever: no effect on success, +10% on cost\n"
204
+ " prompt*model interaction: +0.05 on success\n"
205
+ )
206
+
207
+ factors = ["prompt", "model", "retriever"]
208
+ runset = simulate_multi_metric(
209
+ factors=factors,
210
+ tasks=tasks,
211
+ trials=2,
212
+ seed=seed,
213
+ success_effects={"prompt": 0.3, "model": 0.16},
214
+ cost_effects={"model": -0.22, "retriever": 0.095},
215
+ success_interaction={("prompt", "model"): 0.10},
216
+ )
217
+
218
+ console.print(
219
+ f"Generated {len(runset.runs)} runs "
220
+ f"({tasks} tasks × {2**3} configs × 2 trials)\n"
221
+ )
222
+
223
+ results = analyze_runset(
224
+ runset,
225
+ metrics=["success", "cost_usd"],
226
+ margin=0.05,
227
+ binary_metrics=["success"],
228
+ )
229
+
230
+ print_rich(results, console=console)
231
+
232
+ if output:
233
+ md = to_markdown(results)
234
+ output.write_text(md)
235
+ console.print(f"Report written to [cyan]{output}[/cyan]")
236
+
237
+
238
+ @app.command()
239
+ def version() -> None:
240
+ """Print the version."""
241
+ from importlib.metadata import version as pkg_version
242
+ try:
243
+ v = pkg_version("agent-attrib")
244
+ except Exception:
245
+ v = "0.1.0 (development)"
246
+ console.print(v)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ app()
agent_attrib/design.py ADDED
@@ -0,0 +1,214 @@
1
+ """Full and fractional 2-level factorial design generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Generator tables for regular 2^(k-p) minimum-aberration designs
13
+ # Source: Wu & Hamada "Experiments", Appendix 4B / Table 5A.1
14
+ # Format: {k: {p: (resolution, {added_col_1based: generator_string})}}
15
+ # Generator string: 1-based indices of base columns whose product gives this col.
16
+ # len(added_map) must equal p.
17
+ # ---------------------------------------------------------------------------
18
+ #
19
+ # 2^(5-1): only one design exists; E = ABCD → resolution V (word length 5).
20
+ # 2^(6-2): E = ABC, F = BCD → I = ABCE = BCDF = ACDEF → resolution IV.
21
+ # 2^(7-3): E = ABC, F = BCD, G = ACD → all words length 4 → resolution IV.
22
+ _GENERATORS: dict[int, dict[int, tuple[str, dict[int, str]]]] = {
23
+ 5: {
24
+ 1: (
25
+ "V",
26
+ {5: "1234"}, # E = ABCD → resolution V
27
+ ),
28
+ },
29
+ 6: {
30
+ 2: (
31
+ "IV",
32
+ {5: "123", 6: "234"}, # E = ABC, F = BCD
33
+ ),
34
+ },
35
+ 7: {
36
+ 3: (
37
+ "IV",
38
+ {5: "123", 6: "234", 7: "134"}, # E = ABC, F = BCD, G = ACD
39
+ ),
40
+ },
41
+ }
42
+
43
+ # Resolution V override table (k=5 already gives V; k=6/7 not supported in v0.1)
44
+ _GENERATORS_RES_V: dict[int, dict[int, tuple[str, dict[int, str]]]] = {
45
+ 5: {
46
+ 1: (
47
+ "V",
48
+ {5: "1234"}, # E = ABCD → resolution V
49
+ ),
50
+ },
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class _DesignMatrix:
56
+ matrix: np.ndarray # shape (n_configs, k), values ±1
57
+ factors: list[str]
58
+ resolution: str
59
+ design_type: str
60
+ alias_structure: list[str]
61
+
62
+
63
+ def _full_factorial(factors: list[str]) -> _DesignMatrix:
64
+ k = len(factors)
65
+ n = 2**k
66
+ cols = []
67
+ for i in range(k):
68
+ period = 2 ** (k - i - 1)
69
+ col = np.array([(-1) ** (j // period) for j in range(n)], dtype=float)
70
+ cols.append(col)
71
+ matrix = np.column_stack(cols)
72
+ return _DesignMatrix(
73
+ matrix=matrix,
74
+ factors=factors,
75
+ resolution="full",
76
+ design_type=f"2^{k} full factorial",
77
+ alias_structure=[],
78
+ )
79
+
80
+
81
+ def _parse_generator(gen_str: str, base_cols: np.ndarray) -> np.ndarray:
82
+ """Compute added column as product of base columns specified by 1-based indices."""
83
+ col = np.ones(base_cols.shape[0], dtype=float)
84
+ for ch in gen_str:
85
+ col = col * base_cols[:, int(ch) - 1]
86
+ return col
87
+
88
+
89
+ def _fractional_factorial(
90
+ factors: list[str], resolution_req: str = "IV"
91
+ ) -> _DesignMatrix:
92
+ k = len(factors)
93
+
94
+ # Pick generator table
95
+ if resolution_req.upper() == "V" and k in _GENERATORS_RES_V:
96
+ gen_table = _GENERATORS_RES_V
97
+ else:
98
+ gen_table = _GENERATORS
99
+
100
+ if k not in gen_table:
101
+ raise ValueError(f"No generator available for k={k}. Supported: {sorted(gen_table)}")
102
+
103
+ p_choices = sorted(gen_table[k].keys())
104
+ res, added_map = gen_table[k][p_choices[0]]
105
+
106
+ # Base design: full factorial on first (k - p) factors
107
+ p = p_choices[0]
108
+ base_k = k - p
109
+ base_dm = _full_factorial(factors[:base_k])
110
+ base_cols = base_dm.matrix # shape (2^base_k, base_k)
111
+
112
+ added_cols = []
113
+ for col_idx in sorted(added_map.keys()):
114
+ gen_str = added_map[col_idx]
115
+ added_cols.append(_parse_generator(gen_str, base_cols))
116
+
117
+ matrix = np.column_stack([base_cols] + [c.reshape(-1, 1) for c in added_cols])
118
+
119
+ alias_structure = _compute_aliases(matrix, factors)
120
+
121
+ return _DesignMatrix(
122
+ matrix=matrix,
123
+ factors=factors,
124
+ resolution=res,
125
+ design_type=f"2^({k}-{p}) fractional factorial",
126
+ alias_structure=alias_structure,
127
+ )
128
+
129
+
130
+ def _compute_aliases(matrix: np.ndarray, factors: list[str]) -> list[str]:
131
+ """Return human-readable alias chains for a fractional design."""
132
+ k = len(factors)
133
+ aliases: list[str] = []
134
+
135
+ # Build all two-factor interactions
136
+ interactions: dict[str, np.ndarray] = {}
137
+ for i, j in itertools.combinations(range(k), 2):
138
+ name = f"{factors[i]}*{factors[j]}"
139
+ interactions[name] = matrix[:, i] * matrix[:, j]
140
+
141
+ # Check which main effects alias with which interactions
142
+ seen: set[str] = set()
143
+ for i in range(k):
144
+ chain = [factors[i]]
145
+ for name, col in interactions.items():
146
+ if np.allclose(matrix[:, i], col) or np.allclose(matrix[:, i], -col):
147
+ chain.append(name)
148
+ seen.add(name)
149
+ if len(chain) > 1:
150
+ aliases.append(" = ".join(chain))
151
+
152
+ # Three-factor interactions could also appear; skip for display brevity
153
+ return aliases
154
+
155
+
156
+ def build_design(
157
+ factors: list[str],
158
+ tasks: int,
159
+ trials: int,
160
+ seed: int,
161
+ resolution: str = "IV",
162
+ ) -> tuple[np.ndarray, _DesignMatrix]:
163
+ """Return (design_matrix ±1, _DesignMatrix metadata)."""
164
+ if len(factors) < 1:
165
+ raise ValueError("Need at least 1 factor")
166
+ if len(factors) > 7:
167
+ raise ValueError(
168
+ "agent-attrib v0.1 supports up to 7 factors. "
169
+ "For k>7, split into independent sub-experiments."
170
+ )
171
+ if tasks < 1:
172
+ raise ValueError("tasks must be >= 1")
173
+ if trials < 1:
174
+ raise ValueError("trials must be >= 1")
175
+
176
+ if len(factors) <= 4:
177
+ dm = _full_factorial(factors)
178
+ else:
179
+ dm = _fractional_factorial(factors, resolution_req=resolution)
180
+
181
+ return dm.matrix, dm
182
+
183
+
184
+ def generate_run_order(
185
+ dm_matrix: np.ndarray,
186
+ factors: list[str],
187
+ task_ids: list[str],
188
+ trials: int,
189
+ seed: int,
190
+ ) -> pd.DataFrame:
191
+ """Expand design matrix to per-run rows and randomize order."""
192
+ rng = np.random.default_rng(seed)
193
+ n_configs = dm_matrix.shape[0]
194
+ rows = []
195
+ for ci in range(n_configs):
196
+ config_id = f"c{ci:03d}"
197
+ factor_levels = {
198
+ f: "candidate" if dm_matrix[ci, fi] == 1 else "baseline"
199
+ for fi, f in enumerate(factors)
200
+ }
201
+ for task_id in task_ids:
202
+ for trial in range(1, trials + 1):
203
+ rows.append(
204
+ {
205
+ "config_id": config_id,
206
+ "task_id": task_id,
207
+ "trial": trial,
208
+ **{f"f_{f}": level for f, level in factor_levels.items()},
209
+ }
210
+ )
211
+ df = pd.DataFrame(rows)
212
+ order = rng.permutation(len(df))
213
+ df["run_order"] = order + 1
214
+ return df.sort_values("run_order").reset_index(drop=True)