probative-labs 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.
Files changed (48) hide show
  1. probative/__init__.py +8 -0
  2. probative/cli.py +431 -0
  3. probative/config.py +239 -0
  4. probative/engine/__init__.py +23 -0
  5. probative/engine/design.py +171 -0
  6. probative/engine/diagnostics.py +188 -0
  7. probative/engine/fe.py +85 -0
  8. probative/engine/imputation.py +413 -0
  9. probative/engine/panel.py +158 -0
  10. probative/governance.py +25 -0
  11. probative/guardrails.py +139 -0
  12. probative/ingest/__init__.py +20 -0
  13. probative/ingest/files.py +87 -0
  14. probative/ingest/health.py +169 -0
  15. probative/ingest/mapping.py +369 -0
  16. probative/ingest/pseudonymize.py +61 -0
  17. probative/lineage.py +44 -0
  18. probative/pipeline.py +816 -0
  19. probative/playbooks/__init__.py +20 -0
  20. probative/playbooks/base.py +30 -0
  21. probative/playbooks/service_desk.py +61 -0
  22. probative/report/__init__.py +5 -0
  23. probative/report/appendix.py +150 -0
  24. probative/report/pack.py +119 -0
  25. probative/report/svg.py +94 -0
  26. probative/report/templates/evidence_pack.html.j2 +278 -0
  27. probative/report/templates/evidence_pack.md.j2 +186 -0
  28. probative/report/templates/method_appendix.md.j2 +16 -0
  29. probative/rollout/__init__.py +26 -0
  30. probative/rollout/power.py +214 -0
  31. probative/rollout/prereg.py +145 -0
  32. probative/rollout/waves.py +83 -0
  33. probative/schema.py +114 -0
  34. probative/simulate.py +625 -0
  35. probative/validation.py +107 -0
  36. probative/value/__init__.py +13 -0
  37. probative/value/deflection.py +103 -0
  38. probative/value/roi.py +175 -0
  39. probative/worktypes/__init__.py +26 -0
  40. probative/worktypes/drift.py +93 -0
  41. probative/worktypes/mix.py +81 -0
  42. probative/worktypes/rules.py +67 -0
  43. probative_labs-0.1.0.dist-info/METADATA +146 -0
  44. probative_labs-0.1.0.dist-info/RECORD +48 -0
  45. probative_labs-0.1.0.dist-info/WHEEL +4 -0
  46. probative_labs-0.1.0.dist-info/entry_points.txt +3 -0
  47. probative_labs-0.1.0.dist-info/licenses/LICENSE +202 -0
  48. probative_labs-0.1.0.dist-info/licenses/NOTICE +9 -0
probative/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Probative Labs: measure what AI actually changed.
2
+
3
+ The open-source measurement engine: typed work units, mix adjustment, causal
4
+ estimates with team-level uncertainty, quality guardrails, ROI ranges and
5
+ reproducible evidence packs.
6
+ """
7
+
8
+ __version__ = "0.1.0"
probative/cli.py ADDED
@@ -0,0 +1,431 @@
1
+ """Command-line interface: ``probative --help``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import webbrowser
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import pandas as pd
12
+ import typer
13
+ import yaml
14
+
15
+ from probative import __version__
16
+ from probative import schema as S
17
+ from probative.config import ProjectConfig
18
+ from probative.guardrails import GuardrailSpec
19
+ from probative.ingest import Pseudonymizer, apply_mapping, check_work_units, read_tables, resolve_mapping
20
+ from probative.ingest.mapping import PRESET_INFO, PRESETS
21
+ from probative.pipeline import AnalysisError, analyze
22
+ from probative.playbooks import get_playbook
23
+ from probative.report import write_pack
24
+ from probative.rollout import NoiseModel, lock_registration, noise_from_units, plan_waves, power_table
25
+ from probative.simulate import EXPORT_FORMATS
26
+
27
+ app = typer.Typer(
28
+ help="Probative Labs: measure what AI actually changed, with graded, reproducible evidence.",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+ plan_app = typer.Typer(help="Rollout designer: plan measurement before go-live.", no_args_is_help=True)
33
+ app.add_typer(plan_app, name="plan")
34
+
35
+ ConfigOpt = Annotated[Path, typer.Option("--config", "-c", help="Project configuration file.")]
36
+
37
+
38
+ def _say(msg: str) -> None:
39
+ typer.secho(f" · {msg}", fg=typer.colors.BRIGHT_BLACK)
40
+
41
+
42
+ def _guardrails(cfg: ProjectConfig) -> list[GuardrailSpec]:
43
+ pb = get_playbook(cfg.playbook)
44
+ out = []
45
+ for g in pb.guardrails:
46
+ spec = g.with_overrides(cfg.guardrails.get(g.key))
47
+ if spec is not None:
48
+ out.append(spec)
49
+ return out
50
+
51
+
52
+ def _load(config: Path) -> ProjectConfig:
53
+ if not config.exists():
54
+ typer.secho(f"No configuration at {config}. Run `probative init` or `probative demo` first.", fg="red")
55
+ raise typer.Exit(2)
56
+ return ProjectConfig.load(config)
57
+
58
+
59
+ @app.callback(invoke_without_command=True)
60
+ def _main(version: Annotated[bool, typer.Option("--version", help="Show the version.")] = False) -> None:
61
+ if version:
62
+ typer.echo(f"probative {__version__}")
63
+ raise typer.Exit()
64
+
65
+
66
+ @app.command()
67
+ def demo(
68
+ directory: Annotated[Path, typer.Argument(help="Where to write the demo project.")] = Path("probative-demo"),
69
+ effect: Annotated[float, typer.Option(help="Planted relative effect on agent hours per ticket.")] = -0.15,
70
+ reopen_effect: Annotated[float, typer.Option(help="Planted change in reopen probability (try 0.03).")] = 0.0,
71
+ reps: Annotated[int, typer.Option(help="Bootstrap draws.")] = 199,
72
+ seed: Annotated[int, typer.Option()] = 7,
73
+ fmt: Annotated[
74
+ str, typer.Option("--format", help="Export format to simulate: " + ", ".join(EXPORT_FORMATS) + ".")
75
+ ] = "servicenow",
76
+ open_report: Annotated[bool, typer.Option("--open", help="Open the HTML evidence pack.")] = False,
77
+ ) -> None:
78
+ """Simulate a service desk with a known AI effect and measure it end to end."""
79
+ from probative.simulate import SimSpec, demo_config, generate
80
+
81
+ if fmt not in EXPORT_FORMATS:
82
+ typer.secho(f"Unknown format {fmt!r}. Choose from: {', '.join(EXPORT_FORMATS)}", fg="red")
83
+ raise typer.Exit(2)
84
+
85
+ typer.secho(f"Simulating a 40-team service desk (planted effect {effect:+.0%}) in {directory}/", bold=True)
86
+ sim = generate(SimSpec(effort_effect=effect, reopen_effect=reopen_effect, seed=seed))
87
+ sim.write(directory / "data", fmt)
88
+ raw = demo_config("data", sim.truth, fmt)
89
+ raw["analysis"]["bootstrap_reps"] = reps
90
+ cfg_path = directory / "probative.yaml"
91
+ cfg_path.write_text(yaml.safe_dump(raw, sort_keys=False))
92
+ cfg = ProjectConfig.load(cfg_path)
93
+ reg_path = cfg.resolve(cfg.registration)
94
+ if not reg_path.exists():
95
+ lock_registration(cfg, _guardrails(cfg), reg_path)
96
+ try:
97
+ result = analyze(cfg, progress=_say).to_dict()
98
+ except AnalysisError as exc:
99
+ typer.secho(f"Analysis failed: {exc}", fg="red")
100
+ raise typer.Exit(1) from exc
101
+ paths = write_pack(result, directory / "out")
102
+ t = sim.truth
103
+ typer.echo("")
104
+ typer.secho(result["headline"], bold=True)
105
+ p = result["primary"]
106
+ if p["metric"] == "effort_hours":
107
+ truth = (
108
+ f"{t['effort_att_hours_per_ticket']:+.3f} h per ticket "
109
+ f"({t['effort_relative']:+.1%} after the learning curve)"
110
+ )
111
+ else:
112
+ truth = (
113
+ f"{t['resolution_att_hours_per_ticket']:+.3f} h of resolution time per ticket "
114
+ f"({fmt} exports carry no agent time)"
115
+ )
116
+ typer.echo(f"\nPlanted truth: {truth}. Estimate: {p['estimate']:+.3f} h [{p['ci_low']:+.3f}, {p['ci_high']:+.3f}].")
117
+ typer.echo(f"\nEvidence pack: {paths['html']}\nMarkdown: {paths['markdown']}\nResults JSON: {paths['json']}")
118
+ if open_report:
119
+ webbrowser.open(paths["html"].resolve().as_uri())
120
+
121
+
122
+ TEMPLATE = """\
123
+ # Probative Labs project: one file per AI program. Docs: docs/configuration.md
124
+ program:
125
+ id: my-ai-program
126
+ name: AI agent assist
127
+ vendor: Your vendor
128
+ tool: Agent assist
129
+ function: it_service_management
130
+ owner: Head of IT Service Management
131
+ contract_ref: null
132
+
133
+ hypothesis:
134
+ statement: >-
135
+ AI summaries and resolution suggestions cut agent hours per ticket by at least
136
+ 10% without lowering quality.
137
+ primary_metric: effort_hours # or resolution_hours
138
+ expected_effect: -0.10
139
+
140
+ playbook: service_desk
141
+
142
+ data:
143
+ work_units: data/incidents.csv # CSV or Parquet; a list of files is fine
144
+ mapping_preset: {preset} # see `probative presets`
145
+ mapping: {{}} # override: canonical_field: source_column
146
+ exposure: data/rollout.csv # team_id, start_date[, end_date, kind, randomized]
147
+ self_service: null # optional: virtual agent sessions
148
+ teams: null # optional: team_id, parent_unit, function, size_band
149
+ staffing: null # optional: team_id, week_start, hours (if time worked is not recorded)
150
+
151
+ analysis:
152
+ start: null # default: first record
153
+ end: null # default: last record
154
+ bootstrap_reps: 499
155
+ min_group_size: 5 # teams with fewer people are left out
156
+
157
+ guardrails: {{}} # e.g. reopen_rate: {{threshold: 0.02}}
158
+
159
+ value:
160
+ currency: USD
161
+ loaded_hourly_cost: {{low: 50, mid: 60, high: 75}}
162
+ realization_rate: {{low: 0.0, mid: 0.0, high: 0.0}} # share of freed hours provably redeployed
163
+ realized_savings: [] # {{description, amount, evidence}}
164
+ owner: null # who agreed these numbers
165
+ agreed_on: null
166
+
167
+ costs:
168
+ - component: license
169
+ description: AI licence uplift
170
+ amount: 100000
171
+ basis: annual # period | annual | monthly | one_time
172
+
173
+ claimed: [] # vendor or dashboard estimates, shown beside the measured value
174
+ """
175
+
176
+
177
+ @app.command()
178
+ def init(
179
+ directory: Annotated[Path, typer.Argument()] = Path("."),
180
+ preset: Annotated[str, typer.Option(help="Field mapping preset: " + ", ".join(PRESETS) + ".")] = "servicenow",
181
+ ) -> None:
182
+ """Write a starter probative.yaml."""
183
+ if preset not in PRESETS:
184
+ typer.secho(f"Unknown preset {preset!r}. Choose from: {', '.join(PRESETS)}", fg="red")
185
+ raise typer.Exit(2)
186
+ path = directory / "probative.yaml"
187
+ if path.exists():
188
+ typer.secho(f"{path} already exists; not overwriting.", fg="yellow")
189
+ raise typer.Exit(1)
190
+ directory.mkdir(parents=True, exist_ok=True)
191
+ path.write_text(TEMPLATE.format(preset=preset))
192
+ typer.echo(f"Wrote {path}. Edit the data paths, then run `probative health`.")
193
+
194
+
195
+ @app.command()
196
+ def simulate(
197
+ out: Annotated[Path, typer.Argument(help="Output directory.")] = Path("data"),
198
+ effect: Annotated[float, typer.Option()] = -0.15,
199
+ resolution_effect: Annotated[float, typer.Option()] = -0.08,
200
+ reopen_effect: Annotated[float, typer.Option()] = 0.0,
201
+ teams: Annotated[int, typer.Option()] = 40,
202
+ weeks: Annotated[int, typer.Option()] = 78,
203
+ deflection: Annotated[bool, typer.Option()] = True,
204
+ seed: Annotated[int, typer.Option()] = 7,
205
+ fmt: Annotated[str, typer.Option("--format", help="Ticket export format: " + ", ".join(EXPORT_FORMATS) + ".")] = (
206
+ "servicenow"
207
+ ),
208
+ ) -> None:
209
+ """Write synthetic exports (ServiceNow, Jira, Zendesk, ...) with a planted effect."""
210
+ from probative.simulate import SimSpec, generate
211
+
212
+ waves = (int(weeks * 0.56), int(weeks * 0.67), int(weeks * 0.77))
213
+ sim = generate(
214
+ SimSpec(
215
+ n_teams=teams,
216
+ n_weeks=weeks,
217
+ wave_weeks=waves,
218
+ effort_effect=effect,
219
+ resolution_effect=resolution_effect,
220
+ reopen_effect=reopen_effect,
221
+ deflection=deflection,
222
+ seed=seed,
223
+ )
224
+ )
225
+ paths = sim.write(out, fmt)
226
+ for k, p in paths.items():
227
+ typer.echo(f"{k:9s} {p}")
228
+
229
+
230
+ @app.command()
231
+ def presets() -> None:
232
+ """List the built-in field mappings for platform exports."""
233
+ for name, info in PRESET_INFO.items():
234
+ note = "" if info["basis"] == "documented" else " (typical export: check your column names)"
235
+ typer.echo(f"{name:26s} {info['label']}{note}")
236
+ typer.echo("\nUse one with `probative init --preset NAME` or `data.mapping_preset` in probative.yaml.")
237
+
238
+
239
+ @app.command()
240
+ def health(config: ConfigOpt = Path("probative.yaml")) -> None:
241
+ """Check data quality and field mapping before analysis."""
242
+ cfg = _load(config)
243
+ raw, files, warnings = read_tables([cfg.resolve(p) for p in cfg.data.work_units])
244
+ mapping = resolve_mapping(cfg.data.mapping_preset, cfg.data.mapping)
245
+ units, rep = apply_mapping(raw, mapping, S.WORK_UNIT_COLUMNS, required=S.REQUIRED_WORK_UNIT_COLUMNS)
246
+ pseud = Pseudonymizer.from_env_or_file(cfg.base_dir / ".probative" / "pseudonym.key")
247
+ units = pseud.apply(units, (*S.PERSON_COLUMNS, *S.SOURCE_ID_COLUMNS))
248
+ clean, report = check_work_units(units)
249
+ for w in warnings:
250
+ report.add("schema_change", "warning", w)
251
+ typer.secho(f"{sum(f.rows for f in files):,} rows in {len(files)} file(s)", bold=True)
252
+ typer.echo(f"Mapped fields: {', '.join(rep.mapped) or 'none'}")
253
+ if rep.missing_optional:
254
+ typer.echo(f"Not available: {', '.join(rep.missing_optional)}")
255
+ for k, n in rep.coercion_failures.items():
256
+ typer.secho(f" {n:,} values in {k} could not be parsed", fg="yellow")
257
+ colors = {"error": "red", "warning": "yellow", "info": "bright_black"}
258
+ for issue in report.issues:
259
+ typer.secho(f" [{issue.severity}] {issue.message}", fg=colors[issue.severity])
260
+ typer.echo(f"Usable rows: {len(clean):,} ({report.rows_excluded:,} excluded)")
261
+ raise typer.Exit(0 if report.ok else 1)
262
+
263
+
264
+ @app.command()
265
+ def register(
266
+ config: ConfigOpt = Path("probative.yaml"),
267
+ force: Annotated[bool, typer.Option(help="Replace an existing registration.")] = False,
268
+ ) -> None:
269
+ """Lock the hypothesis, metrics, guardrails and window before results exist."""
270
+ cfg = _load(config)
271
+ path = cfg.resolve(cfg.registration)
272
+ try:
273
+ rec = lock_registration(cfg, _guardrails(cfg), path, force=force)
274
+ except FileExistsError as exc:
275
+ typer.secho(str(exc), fg="yellow")
276
+ raise typer.Exit(1) from exc
277
+ typer.echo(f"Registered at {rec['registered_at']} → {path}\nSHA-256 {rec['sha256']}")
278
+ typer.echo("Commit this file to version control so its timestamp can be verified.")
279
+
280
+
281
+ @app.command("analyze")
282
+ def analyze_cmd(
283
+ config: ConfigOpt = Path("probative.yaml"),
284
+ out: Annotated[Path, typer.Option("--out", "-o", help="Output directory.")] = Path("out"),
285
+ reps: Annotated[int | None, typer.Option(help="Override bootstrap draws.")] = None,
286
+ open_report: Annotated[bool, typer.Option("--open", help="Open the HTML evidence pack.")] = False,
287
+ ) -> None:
288
+ """Run the analysis and write the evidence pack."""
289
+ cfg = _load(config)
290
+ if reps:
291
+ cfg.analysis.bootstrap_reps = reps
292
+ try:
293
+ result = analyze(cfg, progress=_say).to_dict()
294
+ except AnalysisError as exc:
295
+ typer.secho(f"Analysis failed: {exc}", fg="red")
296
+ if exc.health:
297
+ for issue in exc.health.issues:
298
+ typer.echo(f" [{issue.severity}] {issue.message}")
299
+ raise typer.Exit(1) from exc
300
+ out_dir = out if out.is_absolute() else cfg.base_dir / out
301
+ paths = write_pack(result, out_dir)
302
+ typer.secho(result["headline"], bold=True)
303
+ typer.echo(f"Recommendation: {result['recommendation']['action']}. {result['recommendation']['reason']}")
304
+ typer.echo(f"\nEvidence pack: {paths['html']}")
305
+ if open_report:
306
+ webbrowser.open(paths["html"].resolve().as_uri())
307
+
308
+
309
+ def _ints(s: str) -> list[int]:
310
+ return [int(x) for x in s.split(",") if x.strip()]
311
+
312
+
313
+ @plan_app.command("power")
314
+ def plan_power(
315
+ effect: Annotated[float, typer.Option(help="Hoped-for relative effect, e.g. -0.10.")] = -0.10,
316
+ teams: Annotated[str, typer.Option(help="Team counts to try, comma-separated.")] = "12,20,30,40",
317
+ weeks_post: Annotated[str, typer.Option(help="Weeks after the last wave, comma-separated.")] = "8,12,16",
318
+ waves: Annotated[int, typer.Option()] = 3,
319
+ weeks_pre: Annotated[int, typer.Option(help="Weeks of history before the first wave.")] = 26,
320
+ spacing: Annotated[int, typer.Option(help="Weeks between waves.")] = 4,
321
+ holdout: Annotated[float, typer.Option(help="Share of teams held back.")] = 0.2,
322
+ config: Annotated[
323
+ Path | None, typer.Option("--config", "-c", help="Calibrate noise from this project's history.")
324
+ ] = None,
325
+ sims: Annotated[int, typer.Option()] = 200,
326
+ ) -> None:
327
+ """How many teams and weeks are needed to detect the hoped-for effect."""
328
+ if config:
329
+ noise = _noise_from_history(_load(config))
330
+ typer.echo(
331
+ f"Calibrated from history: mean {noise.mean:.3f}, team sd {noise.sd_team:.3f}, "
332
+ f"week sd {noise.sd_week:.3f}, cell sd {noise.sd_cell:.3f}, autocorrelation {noise.rho:.2f}"
333
+ )
334
+ else:
335
+ noise = NoiseModel()
336
+ typer.echo("Using default noise (pass --config to calibrate from your own history).")
337
+ rows = power_table(
338
+ team_options=_ints(teams),
339
+ post_options=_ints(weeks_post),
340
+ n_waves=waves,
341
+ weeks_pre=weeks_pre,
342
+ effect=effect * noise.mean,
343
+ noise=noise,
344
+ wave_spacing=spacing,
345
+ holdout_share=holdout,
346
+ sims=sims,
347
+ )
348
+ typer.secho(f"\nPower to detect {effect:+.0%} ({waves} waves, {holdout:.0%} held back):", bold=True)
349
+ typer.echo(f"{'teams':>6} {'weeks after':>12} {'power':>7} {'smallest detectable':>21}")
350
+ for r in rows:
351
+ mark = " ✓" if r.power >= 0.8 else ""
352
+ typer.echo(f"{r.n_teams:>6} {r.weeks_post:>12} {r.power:>7.0%} {r.mde_relative:>20.1%}{mark}")
353
+ typer.echo("\n✓ = at least 80% power. If nothing qualifies, the program is too small to measure as planned.")
354
+
355
+
356
+ def _noise_from_history(cfg: ProjectConfig) -> NoiseModel:
357
+ raw, _, _ = read_tables([cfg.resolve(p) for p in cfg.data.work_units])
358
+ units, _ = apply_mapping(
359
+ raw,
360
+ resolve_mapping(cfg.data.mapping_preset, cfg.data.mapping),
361
+ S.WORK_UNIT_COLUMNS,
362
+ required=S.REQUIRED_WORK_UNIT_COLUMNS,
363
+ )
364
+ units, _ = check_work_units(units)
365
+ units = get_playbook(cfg.playbook).derive(units)
366
+ return noise_from_units(units, cfg.hypothesis.primary_metric)
367
+
368
+
369
+ @plan_app.command("waves")
370
+ def plan_waves_cmd(
371
+ teams_file: Annotated[Path, typer.Argument(help="CSV with a team_id column (and optional strata).")],
372
+ waves: Annotated[int, typer.Option()] = 3,
373
+ start: Annotated[str, typer.Option(help="First wave start date, YYYY-MM-DD.")] = "",
374
+ spacing: Annotated[int, typer.Option(help="Weeks between waves.")] = 4,
375
+ holdout: Annotated[float, typer.Option(help="Share of teams held back.")] = 0.2,
376
+ holdout_weeks: Annotated[int, typer.Option(help="Weeks after the last wave before holdout teams get the AI.")] = 12,
377
+ stratify: Annotated[str | None, typer.Option(help="Column to balance waves on, e.g. parent_unit.")] = None,
378
+ seed: Annotated[int | None, typer.Option(help="Randomization seed (recorded in the output).")] = None,
379
+ out: Annotated[Path, typer.Option("--out", "-o")] = Path("rollout.csv"),
380
+ ) -> None:
381
+ """Assign teams to rollout waves by lottery and write an exposure file."""
382
+ teams = pd.read_csv(teams_file, dtype=str)
383
+ if S.TEAM not in teams.columns:
384
+ typer.secho(f"{teams_file} needs a {S.TEAM} column", fg="red")
385
+ raise typer.Exit(2)
386
+ seed = seed if seed is not None else secrets.randbelow(2**31)
387
+ first = date.fromisoformat(start) if start else date.today()
388
+ plan = plan_waves(
389
+ teams,
390
+ n_waves=waves,
391
+ first_start=first,
392
+ spacing_weeks=spacing,
393
+ holdout_share=holdout,
394
+ holdout_weeks=holdout_weeks,
395
+ stratify_by=stratify,
396
+ seed=seed,
397
+ )
398
+ plan.to_csv(out, index=False)
399
+ summary = plan.groupby(["wave", "start_date"]).size().rename("teams").reset_index()
400
+ typer.echo(summary.to_string(index=False))
401
+ typer.echo(f"\nWrote {out} (seed {seed}). Register the analysis now: `probative register`.")
402
+
403
+
404
+ @app.command()
405
+ def validate(
406
+ runs: Annotated[int, typer.Option(help="Simulated datasets per planted effect.")] = 10,
407
+ reps: Annotated[int, typer.Option(help="Bootstrap draws per run.")] = 99,
408
+ effects: Annotated[str, typer.Option(help="Planted relative effects, comma-separated.")] = "-0.15,0",
409
+ seed: Annotated[int, typer.Option()] = 1,
410
+ out: Annotated[Path | None, typer.Option(help="Write per-run results to this CSV.")] = None,
411
+ ) -> None:
412
+ """Check the estimator against planted effects, including zero."""
413
+ from probative.validation import run_validation, summarize
414
+
415
+ df = run_validation(
416
+ effects=tuple(float(e) for e in effects.split(",")), runs=runs, reps=reps, seed=seed, progress=_say
417
+ )
418
+ if out:
419
+ df.to_csv(out, index=False)
420
+ s = summarize(df)
421
+ typer.secho("\nValidation summary", bold=True)
422
+ with pd.option_context("display.float_format", "{:.4f}".format, "display.width", 140):
423
+ typer.echo(s.to_string(index=False))
424
+ typer.echo(
425
+ "\ncoverage should be near 0.95; rejects_zero should be near 0.05 when the planted effect is 0 "
426
+ "and near 1 otherwise."
427
+ )
428
+
429
+
430
+ if __name__ == "__main__":
431
+ app()
probative/config.py ADDED
@@ -0,0 +1,239 @@
1
+ """Project configuration: one ``probative.yaml`` per AI program being measured."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import numpy as np
10
+ import yaml
11
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
12
+
13
+
14
+ class Range(BaseModel):
15
+ """An assumption with uncertainty: low, most likely and high values.
16
+
17
+ Accepts a plain number (no uncertainty), a ``[low, mid, high]`` list or a mapping.
18
+ Sampled as a triangular distribution in the ROI simulation.
19
+ """
20
+
21
+ model_config = ConfigDict(frozen=True)
22
+
23
+ low: float
24
+ mid: float
25
+ high: float
26
+
27
+ @model_validator(mode="before")
28
+ @classmethod
29
+ def _coerce(cls, v: Any) -> Any:
30
+ if isinstance(v, (int, float)):
31
+ return {"low": v, "mid": v, "high": v}
32
+ if isinstance(v, (list, tuple)) and len(v) == 3:
33
+ return dict(zip(("low", "mid", "high"), v, strict=True))
34
+ return v
35
+
36
+ @model_validator(mode="after")
37
+ def _ordered(self) -> Range:
38
+ if not self.low <= self.mid <= self.high:
39
+ raise ValueError(f"range must satisfy low <= mid <= high, got {self.low}, {self.mid}, {self.high}")
40
+ return self
41
+
42
+ @property
43
+ def is_point(self) -> bool:
44
+ return self.low == self.high
45
+
46
+ def sample(self, rng: np.random.Generator, size: int) -> np.ndarray:
47
+ if self.is_point:
48
+ return np.full(size, self.mid, dtype=float)
49
+ return rng.triangular(self.low, self.mid, self.high, size)
50
+
51
+
52
+ class MappingEntry(BaseModel):
53
+ """A source column (or several candidate names, first match wins) and an optional transform."""
54
+
55
+ source: str | list[str]
56
+ transform: str | None = None
57
+
58
+ @property
59
+ def candidates(self) -> list[str]:
60
+ return [self.source] if isinstance(self.source, str) else list(self.source)
61
+
62
+ @property
63
+ def label(self) -> str:
64
+ return " or ".join(repr(c) for c in self.candidates)
65
+
66
+
67
+ class ProgramConfig(BaseModel):
68
+ id: str
69
+ name: str
70
+ vendor: str | None = None
71
+ tool: str | None = None
72
+ models: list[str] = Field(default_factory=list)
73
+ function: str = "it_service_management"
74
+ owner: str | None = None
75
+ contract_ref: str | None = None
76
+
77
+
78
+ class HypothesisConfig(BaseModel):
79
+ statement: str
80
+ primary_metric: Literal["effort_hours", "resolution_hours"] = "effort_hours"
81
+ expected_effect: float | None = Field(
82
+ None, description="Expected relative change in the primary metric, e.g. -0.15 for 15% less."
83
+ )
84
+ by: date | None = None
85
+
86
+
87
+ class DataConfig(BaseModel):
88
+ work_units: list[Path]
89
+ mapping_preset: Literal[
90
+ "canonical", "servicenow", "jira_service_management", "zendesk", "freshservice", "bmc_helix", "ivanti"
91
+ ] = "canonical"
92
+ mapping: dict[str, str | MappingEntry] = Field(default_factory=dict)
93
+ exposure: Path
94
+ self_service: Path | None = None
95
+ self_service_mapping: dict[str, str | MappingEntry] = Field(default_factory=dict)
96
+ teams: Path | None = None
97
+ staffing: Path | None = None
98
+ pseudonymized: bool = Field(
99
+ False, description="Identifiers were already pseudonymized upstream (e.g. by a connector); skip hashing."
100
+ )
101
+
102
+ @field_validator("work_units", mode="before")
103
+ @classmethod
104
+ def _listify(cls, v: Any) -> Any:
105
+ return [v] if isinstance(v, (str, Path)) else v
106
+
107
+
108
+ class TypeRule(BaseModel):
109
+ """First matching rule wins. Values are exact matches; prefix with ``re:`` for a regex."""
110
+
111
+ name: str
112
+ when: dict[str, str | list[str]]
113
+
114
+
115
+ class TypingConfig(BaseModel):
116
+ fields: list[str] = Field(default_factory=lambda: ["category", "subcategory"])
117
+ rules: list[TypeRule] = Field(default_factory=list)
118
+ min_share: float = Field(0.01, ge=0, lt=0.5)
119
+ other_label: str = "Other"
120
+
121
+
122
+ class AnalysisConfig(BaseModel):
123
+ start: date | None = None
124
+ end: date | None = None
125
+ design: Literal["auto", "staggered", "two_group"] = "auto"
126
+ randomized: bool | None = Field(
127
+ None, description="Override; by default read from the exposure file's 'randomized' column."
128
+ )
129
+ bootstrap_reps: int = Field(499, ge=49)
130
+ seed: int = 20261014
131
+ confidence: float = Field(0.95, gt=0.5, lt=1)
132
+ min_group_size: int = Field(5, ge=1)
133
+ min_teams: int = Field(8, ge=2)
134
+ pretrend_weeks: int = Field(8, ge=1)
135
+ max_event_weeks: int | None = None
136
+ placebo_shift_weeks: int | None = None
137
+ placebo_outcomes: list[str] = Field(default_factory=lambda: ["after_hours"])
138
+ censoring_threshold: float = Field(0.05, gt=0, lt=1)
139
+ min_type_units: int = Field(200, ge=10)
140
+
141
+
142
+ class GuardrailConfig(BaseModel):
143
+ enabled: bool = True
144
+ threshold: float | None = None
145
+ kind: Literal["absolute", "relative"] | None = None
146
+
147
+
148
+ class RealizedSaving(BaseModel):
149
+ description: str
150
+ amount: float
151
+ evidence: str
152
+
153
+
154
+ class ValueConfig(BaseModel):
155
+ currency: str = "USD"
156
+ loaded_hourly_cost: Range
157
+ realization_rate: Range = Field(default_factory=lambda: Range(low=0, mid=0, high=0))
158
+ false_deflection_cost_hours: Range = Field(default_factory=lambda: Range(low=0.25, mid=0.5, high=1.0))
159
+ realized_savings: list[RealizedSaving] = Field(default_factory=list)
160
+ simulations: int = Field(10000, ge=1000)
161
+ owner: str | None = Field(None, description="Who agreed these unit values, e.g. 'Finance partner, IT'.")
162
+ agreed_on: date | None = None
163
+
164
+ @field_validator("realization_rate")
165
+ @classmethod
166
+ def _share(cls, v: Range) -> Range:
167
+ if v.low < 0 or v.high > 1:
168
+ raise ValueError("realization_rate must be between 0 and 1")
169
+ return v
170
+
171
+
172
+ CostComponent = Literal[
173
+ "license",
174
+ "model_usage",
175
+ "infrastructure",
176
+ "build_integration",
177
+ "change_training",
178
+ "run_govern",
179
+ "human_oversight",
180
+ "quality",
181
+ "other",
182
+ ]
183
+
184
+
185
+ class CostLine(BaseModel):
186
+ component: CostComponent
187
+ description: str
188
+ amount: Range
189
+ basis: Literal["period", "annual", "monthly", "one_time"] = "period"
190
+ source: str | None = None
191
+
192
+
193
+ class ClaimedValue(BaseModel):
194
+ source: str
195
+ hours_saved: float | None = None
196
+ value: float | None = None
197
+ note: str | None = None
198
+
199
+
200
+ class ProjectConfig(BaseModel):
201
+ """Everything needed to measure one AI program."""
202
+
203
+ program: ProgramConfig
204
+ hypothesis: HypothesisConfig
205
+ playbook: Literal["service_desk"] = "service_desk"
206
+ data: DataConfig
207
+ typing: TypingConfig = Field(default_factory=TypingConfig)
208
+ analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
209
+ guardrails: dict[str, GuardrailConfig] = Field(default_factory=dict)
210
+ value: ValueConfig
211
+ costs: list[CostLine] = Field(default_factory=list)
212
+ claimed: list[ClaimedValue] = Field(default_factory=list)
213
+ registration: Path = Path("registration.yaml")
214
+
215
+ _base_dir: Path = PrivateAttr(default=Path("."))
216
+ _source_path: Path | None = PrivateAttr(default=None)
217
+
218
+ @property
219
+ def base_dir(self) -> Path:
220
+ return self._base_dir
221
+
222
+ def resolve(self, p: Path) -> Path:
223
+ return p if p.is_absolute() else (self._base_dir / p)
224
+
225
+ @classmethod
226
+ def load(cls, path: str | Path) -> ProjectConfig:
227
+ path = Path(path)
228
+ with path.open() as fh:
229
+ raw = yaml.safe_load(fh) or {}
230
+ cfg = cls.model_validate(raw)
231
+ cfg._base_dir = path.resolve().parent
232
+ cfg._source_path = path.resolve()
233
+ return cfg
234
+
235
+ @classmethod
236
+ def from_dict(cls, raw: dict[str, Any], base_dir: str | Path = ".") -> ProjectConfig:
237
+ cfg = cls.model_validate(raw)
238
+ cfg._base_dir = Path(base_dir).resolve()
239
+ return cfg