Writing custom ModelSpecs¶
A ModelSpec is one regression recipe: a fixture selector plus a map from
fitted coefficients to gas-param names. The 101 bundled
presets are themselves ModelSpec instances —
choosing a preset under models.presets is exactly the same as pasting its
literal under models.custom.
When a behaviour you need isn't covered by a preset, add a models.custom
entry. This page documents every field on the spec.
Minimal custom spec¶
version: 1
anchor_rate: 1.0e8
clients: [geth]
gas_costs:
fork: osaka
models:
custom:
- test_name: test_arithmetic
target_operation: ADD
filter_by: ["opcode_ADD-"]
model_params:
target_coef: OPCODE_ADD
This is identical to the bundled arithmetic_add
preset. Read on for the rest of the fields.
Field reference¶
test_name (required)¶
The EEST test file name that owns this fixture family — for example
test_arithmetic, test_memory_access, test_account_access. Only
fixtures whose parsed test_name matches are considered. See the runtimes
CSV / opcounts JSON input contract under the
implementation plan
if you're producing your own fixtures.
target_operation or target_operation_param (exactly one)¶
Tells the fit what target_opcode means for each row:
target_operation: ADD— literal opcode mnemonic. Every fixture in this group must executeADD, andopcountmust equal the per-fixtureADDcount (the invariant check runs at fit time).target_operation_param: opcode— pull the target opcode from a parsed-param column. Use this when one test file produces fixtures for multiple targets and you want to model them jointly. Mutually exclusive withtarget_operation.
target_operation_count_source (optional)¶
Precompile escape hatch — only valid alongside a literal target_operation.
A precompile like SHA256 has no opcount column of its own in the runtimes
data, so its opcount must come from the dispatching opcode (typically
STATICCALL). Set this to STATICCALL (or the relevant dispatcher) when
modelling a precompile.
filter_by (optional, default [])¶
A list of substrings AND-matched against fixture_name. A !-prefixed token
negates — the fixture name must not contain it.
filter_by:
- "opcode_ADD-" # require this substring
- "!benchmark_combined" # exclude fixtures containing this substring
Empty list ⇒ no filtering. Substrings are matched literally (no regex).
model_by (optional, default [])¶
Group-by axes. The fit is repeated once per unique combination of values in
these columns; each combination produces its own row in results.csv.
Useful when the target's runtime depends on a fixture parameter (e.g.
modulus bit-width) that you want to keep as a separate dimension rather
than collapse into a single coefficient.
model_by entries can reference either raw parsed params (mem_size,
account_mode) — internally prefixed as param_<name> — or a derived
column declared in fixture_params.
fixture_params (optional, default {})¶
Materializes derived columns on the filtered fixtures. Each key is the
column name you'll use in model_by or model_params; the value is a
FixtureParamSpec carrying:
| Subfield | Type | Meaning |
|---|---|---|
source |
str |
Name of the raw parsed param (without the param_ prefix). |
transform |
"bytes_to_words" (optional) |
Compute ceil(source / 32), the per-word size used by copy-style opcodes. |
values |
dict[str, float] (optional) |
Remap non-numeric values to floats. Mutually exclusive with transform. |
Two patterns from the bundled catalog:
# Bytes → 32-byte words (used by MCOPY and the per-word precompiles).
fixture_params:
copy_words:
source: copy_size
transform: bytes_to_words
# Remap a non-numeric raw param to floats (e.g. a boolean flag as 0/1) so it
# can drive a coefficient or a model_by axis.
fixture_params:
is_write:
source: value_sent
values:
"False": 0
"True": 1
A transform: bytes_to_words source must parse as numeric; a values:
source can be anything but every observed value must appear as a key
(unmapped values raise ModelingError).
model_params (required)¶
The coefficient → gas-param map. Must contain a target_coef key:
model_params:
target_coef: OPCODE_MCOPY_BASE # required
copy_words: OPCODE_MCOPY_PER_WORD # one extra per non-target feature
Keys other than target_coef must resolve to either a fixture_params name
or a raw parsed-param column (auto-prefixed param_<name> at fit time).
Each becomes a feature opcount × value in the design matrix — see
NNLS modeling.
Values (RHS) are the gas-param names that the fitted coefficient will
contribute to. Every RHS that isn't already a raw fork field on
gas_costs.fork must appear in new_params
— this catches typos at config load.
Worked example: a copy-style opcode¶
MCOPY has a base cost and a per-word cost. The bundled preset
memory_mcopy groups by both the
copy size and memory size dimensions and produces both gas params at once:
models:
custom:
- test_name: test_mcopy
target_operation: MCOPY
model_by:
- copy_size
- mem_size
fixture_params:
copy_words:
source: copy_size
transform: bytes_to_words
model_params:
target_coef: OPCODE_MCOPY_BASE
copy_words: OPCODE_MCOPY_PER_WORD
target_coef carries the base cost (gas per MCOPY execution); the
copy_words coefficient carries the per-word cost (gas per 32-byte word
copied). The model_by dimensions ensure that different copy_size /
mem_size slices each get their own fit, so a single dominant slice can be
flagged as the worst case downstream.
Worked example: shared target via target_operation_param¶
When one test file produces fixtures for multiple targets that you want modelled jointly:
models:
custom:
- test_name: test_account_access
target_operation_param: opcode
filter_by:
- "CacheStrategy.NO_CACHE"
- "!AccountMode.EXISTING_CONTRACT"
- "value_sent_0"
model_by:
- opcode
- account_mode
model_params:
target_coef: COLD_ACCOUNT_NOCODE_ACCESS
Here opcode is both a target_operation_param (each fixture's target is
read from the column) and a model_by axis (each opcode gets its own
fit).
Pricing a write delta jointly¶
A write to new state is never charged without its cold access, so the
chargeable cost is the combined access + write. Pricing the two as
independent per-param worst-cases over-charges, because max is subadditive —
the worst-access client and the worst-write client can differ, and their sum
exceeds any single client's combined cost. The bundled catalog instead splits
each test by the "touches new state" signal (value_sent_{0,1} here,
write_new_value_{False,True} for SSTORE) into a read-only access fit and a
combined access+write fit, then recovers the delta in
derived with the max built-in:
models:
custom:
# read-only access (value_sent_0) — fits COLD_ACCOUNT_NOCODE_ACCESS above
- test_name: test_account_access
target_operation_param: opcode
filter_by: ["CacheStrategy.NO_CACHE", "!AccountMode.EXISTING_CONTRACT", "value_sent_1"]
model_by: [opcode, account_mode]
model_params:
target_coef: COLD_ACCOUNT_NOCODE_WRITE # combined access+write
derived:
ACCOUNT_WRITE:
formula: "max(0, COLD_ACCOUNT_NOCODE_WRITE - COLD_ACCOUNT_NOCODE_ACCESS)"
The combined cost is bounded by one worst-case client (tighter than the sum of
two maxima), and the max(0, …) floor keeps a degenerate negative delta from
leaking. The two specs share test_name + target_operation_param +
model_by and differ only in filter_by, so the aggregator routes each fit
back to its producing preset by source_label rather than the key shape — the
write spec keeps its own (higher) coefficient instead of inheriting the read
spec's.
After the fit¶
Every fitted coefficient becomes a gas value via the anchor_rate
conversion; see Deriving gas params for the
per-client and across-client selection that follows. If your spec
introduces new gas-param names, remember to declare them in
new_params — null if there's no
prior default to diff against, or an integer baseline.