Skip to content

Commit 516baa4

Browse files
committed
Speed up dedup/embedding/scoring, add local-model support and CLI info/sample commands
1 parent 7c51f12 commit 516baa4

15 files changed

Lines changed: 1861 additions & 1418 deletions

benchmarks/benchmark_buffdata.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,7 @@ async def main(args: argparse.Namespace) -> None:
975975
parser.add_argument("--datasets", nargs="+", choices=sorted(DATASETS))
976976
parser.add_argument("--gemini-audit-rows", type=int, default=0)
977977
parser.add_argument("--gemini-model", default="gemini-3.7-flash")
978+
parser.add_argument("--openai-model", default="gpt-5")
978979
parser.add_argument("--anthropic-model", default="claude-sonnet-5")
979-
parser.add_argument("--audit-provider", choices=["gemini", "anthropic", "both"], default="gemini")
980+
parser.add_argument("--audit-provider", choices=["gemini", "openai", "anthropic", "both"], default="gemini")
980981
asyncio.run(main(parser.parse_args()))

benchmarks/benchmark_claude.py

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
--provider openai_compatible --base-url https://proxy.corp/v1 \\
4747
--model claude-sonnet-5
4848
49+
# local model via LM Studio on a shared box (free inference, so audit +
50+
# classification can stay on); --model must match the id LM Studio shows
51+
python benchmarks/benchmark_claude.py --smoke \\
52+
--provider lmstudio --base-url http://bionic:1234/v1 \\
53+
--model qwen3-27b
54+
4955
# offline plumbing check: zero API calls
5056
python benchmarks/benchmark_claude.py --smoke --mock
5157
@@ -75,6 +81,18 @@
7581

7682
sys.path.insert(0, str(Path(__file__).resolve().parent))
7783

84+
# Parse --request-timeout BEFORE importing the client stack: engine/client.py
85+
# binds DEFAULT_REQUEST_TIMEOUT_SECONDS at import time, so the env var must be
86+
# set first. Slow local models (27B reasoning on CPU/offload) need 900-1800s;
87+
# cloud APIs are fine with the 120s default.
88+
_request_parser = argparse.ArgumentParser(add_help=False)
89+
_request_parser.add_argument("--request-timeout", type=float, default=None)
90+
_request_known, _ = _request_parser.parse_known_args()
91+
if _request_known.request_timeout:
92+
import os as _os
93+
94+
_os.environ["BUFFDATA_REQUEST_TIMEOUT"] = str(_request_known.request_timeout)
95+
7896
from benchmark_buffdata import ( # noqa: E402
7997
build_vocab,
8098
make_dirty,
@@ -90,24 +108,41 @@
90108
DEFAULT_DATASETS = ["ag_news", "imdb"]
91109

92110

111+
# provider -> (base-url env var, api-key env var) read by create_llm_client
112+
# when --base-url is not passed explicitly. Local servers (ollama/lmstudio/...)
113+
# also have loopback presets, so --base-url is only needed for shared boxes
114+
# like --base-url http://bionic:1234/v1.
115+
_GATEWAY_ENVS = {
116+
"openai_compatible": ("OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"),
117+
"ollama": ("OLLAMA_BASE_URL", "OLLAMA_API_KEY"),
118+
"lmstudio": ("LMSTUDIO_BASE_URL", "LMSTUDIO_API_KEY"),
119+
"vllm": ("VLLM_BASE_URL", "VLLM_API_KEY"),
120+
"llamacpp": ("LLAMACPP_BASE_URL", "LLAMACPP_API_KEY"),
121+
}
122+
123+
93124
def _configure_gateway(provider: str, base_url: str | None) -> None:
94125
"""Forward gateway settings into the env vars the client layer reads.
95126
96127
The optimize() helper below builds its own PipelineConfig without a
97-
base_url, but create_llm_client(provider="openai_compatible") falls back
98-
to OPENAI_COMPATIBLE_BASE_URL / OPENAI_COMPATIBLE_API_KEY env vars -- so
99-
exporting here makes both the direct classifier calls and the full
100-
pipeline audit path reach the proxy. Reuses .env ANTHROPIC_API_KEY as the
101-
gateway key when no gateway-specific key is set.
128+
base_url, but create_llm_client falls back to the provider's *_BASE_URL /
129+
*_API_KEY env vars -- so exporting here makes both the direct classifier
130+
calls and the full pipeline audit path reach the proxy or local server.
131+
For openai_compatible, reuses .env ANTHROPIC_API_KEY as the gateway key
132+
when no gateway-specific key is set. Local servers need no key.
102133
"""
103134
import os
104135

136+
envs = _GATEWAY_ENVS.get(provider)
137+
if not envs:
138+
return
139+
base_env, key_env = envs
105140
if base_url:
106-
os.environ["OPENAI_COMPATIBLE_BASE_URL"] = base_url
107-
if provider == "openai_compatible" and not os.getenv("OPENAI_COMPATIBLE_API_KEY"):
141+
os.environ[base_env] = base_url
142+
if provider == "openai_compatible" and not os.getenv(key_env):
108143
fallback = os.getenv("ANTHROPIC_API_KEY")
109144
if fallback:
110-
os.environ["OPENAI_COMPATIBLE_API_KEY"] = fallback
145+
os.environ[key_env] = fallback
111146

112147

113148
def _synthetic_rows(count: int, classes: int, seed: int) -> list[dict[str, Any]]:
@@ -225,6 +260,25 @@ async def measure_classification(
225260
}
226261

227262

263+
async def _safe_optimize(
264+
rows: list[dict[str, Any]], label: str, **kwargs: Any,
265+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
266+
"""optimize() that never kills the benchmark: on provider failure (wedged
267+
local server, dead quota, 503 spike) fall back to the unoptimized input so
268+
recovery still reports -- with the error recorded in quality instead of
269+
silently presenting fallback numbers as a real result."""
270+
try:
271+
return await optimize(rows, **kwargs)
272+
except Exception as exc: # noqa: BLE001
273+
print(f" [{label}] optimize failed, using unoptimized input: {exc}", flush=True)
274+
return list(rows), {
275+
"error": f"{type(exc).__name__}: {exc}",
276+
"input_rows": len(rows), "output_rows": len(rows),
277+
"provider": kwargs.get("provider"), "model": kwargs.get("anthropic_model"),
278+
"usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
279+
}
280+
281+
228282
async def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
229283
"""Synthetic end-to-end check without Hugging Face downloads."""
230284
print("=== smoke: synthetic binary classification (no HF download) ===", flush=True)
@@ -239,9 +293,11 @@ async def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
239293
# force the audit off (quality_mode="off") instead of attempting a live
240294
# sampled audit with an absent key.
241295
audit_rows = 0 if args.mock else args.audit_rows
242-
clean_opt, clean_q = await optimize(clean, provider=args.provider, anthropic_model=args.model)
243-
dirty_opt, dirty_q = await optimize(
244-
optimizer_input,
296+
clean_opt, clean_q = await _safe_optimize(
297+
clean, "smoke-clean", provider=args.provider, anthropic_model=args.model,
298+
)
299+
dirty_opt, dirty_q = await _safe_optimize(
300+
optimizer_input, "smoke-dirty",
245301
gemini_audit_rows=audit_rows,
246302
gemini_model=args.model,
247303
anthropic_model=args.model,
@@ -304,9 +360,11 @@ async def run_dataset(
304360
clean_test = stratified_rows(test_split, args.test_rows, classes, text_fields, 200 + dataset_offset, label_field=label_col)
305361
_, optimizer_input, defects = make_dirty(clean_train, classes, 300 + dataset_offset)
306362

307-
clean_opt, clean_q = await optimize(clean_train, provider=args.provider, anthropic_model=args.model)
308-
dirty_opt, dirty_q = await optimize(
309-
optimizer_input,
363+
clean_opt, clean_q = await _safe_optimize(
364+
clean_train, f"{name}-clean", provider=args.provider, anthropic_model=args.model,
365+
)
366+
dirty_opt, dirty_q = await _safe_optimize(
367+
optimizer_input, f"{name}-dirty",
310368
gemini_audit_rows=args.audit_rows,
311369
gemini_model=args.model,
312370
anthropic_model=args.model,
@@ -367,7 +425,7 @@ def markdown_report(payload: dict[str, Any]) -> str:
367425
f"# {title}",
368426
"",
369427
f"Provider: `{m['provider']}` | Model: `{m['model']}` | Base URL: `{m.get('base_url') or '-'}` | Seeds: {m['seeds']} | Epochs: {m['epochs']} |",
370-
f"Audit rows: {m['audit_rows']} | Classify sample: {m['classify_sample_size']}",
428+
f"Audit rows: {m['audit_rows']} | Classify sample: {m['classify_sample_size']} | Request timeout: {m.get('request_timeout_s', '-')}s",
371429
"",
372430
]
373431
if payload.get("smoke"):
@@ -396,9 +454,12 @@ def markdown_report(payload: dict[str, Any]) -> str:
396454
q = r.get("dirty_audit_quality", {})
397455
u = q.get("usage", {})
398456
cl = (r.get("classification") or {}).get("usage", {})
457+
audit_cell: Any = q.get("audit_avg_score", "n/a")
458+
if q.get("error"):
459+
audit_cell = f"error ({str(q['error'])[:80]})"
399460
lines.append(
400461
f"| {r['dataset']} | {u.get('input_tokens', 0)}/{u.get('output_tokens', 0)}/{u.get('total_tokens', 0)} | "
401-
f"{q.get('audit_avg_score', 'n/a')} | {cl.get('total_tokens', cl.get('total', 0))} |"
462+
f"{audit_cell} | {cl.get('total_tokens', cl.get('total', 0))} |"
402463
)
403464
lines.append("")
404465
return "\n".join(lines)
@@ -423,9 +484,13 @@ async def main(args: argparse.Namespace) -> None:
423484

424485
from benchmark_buffdata import DATASETS
425486

487+
from buffdata.engine.client import DEFAULT_REQUEST_TIMEOUT_SECONDS
488+
489+
print(f"request timeout: {DEFAULT_REQUEST_TIMEOUT_SECONDS:g}s", flush=True)
426490
payload: dict[str, Any] = {
427491
"method": {
428492
"provider": args.provider, "model": args.model, "base_url": args.base_url,
493+
"request_timeout_s": DEFAULT_REQUEST_TIMEOUT_SECONDS,
429494
"seeds": args.seeds,
430495
"epochs": args.epochs, "audit_rows": args.audit_rows,
431496
"classify_sample_size": args.classify_sample_size,
@@ -465,4 +530,9 @@ async def main(args: argparse.Namespace) -> None:
465530
parser.add_argument("--skip-classification", action="store_true")
466531
parser.add_argument("--smoke", action="store_true", help="Synthetic data, no HF download.")
467532
parser.add_argument("--mock", action="store_true", help="Zero API calls (offline plumbing check).")
533+
parser.add_argument(
534+
"--request-timeout", type=float, default=None,
535+
help="Per-request seconds (sets BUFFDATA_REQUEST_TIMEOUT before client import). "
536+
"Use 900-1800 for slow local models; default 120 suits cloud APIs.",
537+
)
468538
asyncio.run(main(parser.parse_args()))

benchmarks/benchmark_claude_classification.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@
5050

5151
sys.path.insert(0, str(Path(__file__).resolve().parent))
5252

53+
# Parse --request-timeout BEFORE any client import (engine/client.py binds its
54+
# default timeout at import time). Slow local models need 900-1800s.
55+
_request_parser = argparse.ArgumentParser(add_help=False)
56+
_request_parser.add_argument("--request-timeout", type=float, default=None)
57+
_request_known, _ = _request_parser.parse_known_args()
58+
if _request_known.request_timeout:
59+
import os as _os
60+
61+
_os.environ["BUFFDATA_REQUEST_TIMEOUT"] = str(_request_known.request_timeout)
62+
5363
DEFAULT_PROVIDER = "anthropic"
5464
DEFAULT_MODEL = "claude-sonnet-5"
5565
DEFAULT_DATASETS = ["imdb", "ag_news", "emotion"]
@@ -106,23 +116,39 @@ async def generate_structured_async(self, prompt: str, response_schema, model=No
106116
)
107117

108118

119+
# provider -> (base-url env var, api-key env var) read by create_llm_client
120+
# when --base-url is not passed explicitly (e.g. --base-url http://bionic:1234/v1).
121+
_GATEWAY_ENVS = {
122+
"openai_compatible": ("OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"),
123+
"ollama": ("OLLAMA_BASE_URL", "OLLAMA_API_KEY"),
124+
"lmstudio": ("LMSTUDIO_BASE_URL", "LMSTUDIO_API_KEY"),
125+
"vllm": ("VLLM_BASE_URL", "VLLM_API_KEY"),
126+
"llamacpp": ("LLAMACPP_BASE_URL", "LLAMACPP_API_KEY"),
127+
}
128+
129+
109130
def _configure_gateway(provider: str, base_url: str | None) -> None:
110131
"""Forward gateway settings into the env vars the client layer reads.
111132
112133
create_llm_client(provider="openai_compatible") resolves its endpoint from
113134
--base-url or OPENAI_COMPATIBLE_BASE_URL and its key from
114-
OPENAI_COMPATIBLE_API_KEY. Reuse the existing .env ANTHROPIC_API_KEY when
115-
the gateway-specific key is unset so external-proxy Claude keys work
116-
without copying secrets by hand.
135+
OPENAI_COMPATIBLE_API_KEY (same pattern for the local-server providers).
136+
Reuse the existing .env ANTHROPIC_API_KEY when the gateway-specific key
137+
is unset so external-proxy Claude keys work without copying secrets by
138+
hand. Local servers need no key.
117139
"""
118140
import os
119141

142+
envs = _GATEWAY_ENVS.get(provider)
143+
if not envs:
144+
return
145+
base_env, key_env = envs
120146
if base_url:
121-
os.environ["OPENAI_COMPATIBLE_BASE_URL"] = base_url
122-
if provider == "openai_compatible" and not os.getenv("OPENAI_COMPATIBLE_API_KEY"):
147+
os.environ[base_env] = base_url
148+
if provider == "openai_compatible" and not os.getenv(key_env):
123149
fallback = os.getenv("ANTHROPIC_API_KEY")
124150
if fallback:
125-
os.environ["OPENAI_COMPATIBLE_API_KEY"] = fallback
151+
os.environ[key_env] = fallback
126152

127153

128154
async def classify_and_score(
@@ -235,6 +261,9 @@ async def main(args: argparse.Namespace) -> None:
235261
results_path = output_dir / "results.json"
236262
report_path = output_dir / "REPORT.md"
237263

264+
from buffdata.engine.client import DEFAULT_REQUEST_TIMEOUT_SECONDS
265+
266+
print(f"request timeout: {DEFAULT_REQUEST_TIMEOUT_SECONDS:g}s", flush=True)
238267
results: list[dict[str, Any]] = []
239268
if args.smoke:
240269
print("=== smoke: synthetic binary + multi-class (no downloads) ===", flush=True)
@@ -285,4 +314,9 @@ async def main(args: argparse.Namespace) -> None:
285314
parser.add_argument("--sample-size", type=int, default=20)
286315
parser.add_argument("--smoke", action="store_true")
287316
parser.add_argument("--mock", action="store_true")
317+
parser.add_argument(
318+
"--request-timeout", type=float, default=None,
319+
help="Per-request seconds (sets BUFFDATA_REQUEST_TIMEOUT before client import). "
320+
"Use 900-1800 for slow local models; default 120 suits cloud APIs.",
321+
)
288322
asyncio.run(main(parser.parse_args()))

buffdata/cli/info.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Quick dataset inspection commands for buffdata CLI."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
import typer
10+
from rich.console import Console
11+
from rich.panel import Panel
12+
from rich.table import Table
13+
14+
from buffdata.models.formats import _is_cloud_url, iter_dataset, read_dataset
15+
16+
console = Console()
17+
18+
19+
def info_cmd(
20+
input_file: str = typer.Argument(..., help="Path to dataset, or a cloud URL"),
21+
max_rows: Optional[int] = typer.Option(None, "--max-rows", "-n", help="Only inspect first N rows"),
22+
):
23+
"""Show quick dataset info: format, row count, columns, and sample values."""
24+
from buffdata.models.formats import _path_format, _cloud_format
25+
26+
if _is_cloud_url(input_file):
27+
dataset_format = _cloud_format(input_file)
28+
source = input_file
29+
else:
30+
path = Path(input_file)
31+
if not path.exists():
32+
console.print(f"[red]File not found: {path}[/red]")
33+
raise typer.Exit(1)
34+
dataset_format = _path_format(path)
35+
source = str(path)
36+
37+
items = []
38+
row_count = 0
39+
columns = set()
40+
sample_values = {}
41+
42+
for item in iter_dataset(input_file):
43+
row_count += 1
44+
if row_count <= 5:
45+
items.append(item)
46+
d = item.to_dict()
47+
columns.update(d.keys())
48+
for k, v in d.items():
49+
if k not in sample_values:
50+
sample_values[k] = v
51+
if max_rows and row_count >= max_rows:
52+
break
53+
54+
table = Table(title="Dataset Info", border_style="cyan")
55+
table.add_column("Property", style="bold")
56+
table.add_column("Value")
57+
table.add_row("Source", source)
58+
table.add_row("Format", dataset_format)
59+
table.add_row("Rows", str(row_count) + (" (limited)" if max_rows else ""))
60+
table.add_row("Columns", ", ".join(sorted(columns)) if columns else "-")
61+
console.print(table)
62+
63+
if items:
64+
console.print()
65+
preview_table = Table(title="Preview (first rows)", border_style="green")
66+
preview_cols = sorted(columns)[:6]
67+
for col in preview_cols:
68+
preview_table.add_column(col, max_width=40)
69+
for item in items[:3]:
70+
d = item.to_dict()
71+
row = [str(d.get(col, ""))[:40] for col in preview_cols]
72+
preview_table.add_row(*row)
73+
console.print(preview_table)
74+
75+
76+
def sample_cmd(
77+
input_file: str = typer.Argument(..., help="Path to dataset, or a cloud URL"),
78+
n: int = typer.Option(5, "-n", "--count", help="Number of rows to show"),
79+
offset: int = typer.Option(0, "--offset", "-o", help="Skip first N rows"),
80+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, jsonl"),
81+
):
82+
"""Preview N rows from a dataset."""
83+
import json
84+
85+
items = []
86+
for i, item in enumerate(iter_dataset(input_file)):
87+
if i < offset:
88+
continue
89+
if len(items) >= n:
90+
break
91+
items.append(item)
92+
93+
if not items:
94+
console.print("[yellow]No rows found.[/yellow]")
95+
return
96+
97+
if format == "json":
98+
console.print_json(data=[item.to_dict() for item in items])
99+
elif format == "jsonl":
100+
for item in items:
101+
console.print(json.dumps(item.to_dict(), ensure_ascii=False))
102+
else:
103+
table = Table(title=f"Sample ({len(items)} rows)", border_style="green")
104+
all_keys = set()
105+
for item in items:
106+
all_keys.update(item.to_dict().keys())
107+
cols = sorted(all_keys)[:5]
108+
for col in cols:
109+
table.add_column(col, max_width=50)
110+
for item in items:
111+
d = item.to_dict()
112+
table.add_row(*[str(d.get(col, ""))[:50] for col in cols])
113+
console.print(table)

0 commit comments

Comments
 (0)