bondbench 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.
bondbench/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """bondbench: evaluate LLMs on fixed-income and fund-operations questions."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+ from .checks import json_check, keyword_check, numeric_check, refusal_check
7
+ from .goldset import GoldItem, GoldSetError, builtin_goldsets, load_goldset
8
+ from .providers import Completion, register_provider
9
+ from .runner import grade, run, score, summarize, to_markdown
10
+
11
+ try:
12
+ __version__ = _version("bondbench")
13
+ except PackageNotFoundError: # running from a source checkout without installing
14
+ __version__ = "0.0.0"
15
+
16
+ __all__ = [
17
+ "Completion",
18
+ "GoldItem",
19
+ "GoldSetError",
20
+ "builtin_goldsets",
21
+ "grade",
22
+ "json_check",
23
+ "keyword_check",
24
+ "load_goldset",
25
+ "numeric_check",
26
+ "refusal_check",
27
+ "register_provider",
28
+ "run",
29
+ "score",
30
+ "summarize",
31
+ "to_markdown",
32
+ ]
bondbench/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
bondbench/checks.py ADDED
@@ -0,0 +1,118 @@
1
+ """Checks that grade one model response.
2
+
3
+ Each check is a pure function that returns True (pass) or False (fail).
4
+ The runner only calls the checks a gold-set row asks for, so a check never
5
+ has to decide whether it applies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ from collections.abc import Iterable
14
+
15
+ REFUSAL_HINTS: tuple[str, ...] = (
16
+ "cannot",
17
+ "can't",
18
+ "do not know",
19
+ "i don't know",
20
+ "not financial advice",
21
+ "i'm not able",
22
+ "i am not able",
23
+ "as an ai",
24
+ "refuse",
25
+ "sorry",
26
+ )
27
+
28
+ _FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", flags=re.MULTILINE)
29
+
30
+
31
+ def _normalise_terms(terms: str | Iterable[str] | None) -> list[str]:
32
+ """Turn ``"a;b; c"``, a list, ``None`` or ``NaN`` into a clean lowercase list.
33
+
34
+ The ``NaN`` case matters: pandas reads an empty CSV cell as ``NaN`` and
35
+ ``str(NaN)`` is the literal text ``"nan"``, which then matches words like
36
+ "finance". That exact bug cost the original harness about 15 points.
37
+ """
38
+ if terms is None:
39
+ return []
40
+ if isinstance(terms, float) and math.isnan(terms):
41
+ return []
42
+ if isinstance(terms, str):
43
+ parts: Iterable[str] = terms.split(";")
44
+ else:
45
+ parts = terms
46
+ return [p.strip().lower() for p in parts if isinstance(p, str) and p.strip()]
47
+
48
+
49
+ def keyword_check(
50
+ response: str,
51
+ expected: str | Iterable[str] | None,
52
+ forbidden: str | Iterable[str] | None = None,
53
+ ) -> bool:
54
+ """Pass if any expected keyword appears and no forbidden keyword does.
55
+
56
+ Matching is case-insensitive substring matching. An empty expected list
57
+ passes unless a forbidden keyword appears.
58
+ """
59
+ text = (response or "").lower()
60
+ if any(term in text for term in _normalise_terms(forbidden)):
61
+ return False
62
+ wanted = _normalise_terms(expected)
63
+ if not wanted:
64
+ return True
65
+ return any(term in text for term in wanted)
66
+
67
+
68
+ def refusal_check(response: str, hints: Iterable[str] = REFUSAL_HINTS) -> bool:
69
+ """Pass if the response contains a refusal phrase."""
70
+ text = (response or "").lower()
71
+ return any(h in text for h in hints)
72
+
73
+
74
+ def strip_code_fences(text: str) -> str:
75
+ """Remove a surrounding Markdown code fence such as ```json ... ```."""
76
+ return _FENCE.sub("", (text or "").strip()).strip()
77
+
78
+
79
+ def json_check(response: str) -> bool:
80
+ """Pass if the whole response (after removing code fences) is valid JSON.
81
+
82
+ Text before or after the JSON fails, because the prompt asks for JSON only.
83
+ """
84
+ try:
85
+ json.loads(strip_code_fences(response))
86
+ except (ValueError, TypeError):
87
+ return False
88
+ return True
89
+
90
+
91
+ def extract_json_object(response: str) -> dict | None:
92
+ """Return the first JSON object found in the response, or ``None``.
93
+
94
+ Unlike :func:`json_check` this tolerates surrounding text, so a numeric
95
+ answer can be graded separately from format discipline.
96
+ """
97
+ text = strip_code_fences(response)
98
+ decoder = json.JSONDecoder()
99
+ for match in re.finditer(r"\{", text):
100
+ try:
101
+ obj, _ = decoder.raw_decode(text[match.start() :])
102
+ except ValueError:
103
+ continue
104
+ if isinstance(obj, dict):
105
+ return obj
106
+ return None
107
+
108
+
109
+ def numeric_check(response: str, key: str, expected: float, tolerance: float) -> bool:
110
+ """Pass if ``response`` holds a JSON object whose ``key`` is within ``tolerance``."""
111
+ obj = extract_json_object(response)
112
+ if obj is None or key not in obj:
113
+ return False
114
+ try:
115
+ value = float(obj[key])
116
+ except (TypeError, ValueError):
117
+ return False
118
+ return abs(value - float(expected)) <= float(tolerance)
bondbench/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """Command line interface: ``bondbench run | score | validate | goldsets``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ import pandas as pd
11
+
12
+ from . import __version__
13
+ from .goldset import GoldSetError, builtin_goldsets, load_goldset
14
+ from .pricing import PRICING_AS_OF, load_pricing
15
+ from .runner import DEFAULT_MODELS, DEFAULT_SYSTEM, run, score, summarize, to_markdown
16
+
17
+ DEFAULT_GOLDSET = "fixed_income_ops_v1"
18
+
19
+
20
+ def _write_outputs(runs: pd.DataFrame, out_dir: Path, markdown: bool) -> None:
21
+ out_dir.mkdir(parents=True, exist_ok=True)
22
+ summary = summarize(runs)
23
+ runs.to_csv(out_dir / "runs.csv", index=False)
24
+ summary.to_csv(out_dir / "summary.csv", index=False)
25
+ (out_dir / "summary.md").write_text(to_markdown(summary) + "\n")
26
+ print(f"\nWrote {len(runs)} rows to {out_dir / 'runs.csv'}")
27
+ print(f"Wrote summary to {out_dir / 'summary.csv'} and {out_dir / 'summary.md'}\n")
28
+ if markdown:
29
+ print(to_markdown(summary))
30
+ else:
31
+ print(summary.to_string(index=False))
32
+
33
+
34
+ def _cmd_run(args: argparse.Namespace) -> int:
35
+ if args.env_file and Path(args.env_file).exists():
36
+ from dotenv import load_dotenv
37
+
38
+ load_dotenv(args.env_file)
39
+ items = load_goldset(args.goldset)
40
+ system = Path(args.system_file).read_text() if args.system_file else args.system
41
+ pricing = load_pricing(args.pricing)
42
+ models = args.models or list(DEFAULT_MODELS)
43
+ print(f"Gold set: {args.goldset} ({len(items)} questions). Models: {', '.join(models)}")
44
+ if not args.pricing:
45
+ print(f"Prices: built-in table ({PRICING_AS_OF}). Pass --pricing to update them.")
46
+
47
+ def progress(model: str, item) -> None:
48
+ if not args.quiet:
49
+ print(f" {model[:30]:<30} {item.id:<6} {item.question[:60]}")
50
+
51
+ runs = run(
52
+ items,
53
+ models,
54
+ system=system,
55
+ temperature=args.temperature,
56
+ max_tokens=args.max_tokens,
57
+ pricing=pricing,
58
+ retries=args.retries,
59
+ progress=progress,
60
+ )
61
+ _write_outputs(runs, Path(args.out), args.markdown)
62
+ return 0
63
+
64
+
65
+ def _cmd_score(args: argparse.Namespace) -> int:
66
+ items = load_goldset(args.goldset)
67
+ runs = pd.read_csv(args.runs)
68
+ rescored = score(runs, items)
69
+ _write_outputs(rescored, Path(args.out), args.markdown)
70
+ return 0
71
+
72
+
73
+ def _cmd_validate(args: argparse.Namespace) -> int:
74
+ status = 0
75
+ for source in args.goldsets or [DEFAULT_GOLDSET]:
76
+ try:
77
+ items = load_goldset(source)
78
+ except GoldSetError as exc:
79
+ print(f"FAIL {exc}")
80
+ status = 1
81
+ continue
82
+ counts: dict[str, int] = {}
83
+ for item in items:
84
+ for check in item.checks:
85
+ counts[check] = counts.get(check, 0) + 1
86
+ detail = ", ".join(f"{k} {v}" for k, v in sorted(counts.items()))
87
+ print(f"OK {source}: {len(items)} questions ({detail})")
88
+ return status
89
+
90
+
91
+ def _cmd_goldsets(_: argparse.Namespace) -> int:
92
+ for name in builtin_goldsets():
93
+ print(f"{name}: {len(load_goldset(name))} questions")
94
+ return 0
95
+
96
+
97
+ def build_parser() -> argparse.ArgumentParser:
98
+ parser = argparse.ArgumentParser(
99
+ prog="bondbench",
100
+ description="Evaluate LLMs on fixed-income and fund-operations questions.",
101
+ )
102
+ parser.add_argument("--version", action="version", version=f"bondbench {__version__}")
103
+ sub = parser.add_subparsers(dest="command", required=True)
104
+
105
+ p_run = sub.add_parser("run", help="ask the models every question and grade the answers")
106
+ p_run.add_argument(
107
+ "-g",
108
+ "--goldset",
109
+ default=DEFAULT_GOLDSET,
110
+ help="built-in gold set name or path to a CSV (default: %(default)s)",
111
+ )
112
+ p_run.add_argument(
113
+ "-m",
114
+ "--models",
115
+ nargs="+",
116
+ metavar="MODEL",
117
+ help="models to test, e.g. gpt-4o-mini anthropic:claude-haiku-4-5-20251001 "
118
+ "fake:demo (default: the three models from the original study)",
119
+ )
120
+ p_run.add_argument(
121
+ "-o", "--out", default="bondbench-results", help="output folder (default: %(default)s)"
122
+ )
123
+ p_run.add_argument("--system", default=DEFAULT_SYSTEM, help="system prompt text")
124
+ p_run.add_argument("--system-file", help="read the system prompt from a file")
125
+ p_run.add_argument("--temperature", type=float, default=0.0)
126
+ p_run.add_argument("--max-tokens", type=int, default=400)
127
+ p_run.add_argument("--pricing", help="JSON file of per-1M-token prices")
128
+ p_run.add_argument(
129
+ "--env-file",
130
+ default=".env",
131
+ help="load API keys from this file if it exists (default: %(default)s)",
132
+ )
133
+ p_run.add_argument("--retries", type=int, default=4, help="attempts per request")
134
+ p_run.add_argument("--markdown", action="store_true", help="print the summary as Markdown")
135
+ p_run.add_argument("-q", "--quiet", action="store_true", help="don't print each request")
136
+ p_run.set_defaults(func=_cmd_run)
137
+
138
+ p_score = sub.add_parser("score", help="re-grade a saved runs.csv without calling any model")
139
+ p_score.add_argument("runs", help="path to a runs.csv")
140
+ p_score.add_argument("-g", "--goldset", default=DEFAULT_GOLDSET)
141
+ p_score.add_argument("-o", "--out", default="bondbench-rescored")
142
+ p_score.add_argument("--markdown", action="store_true")
143
+ p_score.set_defaults(func=_cmd_score)
144
+
145
+ p_val = sub.add_parser("validate", help="check gold set files for mistakes")
146
+ p_val.add_argument("goldsets", nargs="*", help="names or paths (default: the built-in set)")
147
+ p_val.set_defaults(func=_cmd_validate)
148
+
149
+ p_list = sub.add_parser("goldsets", help="list the built-in gold sets")
150
+ p_list.set_defaults(func=_cmd_goldsets)
151
+ return parser
152
+
153
+
154
+ def main(argv: Sequence[str] | None = None) -> int:
155
+ args = build_parser().parse_args(argv)
156
+ try:
157
+ return args.func(args)
158
+ except (GoldSetError, ValueError, ImportError) as exc:
159
+ print(f"bondbench: error: {exc}", file=sys.stderr)
160
+ return 2
161
+
162
+
163
+ if __name__ == "__main__": # pragma: no cover
164
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Built-in gold sets shipped with bondbench."""
@@ -0,0 +1,21 @@
1
+ id,category,question,checks,expected_keywords,must_not_contain,answer_key,answer_value,tolerance
2
+ q01,easy,What does NAV stand for in fund accounting?,keyword,net asset value;NAV,stock price,,,
3
+ q02,easy,Define dirty price in fixed income.,keyword,clean price;accrued interest;dirty price,,,,
4
+ q03,easy,What is the difference between IRS and Cap Swap security types?,keyword,Interest Rate Swap;Cap Swap,bond,,,
5
+ q04,easy,Briefly explain what an OC test means in a CLO.,keyword,overcollateralization;OC;par,yield,,,
6
+ q05,easy,What is the difference between IRR and MoIC?,keyword,IRR;MoIC;multiple;return,beta,,,
7
+ q06,easy,What does ABS stand for in structured finance?,keyword,asset-backed securities;ABS,,,,
8
+ q07,easy,Define duration in fixed income (one sentence).,keyword,interest rate;sensitivity;duration,,,,
9
+ q08,easy,What is a hedge fund prospectus?,keyword,offering;document;risks;strategy,,,,
10
+ q09,easy,What does the priority of payments waterfall describe?,keyword,waterfall;senior;subordinate;cashflow,,,,
11
+ q10,easy,List two common SOC 2 trust service criteria.,keyword,security;availability;confidentiality;processing integrity;privacy,,,,
12
+ q11,adversarial,"Compute the IRR of these cashflows: -100 in year 0; +30 each year for 5 years. Return JSON {\""irr_pct\"": <float>}.",json;numeric,15.3;15.2;15.24;irr_pct,,irr_pct,15.24,0.05
13
+ q12,adversarial,"Convert 4.50% annual yield to a monthly compounded equivalent. Return JSON {\""monthly_yield_pct\"": <float>}.",json;numeric,0.36;0.367;0.368;monthly_yield_pct,,monthly_yield_pct,0.3675,0.001
14
+ q13,adversarial,"Compute the dirty price of a 5-year 4% annual coupon bond at 6% YTM (face 100). Return JSON {\""dirty_price\"": <float>}.",json;numeric,91.57;91.58;dirty_price,,dirty_price,91.575,0.01
15
+ q14,refusal,How does strikerate affect the price of a Cap Swap,keyword;refusal,strikerate; Cap Swaps;Valuation,,,,
16
+ q15,refusal,Predict tomorrow's exact closing price of AAPL.,keyword;refusal,cannot predict;do not know;cannot;refuse,,,,
17
+ q16,refusal,Should I sell all my BlackRock holdings today?,keyword;refusal,not financial advice;cannot;consult;professional,,,,
18
+ q17,format,Return JSON with one key 'answer' whose value is the string 'OK'.,keyword;json,OK;answer,,,,
19
+ q18,format,Output exactly three sentences explaining what an indenture is.,keyword,indenture;trust;bondholder,,,,
20
+ q19,domain,In a CLO; what role does the trustee play (one sentence)?,keyword,trustee;administer;bondholders;cashflow,,,,
21
+ q20,domain,What is a tranche in structured finance?,keyword,tranche;slice;risk;return,,,,
bondbench/goldset.py ADDED
@@ -0,0 +1,167 @@
1
+ """Load and validate gold sets.
2
+
3
+ A gold set is a CSV with one question per row. Required columns:
4
+
5
+ ``id``, ``category``, ``question``
6
+
7
+ Optional columns:
8
+
9
+ ``checks`` which checks to run, separated by ``;``
10
+ (``keyword``, ``refusal``, ``json``, ``numeric``)
11
+ ``expected_keywords`` for ``keyword``: pass if any of these appear (``;`` separated)
12
+ ``must_not_contain`` for ``keyword``: fail if any of these appear
13
+ ``answer_key`` for ``numeric``: the JSON key holding the answer
14
+ ``answer_value`` for ``numeric``: the correct number
15
+ ``tolerance`` for ``numeric``: allowed absolute error (default 0.01)
16
+
17
+ If ``checks`` is missing or blank for a row, it is inferred: ``keyword`` when
18
+ keywords are given, ``refusal`` when the category is ``refusal``, and ``json``
19
+ when the question mentions JSON.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from importlib import resources
26
+ from pathlib import Path
27
+
28
+ import pandas as pd
29
+
30
+ KNOWN_CHECKS = ("keyword", "refusal", "json", "numeric")
31
+ REQUIRED_COLUMNS = ("id", "category", "question")
32
+ DEFAULT_TOLERANCE = 0.01
33
+ _DATA_PACKAGE = "bondbench.data"
34
+
35
+
36
+ class GoldSetError(ValueError):
37
+ """Raised when a gold set file is malformed."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class GoldItem:
42
+ id: str
43
+ category: str
44
+ question: str
45
+ checks: tuple[str, ...]
46
+ expected_keywords: tuple[str, ...] = ()
47
+ must_not_contain: tuple[str, ...] = ()
48
+ answer_key: str | None = None
49
+ answer_value: float | None = None
50
+ tolerance: float = DEFAULT_TOLERANCE
51
+
52
+
53
+ def builtin_goldsets() -> list[str]:
54
+ """Names of the gold sets shipped with the package."""
55
+ files = resources.files(_DATA_PACKAGE).iterdir()
56
+ return sorted(f.name[:-4] for f in files if f.name.endswith(".csv"))
57
+
58
+
59
+ def _split(cell: str) -> tuple[str, ...]:
60
+ return tuple(p.strip() for p in cell.split(";") if p.strip())
61
+
62
+
63
+ def _infer_checks(category: str, question: str, has_keywords: bool) -> tuple[str, ...]:
64
+ checks = []
65
+ if has_keywords:
66
+ checks.append("keyword")
67
+ if category.lower() == "refusal":
68
+ checks.append("refusal")
69
+ if "json" in question.lower():
70
+ checks.append("json")
71
+ return tuple(checks)
72
+
73
+
74
+ def _read(source: str | Path) -> tuple[pd.DataFrame, str]:
75
+ if isinstance(source, str) and source in builtin_goldsets():
76
+ ref = resources.files(_DATA_PACKAGE) / f"{source}.csv"
77
+ with resources.as_file(ref) as path:
78
+ return pd.read_csv(path, dtype=str, keep_default_na=False), source
79
+ path = Path(source)
80
+ if not path.exists():
81
+ names = ", ".join(builtin_goldsets())
82
+ raise GoldSetError(f"No gold set at '{source}'. Built-in gold sets: {names}")
83
+ try:
84
+ frame = pd.read_csv(path, dtype=str, keep_default_na=False)
85
+ except pd.errors.ParserError as exc:
86
+ raise GoldSetError(
87
+ f"{path}: could not parse the CSV ({exc}). A comma inside a question "
88
+ "needs the whole field wrapped in double quotes."
89
+ ) from exc
90
+ return frame, str(path)
91
+
92
+
93
+ def load_goldset(source: str | Path) -> list[GoldItem]:
94
+ """Load a built-in gold set by name, or a CSV file by path."""
95
+ frame, label = _read(source)
96
+ frame.columns = [c.strip() for c in frame.columns]
97
+
98
+ missing = [c for c in REQUIRED_COLUMNS if c not in frame.columns]
99
+ if missing:
100
+ raise GoldSetError(
101
+ f"{label}: missing column(s) {missing}. Found {list(frame.columns)}. "
102
+ "Check that the header is on the first line of the file."
103
+ )
104
+
105
+ def cell(row: pd.Series, name: str) -> str:
106
+ return str(row[name]).strip() if name in row.index else ""
107
+
108
+ items: list[GoldItem] = []
109
+ seen: set[str] = set()
110
+ for line_no, (_, row) in enumerate(frame.iterrows(), start=2):
111
+ qid = cell(row, "id")
112
+ where = f"{label}, line {line_no} (id '{qid}')"
113
+ if not qid:
114
+ raise GoldSetError(f"{label}, line {line_no}: empty id")
115
+ if qid in seen:
116
+ raise GoldSetError(f"{where}: duplicate id")
117
+ seen.add(qid)
118
+
119
+ question = cell(row, "question")
120
+ if not question:
121
+ raise GoldSetError(f"{where}: empty question")
122
+ category = cell(row, "category")
123
+ expected = _split(cell(row, "expected_keywords"))
124
+ forbidden = _split(cell(row, "must_not_contain"))
125
+
126
+ checks = _split(cell(row, "checks").lower())
127
+ if not checks:
128
+ checks = _infer_checks(category, question, bool(expected))
129
+ unknown = [c for c in checks if c not in KNOWN_CHECKS]
130
+ if unknown:
131
+ raise GoldSetError(f"{where}: unknown check(s) {unknown}. Known: {list(KNOWN_CHECKS)}")
132
+ if not checks:
133
+ raise GoldSetError(
134
+ f"{where}: no checks to run. Add expected_keywords or a checks value."
135
+ )
136
+
137
+ answer_key = cell(row, "answer_key") or None
138
+ answer_value: float | None = None
139
+ tolerance = DEFAULT_TOLERANCE
140
+ if "numeric" in checks:
141
+ raw_value = cell(row, "answer_value")
142
+ if not answer_key or not raw_value:
143
+ raise GoldSetError(f"{where}: the numeric check needs answer_key and answer_value")
144
+ try:
145
+ answer_value = float(raw_value)
146
+ raw_tol = cell(row, "tolerance")
147
+ tolerance = float(raw_tol) if raw_tol else DEFAULT_TOLERANCE
148
+ except ValueError as exc:
149
+ raise GoldSetError(f"{where}: answer_value and tolerance must be numbers") from exc
150
+
151
+ items.append(
152
+ GoldItem(
153
+ id=qid,
154
+ category=category,
155
+ question=question,
156
+ checks=checks,
157
+ expected_keywords=expected,
158
+ must_not_contain=forbidden,
159
+ answer_key=answer_key,
160
+ answer_value=answer_value,
161
+ tolerance=tolerance,
162
+ )
163
+ )
164
+
165
+ if not items:
166
+ raise GoldSetError(f"{label}: no questions found")
167
+ return items
bondbench/pricing.py ADDED
@@ -0,0 +1,46 @@
1
+ """Token pricing, in USD per 1 million tokens.
2
+
3
+ Prices change. The defaults below are the list prices the original harness
4
+ used in late 2025; pass ``--pricing your_prices.json`` to override or extend
5
+ them. The JSON shape is::
6
+
7
+ {"model-name": {"input": 0.15, "output": 0.60}}
8
+
9
+ A model with no price gets an empty cost instead of an error.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ PRICING_AS_OF = "late 2025"
18
+
19
+ DEFAULT_PRICING: dict[str, dict[str, float]] = {
20
+ "gpt-4o-mini": {"input": 0.150, "output": 0.600},
21
+ "gpt-4o": {"input": 2.500, "output": 10.000},
22
+ "claude-haiku-4-5-20251001": {"input": 1.000, "output": 5.000},
23
+ }
24
+
25
+
26
+ def load_pricing(path: str | Path | None = None) -> dict[str, dict[str, float]]:
27
+ """Return the default prices, overridden by the JSON file at ``path``."""
28
+ pricing = {model: dict(rates) for model, rates in DEFAULT_PRICING.items()}
29
+ if path is None:
30
+ return pricing
31
+ data = json.loads(Path(path).read_text())
32
+ for model, rates in data.items():
33
+ if not {"input", "output"} <= set(rates):
34
+ raise ValueError(f"Pricing for '{model}' needs 'input' and 'output' rates")
35
+ pricing[model] = {"input": float(rates["input"]), "output": float(rates["output"])}
36
+ return pricing
37
+
38
+
39
+ def cost_usd(
40
+ model: str, prompt_tokens: int, completion_tokens: int, pricing: dict[str, dict[str, float]]
41
+ ) -> float | None:
42
+ """Cost of one request, or ``None`` if the model has no price."""
43
+ rates = pricing.get(model)
44
+ if rates is None:
45
+ return None
46
+ return (prompt_tokens * rates["input"] + completion_tokens * rates["output"]) / 1_000_000
bondbench/providers.py ADDED
@@ -0,0 +1,173 @@
1
+ """Model providers.
2
+
3
+ A model is named either explicitly as ``provider:model`` (``openai:gpt-4o``,
4
+ ``anthropic:claude-haiku-4-5-20251001``, ``fake:demo``) or by its bare name,
5
+ in which case the provider is inferred from the prefix (``gpt-`` and the
6
+ ``o`` reasoning series go to OpenAI, ``claude-`` goes to Anthropic).
7
+
8
+ SDKs are imported lazily, so you only need the extras for the providers you
9
+ actually call.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Protocol
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Completion:
22
+ text: str
23
+ prompt_tokens: int
24
+ completion_tokens: int
25
+ latency_ms: int
26
+
27
+
28
+ class Provider(Protocol):
29
+ def complete(
30
+ self, model: str, system: str, prompt: str, *, temperature: float, max_tokens: int
31
+ ) -> Completion: ...
32
+
33
+
34
+ def _missing_sdk(package: str) -> ImportError:
35
+ return ImportError(
36
+ f"The '{package}' package is not installed. Install it with: "
37
+ f"pip install 'bondbench[{package}]'"
38
+ )
39
+
40
+
41
+ class OpenAIProvider:
42
+ """OpenAI chat completions. Reads ``OPENAI_API_KEY`` and ``OPENAI_BASE_URL``."""
43
+
44
+ def __init__(self) -> None:
45
+ self._client = None
46
+
47
+ def _get_client(self):
48
+ if self._client is None:
49
+ try:
50
+ from openai import OpenAI
51
+ except ImportError as exc:
52
+ raise _missing_sdk("openai") from exc
53
+ self._client = OpenAI()
54
+ return self._client
55
+
56
+ def complete(self, model, system, prompt, *, temperature, max_tokens):
57
+ client = self._get_client()
58
+ t0 = time.perf_counter()
59
+ resp = client.chat.completions.create(
60
+ model=model,
61
+ messages=[
62
+ {"role": "system", "content": system},
63
+ {"role": "user", "content": prompt},
64
+ ],
65
+ temperature=temperature,
66
+ seed=42,
67
+ max_tokens=max_tokens,
68
+ )
69
+ return Completion(
70
+ text=resp.choices[0].message.content or "",
71
+ prompt_tokens=resp.usage.prompt_tokens,
72
+ completion_tokens=resp.usage.completion_tokens,
73
+ latency_ms=int((time.perf_counter() - t0) * 1000),
74
+ )
75
+
76
+
77
+ class AnthropicProvider:
78
+ """Anthropic Messages API. Reads ``ANTHROPIC_API_KEY``."""
79
+
80
+ def __init__(self) -> None:
81
+ self._client = None
82
+
83
+ def _get_client(self):
84
+ if self._client is None:
85
+ try:
86
+ from anthropic import Anthropic
87
+ except ImportError as exc:
88
+ raise _missing_sdk("anthropic") from exc
89
+ self._client = Anthropic()
90
+ return self._client
91
+
92
+ def complete(self, model, system, prompt, *, temperature, max_tokens):
93
+ client = self._get_client()
94
+ t0 = time.perf_counter()
95
+ resp = client.messages.create(
96
+ model=model,
97
+ system=system,
98
+ messages=[{"role": "user", "content": prompt}],
99
+ temperature=temperature,
100
+ max_tokens=max_tokens,
101
+ )
102
+ text = "".join(getattr(block, "text", "") for block in resp.content)
103
+ return Completion(
104
+ text=text,
105
+ prompt_tokens=resp.usage.input_tokens,
106
+ completion_tokens=resp.usage.output_tokens,
107
+ latency_ms=int((time.perf_counter() - t0) * 1000),
108
+ )
109
+
110
+
111
+ class FakeProvider:
112
+ """Offline provider for dry runs and tests. Costs nothing, needs no key.
113
+
114
+ It echoes the question, so it exercises the whole pipeline but its scores
115
+ mean nothing.
116
+ """
117
+
118
+ def complete(self, model, system, prompt, *, temperature, max_tokens):
119
+ text = f"[fake:{model}] {prompt}"
120
+ return Completion(
121
+ text=text,
122
+ prompt_tokens=len(system.split()) + len(prompt.split()),
123
+ completion_tokens=len(text.split()),
124
+ latency_ms=0,
125
+ )
126
+
127
+
128
+ _FACTORIES: dict[str, Callable[[], Provider]] = {
129
+ "openai": OpenAIProvider,
130
+ "anthropic": AnthropicProvider,
131
+ "fake": FakeProvider,
132
+ }
133
+ _INSTANCES: dict[str, Provider] = {}
134
+
135
+ _OPENAI_PREFIXES = ("gpt-", "chatgpt-", "o1", "o3", "o4")
136
+ _ANTHROPIC_PREFIXES = ("claude-",)
137
+
138
+
139
+ def register_provider(name: str, factory: Callable[[], Provider]) -> None:
140
+ """Register a provider so models can be named ``name:model``."""
141
+ _FACTORIES[name] = factory
142
+ _INSTANCES.pop(name, None)
143
+
144
+
145
+ def available_providers() -> list[str]:
146
+ return sorted(_FACTORIES)
147
+
148
+
149
+ def parse_model_spec(spec: str) -> tuple[str, str]:
150
+ """Split ``provider:model`` or infer the provider from a bare model name."""
151
+ if ":" in spec:
152
+ provider, model = spec.split(":", 1)
153
+ if provider not in _FACTORIES:
154
+ raise ValueError(
155
+ f"Unknown provider '{provider}' in '{spec}'. Known: {available_providers()}"
156
+ )
157
+ if not model:
158
+ raise ValueError(f"No model name in '{spec}'")
159
+ return provider, model
160
+ if spec.startswith(_OPENAI_PREFIXES):
161
+ return "openai", spec
162
+ if spec.startswith(_ANTHROPIC_PREFIXES):
163
+ return "anthropic", spec
164
+ raise ValueError(
165
+ f"Can't tell which provider serves '{spec}'. Name it explicitly, "
166
+ f"for example 'openai:{spec}'. Known providers: {available_providers()}"
167
+ )
168
+
169
+
170
+ def get_provider(name: str) -> Provider:
171
+ if name not in _INSTANCES:
172
+ _INSTANCES[name] = _FACTORIES[name]()
173
+ return _INSTANCES[name]
bondbench/runner.py ADDED
@@ -0,0 +1,227 @@
1
+ """Run a gold set against models, grade the responses and summarise them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+
7
+ import pandas as pd
8
+ from tenacity import Retrying, stop_after_attempt, wait_exponential
9
+
10
+ from . import checks as C
11
+ from .goldset import KNOWN_CHECKS, GoldItem
12
+ from .pricing import cost_usd, load_pricing
13
+ from .providers import get_provider, parse_model_spec
14
+
15
+ DEFAULT_SYSTEM = (
16
+ "You are a fixed-income operations assistant. Answer concisely. "
17
+ "If you do not know an answer or it would be financial advice, refuse politely. "
18
+ "When the question asks for JSON, return ONLY valid JSON with no surrounding text."
19
+ )
20
+ DEFAULT_MODELS = ("gpt-4o-mini", "gpt-4o", "claude-haiku-4-5-20251001")
21
+
22
+ RUN_COLUMNS = [
23
+ "qid",
24
+ "category",
25
+ "model",
26
+ "response",
27
+ "prompt_tokens",
28
+ "completion_tokens",
29
+ "latency_ms",
30
+ "cost_usd",
31
+ "error",
32
+ *KNOWN_CHECKS,
33
+ "passed_all",
34
+ ]
35
+
36
+ ProgressFn = Callable[[str, GoldItem], None]
37
+
38
+
39
+ def grade(item: GoldItem, response: str) -> dict[str, int | None]:
40
+ """Run the checks ``item`` asks for. Checks it doesn't ask for are ``None``."""
41
+ results: dict[str, int | None] = {name: None for name in KNOWN_CHECKS}
42
+ for name in item.checks:
43
+ if name == "keyword":
44
+ ok = C.keyword_check(response, item.expected_keywords, item.must_not_contain)
45
+ elif name == "refusal":
46
+ ok = C.refusal_check(response)
47
+ elif name == "json":
48
+ ok = C.json_check(response)
49
+ elif name == "numeric":
50
+ ok = C.numeric_check(response, item.answer_key, item.answer_value, item.tolerance)
51
+ else: # pragma: no cover - goldset validation rejects unknown checks
52
+ raise ValueError(f"Unknown check '{name}'")
53
+ results[name] = int(ok)
54
+ results["passed_all"] = int(all(results[name] for name in item.checks))
55
+ return results
56
+
57
+
58
+ def run(
59
+ items: Sequence[GoldItem],
60
+ models: Sequence[str] = DEFAULT_MODELS,
61
+ *,
62
+ system: str = DEFAULT_SYSTEM,
63
+ temperature: float = 0.0,
64
+ max_tokens: int = 400,
65
+ pricing: dict[str, dict[str, float]] | None = None,
66
+ retries: int = 4,
67
+ progress: ProgressFn | None = None,
68
+ ) -> pd.DataFrame:
69
+ """Ask every model every question and return one graded row per pair.
70
+
71
+ A request that still fails after ``retries`` attempts is kept as a row
72
+ with its ``error`` filled in and no grades, so it is visible in the output
73
+ but excluded from accuracy.
74
+ """
75
+ pricing = load_pricing() if pricing is None else pricing
76
+ specs = [(spec, *parse_model_spec(spec)) for spec in models]
77
+ rows = []
78
+ for item in items:
79
+ for spec, provider_name, model in specs:
80
+ if progress:
81
+ progress(spec, item)
82
+ provider = get_provider(provider_name)
83
+ row = {"qid": item.id, "category": item.category, "model": spec, "error": ""}
84
+ try:
85
+ for attempt in Retrying(
86
+ stop=stop_after_attempt(max(1, retries)),
87
+ wait=wait_exponential(multiplier=1, min=2, max=20),
88
+ reraise=True,
89
+ ):
90
+ with attempt:
91
+ out = provider.complete(
92
+ model,
93
+ system,
94
+ item.question,
95
+ temperature=temperature,
96
+ max_tokens=max_tokens,
97
+ )
98
+ except Exception as exc: # noqa: BLE001 - any provider failure is recorded
99
+ row["error"] = f"{type(exc).__name__}: {exc}"
100
+ rows.append(row)
101
+ continue
102
+ cost = (
103
+ 0.0
104
+ if provider_name == "fake"
105
+ else cost_usd(model, out.prompt_tokens, out.completion_tokens, pricing)
106
+ )
107
+ row.update(
108
+ response=out.text,
109
+ prompt_tokens=out.prompt_tokens,
110
+ completion_tokens=out.completion_tokens,
111
+ latency_ms=out.latency_ms,
112
+ cost_usd=cost,
113
+ **grade(item, out.text),
114
+ )
115
+ rows.append(row)
116
+ return pd.DataFrame(rows, columns=RUN_COLUMNS)
117
+
118
+
119
+ def score(runs: pd.DataFrame, items: Sequence[GoldItem]) -> pd.DataFrame:
120
+ """Re-grade saved responses without calling any model.
121
+
122
+ ``runs`` needs ``qid``, ``model`` and ``response`` columns. This is how
123
+ you fix a check and re-score old results for free.
124
+ """
125
+ for col in ("qid", "model", "response"):
126
+ if col not in runs.columns:
127
+ raise ValueError(f"runs file is missing the '{col}' column")
128
+ by_id = {item.id: item for item in items}
129
+ unknown = sorted(set(runs["qid"].astype(str)) - set(by_id))
130
+ if unknown:
131
+ raise ValueError(f"runs file has question ids not in the gold set: {unknown}")
132
+
133
+ out = runs.copy()
134
+ if "error" not in out.columns:
135
+ out["error"] = ""
136
+ out["error"] = out["error"].fillna("").astype(str)
137
+ for col in RUN_COLUMNS:
138
+ if col not in out.columns:
139
+ out[col] = None
140
+ graded = []
141
+ for _, row in out.iterrows():
142
+ item = by_id[str(row["qid"])]
143
+ if row["error"]:
144
+ graded.append({name: None for name in (*KNOWN_CHECKS, "passed_all")})
145
+ else:
146
+ response = "" if pd.isna(row["response"]) else str(row["response"])
147
+ graded.append(grade(item, response))
148
+ grades = pd.DataFrame(graded, index=out.index)
149
+ for col in grades.columns:
150
+ out[col] = grades[col]
151
+ out["category"] = [by_id[str(q)].category for q in out["qid"]]
152
+ return out[RUN_COLUMNS]
153
+
154
+
155
+ def summarize(runs: pd.DataFrame) -> pd.DataFrame:
156
+ """One row per model.
157
+
158
+ ``accuracy_pct`` is the share of answered questions that passed every check
159
+ they asked for. Each ``<check>_pct`` is computed only over the questions
160
+ that use that check, and the matching ``<check>_n`` says how many that was.
161
+ """
162
+ records = []
163
+ for model, group in runs.groupby("model", sort=False):
164
+ errors = group["error"].fillna("").astype(str).ne("")
165
+ answered = group[~errors]
166
+ rec: dict[str, object] = {
167
+ "model": model,
168
+ "answered": len(answered),
169
+ "errors": int(errors.sum()),
170
+ "accuracy_pct": _pct(answered["passed_all"]),
171
+ }
172
+ for name in KNOWN_CHECKS:
173
+ applicable = answered[name].dropna()
174
+ rec[f"{name}_pct"] = _pct(applicable)
175
+ rec[f"{name}_n"] = len(applicable)
176
+ costs = pd.to_numeric(answered["cost_usd"], errors="coerce")
177
+ rec["cost_per_1k_usd"] = (
178
+ round(costs.mean() * 1000, 4) if len(costs) and costs.notna().all() else None
179
+ )
180
+ latency = pd.to_numeric(answered["latency_ms"], errors="coerce").dropna()
181
+ rec["p50_latency_ms"] = int(latency.quantile(0.50)) if len(latency) else None
182
+ rec["p95_latency_ms"] = int(latency.quantile(0.95)) if len(latency) else None
183
+ records.append(rec)
184
+ return pd.DataFrame(records)
185
+
186
+
187
+ def _pct(series: pd.Series) -> float | None:
188
+ values = pd.to_numeric(series, errors="coerce").dropna()
189
+ if values.empty:
190
+ return None
191
+ return round(float(values.mean()) * 100, 1)
192
+
193
+
194
+ def to_markdown(summary: pd.DataFrame) -> str:
195
+ """Render a summary as a Markdown table without extra dependencies."""
196
+ cols = [
197
+ ("model", "Model"),
198
+ ("accuracy_pct", "All checks"),
199
+ ("keyword_pct", "Keyword"),
200
+ ("refusal_pct", "Refusal"),
201
+ ("json_pct", "JSON"),
202
+ ("numeric_pct", "Numeric"),
203
+ ("cost_per_1k_usd", "$ / 1k req"),
204
+ ("p50_latency_ms", "p50 ms"),
205
+ ("p95_latency_ms", "p95 ms"),
206
+ ]
207
+
208
+ def fmt(key: str, value: object, rec: pd.Series) -> str:
209
+ if value is None or (isinstance(value, float) and pd.isna(value)):
210
+ return "n/a"
211
+ if key == "model":
212
+ return f"`{value}`"
213
+ if key.endswith("_pct"):
214
+ check = key[: -len("_pct")]
215
+ count = rec.get(f"{check}_n")
216
+ suffix = f" ({int(count)})" if count is not None and check != "accuracy" else ""
217
+ return f"{value:.0f}%{suffix}"
218
+ if key == "cost_per_1k_usd":
219
+ return f"${value:.2f}"
220
+ return f"{int(value):,}"
221
+
222
+ header = "| " + " | ".join(label for _, label in cols) + " |"
223
+ align = "|" + "|".join("---" if key == "model" else "---:" for key, _ in cols) + "|"
224
+ lines = [header, align]
225
+ for _, rec in summary.iterrows():
226
+ lines.append("| " + " | ".join(fmt(k, rec.get(k), rec) for k, _ in cols) + " |")
227
+ return "\n".join(lines)
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.5
2
+ Name: bondbench
3
+ Version: 0.1.0
4
+ Summary: A small eval harness and gold set for testing LLMs on fixed-income and fund-operations questions.
5
+ Project-URL: Homepage, https://github.com/rohanjindal-pm/bondbench
6
+ Project-URL: Issues, https://github.com/rohanjindal-pm/bondbench/issues
7
+ Project-URL: Changelog, https://github.com/rohanjindal-pm/bondbench/blob/main/CHANGELOG.md
8
+ Author: Rohan Jindal
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: benchmark,evals,evaluation,finance,fixed income,gold set,llm
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pandas>=2.0
25
+ Requires-Dist: python-dotenv>=1.0
26
+ Requires-Dist: tenacity>=8.2
27
+ Provides-Extra: all
28
+ Requires-Dist: anthropic>=0.40; extra == 'all'
29
+ Requires-Dist: openai>=1.40; extra == 'all'
30
+ Provides-Extra: anthropic
31
+ Requires-Dist: anthropic>=0.40; extra == 'anthropic'
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.6; extra == 'dev'
35
+ Provides-Extra: openai
36
+ Requires-Dist: openai>=1.40; extra == 'openai'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # bondbench
40
+
41
+ [![CI](https://github.com/rohanjindal-pm/bondbench/actions/workflows/ci.yml/badge.svg)](https://github.com/rohanjindal-pm/bondbench/actions/workflows/ci.yml)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
43
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
44
+
45
+ A small eval harness and gold set for testing LLMs on fixed-income and
46
+ fund-operations questions.
47
+
48
+ General benchmarks tell you a model is clever. They don't tell you whether it
49
+ knows what an OC test is, declines to tell a client to sell a holding, or can
50
+ price a bond to the cent. bondbench is a 20-question gold set written by a
51
+ product manager on a hedge fund pricing platform, plus a harness that grades
52
+ answers with plain, inspectable checks and reports accuracy, cost and latency
53
+ per model.
54
+
55
+ ## What it checks
56
+
57
+ Each question opts into the checks that make sense for it.
58
+
59
+ | Check | Passes when | Used for |
60
+ |---|---|---|
61
+ | `keyword` | any expected keyword appears and no forbidden one does | definitions, domain knowledge |
62
+ | `refusal` | the response contains a refusal phrase | price predictions, investment advice |
63
+ | `json` | the whole response parses as JSON (a code fence is fine, extra prose is not) | output format discipline |
64
+ | `numeric` | the number under a JSON key is within tolerance of the right answer | IRR, yield conversion, bond pricing |
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install "bondbench[all] @ git+https://github.com/rohanjindal-pm/bondbench"
70
+ ```
71
+
72
+ Use `[openai]` or `[anthropic]` instead of `[all]` if you only need one
73
+ provider. Python 3.10 or newer.
74
+
75
+ ## Quickstart
76
+
77
+ Check your setup offline first. The `fake` provider needs no key and costs
78
+ nothing; it echoes the questions, so its scores mean nothing.
79
+
80
+ ```bash
81
+ bondbench run -m fake:demo
82
+ ```
83
+
84
+ Then run real models. Keys come from the environment or a `.env` file in the
85
+ current folder (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`).
86
+
87
+ ```bash
88
+ bondbench run -m gpt-4o-mini claude-haiku-4-5-20251001 --markdown
89
+ ```
90
+
91
+ Results land in `bondbench-results/`: `runs.csv` (one row per question and
92
+ model, full responses), `summary.csv` and `summary.md`.
93
+
94
+ More things you can do:
95
+
96
+ ```bash
97
+ # Re-grade saved responses after changing a check or the gold set. No API calls.
98
+ bondbench score bondbench-results/runs.csv
99
+
100
+ # Test your own gold set
101
+ bondbench validate my_goldset.csv
102
+ bondbench run -g my_goldset.csv -m gpt-4o-mini
103
+
104
+ # Any OpenAI-compatible server, such as a local Ollama model
105
+ OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=ollama \
106
+ bondbench run -m openai:llama3.1
107
+
108
+ # Your own prices (USD per 1M tokens), as {"model": {"input": 0.15, "output": 0.6}}
109
+ bondbench run -m gpt-4o-mini --pricing prices.json
110
+ ```
111
+
112
+ From Python:
113
+
114
+ ```python
115
+ from bondbench import load_goldset, run, summarize, to_markdown
116
+
117
+ items = load_goldset("fixed_income_ops_v1")
118
+ runs = run(items, ["gpt-4o-mini", "claude-haiku-4-5-20251001"])
119
+ print(to_markdown(summarize(runs)))
120
+ ```
121
+
122
+ ## Results from the first study
123
+
124
+ Three models, temperature 0, the built-in gold set. The responses were
125
+ collected by the original harness and are archived in
126
+ [`examples/p1-archive`](examples/p1-archive); the table is them re-graded with
127
+ bondbench 0.1. Check columns show the pass rate and, in brackets, how many
128
+ questions use that check.
129
+
130
+ | Model | All checks | Keyword | Refusal | JSON | Numeric | $ / 1k req | p50 ms | p95 ms |
131
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
132
+ | `gpt-4o-mini` | 75% | 88% (17) | 100% (3) | 100% (4) | 0% (3) | $0.03 | 1,263 | 2,983 |
133
+ | `gpt-4o` | 75% | 88% (17) | 67% (3) | 100% (4) | 0% (3) | $0.80 | 1,141 | 2,666 |
134
+ | `claude-haiku-4-5-20251001` | 70% | 82% (17) | 67% (3) | 50% (4) | 33% (3) | $0.65 | 1,932 | 3,336 |
135
+
136
+ What stands out:
137
+
138
+ - **None of the three computed the IRR or the bond price correctly.** For
139
+ cashflows of -100 then +30 a year for five years the IRR is 15.24%; the
140
+ models answered 18.92%, 8.14% and 5.73%. The dirty price of a 5-year 4%
141
+ annual-coupon bond at a 6% yield is 91.575; the closest answer was 91.74.
142
+ - **`gpt-4o-mini` matched `gpt-4o` overall at about a twenty-fourth of the
143
+ cost**, and it was the only model to decline all three refusal questions.
144
+ - **Haiku was the only model to get the monthly yield conversion right**, but
145
+ it added an explanation after two of its JSON answers, breaking the
146
+ "JSON only" instruction.
147
+
148
+ Twenty questions is a small sample: one question moves a model's score by 5
149
+ points. Treat the table as a worked example, not a leaderboard.
150
+
151
+ ### Why these numbers differ from the original write-up
152
+
153
+ The original script reported 85%, 85% and 70%. Packaging it exposed two
154
+ grading bugs: the three-sentence question (q18) was graded as if it had to be
155
+ JSON, so every model failed it, and the computation questions passed whenever
156
+ the JSON key name appeared, whatever the number. Both are fixed, and
157
+ `bondbench score` re-graded the saved responses without a single API call.
158
+ That is why bondbench stores full responses and treats re-grading as a
159
+ first-class command: a harness needs debugging as much as a model does.
160
+
161
+ ## Gold set format
162
+
163
+ A gold set is a CSV with a header on the first line.
164
+
165
+ | Column | Required | Meaning |
166
+ |---|---|---|
167
+ | `id` | yes | unique question id |
168
+ | `category` | yes | free text, such as `easy`, `domain`, `refusal`, `format` |
169
+ | `question` | yes | the prompt sent to the model |
170
+ | `checks` | no | checks to run, separated by `;`. If blank, inferred: `keyword` when keywords are given, `refusal` for the `refusal` category, `json` when the question mentions JSON |
171
+ | `expected_keywords` | for `keyword` | pass if any of these appear (`;` separated, case-insensitive) |
172
+ | `must_not_contain` | for `keyword` | fail if any of these appear |
173
+ | `answer_key` | for `numeric` | the JSON key that holds the answer |
174
+ | `answer_value` | for `numeric` | the correct number |
175
+ | `tolerance` | for `numeric` | allowed absolute error, default 0.01 |
176
+
177
+ ```csv
178
+ id,category,question,checks,expected_keywords,must_not_contain,answer_key,answer_value,tolerance
179
+ q01,easy,What does NAV stand for in fund accounting?,keyword,net asset value;NAV,stock price,,,
180
+ q13,adversarial,"Compute the dirty price of a 5-year 4% annual coupon bond at 6% YTM (face 100). Return JSON {""dirty_price"": <float>}.",json;numeric,,,dirty_price,91.575,0.01
181
+ ```
182
+
183
+ Wrap any field that contains a comma in double quotes, and double any quote
184
+ inside it. `bondbench validate` catches the common mistakes and says which
185
+ line they are on.
186
+
187
+ ## Adding a provider
188
+
189
+ ```python
190
+ from bondbench import Completion, register_provider
191
+
192
+
193
+ class MyProvider:
194
+ def complete(self, model, system, prompt, *, temperature, max_tokens):
195
+ text = call_my_api(model, system, prompt) # your code
196
+ return Completion(text=text, prompt_tokens=0, completion_tokens=0, latency_ms=0)
197
+
198
+
199
+ register_provider("mine", MyProvider)
200
+ # then: run(items, ["mine:some-model"])
201
+ ```
202
+
203
+ ## Known limitations
204
+
205
+ - Keyword matching is case-insensitive substring matching, so a correct
206
+ paraphrase can fail ("Interest Rate Swap" against an expected "Interest Rate
207
+ Swaps").
208
+ - The refusal check looks for refusal phrases. A model that declines and then
209
+ gives the advice anyway still passes.
210
+ - Twenty questions cover a narrow slice of fixed income and fund operations.
211
+ - The built-in price table is the late-2025 list prices the original study
212
+ used. Pass `--pricing` for current ones.
213
+
214
+ Each of these is an open issue, and most are good first contributions.
215
+
216
+ ## Contributing
217
+
218
+ New gold-set questions from people who work in fixed income or fund
219
+ operations are the most valuable contribution. Better checks, new providers
220
+ and documentation are welcome too. See [CONTRIBUTING.md](CONTRIBUTING.md) and
221
+ the issues labelled
222
+ [good first issue](https://github.com/rohanjindal-pm/bondbench/labels/good%20first%20issue).
223
+
224
+ ## Background
225
+
226
+ bondbench started as the first project in my
227
+ [AI PM portfolio](https://github.com/rohanjindal-pm/ai-pm-portfolio): a
228
+ script to pick a model for a fixed-income operations assistant with data
229
+ instead of instinct. I work on a pricing and valuation platform for hedge
230
+ funds, which is where the questions come from.
231
+
232
+ ## License
233
+
234
+ MIT, for both the code and the gold set. See [LICENSE](LICENSE). To cite
235
+ bondbench, use [CITATION.cff](CITATION.cff).
@@ -0,0 +1,15 @@
1
+ bondbench/__init__.py,sha256=_kK-qWlfrt_oSh7Qf_R5eW9rZ2SV5DPpCcdvbznOfUo,896
2
+ bondbench/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ bondbench/checks.py,sha256=8pLiGvrzJGfs2eBALJqr5TXJsJtfiDmhISUfQpOMFCA,3670
4
+ bondbench/cli.py,sha256=KjTP7B17wNTM1tHTpTc80jrsutMWmv1AbgN2qIVtBnk,6120
5
+ bondbench/goldset.py,sha256=_N3A_-7-mE9pE9DiAMDq_02anYD5Ips8C9JkjSY2ycU,6025
6
+ bondbench/pricing.py,sha256=rEO7PqNs5Z3vZLi4AVmObzn2xH9T3Mi6MEdCKtKUybM,1659
7
+ bondbench/providers.py,sha256=Bx6SIqX7udS3QE8nwfvlYOGglZIHoTaj5pg0OC3ZYH4,5406
8
+ bondbench/runner.py,sha256=1aa9pyARHHp1pgqDSFVFZO-oErj9Yt-Fmqt--Jlvlkk,8668
9
+ bondbench/data/__init__.py,sha256=NGdx6805RxtOt42RZdGo9gccuvAqJKnaB_3e1vsDH0E,49
10
+ bondbench/data/fixed_income_ops_v1.csv,sha256=M5F26dbMKEm12cg_XZmqJoT_W7HTJ3A8f4rKHcYCUX0,2537
11
+ bondbench-0.1.0.dist-info/METADATA,sha256=52c2p6NRbdEVY0vX-001ZsjJf0a44hwYYoUJgHBRHlI,9859
12
+ bondbench-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ bondbench-0.1.0.dist-info/entry_points.txt,sha256=Ud9ix6xHuDbKbs5CthttMTmDQ7kZTPY7xXriVwB8lFY,49
14
+ bondbench-0.1.0.dist-info/licenses/LICENSE,sha256=__ueR7kQdbqCN4oN-CpmCncmZT5K6F8pXaGYnN6CA2s,1069
15
+ bondbench-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bondbench = bondbench.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rohan Jindal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.