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.
- probative/__init__.py +8 -0
- probative/cli.py +431 -0
- probative/config.py +239 -0
- probative/engine/__init__.py +23 -0
- probative/engine/design.py +171 -0
- probative/engine/diagnostics.py +188 -0
- probative/engine/fe.py +85 -0
- probative/engine/imputation.py +413 -0
- probative/engine/panel.py +158 -0
- probative/governance.py +25 -0
- probative/guardrails.py +139 -0
- probative/ingest/__init__.py +20 -0
- probative/ingest/files.py +87 -0
- probative/ingest/health.py +169 -0
- probative/ingest/mapping.py +369 -0
- probative/ingest/pseudonymize.py +61 -0
- probative/lineage.py +44 -0
- probative/pipeline.py +816 -0
- probative/playbooks/__init__.py +20 -0
- probative/playbooks/base.py +30 -0
- probative/playbooks/service_desk.py +61 -0
- probative/report/__init__.py +5 -0
- probative/report/appendix.py +150 -0
- probative/report/pack.py +119 -0
- probative/report/svg.py +94 -0
- probative/report/templates/evidence_pack.html.j2 +278 -0
- probative/report/templates/evidence_pack.md.j2 +186 -0
- probative/report/templates/method_appendix.md.j2 +16 -0
- probative/rollout/__init__.py +26 -0
- probative/rollout/power.py +214 -0
- probative/rollout/prereg.py +145 -0
- probative/rollout/waves.py +83 -0
- probative/schema.py +114 -0
- probative/simulate.py +625 -0
- probative/validation.py +107 -0
- probative/value/__init__.py +13 -0
- probative/value/deflection.py +103 -0
- probative/value/roi.py +175 -0
- probative/worktypes/__init__.py +26 -0
- probative/worktypes/drift.py +93 -0
- probative/worktypes/mix.py +81 -0
- probative/worktypes/rules.py +67 -0
- probative_labs-0.1.0.dist-info/METADATA +146 -0
- probative_labs-0.1.0.dist-info/RECORD +48 -0
- probative_labs-0.1.0.dist-info/WHEEL +4 -0
- probative_labs-0.1.0.dist-info/entry_points.txt +3 -0
- probative_labs-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|