From faea372c74a1e6d3b5c68f42cc08cba125ef175f Mon Sep 17 00:00:00 2001 From: Yifan Lu Date: Sat, 31 Oct 2026 17:52:03 -0500 Subject: [PATCH] Price models that routers run themselves with a market-based rule A profile marks a local (on-device or self-hosted) model with local: {base: , bits: N} when its weights are a listed model's, or local: {active_params_b: N, bits: N} otherwise. scripts/pricing/ local_price.py prices it by model_cost/local_pricing.yaml: the listed model's price, or the size curve's price for its active parameters, times a quantization factor. --write updates the profiles and model_cost.json; --check (a pre-commit hook) fails when a local price is out of date, e.g. after a sync moves its base model's price. The rule's numbers come from 103 open-weight models on OpenRouter at standard prices (standard tier, promotions undone, median over 8-bit+ hosts): price ~ active_params^0.32 (input 0.0669 x B^0.325, output 0.2153 x B^0.316), and same-model hosts charge 1.00x at 8 vs 16 bits and about 0.8x at 4 bits. Factors: 1.0 at 8 bits or more, 0.8 below. scripts/pricing/fit_local_rule.py refits them from live data. Co-Authored-By: Claude Opus 5.5 --- .pre-commit-config.yaml | 7 + README.md | 2 + model_cost/local_pricing.yaml | 33 +++++ model_cost/model_profiles.yaml | 3 + scripts/pricing/fit_local_rule.py | 209 ++++++++++++++++++++++++++++++ scripts/pricing/local_price.py | 205 +++++++++++++++++++++++++++++ tests/test_local_price.py | 109 ++++++++++++++++ 7 files changed, 568 insertions(+) create mode 100644 model_cost/local_pricing.yaml create mode 100644 scripts/pricing/fit_local_rule.py create mode 100644 scripts/pricing/local_price.py create mode 100644 tests/test_local_price.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea2fb245..c08b7443 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,6 +73,13 @@ repos: additional_dependencies: [pyyaml] pass_filenames: false files: '^(model_cost/|scripts/pricing/)' + - id: local-prices-in-sync + name: local model prices follow model_cost/local_pricing.yaml + language: python + entry: python scripts/pricing/local_price.py --check + additional_dependencies: [pyyaml] + pass_filenames: false + files: '^(model_cost/|scripts/pricing/)' # Add mypy hooks for both local runs and CI (manual stage) - repo: local hooks: diff --git a/README.md b/README.md index 6ecd02f0..4a0a2cfd 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ Every model in your config needs a price. Prices live in [`model_cost/model_prof Then regenerate [`model_cost/model_cost.json`](./model_cost/model_cost.json), which the evaluator reads, with `python scripts/pricing/build_model_cost.py`. A model name with no entry is charged the highest price in the table. +For a model your router runs itself (on-device or self-hosted), don't enter your own cost. Add `local: {base: , bits: }`, or `local: {active_params_b: , bits: <...>}` if no listed model has these weights, plus `sync: false`. Then run `python scripts/pricing/local_price.py --write`, which prices it by [`model_cost/local_pricing.yaml`](./model_cost/local_pricing.yaml): the listed model's price, or the market price of an open-weight model of that size, times a quantization factor. + > [!NOTE] > Ensure all models in your above config files are listed in [`./universal_model_names.py`](./universal_model_names.py). If you add a new model, you must also add the API inference endpoint in [`llm_inference/model_inference.py`](./llm_inference/model_inference.py). diff --git a/model_cost/local_pricing.yaml b/model_cost/local_pricing.yaml new file mode 100644 index 00000000..6826f19b --- /dev/null +++ b/model_cost/local_pricing.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright contributors to the RouterArena project +# SPDX-License-Identifier: Apache-2.0 +# +# Price rule for models a router runs itself (on-device or self-hosted). Applied by +# python scripts/pricing/local_price.py +# to every profile in model_profiles.yaml that has a `local` field. +# +# A local model is priced like the same model bought from a host, so that serving it +# yourself neither hides its cost nor inflates it: +# 1. if its weights are a listed model's (`local.base`), at that model's price; +# 2. otherwise at the market price of an open-weight model of its size (size_curve); +# times the quantization factor of the weights actually served (`local.bits`). +# +# The numbers come from OpenRouter's open-weight models, at standard prices (standard +# tier, promotions undone, median over hosts at 8 bits or more). Refit them with +# python scripts/pricing/fit_local_rule.py + +fitted: "2026-10-31" +data: "103 open-weight models on OpenRouter with a known parameter count" + +# USD per 1M tokens = coef * (active parameters, in billions) ** exponent. +# Residual spread around the curve is about x2.3 (input) and x2.8 (output). +size_curve: + input: {coef: 0.0669, exponent: 0.325} + output: {coef: 0.2153, exponent: 0.316} + +# Multiplier by the precision of the served weights, highest threshold first. +# Hosts of the same model charge the same at 8 and 16 bits (median ratio 1.00 over 23 +# models) and about 0.8x at 4 bits (0.80 input, 0.83 output, over 41 models). There is +# no market data below 4 bits, so 2- and 3-bit weights get the 4-bit factor. +quantization: + - {min_bits: 8, factor: 1.0} + - {min_bits: 0, factor: 0.8} diff --git a/model_cost/model_profiles.yaml b/model_cost/model_profiles.yaml index 4d280dce..dba79903 100644 --- a/model_cost/model_profiles.yaml +++ b/model_cost/model_profiles.yaml @@ -15,6 +15,9 @@ # note free text, e.g. known conflicts to resolve # openrouter_id OpenRouter id to sync from, when the key isn't one # sync false keeps the price fixed (retired or never-listed models) +# local a model the router runs itself: {base: , bits: N} +# or {active_params_b: N, bits: N}; priced by the rule in +# local_pricing.yaml (python scripts/pricing/local_price.py --write) # # Profiles keyed by an OpenRouter id (or with openrouter_id) take OpenRouter's list price # when a maintainer runs: diff --git a/scripts/pricing/fit_local_rule.py b/scripts/pricing/fit_local_rule.py new file mode 100644 index 00000000..d5af9315 --- /dev/null +++ b/scripts/pricing/fit_local_rule.py @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: Copyright contributors to the RouterArena project +# SPDX-License-Identifier: Apache-2.0 + +"""Refit the local-model price rule (model_cost/local_pricing.yaml) from market data. + +Reads every OpenRouter model that names its Hugging Face weights, its per-provider +endpoints, and the parameter count from Hugging Face. Each model's standard price is the +median over its standard-tier hosts at 8 bits or more, with promotions undone. Then: + +* size curve: log10(price) = log10(coef) + exponent * log10(active parameters, billions), + fit by least squares, for input and output separately. Mixture-of-experts models count + only when their id names the active size (e.g. ``-a22b``); +* quantization: for models served at both precisions, the ratio of the median 4-bit + host price to the median 8/16-bit one, and of 8-bit to 16-bit. + +It prints the fitted numbers; a maintainer copies them into local_pricing.yaml (the +quantization factors are a policy choice informed by the ratios). Takes a few minutes. + +Usage: + python scripts/pricing/fit_local_rule.py [--save raw.jsonl] [--load raw.jsonl] +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import statistics +import sys +import time +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +BITS = {"bf16": 16, "fp16": 16, "fp32": 16, "fp8": 8, "int8": 8} +BITS.update({q: 4 for q in ("fp4", "nvfp4", "mxfp4", "int4")}) +MOE_TYPES = { + "deepseek_v3", + "deepseek_v4", + "gpt_oss", + "mixtral", + "llama4", + "dbrx", + "jamba", + "glm_moe_dsa", + "glm4_moe", + "minimax_m2", + "minimax_m3", + "minimax_m3_vl", + "kimi_k2", + "qwen3_moe", + "qwen3_next", +} + + +def get(url: str) -> Any: + for attempt in range(3): + try: + request = urllib.request.Request( + url, headers={"User-Agent": "routerarena-local-price-fit"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except Exception: + time.sleep(2 * (attempt + 1)) + return {} + + +def collect() -> List[Dict[str, Any]]: + models = get("https://openrouter.ai/api/v1/models").get("data", []) + records = [] + for m in models: + if not m.get("hugging_face_id") or m["id"].endswith(":free"): + continue + endpoints = ( + get(f"https://openrouter.ai/api/v1/models/{m['id']}/endpoints").get("data") + or {} + ).get("endpoints", []) + hf = get(f"https://huggingface.co/api/models/{m['hugging_face_id']}") + records.append( + { + "id": m["id"], + "params_total": (hf.get("safetensors") or {}).get("total"), + "model_type": (hf.get("config") or {}).get("model_type"), + "endpoints": [ + { + "quantization": e.get("quantization"), + "tier": e.get("service_tier"), + "status": e.get("status"), + "prompt": (e.get("pricing") or {}).get("prompt"), + "completion": (e.get("pricing") or {}).get("completion"), + "discount": (e.get("pricing") or {}).get("discount"), + } + for e in endpoints + ], + } + ) + time.sleep(0.3) + return records + + +def active_params_b(record: Dict[str, Any]) -> Optional[float]: + named = re.search(r"[-_]a(\d+(?:\.\d+)?)b", record["id"].lower()) + if named: + return float(named.group(1)) + model_type = (record.get("model_type") or "").lower() + if model_type in MOE_TYPES or "moe" in model_type or not record.get("params_total"): + return None + return record["params_total"] / 1e9 + + +def standard_endpoints(record: Dict[str, Any]) -> List[tuple]: + out = [] + for e in record["endpoints"]: + if e.get("tier") not in (None, "standard"): + continue + try: + p_in, p_out = float(e["prompt"]) * 1e6, float(e["completion"]) * 1e6 + except (TypeError, ValueError): + continue + if p_in <= 0 or p_out <= 0: + continue + discount = float(e.get("discount") or 0) + factor = 1 / (1 - discount) if 0 < discount < 1 else 1.0 + active = (e.get("status") or 0) >= 0 + out.append( + ( + BITS.get(str(e.get("quantization"))), + p_in * factor, + p_out * factor, + active, + ) + ) + return [e for e in out if e[3]] or out + + +def fit(records: List[Dict[str, Any]]) -> None: + points: List[Tuple[float, float, float]] = [] + ratio4: List[Tuple[float, float]] = [] + ratio8: List[Tuple[float, float]] = [] + for r in records: + eps = standard_endpoints(r) + full = [e for e in eps if e[0] != 4] + size = active_params_b(r) + if full and size: + points.append( + ( + size, + statistics.median(e[1] for e in full), + statistics.median(e[2] for e in full), + ) + ) + for low_bits, high_bits, store in ( + (4, (8, 16, None), ratio4), + (8, (16,), ratio8), + ): + low = [e for e in eps if e[0] == low_bits] + high = [e for e in eps if e[0] in high_bits] + if low and high: + store.append( + ( + statistics.median(e[1] for e in low) + / statistics.median(e[1] for e in high), + statistics.median(e[2] for e in low) + / statistics.median(e[2] for e in high), + ) + ) + print(f"{len(points)} models with a parameter count and a full-precision price") + print("size_curve:") + for k, name in ((1, "input"), (2, "output")): + xs = [math.log10(p[0]) for p in points] + ys = [math.log10(p[k]) for p in points] + mx, my = statistics.mean(xs), statistics.mean(ys) + b = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sum( + (x - mx) ** 2 for x in xs + ) + a = my - b * mx + spread = statistics.pstdev([y - (a + b * x) for x, y in zip(xs, ys)]) + print( + f" {name}: {{coef: {10**a:.4f}, exponent: {b:.3f}}} # residual x{10**spread:.2f}" + ) + for label, ratios in (("4-bit vs 8/16-bit", ratio4), ("8-bit vs 16-bit", ratio8)): + if ratios: + print( + f"{label}: {len(ratios)} models, median ratio " + f"input {statistics.median(r[0] for r in ratios):.2f}, " + f"output {statistics.median(r[1] for r in ratios):.2f}" + ) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--save", help="also save the collected data (JSON lines)") + parser.add_argument("--load", help="fit from saved data instead of collecting") + args = parser.parse_args(argv) + if args.load: + with open(args.load, encoding="utf-8") as f: + records = [json.loads(line) for line in f] + else: + records = collect() + if args.save: + with open(args.save, "w", encoding="utf-8") as f: + f.writelines(json.dumps(r) + "\n" for r in records) + fit(records) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pricing/local_price.py b/scripts/pricing/local_price.py new file mode 100644 index 00000000..df72dda6 --- /dev/null +++ b/scripts/pricing/local_price.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: Copyright contributors to the RouterArena project +# SPDX-License-Identifier: Apache-2.0 + +"""Price models that a router runs itself, with the rule in model_cost/local_pricing.yaml. + +A profile in model_cost/model_profiles.yaml marks a local model with a ``local`` field: + + "lemonade/Qwen3.8-27B-GGUF-UD-Q4_K_XL": + price: {input: 0.156, output: 0.488} + local: {active_params_b: 27, bits: 4} # no listed model with these weights + sync: false + "lemonade/deepseek-v4-flash": + price: {input: 0.112, output: 0.224} + local: {base: "deepseek/deepseek-v4-flash", bits: 2} # a listed model's weights + sync: false + +``base`` names a profile (id or alias) whose price the local model takes; otherwise +``active_params_b`` (active parameters in billions; for a mixture-of-experts model, the +active ones) prices it from the size curve. ``bits`` is the precision of the served +weights. Both are multiplied by the rule's quantization factor. + +Usage: + python scripts/pricing/local_price.py --active-params-b 27 --bits 4 # price a model + python scripts/pricing/local_price.py --base deepseek/deepseek-v4-flash --bits 2 + python scripts/pricing/local_price.py --check # exit 1 if a local profile is out of date + python scripts/pricing/local_price.py --write # update local profiles, regenerate JSON +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import build_model_cost # noqa: E402 + +RULE_PATH = build_model_cost.REPO_ROOT / "model_cost" / "local_pricing.yaml" + +Price = Tuple[float, float] + + +def load_rule(path: Optional[Path] = None) -> Dict[str, Any]: + with open(path or RULE_PATH, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def _round(value: float) -> float: + return float(f"{value:.3g}") + + +def quantization_factor(bits: float, rule: Dict[str, Any]) -> float: + for step in rule["quantization"]: + if bits >= step["min_bits"]: + return float(step["factor"]) + raise ValueError(f"no quantization factor for {bits} bits") + + +def size_curve_price(active_params_b: float, rule: Dict[str, Any]) -> Price: + if active_params_b <= 0: + raise ValueError("active_params_b must be positive") + curve = rule["size_curve"] + return ( + curve["input"]["coef"] * active_params_b ** curve["input"]["exponent"], + curve["output"]["coef"] * active_params_b ** curve["output"]["exponent"], + ) + + +def find_profile( + name: str, profiles: Dict[str, Dict[str, Any]] +) -> Optional[Tuple[str, Dict[str, Any]]]: + if name in profiles: + return name, profiles[name] + for model_id, profile in profiles.items(): + if name in (profile.get("aliases") or []): + return model_id, profile + return None + + +def local_price( + spec: Dict[str, Any], profiles: Dict[str, Dict[str, Any]], rule: Dict[str, Any] +) -> Tuple[Price, str]: + """Return ((input, output), basis) for a ``local`` spec.""" + bits = spec.get("bits") + if not isinstance(bits, (int, float)) or isinstance(bits, bool) or bits <= 0: + raise ValueError("local.bits must be a positive number") + factor = quantization_factor(bits, rule) + if spec.get("base"): + found = find_profile(spec["base"], profiles) + if found is None: + raise ValueError(f"local.base {spec['base']!r} has no profile") + base_id, base = found + if base.get("local"): + raise ValueError(f"local.base {base_id!r} is itself a local model") + price = (float(base["price"]["input"]), float(base["price"]["output"])) + basis = f"price of {base_id}" + else: + active = spec.get("active_params_b") + if not isinstance(active, (int, float)) or isinstance(active, bool): + raise ValueError("local needs base or active_params_b") + price = size_curve_price(float(active), rule) + basis = f"size curve at {active:g}B active parameters" + basis += f", x{factor:g} for {bits:g}-bit weights" + return (_round(price[0] * factor), _round(price[1] * factor)), basis + + +def plan( + profiles: Dict[str, Dict[str, Any]], rule: Dict[str, Any] +) -> List[Dict[str, Any]]: + """Every local profile whose price differs from the rule's.""" + changes = [] + for model_id, profile in profiles.items(): + spec = profile.get("local") + if not spec: + continue + new, basis = local_price(spec, profiles, rule) + old = (float(profile["price"]["input"]), float(profile["price"]["output"])) + if abs(old[0] - new[0]) > 1e-12 or abs(old[1] - new[1]) > 1e-12: + changes.append({"model": model_id, "old": old, "new": new, "basis": basis}) + return changes + + +def _fmt(value: float) -> str: + return f"{value:.6f}".rstrip("0").rstrip(".") or "0" + + +def apply(text: str, changes: List[Dict[str, Any]]) -> str: + """Rewrite the price line of each changed profile, leaving other lines alone.""" + lines = text.split("\n") + for change in changes: + header = re.compile(r'^ ("?)' + re.escape(change["model"]) + r"\1:\s*$") + start = next((i for i, line in enumerate(lines) if header.match(line)), None) + if start is None: + raise ValueError(f"profile {change['model']!r} not found in the YAML file") + new_in, new_out = change["new"] + for j in range(start + 1, len(lines)): + if not lines[j].startswith(" "): + break + if lines[j].startswith(" price:"): + lines[j] = ( + f" price: {{input: {_fmt(new_in)}, output: {_fmt(new_out)}}}" + ) + return "\n".join(lines) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", help="profile whose weights the local model serves") + parser.add_argument("--active-params-b", type=float) + parser.add_argument("--bits", type=float, default=16) + parser.add_argument( + "--check", action="store_true", help="exit 1 if a local profile is stale" + ) + parser.add_argument( + "--write", action="store_true", help="update local profiles and model_cost.json" + ) + args = parser.parse_args(argv) + rule = load_rule() + profiles = build_model_cost.load_profiles(build_model_cost.PROFILES_PATH) + + if args.base or args.active_params_b: + spec = { + "base": args.base, + "active_params_b": args.active_params_b, + "bits": args.bits, + } + (p_in, p_out), basis = local_price(spec, profiles, rule) + print(f"input {_fmt(p_in)} / output {_fmt(p_out)} USD per 1M tokens ({basis})") + return 0 + + changes = plan(profiles, rule) + for c in changes: + print( + f"{c['model']}: {_fmt(c['old'][0])} / {_fmt(c['old'][1])} -> " + f"{_fmt(c['new'][0])} / {_fmt(c['new'][1])} ({c['basis']})" + ) + if args.check: + if changes: + print( + "Local model prices are out of date. Run: " + "python scripts/pricing/local_price.py --write", + file=sys.stderr, + ) + return 1 + print("Local model prices are up to date.") + return 0 + if args.write and changes: + path = build_model_cost.PROFILES_PATH + path.write_text( + apply(path.read_text(encoding="utf-8"), changes), encoding="utf-8" + ) + updated = build_model_cost.load_profiles(path) + build_model_cost.COST_PATH.write_text( + build_model_cost.render(build_model_cost.build_cost_table(updated)), + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_local_price.py b/tests/test_local_price.py new file mode 100644 index 00000000..2d8a13b4 --- /dev/null +++ b/tests/test_local_price.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Copyright contributors to the RouterArena project +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the local-model price rule (scripts/pricing/local_price.py).""" + +import json +import sys +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts" / "pricing")) + +import build_model_cost # noqa: E402 +import local_price # noqa: E402 + +RULE = { + "size_curve": { + "input": {"coef": 0.1, "exponent": 0.5}, + "output": {"coef": 0.4, "exponent": 0.5}, + }, + "quantization": [{"min_bits": 8, "factor": 1.0}, {"min_bits": 0, "factor": 0.8}], +} + +PROFILES = """\ +models: + "vendor/hosted": + price: {input: 0.5, output: 2} + aliases: ["hosted"] + "me/hosted-q4": + price: {input: 1, output: 1} + local: {base: "hosted", bits: 4} + sync: false + "me/tiny": + price: {input: 1, output: 1} + local: {active_params_b: 4, bits: 16} + sync: false +""" + + +def test_quantization_factor_by_bits(): + assert local_price.quantization_factor(16, RULE) == 1.0 + assert local_price.quantization_factor(8, RULE) == 1.0 + assert local_price.quantization_factor(4, RULE) == 0.8 + assert local_price.quantization_factor(2, RULE) == 0.8 + + +def test_listed_weights_take_the_listed_price_times_the_factor(): + profiles = yaml.safe_load(PROFILES)["models"] + price, basis = local_price.local_price( + {"base": "hosted", "bits": 4}, profiles, RULE + ) + assert price == (0.4, 1.6) + assert "vendor/hosted" in basis and "x0.8" in basis + + +def test_unlisted_weights_take_the_size_curve(): + price, basis = local_price.local_price({"active_params_b": 16, "bits": 8}, {}, RULE) + assert price == (0.4, 1.6) # 0.1 * 16 ** 0.5, 0.4 * 16 ** 0.5 + assert "16B" in basis + + +@pytest.mark.parametrize( + "spec", + [ + {"base": "missing", "bits": 4}, + {"base": "me/tiny", "bits": 4}, # a local model can't be another's base + {"active_params_b": 4}, # no bits + {"bits": 4}, # neither base nor size + ], +) +def test_bad_specs_are_rejected(spec): + profiles = yaml.safe_load(PROFILES)["models"] + with pytest.raises(ValueError): + local_price.local_price(spec, profiles, RULE) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + profiles = tmp_path / "model_profiles.yaml" + profiles.write_text(PROFILES, encoding="utf-8") + rule = tmp_path / "local_pricing.yaml" + rule.write_text(yaml.safe_dump(RULE), encoding="utf-8") + monkeypatch.setattr(build_model_cost, "PROFILES_PATH", profiles) + monkeypatch.setattr(build_model_cost, "COST_PATH", tmp_path / "model_cost.json") + monkeypatch.setattr(local_price, "RULE_PATH", rule) + return tmp_path, profiles + + +def test_check_fails_until_write_updates_the_local_profiles(repo): + tmp_path, profiles = repo + assert local_price.main(["--check"]) == 1 + assert local_price.main(["--write"]) == 0 + data = yaml.safe_load(profiles.read_text(encoding="utf-8"))["models"] + assert data["me/hosted-q4"]["price"] == {"input": 0.4, "output": 1.6} + assert data["me/tiny"]["price"] == {"input": 0.2, "output": 0.8} + assert data["vendor/hosted"]["price"] == {"input": 0.5, "output": 2} + table = json.loads((tmp_path / "model_cost.json").read_text()) + assert table["me/hosted-q4"]["input_token_price_per_million"] == 0.4 + assert local_price.main(["--check"]) == 0 + + +def test_the_repository_rule_and_profiles_are_consistent(): + rule = local_price.load_rule() + assert local_price.quantization_factor(16, rule) == 1.0 + profiles = build_model_cost.load_profiles() + assert local_price.plan(profiles, rule) == []