hmft 0.2.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.
- hmft/__init__.py +15 -0
- hmft/data/throughput.json +272 -0
- hmft/estimate.py +113 -0
- hmft/length.py +301 -0
- hmft/scoring.py +74 -0
- hmft/stream.py +181 -0
- hmft/telemetry.py +164 -0
- hmft/throughput.py +136 -0
- hmft/tokens.py +88 -0
- hmft/wrap.py +451 -0
- hmft-0.2.0.dist-info/METADATA +252 -0
- hmft-0.2.0.dist-info/RECORD +15 -0
- hmft-0.2.0.dist-info/WHEEL +5 -0
- hmft-0.2.0.dist-info/licenses/LICENSE +201 -0
- hmft-0.2.0.dist-info/top_level.txt +1 -0
hmft/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""hmft: how much time will this LLM call take?"""
|
|
2
|
+
__version__ = "0.2.0"
|
|
3
|
+
|
|
4
|
+
from .estimate import Estimate, estimate # noqa: E402
|
|
5
|
+
from .length import Band, HeuristicLengthEstimator, LengthEstimator, LengthOptions # noqa: E402
|
|
6
|
+
from .stream import ETA, LiveETA # noqa: E402
|
|
7
|
+
from .throughput import ThroughputEntry, ThroughputTable, lookup # noqa: E402
|
|
8
|
+
from .wrap import Actual, CallResult, TrackedStream, wrap_anthropic, wrap_openai # noqa: E402
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"estimate", "Estimate", "Band", "ETA", "LiveETA",
|
|
12
|
+
"HeuristicLengthEstimator", "LengthEstimator", "LengthOptions",
|
|
13
|
+
"ThroughputEntry", "ThroughputTable", "lookup", "__version__",
|
|
14
|
+
"wrap_openai", "wrap_anthropic", "Actual", "CallResult", "TrackedStream",
|
|
15
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"updated_at": "2026-09-16T17:58:00Z",
|
|
4
|
+
"entries": {
|
|
5
|
+
"openai/_default": {
|
|
6
|
+
"provider": "openai",
|
|
7
|
+
"model": "_default",
|
|
8
|
+
"measured": true,
|
|
9
|
+
"samples": 20,
|
|
10
|
+
"measured_at": "2026-09-16T07:26:07Z",
|
|
11
|
+
"ttft_s": {
|
|
12
|
+
"p50": 0.725,
|
|
13
|
+
"p90": 2.229
|
|
14
|
+
},
|
|
15
|
+
"tokens_per_s": {
|
|
16
|
+
"p50": 125.2,
|
|
17
|
+
"p10": 90.5
|
|
18
|
+
},
|
|
19
|
+
"ttft_per_1k_prompt_tokens_s": 0.0,
|
|
20
|
+
"reasoning_tokens_p50": null,
|
|
21
|
+
"source": "scripts/benchmark.py (aggregate of measured models)",
|
|
22
|
+
"notes": "provider fallback: median p50 / worst slow side across gpt-4.1, gpt-4.1-mini"
|
|
23
|
+
},
|
|
24
|
+
"openai/gpt-4.1-mini": {
|
|
25
|
+
"provider": "openai",
|
|
26
|
+
"model": "gpt-4.1-mini",
|
|
27
|
+
"measured": true,
|
|
28
|
+
"samples": 10,
|
|
29
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
30
|
+
"ttft_s": {
|
|
31
|
+
"p50": 0.69,
|
|
32
|
+
"p90": 1.622
|
|
33
|
+
},
|
|
34
|
+
"tokens_per_s": {
|
|
35
|
+
"p50": 139.2,
|
|
36
|
+
"p10": 119.1
|
|
37
|
+
},
|
|
38
|
+
"ttft_per_1k_prompt_tokens_s": 0.0,
|
|
39
|
+
"reasoning_tokens_p50": null,
|
|
40
|
+
"source": "scripts/benchmark.py",
|
|
41
|
+
"notes": ""
|
|
42
|
+
},
|
|
43
|
+
"openai/gpt-4.1": {
|
|
44
|
+
"provider": "openai",
|
|
45
|
+
"model": "gpt-4.1",
|
|
46
|
+
"measured": true,
|
|
47
|
+
"samples": 10,
|
|
48
|
+
"measured_at": "2026-09-16T07:26:07Z",
|
|
49
|
+
"ttft_s": {
|
|
50
|
+
"p50": 0.76,
|
|
51
|
+
"p90": 2.229
|
|
52
|
+
},
|
|
53
|
+
"tokens_per_s": {
|
|
54
|
+
"p50": 111.3,
|
|
55
|
+
"p10": 90.5
|
|
56
|
+
},
|
|
57
|
+
"ttft_per_1k_prompt_tokens_s": 0.0,
|
|
58
|
+
"reasoning_tokens_p50": null,
|
|
59
|
+
"source": "scripts/benchmark.py",
|
|
60
|
+
"notes": "slope from median method (raw samples of this run not re-processed)"
|
|
61
|
+
},
|
|
62
|
+
"openai/gpt-5-mini": {
|
|
63
|
+
"provider": "openai",
|
|
64
|
+
"model": "gpt-5-mini",
|
|
65
|
+
"measured": true,
|
|
66
|
+
"samples": 10,
|
|
67
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
68
|
+
"ttft_s": {
|
|
69
|
+
"p50": 22.94,
|
|
70
|
+
"p90": 39.774
|
|
71
|
+
},
|
|
72
|
+
"tokens_per_s": {
|
|
73
|
+
"p50": 75.2,
|
|
74
|
+
"p10": 67.1
|
|
75
|
+
},
|
|
76
|
+
"ttft_per_1k_prompt_tokens_s": 1.9401,
|
|
77
|
+
"reasoning_tokens_p50": 1824,
|
|
78
|
+
"source": "scripts/benchmark.py",
|
|
79
|
+
"notes": "reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
|
|
80
|
+
},
|
|
81
|
+
"openai/gpt-5@low": {
|
|
82
|
+
"provider": "openai",
|
|
83
|
+
"model": "gpt-5@low",
|
|
84
|
+
"measured": true,
|
|
85
|
+
"samples": 10,
|
|
86
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
87
|
+
"ttft_s": {
|
|
88
|
+
"p50": 9.5,
|
|
89
|
+
"p90": 17.52
|
|
90
|
+
},
|
|
91
|
+
"tokens_per_s": {
|
|
92
|
+
"p50": 123.0,
|
|
93
|
+
"p10": 69.4
|
|
94
|
+
},
|
|
95
|
+
"ttft_per_1k_prompt_tokens_s": 5.9433,
|
|
96
|
+
"reasoning_tokens_p50": 1312,
|
|
97
|
+
"source": "scripts/benchmark.py",
|
|
98
|
+
"notes": "reasoning model at reasoning_effort=low; reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
|
|
99
|
+
},
|
|
100
|
+
"openai/gpt-5@medium": {
|
|
101
|
+
"provider": "openai",
|
|
102
|
+
"model": "gpt-5@medium",
|
|
103
|
+
"measured": true,
|
|
104
|
+
"samples": 10,
|
|
105
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
106
|
+
"ttft_s": {
|
|
107
|
+
"p50": 24.51,
|
|
108
|
+
"p90": 26.682
|
|
109
|
+
},
|
|
110
|
+
"tokens_per_s": {
|
|
111
|
+
"p50": 169.1,
|
|
112
|
+
"p10": 149.6
|
|
113
|
+
},
|
|
114
|
+
"ttft_per_1k_prompt_tokens_s": 0.0,
|
|
115
|
+
"reasoning_tokens_p50": 1760,
|
|
116
|
+
"source": "scripts/benchmark.py",
|
|
117
|
+
"notes": "reasoning model at reasoning_effort=medium; reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
|
|
118
|
+
},
|
|
119
|
+
"anthropic/_default": {
|
|
120
|
+
"provider": "anthropic",
|
|
121
|
+
"model": "_default",
|
|
122
|
+
"measured": true,
|
|
123
|
+
"samples": 50,
|
|
124
|
+
"measured_at": "2026-09-16T17:58:00Z",
|
|
125
|
+
"ttft_s": {
|
|
126
|
+
"p50": 0.765,
|
|
127
|
+
"p90": 1.355
|
|
128
|
+
},
|
|
129
|
+
"tokens_per_s": {
|
|
130
|
+
"p50": 60.1,
|
|
131
|
+
"p10": 34.6
|
|
132
|
+
},
|
|
133
|
+
"ttft_per_1k_prompt_tokens_s": 0.2326,
|
|
134
|
+
"reasoning_tokens_p50": null,
|
|
135
|
+
"source": "scripts/benchmark.py (aggregate of measured models)",
|
|
136
|
+
"notes": "provider fallback: median p50 / worst slow side across claude-haiku-4-5, claude-sonnet-4-5"
|
|
137
|
+
},
|
|
138
|
+
"anthropic/claude-sonnet-4-5": {
|
|
139
|
+
"provider": "anthropic",
|
|
140
|
+
"model": "claude-sonnet-4-5",
|
|
141
|
+
"measured": true,
|
|
142
|
+
"samples": 40,
|
|
143
|
+
"measured_at": "2026-09-16T17:58:00Z",
|
|
144
|
+
"ttft_s": {
|
|
145
|
+
"p50": 0.86,
|
|
146
|
+
"p90": 1.355
|
|
147
|
+
},
|
|
148
|
+
"tokens_per_s": {
|
|
149
|
+
"p50": 36.8,
|
|
150
|
+
"p10": 34.6
|
|
151
|
+
},
|
|
152
|
+
"ttft_per_1k_prompt_tokens_s": 0.2326,
|
|
153
|
+
"reasoning_tokens_p50": null,
|
|
154
|
+
"source": "scripts/benchmark.py",
|
|
155
|
+
"notes": "two 10-run benchmarks pooled (17:49Z and 17:58Z)"
|
|
156
|
+
},
|
|
157
|
+
"anthropic/claude-haiku-4-5": {
|
|
158
|
+
"provider": "anthropic",
|
|
159
|
+
"model": "claude-haiku-4-5",
|
|
160
|
+
"measured": true,
|
|
161
|
+
"samples": 10,
|
|
162
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
163
|
+
"ttft_s": {
|
|
164
|
+
"p50": 0.67,
|
|
165
|
+
"p90": 0.686
|
|
166
|
+
},
|
|
167
|
+
"tokens_per_s": {
|
|
168
|
+
"p50": 83.4,
|
|
169
|
+
"p10": 79.5
|
|
170
|
+
},
|
|
171
|
+
"ttft_per_1k_prompt_tokens_s": 0.0,
|
|
172
|
+
"reasoning_tokens_p50": null,
|
|
173
|
+
"source": "scripts/benchmark.py",
|
|
174
|
+
"notes": ""
|
|
175
|
+
},
|
|
176
|
+
"openrouter/openai/gpt-4.1-mini": {
|
|
177
|
+
"provider": "openrouter",
|
|
178
|
+
"model": "openai/gpt-4.1-mini",
|
|
179
|
+
"measured": true,
|
|
180
|
+
"samples": 10,
|
|
181
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
182
|
+
"ttft_s": {
|
|
183
|
+
"p50": 0.81,
|
|
184
|
+
"p90": 1.17
|
|
185
|
+
},
|
|
186
|
+
"tokens_per_s": {
|
|
187
|
+
"p50": 108.1,
|
|
188
|
+
"p10": 82.7
|
|
189
|
+
},
|
|
190
|
+
"ttft_per_1k_prompt_tokens_s": 0.171,
|
|
191
|
+
"reasoning_tokens_p50": null,
|
|
192
|
+
"source": "scripts/benchmark.py",
|
|
193
|
+
"notes": ""
|
|
194
|
+
},
|
|
195
|
+
"openrouter/openai/gpt-4.1": {
|
|
196
|
+
"provider": "openrouter",
|
|
197
|
+
"model": "openai/gpt-4.1",
|
|
198
|
+
"measured": true,
|
|
199
|
+
"samples": 10,
|
|
200
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
201
|
+
"ttft_s": {
|
|
202
|
+
"p50": 0.85,
|
|
203
|
+
"p90": 1.024
|
|
204
|
+
},
|
|
205
|
+
"tokens_per_s": {
|
|
206
|
+
"p50": 138.6,
|
|
207
|
+
"p10": 87.2
|
|
208
|
+
},
|
|
209
|
+
"ttft_per_1k_prompt_tokens_s": 0.1603,
|
|
210
|
+
"reasoning_tokens_p50": null,
|
|
211
|
+
"source": "scripts/benchmark.py",
|
|
212
|
+
"notes": ""
|
|
213
|
+
},
|
|
214
|
+
"openrouter/openai/gpt-5-mini": {
|
|
215
|
+
"provider": "openrouter",
|
|
216
|
+
"model": "openai/gpt-5-mini",
|
|
217
|
+
"measured": true,
|
|
218
|
+
"samples": 10,
|
|
219
|
+
"measured_at": "2026-09-16T07:33:42Z",
|
|
220
|
+
"ttft_s": {
|
|
221
|
+
"p50": 32.84,
|
|
222
|
+
"p90": 41.578
|
|
223
|
+
},
|
|
224
|
+
"tokens_per_s": {
|
|
225
|
+
"p50": 84.9,
|
|
226
|
+
"p10": 55.9
|
|
227
|
+
},
|
|
228
|
+
"ttft_per_1k_prompt_tokens_s": 2.3944,
|
|
229
|
+
"reasoning_tokens_p50": 1536,
|
|
230
|
+
"source": "scripts/benchmark.py",
|
|
231
|
+
"notes": "reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
|
|
232
|
+
},
|
|
233
|
+
"openrouter/anthropic/claude-sonnet-4.5": {
|
|
234
|
+
"provider": "openrouter",
|
|
235
|
+
"model": "anthropic/claude-sonnet-4.5",
|
|
236
|
+
"measured": true,
|
|
237
|
+
"samples": 6,
|
|
238
|
+
"measured_at": "2026-09-16T07:21:48Z",
|
|
239
|
+
"ttft_s": {
|
|
240
|
+
"p50": 1.96,
|
|
241
|
+
"p90": 3.024
|
|
242
|
+
},
|
|
243
|
+
"tokens_per_s": {
|
|
244
|
+
"p50": 39.8,
|
|
245
|
+
"p10": 34.2
|
|
246
|
+
},
|
|
247
|
+
"ttft_per_1k_prompt_tokens_s": 0.3149,
|
|
248
|
+
"reasoning_tokens_p50": null,
|
|
249
|
+
"source": "scripts/benchmark.py",
|
|
250
|
+
"notes": ""
|
|
251
|
+
},
|
|
252
|
+
"openrouter/anthropic/claude-haiku-4.5": {
|
|
253
|
+
"provider": "openrouter",
|
|
254
|
+
"model": "anthropic/claude-haiku-4.5",
|
|
255
|
+
"measured": true,
|
|
256
|
+
"samples": 10,
|
|
257
|
+
"measured_at": "2026-09-16T07:33:42Z",
|
|
258
|
+
"ttft_s": {
|
|
259
|
+
"p50": 1.13,
|
|
260
|
+
"p90": 1.334
|
|
261
|
+
},
|
|
262
|
+
"tokens_per_s": {
|
|
263
|
+
"p50": 88.7,
|
|
264
|
+
"p10": 85.2
|
|
265
|
+
},
|
|
266
|
+
"ttft_per_1k_prompt_tokens_s": 0.0339,
|
|
267
|
+
"reasoning_tokens_p50": null,
|
|
268
|
+
"source": "scripts/benchmark.py",
|
|
269
|
+
"notes": ""
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
hmft/estimate.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pre-call estimate: how long will this call take?
|
|
2
|
+
|
|
3
|
+
total ≈ TTFT(prompt_tokens, model, provider) + output_tokens / tokens_per_s
|
|
4
|
+
|
|
5
|
+
Every number is a Band(p50, p90) in the *time* sense: p90 is the slow side.
|
|
6
|
+
The throughput table stores tokens_per_s as p50/p10 because for throughput
|
|
7
|
+
the slow side is the LOW percentile. So:
|
|
8
|
+
|
|
9
|
+
time p50 = ttft.p50 + output.p50 / tps.p50
|
|
10
|
+
time p90 = ttft.p90 + output.p90 / tps.p10 <- throughput p10, not p90
|
|
11
|
+
|
|
12
|
+
If the table has no usable entry for the model or its provider default, the
|
|
13
|
+
token estimate is still returned and the time fields are None with
|
|
14
|
+
basis="unmeasured". Nothing is invented.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from typing import Any, Iterable, Optional
|
|
20
|
+
|
|
21
|
+
from .length import DEFAULT_LENGTH_ESTIMATOR, Band, LengthEstimator, LengthOptions
|
|
22
|
+
from .throughput import ThroughputEntry, lookup
|
|
23
|
+
from .tokens import count_messages
|
|
24
|
+
|
|
25
|
+
# Reasoning models emit hidden thinking tokens that are billed and take wall
|
|
26
|
+
# time but are not in the visible output. v1 does not model them; the
|
|
27
|
+
# benchmark measures the damage per effort level (see throughput.json notes).
|
|
28
|
+
REASONING_EFFORT_SUFFIX = "@"
|
|
29
|
+
# Bare reasoning-model calls run at the provider's default effort. Rows are keyed per effort, so a call
|
|
30
|
+
# with no effort set falls back to the @medium row when no bare row exists (gpt-5 -> gpt-5@medium).
|
|
31
|
+
DEFAULT_REASONING_EFFORT = "medium"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Estimate:
|
|
36
|
+
ttft_s: Optional[Band]
|
|
37
|
+
output_tokens: Band
|
|
38
|
+
total_s: Optional[Band]
|
|
39
|
+
prompt_tokens: int
|
|
40
|
+
model: str
|
|
41
|
+
provider: str
|
|
42
|
+
basis: str # "measured" | "provider_default" | "unmeasured"
|
|
43
|
+
basis_key: str # throughput table key that was used, "" if none
|
|
44
|
+
reasoning_effort: Optional[str] = None # effort requested, or the one the table row implies (gpt-5 -> medium); None for non-reasoning
|
|
45
|
+
effort_source: Optional[str] = None # "requested" | "default" when reasoning_effort is set, else None
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return asdict(self)
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
if self.total_s is None:
|
|
52
|
+
return (f"~{self.output_tokens.p50:.0f} output tokens (p90 {self.output_tokens.p90:.0f}); "
|
|
53
|
+
f"no timing for {self.provider}/{self.model} (unmeasured)")
|
|
54
|
+
return (f"~{self.total_s.p50:.1f}s (p90 {self.total_s.p90:.1f}s): "
|
|
55
|
+
f"ttft {self.ttft_s.p50:.2f}s, ~{self.output_tokens.p50:.0f} tokens [{self.basis}]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def table_key_for(model: str, reasoning_effort: Optional[str]) -> str:
|
|
59
|
+
"""gpt-5 + effort 'low' -> 'gpt-5@low'. Table entries for reasoning models are per effort."""
|
|
60
|
+
return f"{model}{REASONING_EFFORT_SUFFIX}{reasoning_effort}" if reasoning_effort else model
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def table_keys_for(model: str, reasoning_effort: Optional[str]) -> list[str]:
|
|
64
|
+
"""Lookup order. With an effort: just that row. Without: the bare row, then @medium."""
|
|
65
|
+
if reasoning_effort:
|
|
66
|
+
return [table_key_for(model, reasoning_effort)]
|
|
67
|
+
return [model, table_key_for(model, DEFAULT_REASONING_EFFORT)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ttft_band(entry: ThroughputEntry, prompt_tokens: int) -> Band:
|
|
71
|
+
slope = entry.ttft_per_1k_prompt_tokens_s or 0.0
|
|
72
|
+
extra = slope * prompt_tokens / 1000.0
|
|
73
|
+
return Band(entry.ttft_p50_s + extra, entry.ttft_p90_s + extra * 1.5)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def estimate(
|
|
77
|
+
messages: Iterable[Any],
|
|
78
|
+
model: str,
|
|
79
|
+
provider: str = "openai",
|
|
80
|
+
*,
|
|
81
|
+
system: Optional[str] = None,
|
|
82
|
+
max_tokens: Optional[int] = None,
|
|
83
|
+
json_mode: bool = False,
|
|
84
|
+
tool_choice_forced: bool = False,
|
|
85
|
+
reasoning_effort: Optional[str] = None,
|
|
86
|
+
length_estimator: Optional[LengthEstimator] = None,
|
|
87
|
+
throughput_entry: Optional[ThroughputEntry] = None,
|
|
88
|
+
) -> Estimate:
|
|
89
|
+
"""Estimate time and output size for a chat call before sending it.
|
|
90
|
+
|
|
91
|
+
`throughput_entry` lets callers (and tests) bypass the table lookup.
|
|
92
|
+
`length_estimator` is the v2 plug point.
|
|
93
|
+
"""
|
|
94
|
+
msgs = list(messages)
|
|
95
|
+
prompt_tokens = count_messages(msgs, model, provider, system=system)
|
|
96
|
+
options = LengthOptions(max_tokens=max_tokens, json_mode=json_mode, tool_choice_forced=tool_choice_forced, system=system)
|
|
97
|
+
output = (length_estimator or DEFAULT_LENGTH_ESTIMATOR).estimate(msgs, model, options)
|
|
98
|
+
|
|
99
|
+
entry = throughput_entry if throughput_entry is not None else lookup(model, provider, table_keys_for(model, reasoning_effort))
|
|
100
|
+
effort, source = reasoning_effort, ("requested" if reasoning_effort else None)
|
|
101
|
+
if effort is None and REASONING_EFFORT_SUFFIX in entry.basis_key:
|
|
102
|
+
# the caller set nothing; the provider's default effort applies, and the table row says which one we assumed
|
|
103
|
+
effort, source = entry.basis_key.rsplit(REASONING_EFFORT_SUFFIX, 1)[1], "default"
|
|
104
|
+
if not entry.usable:
|
|
105
|
+
return Estimate(None, output, None, prompt_tokens, model, provider, "unmeasured", entry.basis_key, effort, source)
|
|
106
|
+
|
|
107
|
+
ttft = _ttft_band(entry, prompt_tokens)
|
|
108
|
+
total = Band(
|
|
109
|
+
ttft.p50 + output.p50 / entry.tps_p50,
|
|
110
|
+
ttft.p90 + output.p90 / entry.tps_p10, # slow throughput -> long time
|
|
111
|
+
)
|
|
112
|
+
basis = "provider_default" if entry.model == "_default" else "measured"
|
|
113
|
+
return Estimate(ttft, output, total, prompt_tokens, model, provider, basis, entry.basis_key, effort, source)
|