cafe-core 0.0.1__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.
- cafe/__init__.py +184 -0
- cafe/_async.py +36 -0
- cafe/_env.py +32 -0
- cafe/cli.py +323 -0
- cafe/datasets.py +97 -0
- cafe/design/__init__.py +19 -0
- cafe/design/factorial.py +76 -0
- cafe/design/fractional.py +210 -0
- cafe/evaluation.py +515 -0
- cafe/examples/__init__.py +5 -0
- cafe/examples/qa.py +72 -0
- cafe/examples/toy.py +86 -0
- cafe/execution/__init__.py +18 -0
- cafe/execution/checkpoint.py +103 -0
- cafe/execution/progress.py +44 -0
- cafe/execution/results.py +175 -0
- cafe/execution/runner.py +199 -0
- cafe/judging/__init__.py +23 -0
- cafe/judging/judge.py +144 -0
- cafe/judging/prompts.py +164 -0
- cafe/judging/ratings.py +134 -0
- cafe/judging/rubric.py +113 -0
- cafe/judging/runner.py +122 -0
- cafe/judging/structured.py +101 -0
- cafe/llm.py +168 -0
- cafe/py.typed +0 -0
- cafe/rubrics.py +104 -0
- cafe/stats/__init__.py +48 -0
- cafe/stats/_format.py +21 -0
- cafe/stats/_frame.py +56 -0
- cafe/stats/clmm.R +80 -0
- cafe/stats/descriptive.py +106 -0
- cafe/stats/glmm.R +84 -0
- cafe/stats/inferential.py +362 -0
- cafe/stats/logistic.py +365 -0
- cafe/stats/ordinal.py +216 -0
- cafe/stats/pareto.py +198 -0
- cafe/stats/plots.py +296 -0
- cafe/stats/reliability.py +421 -0
- cafe/stats/stability.py +101 -0
- cafe/study.py +291 -0
- cafe/system.py +101 -0
- cafe/techniques/__init__.py +16 -0
- cafe/techniques/composed.py +143 -0
- cafe/techniques/context.py +147 -0
- cafe/techniques/pipe.py +170 -0
- cafe/techniques/registry.py +23 -0
- cafe_core-0.0.1.dist-info/METADATA +116 -0
- cafe_core-0.0.1.dist-info/RECORD +52 -0
- cafe_core-0.0.1.dist-info/WHEEL +4 -0
- cafe_core-0.0.1.dist-info/entry_points.txt +2 -0
- cafe_core-0.0.1.dist-info/licenses/LICENSE +201 -0
cafe/__init__.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""CAFE core — a design-of-experiments evaluation engine for compound AI systems.
|
|
2
|
+
|
|
3
|
+
CAFE *measures* compound AI systems; it does not implement them. You provide a
|
|
4
|
+
black box ``run(config, item) -> output`` and declare the factors to vary; CAFE
|
|
5
|
+
generates the design, executes every configuration with replication, and judges
|
|
6
|
+
and attributes the results.
|
|
7
|
+
|
|
8
|
+
Quick start (no API keys needed)::
|
|
9
|
+
|
|
10
|
+
from cafe.examples import build_example_study
|
|
11
|
+
results = build_example_study().run()
|
|
12
|
+
print(results.summary())
|
|
13
|
+
|
|
14
|
+
Or define your own::
|
|
15
|
+
|
|
16
|
+
import cafe
|
|
17
|
+
|
|
18
|
+
async def my_system(config, item):
|
|
19
|
+
return f"answer for {item} using {config['model']}"
|
|
20
|
+
|
|
21
|
+
study = cafe.Study(
|
|
22
|
+
name="my-study",
|
|
23
|
+
system=my_system,
|
|
24
|
+
factors=[cafe.Factor("model", ["a", "b"])],
|
|
25
|
+
dataset=["question 1", "question 2"],
|
|
26
|
+
)
|
|
27
|
+
results = study.run()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from cafe import (
|
|
31
|
+
datasets,
|
|
32
|
+
design,
|
|
33
|
+
evaluation,
|
|
34
|
+
execution,
|
|
35
|
+
judging,
|
|
36
|
+
llm,
|
|
37
|
+
rubrics,
|
|
38
|
+
stats,
|
|
39
|
+
study,
|
|
40
|
+
system,
|
|
41
|
+
techniques,
|
|
42
|
+
)
|
|
43
|
+
from cafe.design import (
|
|
44
|
+
FractionalDesign,
|
|
45
|
+
fractional_factorial,
|
|
46
|
+
fractional_factorial_design,
|
|
47
|
+
full_factorial,
|
|
48
|
+
generate,
|
|
49
|
+
single,
|
|
50
|
+
size,
|
|
51
|
+
)
|
|
52
|
+
from cafe.techniques import (
|
|
53
|
+
Pipeline,
|
|
54
|
+
PipelineView,
|
|
55
|
+
pipeline,
|
|
56
|
+
stage_report,
|
|
57
|
+
)
|
|
58
|
+
from cafe.evaluation import Evaluation, Preflight, evaluate, preflight
|
|
59
|
+
from cafe.execution import estimate, run_study
|
|
60
|
+
from cafe.judging import (
|
|
61
|
+
JUDGE_PRESETS,
|
|
62
|
+
Judge,
|
|
63
|
+
JudgeOutput,
|
|
64
|
+
LLMJudge,
|
|
65
|
+
Rating,
|
|
66
|
+
Ratings,
|
|
67
|
+
build_judge_prompt,
|
|
68
|
+
judge_results,
|
|
69
|
+
)
|
|
70
|
+
from cafe.llm import LLMError, complete, set_model_cost
|
|
71
|
+
from cafe.execution.results import Observation, Results, config_id, config_label
|
|
72
|
+
from cafe.judging.rubric import ANSWER_QUALITY_1_5, Level, Rubric, ScaleType
|
|
73
|
+
from cafe.stats import (
|
|
74
|
+
Attribution,
|
|
75
|
+
CLMMResult,
|
|
76
|
+
Effects,
|
|
77
|
+
HumanRatings,
|
|
78
|
+
JudgeStability,
|
|
79
|
+
Logistic,
|
|
80
|
+
ParetoResult,
|
|
81
|
+
Reliability,
|
|
82
|
+
answer_sheet,
|
|
83
|
+
attribute,
|
|
84
|
+
check_r,
|
|
85
|
+
fit_clmm,
|
|
86
|
+
fit_effects,
|
|
87
|
+
fit_logistic,
|
|
88
|
+
human_evaluation,
|
|
89
|
+
human_ratings,
|
|
90
|
+
judge_stability,
|
|
91
|
+
krippendorff_alpha,
|
|
92
|
+
pareto,
|
|
93
|
+
ratings_from_human,
|
|
94
|
+
reliability,
|
|
95
|
+
)
|
|
96
|
+
from cafe.study import Factor, FactorType, Study
|
|
97
|
+
from cafe.system import System, as_system
|
|
98
|
+
|
|
99
|
+
__version__ = "0.0.1"
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
# ── data ──
|
|
103
|
+
"datasets",
|
|
104
|
+
"rubrics",
|
|
105
|
+
# ── public submodules (for `cafe.<tab>`, `from cafe import *`, API docs) ──
|
|
106
|
+
"design",
|
|
107
|
+
"evaluation",
|
|
108
|
+
"execution",
|
|
109
|
+
"judging",
|
|
110
|
+
"llm",
|
|
111
|
+
"stats",
|
|
112
|
+
"study",
|
|
113
|
+
"system",
|
|
114
|
+
"techniques",
|
|
115
|
+
# ── define an experiment ──
|
|
116
|
+
"Study",
|
|
117
|
+
"Factor",
|
|
118
|
+
"FactorType",
|
|
119
|
+
"System",
|
|
120
|
+
"as_system",
|
|
121
|
+
# ── composed pipelines (techniques) ──
|
|
122
|
+
"Pipeline",
|
|
123
|
+
"PipelineView",
|
|
124
|
+
"stage_report",
|
|
125
|
+
"pipeline",
|
|
126
|
+
# ── run it ──
|
|
127
|
+
"evaluate", # the complete pipeline: answers -> judge -> attribution
|
|
128
|
+
"preflight", # cheap pre-run check + cost estimate
|
|
129
|
+
"run_study", # lower-level: answers only
|
|
130
|
+
"Evaluation",
|
|
131
|
+
"Preflight",
|
|
132
|
+
"Results",
|
|
133
|
+
"Observation",
|
|
134
|
+
"estimate",
|
|
135
|
+
# ── designs ──
|
|
136
|
+
"generate",
|
|
137
|
+
"size",
|
|
138
|
+
"full_factorial",
|
|
139
|
+
"single",
|
|
140
|
+
"fractional_factorial",
|
|
141
|
+
"fractional_factorial_design",
|
|
142
|
+
"FractionalDesign",
|
|
143
|
+
"config_id",
|
|
144
|
+
"config_label",
|
|
145
|
+
# ── judging ──
|
|
146
|
+
"complete",
|
|
147
|
+
"set_model_cost",
|
|
148
|
+
"LLMError",
|
|
149
|
+
"Rubric",
|
|
150
|
+
"Level",
|
|
151
|
+
"ScaleType",
|
|
152
|
+
"ANSWER_QUALITY_1_5",
|
|
153
|
+
"LLMJudge",
|
|
154
|
+
"Judge",
|
|
155
|
+
"JudgeOutput",
|
|
156
|
+
"JUDGE_PRESETS",
|
|
157
|
+
"judge_results",
|
|
158
|
+
"build_judge_prompt",
|
|
159
|
+
"Rating",
|
|
160
|
+
"Ratings",
|
|
161
|
+
# ── statistics ──
|
|
162
|
+
"attribute",
|
|
163
|
+
"Attribution",
|
|
164
|
+
"fit_effects",
|
|
165
|
+
"Effects",
|
|
166
|
+
"fit_clmm",
|
|
167
|
+
"CLMMResult",
|
|
168
|
+
"check_r",
|
|
169
|
+
"pareto",
|
|
170
|
+
"ParetoResult",
|
|
171
|
+
"judge_stability",
|
|
172
|
+
"JudgeStability",
|
|
173
|
+
"fit_logistic",
|
|
174
|
+
"Logistic",
|
|
175
|
+
"reliability",
|
|
176
|
+
"Reliability",
|
|
177
|
+
"human_ratings",
|
|
178
|
+
"HumanRatings",
|
|
179
|
+
"answer_sheet",
|
|
180
|
+
"krippendorff_alpha",
|
|
181
|
+
"ratings_from_human",
|
|
182
|
+
"human_evaluation",
|
|
183
|
+
"__version__",
|
|
184
|
+
]
|
cafe/_async.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Run a coroutine to completion from sync code, in a script *or* a notebook.
|
|
2
|
+
|
|
3
|
+
If no event loop is running (a plain script) we just ``asyncio.run``. If one is already
|
|
4
|
+
running (e.g. a Jupyter cell), running another loop on the same thread would raise
|
|
5
|
+
``asyncio.run() cannot be called from a running event loop`` — so we run it on a short-lived
|
|
6
|
+
worker thread and join. This is what lets ``study.evaluate()`` / ``result.rejudge()`` be
|
|
7
|
+
plain sync calls everywhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_blocking(make_coro: Callable[[], Any]) -> Any:
|
|
18
|
+
try:
|
|
19
|
+
asyncio.get_running_loop()
|
|
20
|
+
except RuntimeError:
|
|
21
|
+
return asyncio.run(make_coro())
|
|
22
|
+
|
|
23
|
+
box: dict[str, Any] = {}
|
|
24
|
+
|
|
25
|
+
def _worker() -> None:
|
|
26
|
+
try:
|
|
27
|
+
box["result"] = asyncio.run(make_coro())
|
|
28
|
+
except BaseException as exc: # noqa: BLE001 — re-raised on the caller's thread
|
|
29
|
+
box["error"] = exc
|
|
30
|
+
|
|
31
|
+
thread = threading.Thread(target=_worker, name="cafe-run")
|
|
32
|
+
thread.start()
|
|
33
|
+
thread.join()
|
|
34
|
+
if "error" in box:
|
|
35
|
+
raise box["error"]
|
|
36
|
+
return box["result"]
|
cafe/_env.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Tiny zero-dependency .env loader.
|
|
2
|
+
|
|
3
|
+
Looks for the nearest ``.env`` file (current directory upward) and loads its
|
|
4
|
+
``KEY=VALUE`` lines into ``os.environ`` without overwriting anything already set.
|
|
5
|
+
Kept dependency-free on purpose — the engine never requires python-dotenv.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_env(start: str | None = None) -> str | None:
|
|
15
|
+
"""Load the nearest ``.env`` into the environment. Returns the path used, or None.
|
|
16
|
+
|
|
17
|
+
Existing environment variables win (we only ``setdefault``), so real env vars
|
|
18
|
+
and CI secrets always override a local ``.env``.
|
|
19
|
+
"""
|
|
20
|
+
base = Path(start or os.getcwd()).resolve()
|
|
21
|
+
for cur in [base, *base.parents]:
|
|
22
|
+
candidate = cur / ".env"
|
|
23
|
+
if candidate.is_file():
|
|
24
|
+
for raw in candidate.read_text(encoding="utf-8").splitlines():
|
|
25
|
+
line = raw.strip()
|
|
26
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
27
|
+
continue
|
|
28
|
+
key, value = line.split("=", 1)
|
|
29
|
+
value = value.strip().strip('"').strip("'")
|
|
30
|
+
os.environ.setdefault(key.strip(), value)
|
|
31
|
+
return str(candidate)
|
|
32
|
+
return None
|
cafe/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""cafe — a design-of-experiments evaluation engine for compound AI systems.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
cafe run example run the bundled toy study
|
|
5
|
+
cafe run path/to/study.py run a study defined in a Python file
|
|
6
|
+
cafe validate [target] expand the design and report its size (no execution)
|
|
7
|
+
cafe doctor check the environment (Python, R stats engine, LLM access)
|
|
8
|
+
cafe version print the version
|
|
9
|
+
|
|
10
|
+
Flags for `run`: --smoke (preflight), --reps, --concurrency,
|
|
11
|
+
--checkpoint PATH (resumable), --out PATH (write results JSONL).
|
|
12
|
+
|
|
13
|
+
The CLI is the headless/CI path; it shares the exact engine the library uses.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import importlib.util
|
|
21
|
+
import statistics
|
|
22
|
+
import sys
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from cafe import design, run_study
|
|
27
|
+
from cafe.execution.results import Observation, Results, config_label
|
|
28
|
+
from cafe.study import Study
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_study(target: str) -> Study:
|
|
32
|
+
"""Resolve a CLI target into a Study.
|
|
33
|
+
|
|
34
|
+
``example`` -> the bundled toy study. Otherwise ``target`` is a path to a
|
|
35
|
+
.py file exposing a module-level ``study`` or a ``build_study()``/
|
|
36
|
+
``build_example_study()`` callable.
|
|
37
|
+
"""
|
|
38
|
+
if target == "example":
|
|
39
|
+
from cafe.examples import build_example_study
|
|
40
|
+
|
|
41
|
+
return build_example_study()
|
|
42
|
+
|
|
43
|
+
path = Path(target)
|
|
44
|
+
if not path.exists():
|
|
45
|
+
raise SystemExit(f"cafe: no such study target: {target!r}")
|
|
46
|
+
|
|
47
|
+
spec = importlib.util.spec_from_file_location("cafe_user_study", path)
|
|
48
|
+
if spec is None or spec.loader is None:
|
|
49
|
+
raise SystemExit(f"cafe: could not import {target!r}")
|
|
50
|
+
module = importlib.util.module_from_spec(spec)
|
|
51
|
+
spec.loader.exec_module(module)
|
|
52
|
+
|
|
53
|
+
for attr in ("study", "STUDY"):
|
|
54
|
+
obj = getattr(module, attr, None)
|
|
55
|
+
if isinstance(obj, Study):
|
|
56
|
+
return obj
|
|
57
|
+
for attr in ("build_study", "build_example_study"):
|
|
58
|
+
fn = getattr(module, attr, None)
|
|
59
|
+
if callable(fn):
|
|
60
|
+
obj = fn()
|
|
61
|
+
if isinstance(obj, Study):
|
|
62
|
+
return obj
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
f"cafe: {target!r} must define a `study` (Study) or a `build_study()` callable"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _progress(_obs: Observation, done: int, total: int) -> None:
|
|
69
|
+
bar_w = 24
|
|
70
|
+
filled = int(bar_w * done / total) if total else bar_w
|
|
71
|
+
bar = "█" * filled + "·" * (bar_w - filled)
|
|
72
|
+
sys.stderr.write(f"\r running [{bar}] {done}/{total}")
|
|
73
|
+
sys.stderr.flush()
|
|
74
|
+
if done == total:
|
|
75
|
+
sys.stderr.write("\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _mean_meta(obs: list[Observation], key: str) -> float | None:
|
|
79
|
+
vals = [o.metadata[key] for o in obs if key in o.metadata and o.metadata[key] is not None]
|
|
80
|
+
return round(statistics.fmean(vals), 3) if vals else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_results_table(results: Results) -> None:
|
|
84
|
+
by_config: dict[str, list[Observation]] = defaultdict(list)
|
|
85
|
+
for o in results.observations:
|
|
86
|
+
by_config[config_label(o.config)].append(o)
|
|
87
|
+
|
|
88
|
+
# Only show sim_q when some observation actually reports it (the toy example does;
|
|
89
|
+
# real Mode-A systems don't, and an always-"-" column is just clutter).
|
|
90
|
+
show_simq = any("sim_quality" in (o.metadata or {}) for o in results.observations)
|
|
91
|
+
|
|
92
|
+
rows: list[tuple[str, ...]] = []
|
|
93
|
+
header = ("configuration", "n", "err", "lat_s", "cost$") + (("sim_q",) if show_simq else ())
|
|
94
|
+
for label in sorted(by_config):
|
|
95
|
+
obs = by_config[label]
|
|
96
|
+
n_err = sum(1 for o in obs if not o.ok)
|
|
97
|
+
lat = _mean_meta(obs, "latency_s")
|
|
98
|
+
cost = _mean_meta(obs, "cost_usd")
|
|
99
|
+
row = (
|
|
100
|
+
label,
|
|
101
|
+
str(len(obs)),
|
|
102
|
+
str(n_err),
|
|
103
|
+
"-" if lat is None else f"{lat:.3f}",
|
|
104
|
+
"-" if cost is None else f"{cost:.4f}",
|
|
105
|
+
)
|
|
106
|
+
if show_simq:
|
|
107
|
+
simq = _mean_meta(obs, "sim_quality")
|
|
108
|
+
row += ("-" if simq is None else f"{simq:.3f}",)
|
|
109
|
+
rows.append(row)
|
|
110
|
+
|
|
111
|
+
widths = [max(len(header[i]), *(len(r[i]) for r in rows)) for i in range(len(header))]
|
|
112
|
+
fmt = " ".join("{:<" + str(w) + "}" for w in widths)
|
|
113
|
+
print()
|
|
114
|
+
print(" " + fmt.format(*header))
|
|
115
|
+
print(" " + fmt.format(*("-" * w for w in widths)))
|
|
116
|
+
for r in rows:
|
|
117
|
+
print(" " + fmt.format(*r))
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
122
|
+
study = _load_study(args.target)
|
|
123
|
+
n_inputs = 1 if args.smoke else max(1, len(study.dataset))
|
|
124
|
+
n_reps = 1 if args.smoke else (args.reps or study.replications)
|
|
125
|
+
n_cells = design.size(study) * n_inputs * n_reps
|
|
126
|
+
mode = "SMOKE (1 input, 1 rep)" if args.smoke else f"{n_reps} reps"
|
|
127
|
+
print(f"study : {study.name}")
|
|
128
|
+
print(f"factors : {', '.join(f'{f.name}{f.levels}' for f in study.factors) or '(none)'}")
|
|
129
|
+
print(f"design : {study.design} -> {design.size(study)} configs")
|
|
130
|
+
print(f"inputs : {len(study.dataset)} mode: {mode}")
|
|
131
|
+
print(f"cells : {n_cells}")
|
|
132
|
+
|
|
133
|
+
results = asyncio.run(
|
|
134
|
+
run_study(
|
|
135
|
+
study,
|
|
136
|
+
replications=args.reps,
|
|
137
|
+
concurrency=args.concurrency,
|
|
138
|
+
checkpoint_path=args.checkpoint,
|
|
139
|
+
smoke=args.smoke,
|
|
140
|
+
on_progress=_progress,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
s = results.summary()
|
|
145
|
+
_print_results_table(results)
|
|
146
|
+
print(
|
|
147
|
+
f" done: {s['n_answers']} obs over {s['n_configs']} configs, "
|
|
148
|
+
f"{s['n_errors']} errors, {s['total_compute_s']}s compute"
|
|
149
|
+
)
|
|
150
|
+
if args.out:
|
|
151
|
+
results.to_jsonl(args.out)
|
|
152
|
+
print(f" wrote {args.out}")
|
|
153
|
+
return 1 if s["n_errors"] else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
157
|
+
study = _load_study(args.target)
|
|
158
|
+
configs = design.generate(study)
|
|
159
|
+
print(f"ok: study {study.name!r} is valid")
|
|
160
|
+
print(f" design : {study.design} -> {len(configs)} configurations")
|
|
161
|
+
print(f" factors : {len(study.factors)} inputs: {len(study.dataset)}")
|
|
162
|
+
for c in configs[: min(8, len(configs))]:
|
|
163
|
+
print(f" - {config_label(c)}")
|
|
164
|
+
if len(configs) > 8:
|
|
165
|
+
print(f" ... (+{len(configs) - 8} more)")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
170
|
+
"""Check the CAFE environment: Python, the R statistics engine, and LLM access.
|
|
171
|
+
|
|
172
|
+
With ``--app``, also checks the web-app prerequisites (Docker + Docker Compose).
|
|
173
|
+
"""
|
|
174
|
+
import os
|
|
175
|
+
import shutil
|
|
176
|
+
import subprocess
|
|
177
|
+
|
|
178
|
+
from cafe import __version__
|
|
179
|
+
from cafe.stats import check_glmer, check_r
|
|
180
|
+
|
|
181
|
+
def mark(ok: bool) -> str:
|
|
182
|
+
return "✓" if ok else "✗"
|
|
183
|
+
|
|
184
|
+
issues = 0
|
|
185
|
+
print("cafe doctor — environment check\n")
|
|
186
|
+
|
|
187
|
+
# --- Runtime -------------------------------------------------------------
|
|
188
|
+
py = sys.version_info
|
|
189
|
+
py_ok = py >= (3, 11)
|
|
190
|
+
if not py_ok:
|
|
191
|
+
issues += 1
|
|
192
|
+
print("Runtime")
|
|
193
|
+
print(f" {mark(py_ok)} Python {py.major}.{py.minor}.{py.micro} (>= 3.11 required)")
|
|
194
|
+
print(f" ✓ cafe {__version__}")
|
|
195
|
+
print()
|
|
196
|
+
|
|
197
|
+
# --- Statistics engine (R) -----------------------------------------------
|
|
198
|
+
print("Statistics engine (required — the mixed-effects models run in R)")
|
|
199
|
+
if shutil.which("Rscript") is None:
|
|
200
|
+
print(" ✗ R not found")
|
|
201
|
+
print(" fix → install R (e.g. `sudo apt install r-base` or `brew install r`),")
|
|
202
|
+
print(" then: Rscript -e 'install.packages(c(\"ordinal\", \"lme4\"))'")
|
|
203
|
+
issues += 1
|
|
204
|
+
else:
|
|
205
|
+
try:
|
|
206
|
+
proc = subprocess.run(["Rscript", "-e", "cat(R.version.string)"],
|
|
207
|
+
capture_output=True, text=True, timeout=30)
|
|
208
|
+
ver = proc.stdout.strip() or "R (version unknown)"
|
|
209
|
+
except Exception: # noqa: BLE001
|
|
210
|
+
ver = "R (version unknown)"
|
|
211
|
+
print(f" ✓ {ver} (Rscript on PATH)")
|
|
212
|
+
for pkg, (ok, _msg), label in (
|
|
213
|
+
("ordinal", check_r(), "ordinal CLMM"),
|
|
214
|
+
("lme4", check_glmer(), "binary logistic GLMM"),
|
|
215
|
+
):
|
|
216
|
+
print(f" {mark(ok)} {pkg:<9}{label}")
|
|
217
|
+
if not ok:
|
|
218
|
+
print(f" fix → Rscript -e 'install.packages(\"{pkg}\")'")
|
|
219
|
+
issues += 1
|
|
220
|
+
print()
|
|
221
|
+
|
|
222
|
+
# --- LLM access ----------------------------------------------------------
|
|
223
|
+
print("LLM access (required for real studies; the bundled example needs none)")
|
|
224
|
+
keys = ("OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY",
|
|
225
|
+
"GEMINI_API_KEY", "OLLAMA_API_KEY")
|
|
226
|
+
any_key = False
|
|
227
|
+
for k in keys:
|
|
228
|
+
present = bool(os.environ.get(k))
|
|
229
|
+
any_key = any_key or present
|
|
230
|
+
print(f" {'✓' if present else '–'} {k}{'' if present else ' not set'}")
|
|
231
|
+
if not any_key:
|
|
232
|
+
print(" set at least one to run studies with a live judge")
|
|
233
|
+
print()
|
|
234
|
+
|
|
235
|
+
# --- Containers (web app) — only with --app ------------------------------
|
|
236
|
+
if getattr(args, "app", False):
|
|
237
|
+
print("Containers (for the self-hostable web app: `docker compose up`)")
|
|
238
|
+
if shutil.which("docker") is None:
|
|
239
|
+
print(" ✗ docker not found — install Docker Engine / Docker Desktop")
|
|
240
|
+
issues += 1
|
|
241
|
+
else:
|
|
242
|
+
try:
|
|
243
|
+
proc = subprocess.run(["docker", "version", "--format", "{{.Server.Version}}"],
|
|
244
|
+
capture_output=True, text=True, timeout=10)
|
|
245
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
246
|
+
print(f" ✓ docker {proc.stdout.strip()} (daemon running)")
|
|
247
|
+
else:
|
|
248
|
+
print(" ✗ docker installed but the daemon isn't responding — start Docker")
|
|
249
|
+
issues += 1
|
|
250
|
+
except Exception: # noqa: BLE001
|
|
251
|
+
print(" ✗ docker present but not responding")
|
|
252
|
+
issues += 1
|
|
253
|
+
try:
|
|
254
|
+
proc = subprocess.run(["docker", "compose", "version", "--short"],
|
|
255
|
+
capture_output=True, text=True, timeout=10)
|
|
256
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
257
|
+
print(f" ✓ docker compose {proc.stdout.strip()}")
|
|
258
|
+
else:
|
|
259
|
+
print(" ✗ 'docker compose' (v2) not available — update Docker")
|
|
260
|
+
issues += 1
|
|
261
|
+
except Exception: # noqa: BLE001
|
|
262
|
+
print(" ✗ 'docker compose' (v2) not available")
|
|
263
|
+
issues += 1
|
|
264
|
+
print()
|
|
265
|
+
|
|
266
|
+
# --- Summary -------------------------------------------------------------
|
|
267
|
+
if issues == 0:
|
|
268
|
+
print("All set — CAFE's statistics and execution layers are ready.")
|
|
269
|
+
return 0
|
|
270
|
+
word = "issue" if issues == 1 else "issues"
|
|
271
|
+
print(f"{issues} {word} — install the items marked ✗ above.")
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _cmd_version(_args: argparse.Namespace) -> int:
|
|
276
|
+
from cafe import __version__
|
|
277
|
+
|
|
278
|
+
print(f"cafe {__version__}")
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv: list[str] | None = None) -> int:
|
|
283
|
+
from cafe._env import load_env
|
|
284
|
+
load_env() # load the nearest .env so `doctor` / `run` see the same keys a study does
|
|
285
|
+
parser = argparse.ArgumentParser(
|
|
286
|
+
prog="cafe", description=__doc__,
|
|
287
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, # keep the docstring's layout
|
|
288
|
+
)
|
|
289
|
+
parser.add_argument("--version", action="store_true", help="print version and exit")
|
|
290
|
+
sub = parser.add_subparsers(dest="command")
|
|
291
|
+
|
|
292
|
+
p_run = sub.add_parser("run", help="run a study (or `example`)")
|
|
293
|
+
p_run.add_argument("target", help="'example' or a path to a .py study file")
|
|
294
|
+
p_run.add_argument("--smoke", action="store_true", help="preflight: 1 input, 1 rep, no judging")
|
|
295
|
+
p_run.add_argument("--reps", type=int, default=None, help="replications per (config, input)")
|
|
296
|
+
p_run.add_argument("--concurrency", type=int, default=8, help="max cells in flight")
|
|
297
|
+
p_run.add_argument("--checkpoint", default=None, help="resumable checkpoint JSONL path")
|
|
298
|
+
p_run.add_argument("--out", default=None, help="write results to this JSONL path")
|
|
299
|
+
p_run.set_defaults(func=_cmd_run)
|
|
300
|
+
|
|
301
|
+
p_val = sub.add_parser("validate", help="expand a study's design without running it")
|
|
302
|
+
p_val.add_argument("target", nargs="?", default="example", help="'example' or a .py study file")
|
|
303
|
+
p_val.set_defaults(func=_cmd_validate)
|
|
304
|
+
|
|
305
|
+
p_doc = sub.add_parser("doctor", help="check the environment (Python, R stats engine, LLM access)")
|
|
306
|
+
p_doc.add_argument("--app", action="store_true",
|
|
307
|
+
help="also check the web-app prerequisites (Docker + Docker Compose)")
|
|
308
|
+
p_doc.set_defaults(func=_cmd_doctor)
|
|
309
|
+
|
|
310
|
+
p_ver = sub.add_parser("version", help="print version and exit")
|
|
311
|
+
p_ver.set_defaults(func=_cmd_version)
|
|
312
|
+
|
|
313
|
+
args = parser.parse_args(argv)
|
|
314
|
+
if args.version:
|
|
315
|
+
return _cmd_version(args)
|
|
316
|
+
if not getattr(args, "command", None):
|
|
317
|
+
parser.print_help()
|
|
318
|
+
return 0
|
|
319
|
+
return args.func(args)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__": # pragma: no cover
|
|
323
|
+
raise SystemExit(main())
|
cafe/datasets.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Load public benchmarks as CAFE datasets.
|
|
2
|
+
|
|
3
|
+
Each loader returns a list of items shaped for ``Study(dataset=...)``:
|
|
4
|
+
``{"id", "text", "reference", ...}`` — so the reference-guided judge has a gold
|
|
5
|
+
answer to score against. Needs the ``datasets`` extra (HuggingFace ``datasets``):
|
|
6
|
+
``pip install 'cafe-core[datasets]'``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _quiet_hf() -> None:
|
|
16
|
+
"""Best-effort: silence HuggingFace's download chatter (the 'unauthenticated requests
|
|
17
|
+
to the HF Hub / set HF_TOKEN' warning, progress logs). Purely cosmetic for showcasing;
|
|
18
|
+
never let it break the actual load."""
|
|
19
|
+
import warnings
|
|
20
|
+
|
|
21
|
+
warnings.filterwarnings("ignore", message=r".*unauthenticated requests to the HF Hub.*")
|
|
22
|
+
for mod in ("huggingface_hub", "datasets"):
|
|
23
|
+
try:
|
|
24
|
+
__import__(mod).logging.set_verbosity_error()
|
|
25
|
+
except Exception: # noqa: BLE001 — cosmetic only
|
|
26
|
+
pass
|
|
27
|
+
try:
|
|
28
|
+
import logging
|
|
29
|
+
|
|
30
|
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
|
31
|
+
except Exception: # noqa: BLE001
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_hf(name: str, *args: Any, **kwargs: Any):
|
|
36
|
+
try:
|
|
37
|
+
from datasets import load_dataset
|
|
38
|
+
except ImportError as exc: # pragma: no cover
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"dataset loaders need the 'datasets' extra: pip install 'cafe-core[datasets]'"
|
|
41
|
+
) from exc
|
|
42
|
+
_quiet_hf()
|
|
43
|
+
return load_dataset(name, *args, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sample(rows: list[Any], n: int | None, *, shuffle: bool, seed: int) -> list[Any]:
|
|
47
|
+
rows = list(rows)
|
|
48
|
+
if shuffle:
|
|
49
|
+
random.Random(seed).shuffle(rows)
|
|
50
|
+
return rows if n is None else rows[:n]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_truthfulqa(
|
|
54
|
+
n: int | None = 20,
|
|
55
|
+
*,
|
|
56
|
+
split: str = "train",
|
|
57
|
+
categories: list[str] | None = None,
|
|
58
|
+
shuffle: bool = True,
|
|
59
|
+
seed: int = 0,
|
|
60
|
+
) -> list[dict[str, Any]]:
|
|
61
|
+
"""TruthfulQA — adversarial questions that elicit common misconceptions.
|
|
62
|
+
|
|
63
|
+
Reference-based: each item carries the gold ``Best Answer``. Returns items
|
|
64
|
+
``{id, text, reference, category}``. ``categories`` filters by Category
|
|
65
|
+
(e.g. ``["Misconceptions", "Law"]``).
|
|
66
|
+
"""
|
|
67
|
+
ds = _load_hf("domenicrosati/TruthfulQA", split=split)
|
|
68
|
+
rows = list(ds)
|
|
69
|
+
if categories:
|
|
70
|
+
wanted = {c.lower() for c in categories}
|
|
71
|
+
rows = [r for r in rows if str(r.get("Category", "")).lower() in wanted]
|
|
72
|
+
rows = _sample(rows, n, shuffle=shuffle, seed=seed)
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
"id": f"tq{i}",
|
|
76
|
+
"text": r["Question"],
|
|
77
|
+
"reference": r["Best Answer"],
|
|
78
|
+
"category": r.get("Category"),
|
|
79
|
+
}
|
|
80
|
+
for i, r in enumerate(rows)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_gsm8k(
|
|
85
|
+
n: int | None = 20,
|
|
86
|
+
*,
|
|
87
|
+
split: str = "test",
|
|
88
|
+
shuffle: bool = True,
|
|
89
|
+
seed: int = 0,
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
"""GSM8K — grade-school math word problems with an exact numeric reference."""
|
|
92
|
+
ds = _load_hf("openai/gsm8k", "main", split=split)
|
|
93
|
+
rows = _sample(list(ds), n, shuffle=shuffle, seed=seed)
|
|
94
|
+
return [
|
|
95
|
+
{"id": f"gsm{i}", "text": r["question"], "reference": r["answer"].split("####")[-1].strip()}
|
|
96
|
+
for i, r in enumerate(rows)
|
|
97
|
+
]
|
cafe/design/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Design of experiments: turn factors into the configurations to run."""
|
|
2
|
+
|
|
3
|
+
from cafe.design.factorial import Config, full_factorial, generate, single, size
|
|
4
|
+
from cafe.design.fractional import (
|
|
5
|
+
FractionalDesign,
|
|
6
|
+
fractional_factorial,
|
|
7
|
+
fractional_factorial_design,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Config",
|
|
12
|
+
"generate",
|
|
13
|
+
"size",
|
|
14
|
+
"full_factorial",
|
|
15
|
+
"single",
|
|
16
|
+
"fractional_factorial",
|
|
17
|
+
"fractional_factorial_design",
|
|
18
|
+
"FractionalDesign",
|
|
19
|
+
]
|