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 +32 -0
- bondbench/__main__.py +3 -0
- bondbench/checks.py +118 -0
- bondbench/cli.py +164 -0
- bondbench/data/__init__.py +1 -0
- bondbench/data/fixed_income_ops_v1.csv +21 -0
- bondbench/goldset.py +167 -0
- bondbench/pricing.py +46 -0
- bondbench/providers.py +173 -0
- bondbench/runner.py +227 -0
- bondbench-0.1.0.dist-info/METADATA +235 -0
- bondbench-0.1.0.dist-info/RECORD +15 -0
- bondbench-0.1.0.dist-info/WHEEL +4 -0
- bondbench-0.1.0.dist-info/entry_points.txt +2 -0
- bondbench-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
[](https://github.com/rohanjindal-pm/bondbench/actions/workflows/ci.yml)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+

|
|
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,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.
|