roadmodel 0.2.71__tar.gz → 0.2.72__tar.gz
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.
- {roadmodel-0.2.71 → roadmodel-0.2.72}/PKG-INFO +1 -1
- {roadmodel-0.2.71/roadmodel/data → roadmodel-0.2.72/docs}/catalog.json +2 -2
- roadmodel-0.2.72/docs/context-windows.json +60 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/model-selector.txt +5 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/hatch_build.py +1 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/pyproject.toml +2 -1
- {roadmodel-0.2.71/docs → roadmodel-0.2.72/roadmodel/data}/catalog.json +2 -2
- roadmodel-0.2.72/roadmodel/data/context-windows.json +60 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/model-selector.txt +5 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/__init__.py +1 -1
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/recommend.py +71 -7
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/scoring.py +150 -28
- {roadmodel-0.2.71 → roadmodel-0.2.72}/.gitignore +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/LICENSE +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/NOTICE +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/benchmarks.json +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/settings-display.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/project-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/user-context.example.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/gcp-killswitch/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/supabase/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/benchmarks.json +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/project-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/settings-display.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/user-context.example.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/agent_context.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/agent_rules.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/update_projects.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/updater_lock.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/upgrade_fleet.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/worktree_memory.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/__main__.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/cli.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/config.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/cost.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/errors.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/mcp_server.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/__init__.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/anthropic.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/google.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/openai.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/openai_compatible.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/registry.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/usage.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/user_context.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: roadmodel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.72
|
|
4
4
|
Summary: BYO-key CLI that recommends the right AI model, platform, and settings for a prompt.
|
|
5
5
|
Project-URL: Homepage, https://roadmodel.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/nathanramoscfa/roadmodel
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "4",
|
|
3
3
|
"output_contract_version": 2,
|
|
4
|
-
"generated_at_utc": "2026-10-
|
|
4
|
+
"generated_at_utc": "2026-10-07T18:08:11Z",
|
|
5
5
|
"source_doc_sha256": {
|
|
6
|
-
"model-selector.txt": "
|
|
6
|
+
"model-selector.txt": "6a75e2265459fa7302cb80d0faf5ec2aad575034fc29866238cd1579c001cc2d",
|
|
7
7
|
"model-tier-cost-scale.md": "2b3b0eb82237eb5cb5bf468a3fe971bcaf0160ad7d0bc700698d2c16640bc41d"
|
|
8
8
|
},
|
|
9
9
|
"models": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Each catalogued model's context window in tokens: OpenRouter's context_length, the largest of its routes (update/extract_openrouter_models.py --context-windows, run by the catalog cron). A surface may cap a model lower.",
|
|
3
|
+
"source": "https://openrouter.ai/api/v1/models",
|
|
4
|
+
"models": {
|
|
5
|
+
"claude-4.5-haiku": 200000,
|
|
6
|
+
"claude-fable-5": 1000000,
|
|
7
|
+
"claude-fable-5.1": 1000000,
|
|
8
|
+
"claude-opus-5": 1000000,
|
|
9
|
+
"claude-opus-5-5": 1000000,
|
|
10
|
+
"claude-sonnet-5": 1000000,
|
|
11
|
+
"claude-sonnet-5-5": 1000000,
|
|
12
|
+
"codestral": 256000,
|
|
13
|
+
"deepseek-flash": 1048576,
|
|
14
|
+
"deepseek-v4-pro": 1048576,
|
|
15
|
+
"gemini-2.5-flash": 1048576,
|
|
16
|
+
"gemini-3-flash": 1048576,
|
|
17
|
+
"gemini-3.1-pro": 1048576,
|
|
18
|
+
"gemini-3.5-flash": 1048576,
|
|
19
|
+
"gemini-3.6-flash": 1048576,
|
|
20
|
+
"gemini-3.7-flash": 1048576,
|
|
21
|
+
"gemini-3.8-flash": 1048576,
|
|
22
|
+
"glm-4.5-air": 131072,
|
|
23
|
+
"glm-4.6": 204800,
|
|
24
|
+
"glm-5.2": 1048576,
|
|
25
|
+
"glm-5.3": 1048576,
|
|
26
|
+
"glm-5.3-flash": 1048576,
|
|
27
|
+
"gpt-5": 400000,
|
|
28
|
+
"gpt-5-mini": 400000,
|
|
29
|
+
"gpt-5.1-codex": 400000,
|
|
30
|
+
"gpt-5.1-codex-max": 400000,
|
|
31
|
+
"gpt-5.1-codex-mini": 400000,
|
|
32
|
+
"gpt-5.2": 400000,
|
|
33
|
+
"gpt-5.2-codex": 400000,
|
|
34
|
+
"gpt-5.3-codex": 400000,
|
|
35
|
+
"gpt-5.4": 1050000,
|
|
36
|
+
"gpt-5.4-mini": 400000,
|
|
37
|
+
"gpt-5.4-nano": 400000,
|
|
38
|
+
"gpt-5.5": 1050000,
|
|
39
|
+
"gpt-5.6-luna": 1050000,
|
|
40
|
+
"gpt-5.6-sol": 1050000,
|
|
41
|
+
"gpt-5.6-terra": 1050000,
|
|
42
|
+
"gpt-6-astra": 1050000,
|
|
43
|
+
"gpt-6-luna": 1050000,
|
|
44
|
+
"gpt-6-sol": 1050000,
|
|
45
|
+
"gpt-6.1-sol": 1050000,
|
|
46
|
+
"gpt-oss-120b": 131072,
|
|
47
|
+
"gpt-oss-20b": 131072,
|
|
48
|
+
"grok-4.3": 1000000,
|
|
49
|
+
"grok-4.6": 500000,
|
|
50
|
+
"grok-4.7": 500000,
|
|
51
|
+
"kimi-k2.7-code": 262144,
|
|
52
|
+
"kimi-k3": 1048576,
|
|
53
|
+
"mistral-medium-3.5": 262144,
|
|
54
|
+
"mistral-small-4": 262144,
|
|
55
|
+
"muse-spark-1.3": 1048576,
|
|
56
|
+
"opus-4.7": 1000000,
|
|
57
|
+
"opus-4.8": 1000000,
|
|
58
|
+
"sonnet-4.6": 1000000
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -469,6 +469,11 @@
|
|
|
469
469
|
`claude agents` to a busy background session now applies right
|
|
470
470
|
away. 2.1.291 is a hotfix for two regressions. Neither release
|
|
471
471
|
changes the `/effort` vocabulary or the extended-thinking controls.
|
|
472
|
+
2.1.292 adds an `effort` parameter to the Agent tool, so a sub-agent
|
|
473
|
+
runs at the effort level you ask for. It also fixes cloud sessions
|
|
474
|
+
forgetting the client's thinking setting after a container restart.
|
|
475
|
+
The `/effort` vocabulary and the extended-thinking controls are
|
|
476
|
+
unchanged.
|
|
472
477
|
- OpenAI (Codex, OpenAI API, ChatGPT advanced controls):
|
|
473
478
|
`reasoning_effort` knob — `low`, `medium`, `high`, `xhigh`, `max`,
|
|
474
479
|
`ultra`. Codex's plan-mode `plan_mode_reasoning_effort` variant
|
|
@@ -37,6 +37,7 @@ BUNDLED_DOCS: dict[str, str] = {
|
|
|
37
37
|
"user-context.example.md": "user-context.example.md",
|
|
38
38
|
"catalog.json": "catalog.json",
|
|
39
39
|
"benchmarks.json": "benchmarks.json",
|
|
40
|
+
"context-windows.json": "context-windows.json",
|
|
40
41
|
"templates/phase-roadmap-template.md": "phase-roadmap-template.md",
|
|
41
42
|
"templates/project-roadmap-template.md": "project-roadmap-template.md",
|
|
42
43
|
"templates/planning-kit-how-to-use.md": "planning-kit-how-to-use.md",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "roadmodel"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.72"
|
|
8
8
|
description = "BYO-key CLI that recommends the right AI model, platform, and settings for a prompt."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -91,6 +91,7 @@ include = [
|
|
|
91
91
|
"docs/user-context.example.md",
|
|
92
92
|
"docs/catalog.json",
|
|
93
93
|
"docs/benchmarks.json",
|
|
94
|
+
"docs/context-windows.json",
|
|
94
95
|
"scripts/update_projects.py",
|
|
95
96
|
"scripts/agent_context.py",
|
|
96
97
|
"scripts/agent_rules.py",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "4",
|
|
3
3
|
"output_contract_version": 2,
|
|
4
|
-
"generated_at_utc": "2026-10-
|
|
4
|
+
"generated_at_utc": "2026-10-07T18:08:11Z",
|
|
5
5
|
"source_doc_sha256": {
|
|
6
|
-
"model-selector.txt": "
|
|
6
|
+
"model-selector.txt": "6a75e2265459fa7302cb80d0faf5ec2aad575034fc29866238cd1579c001cc2d",
|
|
7
7
|
"model-tier-cost-scale.md": "2b3b0eb82237eb5cb5bf468a3fe971bcaf0160ad7d0bc700698d2c16640bc41d"
|
|
8
8
|
},
|
|
9
9
|
"models": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Each catalogued model's context window in tokens: OpenRouter's context_length, the largest of its routes (update/extract_openrouter_models.py --context-windows, run by the catalog cron). A surface may cap a model lower.",
|
|
3
|
+
"source": "https://openrouter.ai/api/v1/models",
|
|
4
|
+
"models": {
|
|
5
|
+
"claude-4.5-haiku": 200000,
|
|
6
|
+
"claude-fable-5": 1000000,
|
|
7
|
+
"claude-fable-5.1": 1000000,
|
|
8
|
+
"claude-opus-5": 1000000,
|
|
9
|
+
"claude-opus-5-5": 1000000,
|
|
10
|
+
"claude-sonnet-5": 1000000,
|
|
11
|
+
"claude-sonnet-5-5": 1000000,
|
|
12
|
+
"codestral": 256000,
|
|
13
|
+
"deepseek-flash": 1048576,
|
|
14
|
+
"deepseek-v4-pro": 1048576,
|
|
15
|
+
"gemini-2.5-flash": 1048576,
|
|
16
|
+
"gemini-3-flash": 1048576,
|
|
17
|
+
"gemini-3.1-pro": 1048576,
|
|
18
|
+
"gemini-3.5-flash": 1048576,
|
|
19
|
+
"gemini-3.6-flash": 1048576,
|
|
20
|
+
"gemini-3.7-flash": 1048576,
|
|
21
|
+
"gemini-3.8-flash": 1048576,
|
|
22
|
+
"glm-4.5-air": 131072,
|
|
23
|
+
"glm-4.6": 204800,
|
|
24
|
+
"glm-5.2": 1048576,
|
|
25
|
+
"glm-5.3": 1048576,
|
|
26
|
+
"glm-5.3-flash": 1048576,
|
|
27
|
+
"gpt-5": 400000,
|
|
28
|
+
"gpt-5-mini": 400000,
|
|
29
|
+
"gpt-5.1-codex": 400000,
|
|
30
|
+
"gpt-5.1-codex-max": 400000,
|
|
31
|
+
"gpt-5.1-codex-mini": 400000,
|
|
32
|
+
"gpt-5.2": 400000,
|
|
33
|
+
"gpt-5.2-codex": 400000,
|
|
34
|
+
"gpt-5.3-codex": 400000,
|
|
35
|
+
"gpt-5.4": 1050000,
|
|
36
|
+
"gpt-5.4-mini": 400000,
|
|
37
|
+
"gpt-5.4-nano": 400000,
|
|
38
|
+
"gpt-5.5": 1050000,
|
|
39
|
+
"gpt-5.6-luna": 1050000,
|
|
40
|
+
"gpt-5.6-sol": 1050000,
|
|
41
|
+
"gpt-5.6-terra": 1050000,
|
|
42
|
+
"gpt-6-astra": 1050000,
|
|
43
|
+
"gpt-6-luna": 1050000,
|
|
44
|
+
"gpt-6-sol": 1050000,
|
|
45
|
+
"gpt-6.1-sol": 1050000,
|
|
46
|
+
"gpt-oss-120b": 131072,
|
|
47
|
+
"gpt-oss-20b": 131072,
|
|
48
|
+
"grok-4.3": 1000000,
|
|
49
|
+
"grok-4.6": 500000,
|
|
50
|
+
"grok-4.7": 500000,
|
|
51
|
+
"kimi-k2.7-code": 262144,
|
|
52
|
+
"kimi-k3": 1048576,
|
|
53
|
+
"mistral-medium-3.5": 262144,
|
|
54
|
+
"mistral-small-4": 262144,
|
|
55
|
+
"muse-spark-1.3": 1048576,
|
|
56
|
+
"opus-4.7": 1000000,
|
|
57
|
+
"opus-4.8": 1000000,
|
|
58
|
+
"sonnet-4.6": 1000000
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -469,6 +469,11 @@
|
|
|
469
469
|
`claude agents` to a busy background session now applies right
|
|
470
470
|
away. 2.1.291 is a hotfix for two regressions. Neither release
|
|
471
471
|
changes the `/effort` vocabulary or the extended-thinking controls.
|
|
472
|
+
2.1.292 adds an `effort` parameter to the Agent tool, so a sub-agent
|
|
473
|
+
runs at the effort level you ask for. It also fixes cloud sessions
|
|
474
|
+
forgetting the client's thinking setting after a container restart.
|
|
475
|
+
The `/effort` vocabulary and the extended-thinking controls are
|
|
476
|
+
unchanged.
|
|
472
477
|
- OpenAI (Codex, OpenAI API, ChatGPT advanced controls):
|
|
473
478
|
`reasoning_effort` knob — `low`, `medium`, `high`, `xhigh`, `max`,
|
|
474
479
|
`ultra`. Codex's plan-mode `plan_mode_reasoning_effort` variant
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# src/roadmodel/__init__.py
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.72"
|
|
@@ -485,7 +485,10 @@ _SAAS_LADDER_TABLE_HEADER: Final = (
|
|
|
485
485
|
"standard derivation) is `routine` however many steps it takes, and its "
|
|
486
486
|
"complexity is how hard that known result is to reproduce. Planning, "
|
|
487
487
|
"architecture, security hardening, design and refactors are `routine` at any "
|
|
488
|
-
"difficulty, as is every other task
|
|
488
|
+
"difficulty, as is every other task. When the task says how much material the "
|
|
489
|
+
"model must read (pages, words, lines of code, files), the next line is "
|
|
490
|
+
"`INPUT: <n> tokens`, your estimate of that material at about 600 tokens per "
|
|
491
|
+
"page, 1.3 per word and 10 per line of code; leave the line out otherwise.\n"
|
|
489
492
|
"2. Three blocks per the <output-format> 'Ladder mode' spec: TIER: QUALITY, "
|
|
490
493
|
"TIER: BALANCED and TIER: COST, in that order.\n\n"
|
|
491
494
|
"Obey strictly:\n"
|
|
@@ -671,6 +674,26 @@ _CLASSIFICATION_RE: Final = re.compile(
|
|
|
671
674
|
)
|
|
672
675
|
|
|
673
676
|
|
|
677
|
+
# The table-mode ladder's optional size line, after CLASSIFICATION:
|
|
678
|
+
# `INPUT: 240000 tokens` (also `~240k tokens`, `240,000 tokens`).
|
|
679
|
+
_INPUT_RE: Final = re.compile(
|
|
680
|
+
r"^[ \t]*INPUT:[ \t]*~?[ \t]*(?P<n>\d[\d,_]*(?:\.\d+)?)[ \t]*(?P<unit>[km])?[ \t]*tokens?\b",
|
|
681
|
+
flags=re.IGNORECASE | re.MULTILINE,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def parse_input_tokens(text: str) -> int | None:
|
|
686
|
+
"""The size in tokens of the material a table-mode response says the task
|
|
687
|
+
gives the model to read (its INPUT line), or None when it declares none."""
|
|
688
|
+
match = _INPUT_RE.search(text)
|
|
689
|
+
if not match:
|
|
690
|
+
return None
|
|
691
|
+
value = float(match.group("n").replace(",", "").replace("_", ""))
|
|
692
|
+
scale = {"k": 1_000, "m": 1_000_000}.get((match.group("unit") or "").lower(), 1)
|
|
693
|
+
tokens = int(value * scale)
|
|
694
|
+
return tokens if tokens > 0 else None
|
|
695
|
+
|
|
696
|
+
|
|
674
697
|
def parse_classification(text: str) -> str | None:
|
|
675
698
|
"""The ladder-table key (``scoring.table_key``) a table-mode response
|
|
676
699
|
declares on its CLASSIFICATION line, or None when it declares none or names
|
|
@@ -781,7 +804,9 @@ def recommend_ladder(
|
|
|
781
804
|
|
|
782
805
|
With ``ladder_table`` (scoring.render_ladder_table) the engine copies the
|
|
783
806
|
picks from the table instead of choosing them, and each base dict carries
|
|
784
|
-
the ``classification`` key the response declared, when it declared one
|
|
807
|
+
the ``classification`` key the response declared, when it declared one,
|
|
808
|
+
and ``input_tokens``, the size of the task's material its INPUT line
|
|
809
|
+
declared."""
|
|
785
810
|
resolved_user_context = (
|
|
786
811
|
user_context_text
|
|
787
812
|
if user_context_text is not None
|
|
@@ -807,9 +832,12 @@ def recommend_ladder(
|
|
|
807
832
|
)
|
|
808
833
|
parsed = parse_ladder_response(raw_response)
|
|
809
834
|
classification = parse_classification(raw_response) if ladder_table else None
|
|
810
|
-
if
|
|
811
|
-
|
|
835
|
+
input_tokens = parse_input_tokens(raw_response) if ladder_table else None
|
|
836
|
+
for base in parsed.values():
|
|
837
|
+
if classification:
|
|
812
838
|
base["classification"] = classification
|
|
839
|
+
if input_tokens:
|
|
840
|
+
base["input_tokens"] = str(input_tokens)
|
|
813
841
|
return parsed
|
|
814
842
|
|
|
815
843
|
|
|
@@ -1450,6 +1478,32 @@ def _ladder_table_for(
|
|
|
1450
1478
|
return {}
|
|
1451
1479
|
|
|
1452
1480
|
|
|
1481
|
+
def _fit_row_to_input(
|
|
1482
|
+
row: scoring.Ladder,
|
|
1483
|
+
input_tokens: int,
|
|
1484
|
+
scoring_text: str,
|
|
1485
|
+
unavailable_models: list[str] | None,
|
|
1486
|
+
) -> tuple[scoring.Ladder, dict[str, Any]]:
|
|
1487
|
+
"""The row with every model whose context window cannot hold the task's
|
|
1488
|
+
material left out (scoring.too_small_for), and what the check found. The
|
|
1489
|
+
table is computed before the engine reads the task, so the row is
|
|
1490
|
+
recomputed here when a model its frontier draws on is too small; with
|
|
1491
|
+
none left that fits, the row stands and ``fits`` says so."""
|
|
1492
|
+
small = set(scoring.too_small_for(input_tokens))
|
|
1493
|
+
drawn = {p.candidate.model_id for p in [*row.frontier, *row.points]}
|
|
1494
|
+
excluded = sorted(small & drawn)
|
|
1495
|
+
report: dict[str, Any] = {"input_tokens": input_tokens, "excluded": excluded, "fits": True}
|
|
1496
|
+
if not excluded:
|
|
1497
|
+
return row, report
|
|
1498
|
+
refit = scoring.ladder(
|
|
1499
|
+
row.task, scoring_text, unavailable_models=[*(unavailable_models or []), *sorted(small)]
|
|
1500
|
+
)
|
|
1501
|
+
if refit is None:
|
|
1502
|
+
report["fits"] = False
|
|
1503
|
+
return row, report
|
|
1504
|
+
return refit, report
|
|
1505
|
+
|
|
1506
|
+
|
|
1453
1507
|
def recommend_structured_ladder(
|
|
1454
1508
|
prompt: str,
|
|
1455
1509
|
config: Config,
|
|
@@ -1521,10 +1575,17 @@ def recommend_structured_ladder(
|
|
|
1521
1575
|
ladder_table=scoring.render_ladder_table(table) or None,
|
|
1522
1576
|
)
|
|
1523
1577
|
frontier_guard: dict[str, Any] | None = None
|
|
1578
|
+
fitted: scoring.Ladder | None = None
|
|
1524
1579
|
if table:
|
|
1525
1580
|
key, found = _table_row_key(ladder, table)
|
|
1526
1581
|
if key is not None:
|
|
1527
1582
|
row = table[key]
|
|
1583
|
+
context: dict[str, Any] | None = None
|
|
1584
|
+
tokens = next(
|
|
1585
|
+
(int(b["input_tokens"]) for b in ladder.values() if b.get("input_tokens")), None
|
|
1586
|
+
)
|
|
1587
|
+
if tokens is not None and scoring_text is not None:
|
|
1588
|
+
row, context = _fit_row_to_input(row, tokens, scoring_text, unavailable_models)
|
|
1528
1589
|
frontier_guard = {
|
|
1529
1590
|
"mode": "frontier",
|
|
1530
1591
|
"classification": key,
|
|
@@ -1538,11 +1599,14 @@ def recommend_structured_ladder(
|
|
|
1538
1599
|
if row.rungs["quality"].specialist
|
|
1539
1600
|
else None
|
|
1540
1601
|
),
|
|
1602
|
+
"context": context,
|
|
1541
1603
|
}
|
|
1604
|
+
fitted = row
|
|
1542
1605
|
else:
|
|
1543
1606
|
frontier_guard = {"mode": "frontier", "classification": None, "found": found}
|
|
1544
1607
|
for base in ladder.values():
|
|
1545
1608
|
base.pop("classification", None)
|
|
1609
|
+
base.pop("input_tokens", None)
|
|
1546
1610
|
picks = {
|
|
1547
1611
|
tier: _base_to_payload(
|
|
1548
1612
|
ladder[tier],
|
|
@@ -1551,9 +1615,9 @@ def recommend_structured_ladder(
|
|
|
1551
1615
|
)
|
|
1552
1616
|
for tier in _LADDER_TIERS
|
|
1553
1617
|
}
|
|
1554
|
-
if
|
|
1555
|
-
_attach_backup_plans(picks,
|
|
1556
|
-
_mark_specialist(picks,
|
|
1618
|
+
if fitted is not None:
|
|
1619
|
+
_attach_backup_plans(picks, fitted)
|
|
1620
|
+
_mark_specialist(picks, fitted)
|
|
1557
1621
|
guard = _ladder_tier_guard(picks)
|
|
1558
1622
|
if frontier_guard is not None:
|
|
1559
1623
|
guard.update(frontier_guard)
|
|
@@ -147,6 +147,15 @@ REQUIREMENT: Final[dict[str, float]] = {"low": 30.0, "medium": 50.0, "high": 70.
|
|
|
147
147
|
REQUIREMENT_NOVEL: Final[float] = 85.0
|
|
148
148
|
# Points lost per point of shortfall below the requirement (steep, but soft).
|
|
149
149
|
SHORTFALL_SLOPE: Final[float] = 1.5
|
|
150
|
+
# Benchmark differences within run-to-run noise, in the scaled evidence's
|
|
151
|
+
# points (0–100 across the measured catalog): two candidates this close in a
|
|
152
|
+
# category stand level there, and the general measure (the AA Index) decides
|
|
153
|
+
# between them. Measured from the cases where AA scores a model lower at a
|
|
154
|
+
# higher effort: on its long-context test (LCR) 90% of the 32 such drops fall
|
|
155
|
+
# within 5.6 points (about 3 points of LCR); on the other benchmarks they stay
|
|
156
|
+
# within about 4.
|
|
157
|
+
TIE_BAND: Final[dict[str, float]] = {"long-context": 6.0}
|
|
158
|
+
DEFAULT_TIE_BAND: Final[float] = 4.0
|
|
150
159
|
# λ: the share of the market's exchange rate K this operator applies to a
|
|
151
160
|
# decade of spend. λ = 1 ranks purely by value (the market-line residual, the
|
|
152
161
|
# same quantity the /models Score shows); λ → 0 ranks purely by quality. It is
|
|
@@ -206,6 +215,10 @@ FUNDING_PREFERENCE: Final[dict[str, int]] = {
|
|
|
206
215
|
BASELINE_JURISDICTIONS: Final[tuple[str, ...]] = ("us", "eu", "uk", "ca", "au", "jp", "kr")
|
|
207
216
|
|
|
208
217
|
BUNDLED_BENCHMARKS_PATH = resources.files("roadmodel.data") / "benchmarks.json"
|
|
218
|
+
BUNDLED_CONTEXT_PATH = resources.files("roadmodel.data") / "context-windows.json"
|
|
219
|
+
# A context window must hold the task's material plus the instructions and the
|
|
220
|
+
# answer: 10% above the material's own size.
|
|
221
|
+
CONTEXT_HEADROOM: Final[float] = 1.1
|
|
209
222
|
|
|
210
223
|
|
|
211
224
|
# --------------------------------------------------------------------------- #
|
|
@@ -257,6 +270,10 @@ class Candidate:
|
|
|
257
270
|
# The effort AA measured the quality evidence at; this candidate runs at
|
|
258
271
|
# it. None: the model's headline figure, at the posture's effort.
|
|
259
272
|
evidence_level: str | None = None
|
|
273
|
+
# The category's evidence on its 0–100 scale (EvidenceScale.points), the
|
|
274
|
+
# figure ties are judged on; None when AA has not measured it there or the
|
|
275
|
+
# category has no evidence (multimodal).
|
|
276
|
+
evidence_points: float | None = None
|
|
260
277
|
|
|
261
278
|
def to_dict(self) -> dict[str, Any]:
|
|
262
279
|
return asdict(self)
|
|
@@ -300,6 +317,28 @@ def _load_benchmarks() -> dict[str, Any]:
|
|
|
300
317
|
return models if isinstance(models, dict) else {}
|
|
301
318
|
|
|
302
319
|
|
|
320
|
+
def context_windows() -> dict[str, int]:
|
|
321
|
+
"""Each catalog model's context window in tokens (docs/context-windows.json,
|
|
322
|
+
from OpenRouter's listing); empty when the file is missing or unreadable."""
|
|
323
|
+
try:
|
|
324
|
+
raw = json.loads(BUNDLED_CONTEXT_PATH.read_text(encoding="utf-8"))
|
|
325
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
326
|
+
return {}
|
|
327
|
+
models = raw.get("models") if isinstance(raw, dict) else None
|
|
328
|
+
if not isinstance(models, dict):
|
|
329
|
+
return {}
|
|
330
|
+
return {str(k): v for k, v in models.items() if isinstance(v, int) and v > 0}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def too_small_for(input_tokens: int, windows: dict[str, int] | None = None) -> list[str]:
|
|
334
|
+
"""The catalog ids whose context window cannot hold ``input_tokens`` of
|
|
335
|
+
material with CONTEXT_HEADROOM to spare. A model with no known window is
|
|
336
|
+
kept: nothing says it cannot."""
|
|
337
|
+
known = windows if windows is not None else context_windows()
|
|
338
|
+
need = input_tokens * CONTEXT_HEADROOM
|
|
339
|
+
return sorted(mid for mid, n in known.items() if n < need)
|
|
340
|
+
|
|
341
|
+
|
|
303
342
|
def with_composites(bench: dict[str, Any]) -> dict[str, Any]:
|
|
304
343
|
"""A copy of ``bench`` with each ``COMPOSITES`` key added to the
|
|
305
344
|
evaluations of every row measured on all its parts: the mean of the row's
|
|
@@ -770,20 +809,35 @@ def _quality(
|
|
|
770
809
|
tiers: dict[str, Any] = raw_tiers if isinstance(raw_tiers, dict) else {}
|
|
771
810
|
letter = str(tiers.get(task.category, "C")).upper()
|
|
772
811
|
letter_q = LETTER_QUALITY.get(letter, 30.0)
|
|
773
|
-
key = CATEGORY_EVIDENCE[task.category]
|
|
774
|
-
v = _evidence(bench, str(model.get("id", "")), key, level)
|
|
775
|
-
if v is None and level is not None and key == "median_output_tokens_per_second":
|
|
776
|
-
# Throughput is the endpoint's at any effort; AA leaves it unmeasured
|
|
777
|
-
# on most of a model's other-effort rows.
|
|
778
|
-
v = _evidence(bench, str(model.get("id", "")), key)
|
|
779
812
|
if scale is None:
|
|
780
813
|
# No evidence exists for this category at all (multimodal): every model
|
|
781
814
|
# is on its letter, so there is nothing to discount against.
|
|
782
815
|
return letter_q, "letter", letter
|
|
783
|
-
|
|
816
|
+
points = _evidence_points(str(model.get("id", "")), task, bench, scale, level)
|
|
817
|
+
if points is None:
|
|
784
818
|
return max(0.0, letter_q - UNMEASURED_DISCOUNT), "letter", letter
|
|
785
|
-
q = EVIDENCE_WEIGHT *
|
|
786
|
-
return q, f"aa:{
|
|
819
|
+
q = EVIDENCE_WEIGHT * points + (1.0 - EVIDENCE_WEIGHT) * letter_q
|
|
820
|
+
return q, f"aa:{CATEGORY_EVIDENCE[task.category]}+letter", letter
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _evidence_points(
|
|
824
|
+
model_id: str,
|
|
825
|
+
task: Task,
|
|
826
|
+
bench: dict[str, Any],
|
|
827
|
+
scale: EvidenceScale | None,
|
|
828
|
+
level: str | None = None,
|
|
829
|
+
) -> float | None:
|
|
830
|
+
"""The category's evidence for ``model_id`` (at ``level`` when given) on
|
|
831
|
+
its 0–100 scale, or None when unmeasured or the category has none."""
|
|
832
|
+
if scale is None:
|
|
833
|
+
return None
|
|
834
|
+
key = CATEGORY_EVIDENCE[task.category]
|
|
835
|
+
# Throughput is the endpoint's at any effort (AA leaves it unmeasured on
|
|
836
|
+
# most of a model's other-effort rows, and its figures there are noise):
|
|
837
|
+
# speed reads the headline row's.
|
|
838
|
+
at = None if key == "median_output_tokens_per_second" else level
|
|
839
|
+
v = _evidence(bench, model_id, key, at)
|
|
840
|
+
return None if v is None else scale.points(v)
|
|
787
841
|
|
|
788
842
|
|
|
789
843
|
@dataclass(frozen=True)
|
|
@@ -959,6 +1013,7 @@ def rank(
|
|
|
959
1013
|
continue
|
|
960
1014
|
provider = _cost.model_provider(model_id) or str(model.get("provider", "unknown"))
|
|
961
1015
|
quality, source, letter = _quality(model, task, bench, scale)
|
|
1016
|
+
points = _evidence_points(model_id, task, bench, scale)
|
|
962
1017
|
shortfall = max(0.0, requirement - quality)
|
|
963
1018
|
penalty = SHORTFALL_SLOPE * shortfall
|
|
964
1019
|
price = blended_price(model)
|
|
@@ -1023,6 +1078,7 @@ def rank(
|
|
|
1023
1078
|
cost_penalty=round(cost_pen, 2),
|
|
1024
1079
|
score=round(score, 2),
|
|
1025
1080
|
notes=notes,
|
|
1081
|
+
evidence_points=None if points is None else round(points, 2),
|
|
1026
1082
|
)
|
|
1027
1083
|
# One row per model: its best reachable platform. A funded platform
|
|
1028
1084
|
# always beats an unfunded one regardless of score; equal scores go
|
|
@@ -1242,6 +1298,35 @@ def _nearest(p: FrontierPoint) -> tuple[float, float, float, float]:
|
|
|
1242
1298
|
return (-p.candidate.requirement_penalty, *_strength(p))
|
|
1243
1299
|
|
|
1244
1300
|
|
|
1301
|
+
def _level_with(a: Candidate, b: Candidate, category: str) -> bool:
|
|
1302
|
+
"""Whether ``a`` stands at least level with ``b`` in the category: its
|
|
1303
|
+
evidence no more than the noise band (``TIE_BAND``) below ``b``'s, or,
|
|
1304
|
+
where either is unmeasured there, its quality no lower."""
|
|
1305
|
+
if a.evidence_points is None or b.evidence_points is None:
|
|
1306
|
+
return a.quality >= b.quality
|
|
1307
|
+
return a.evidence_points >= b.evidence_points - TIE_BAND.get(category, DEFAULT_TIE_BAND)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _tie_break(p: FrontierPoint, category: str) -> tuple[float, float, float]:
|
|
1311
|
+
"""Between points level in the category: the higher AA Index, then the
|
|
1312
|
+
cheaper. On speed work the lower effort comes first: throughput is the
|
|
1313
|
+
same at every effort, and more reasoning only delays the answer."""
|
|
1314
|
+
slower = 0.0
|
|
1315
|
+
if category == "speed":
|
|
1316
|
+
level = p.candidate.evidence_level or p.candidate.effort
|
|
1317
|
+
slower = -float(EFFORT_LADDER.index(level)) if level in EFFORT_LADDER else 0.0
|
|
1318
|
+
return (slower, -1.0 if p.aa_index is None else p.aa_index, -p.price_usd)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _top(points: list[FrontierPoint], category: str) -> FrontierPoint:
|
|
1322
|
+
"""The strongest point for the category: of those level with its leader
|
|
1323
|
+
there (:func:`_level_with`), the one ahead on :func:`_tie_break`. A
|
|
1324
|
+
difference within run-to-run noise does not decide it."""
|
|
1325
|
+
leader = max(points, key=_strength)
|
|
1326
|
+
level = [p for p in points if _level_with(p.candidate, leader.candidate, category)]
|
|
1327
|
+
return max(level, key=lambda p: _tie_break(p, category))
|
|
1328
|
+
|
|
1329
|
+
|
|
1245
1330
|
# Letters from weakest to strongest.
|
|
1246
1331
|
LETTER_ORDER: Final[str] = "DCBAS"
|
|
1247
1332
|
# Categories whose evidence is something other than the AA Intelligence Index,
|
|
@@ -1276,6 +1361,7 @@ def _specialist(
|
|
|
1276
1361
|
and c.requirement_penalty == 0
|
|
1277
1362
|
and rank(c.letter) > rank(t.letter)
|
|
1278
1363
|
and c.quality > t.quality
|
|
1364
|
+
and not _level_with(t, c, task.category)
|
|
1279
1365
|
]
|
|
1280
1366
|
if not stronger:
|
|
1281
1367
|
return None
|
|
@@ -1325,6 +1411,7 @@ def _at_level(
|
|
|
1325
1411
|
model is not up to it.
|
|
1326
1412
|
``scales`` are the category's evidence scale and the planning one."""
|
|
1327
1413
|
quality, source, letter = _quality(model, task, bench, scales[0], level)
|
|
1414
|
+
points = _evidence_points(c.model_id, task, bench, scales[0], level)
|
|
1328
1415
|
general, _, _ = _quality(model, replace(task, category="planning"), bench, scales[1], level)
|
|
1329
1416
|
penalty = SHORTFALL_SLOPE * max(0.0, c.requirement - min(quality, general))
|
|
1330
1417
|
if level is None:
|
|
@@ -1354,6 +1441,7 @@ def _at_level(
|
|
|
1354
1441
|
cost_penalty=round(cost_pen, 2),
|
|
1355
1442
|
score=round(quality - penalty - cost_pen + nudge, 2),
|
|
1356
1443
|
evidence_level=level,
|
|
1444
|
+
evidence_points=None if points is None else round(points, 2),
|
|
1357
1445
|
)
|
|
1358
1446
|
|
|
1359
1447
|
|
|
@@ -1448,8 +1536,9 @@ def ladder(
|
|
|
1448
1536
|
COST and BALANCED stay on the frontier, BALANCED then spanning every
|
|
1449
1537
|
adequate point above COST.
|
|
1450
1538
|
- Each rung carries a BACKUP from another maker (:func:`_backup_for`):
|
|
1451
|
-
the
|
|
1452
|
-
|
|
1539
|
+
the cheapest point on the other makers' frontier that does the job as
|
|
1540
|
+
well as the rung, else the nearest below it. ``backup_warning`` says why
|
|
1541
|
+
a rung has none.
|
|
1453
1542
|
|
|
1454
1543
|
None when no pool model carries an AA Index (no frontier to read)."""
|
|
1455
1544
|
cat = catalog if catalog is not None else _cost._load_catalog()
|
|
@@ -1478,7 +1567,7 @@ def ladder(
|
|
|
1478
1567
|
adequate = [p for p in points if p.candidate.requirement_penalty == 0]
|
|
1479
1568
|
if not adequate:
|
|
1480
1569
|
adequate = [max(points, key=_nearest)]
|
|
1481
|
-
top =
|
|
1570
|
+
top = _top(adequate, task.category)
|
|
1482
1571
|
span = adequate[: adequate.index(top) + 1]
|
|
1483
1572
|
specialist = _specialist(task, settings, points, top, bench)
|
|
1484
1573
|
by_level = {(c.model_id, c.evidence_level): c for c in settings if c.evidence_level}
|
|
@@ -1541,11 +1630,12 @@ def ladder(
|
|
|
1541
1630
|
)
|
|
1542
1631
|
# With a specialist on QUALITY, BALANCED spans every adequate frontier point
|
|
1543
1632
|
# above COST, the frontier's strongest included. A BALANCED point earns its
|
|
1544
|
-
# higher price only by
|
|
1545
|
-
# frontier is drawn on the AA Index, and a point above COST
|
|
1546
|
-
# can still trail it in the category the task needs.
|
|
1633
|
+
# higher price only by standing at least level with COST in the task's own
|
|
1634
|
+
# category: the frontier is drawn on the AA Index, and a point above COST
|
|
1635
|
+
# on that axis can still trail it in the category the task needs. A gap
|
|
1636
|
+
# within run-to-run noise is level, and the AA Index then decides.
|
|
1547
1637
|
between = span[lo + 1 :] if specialist is not None else span[lo + 1 : -1]
|
|
1548
|
-
between = [p for p in between if p.candidate
|
|
1638
|
+
between = [p for p in between if _level_with(p.candidate, cost_point.candidate, task.category)]
|
|
1549
1639
|
# Like COST, BALANCED draws on what is already paid for first: a per-token
|
|
1550
1640
|
# point stands between only when no prepaid one out-scores COST there.
|
|
1551
1641
|
between = [p for p in between if p.candidate.scarcity < 1.0] or between
|
|
@@ -1574,8 +1664,12 @@ def ladder(
|
|
|
1574
1664
|
)
|
|
1575
1665
|
balanced_rung = settle(_below(balanced_rung, quality_rung, cat))
|
|
1576
1666
|
cost_rung = settle(_below(cost_rung, balanced_rung, cat))
|
|
1667
|
+
# General capability is level within the same noise band, read on the
|
|
1668
|
+
# AA Index's own 0–100 scale (the planning evidence).
|
|
1669
|
+
gscale = _evidence_scale(cat, bench, FRONTIER_INDEX)
|
|
1670
|
+
aa_band = 0.0 if gscale is None else DEFAULT_TIE_BAND * (gscale.hi - gscale.lo) / 100.0
|
|
1577
1671
|
rungs = {
|
|
1578
|
-
r.tier: replace(r, backup=_backup_for(r, settings, task, headroom, bench, cat))
|
|
1672
|
+
r.tier: replace(r, backup=_backup_for(r, settings, task, headroom, bench, cat, aa_band))
|
|
1579
1673
|
for r in (cost_rung, balanced_rung, quality_rung)
|
|
1580
1674
|
}
|
|
1581
1675
|
warning: str | None = None
|
|
@@ -1603,23 +1697,51 @@ def _backup_for(
|
|
|
1603
1697
|
headroom: str,
|
|
1604
1698
|
bench: dict[str, Any],
|
|
1605
1699
|
catalog: dict[str, Any],
|
|
1700
|
+
aa_band: float = 0.0,
|
|
1606
1701
|
) -> Backup | None:
|
|
1607
|
-
"""The rung's backup: the
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
Over the pool's models from a maker other than the rung model's,
|
|
1611
|
-
frontier and keep its adequate points
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1702
|
+
"""The rung's backup: the substitute from another maker that does the job
|
|
1703
|
+
as well as the rung, for the least.
|
|
1704
|
+
|
|
1705
|
+
Over the pool's models from a maker other than the rung model's, each at
|
|
1706
|
+
its measured efforts, draw the frontier and keep its adequate points,
|
|
1707
|
+
those already paid for ahead of per-token ones. A point matches the rung
|
|
1708
|
+
when it stands level with it in the task's category
|
|
1709
|
+
(:func:`_level_with`) and in general capability (an AA Index no more than
|
|
1710
|
+
``aa_band`` below). The backup is the cheapest match; with none, the
|
|
1711
|
+
substitute the QUALITY rule would pick (:func:`_top`: strongest in the
|
|
1712
|
+
category, a near tie going to the higher AA Index); with no adequate
|
|
1713
|
+
point, the one that falls shortest of the bar. It runs at its
|
|
1714
|
+
point's measured effort, else at the rung's posture's effort, on its own
|
|
1715
|
+
platform. None when the pool holds no other
|
|
1716
|
+
maker's model."""
|
|
1616
1717
|
front = frontier([c for c in pool if c.provider != rung.candidate.provider], bench)
|
|
1617
1718
|
if not front:
|
|
1618
1719
|
return None
|
|
1619
1720
|
adequate = [p for p in front if p.candidate.requirement_penalty == 0]
|
|
1721
|
+
r = rung.point
|
|
1722
|
+
|
|
1723
|
+
def paid_first(points: list[FrontierPoint]) -> list[FrontierPoint]:
|
|
1724
|
+
return [p for p in points if p.candidate.scarcity < 1.0] or points
|
|
1725
|
+
|
|
1726
|
+
def aa(p: FrontierPoint) -> float:
|
|
1727
|
+
return -1.0 if p.aa_index is None else p.aa_index
|
|
1728
|
+
|
|
1620
1729
|
if adequate:
|
|
1621
|
-
|
|
1622
|
-
|
|
1730
|
+
# The cheapest substitute level with the rung in the category and in
|
|
1731
|
+
# general capability; with none, the one the QUALITY rule would pick
|
|
1732
|
+
# among the substitutes (:func:`_top`).
|
|
1733
|
+
near = paid_first(adequate)
|
|
1734
|
+
matches = [
|
|
1735
|
+
p
|
|
1736
|
+
for p in near
|
|
1737
|
+
if _level_with(p.candidate, r.candidate, task.category)
|
|
1738
|
+
and (r.aa_index is None or aa(p) >= r.aa_index - aa_band)
|
|
1739
|
+
]
|
|
1740
|
+
chosen = (
|
|
1741
|
+
min(matches, key=lambda p: (p.price_usd, -aa(p)))
|
|
1742
|
+
if matches
|
|
1743
|
+
else _top(near, task.category)
|
|
1744
|
+
)
|
|
1623
1745
|
else:
|
|
1624
1746
|
chosen = max(front, key=_nearest)
|
|
1625
1747
|
c = chosen.candidate
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|