roadmodel 0.2.71__tar.gz → 0.2.73__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.73}/PKG-INFO +1 -1
- {roadmodel-0.2.71/roadmodel/data → roadmodel-0.2.73/docs}/catalog.json +2 -2
- roadmodel-0.2.73/docs/context-windows.json +60 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/model-selector.txt +5 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/hatch_build.py +1 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/pyproject.toml +2 -1
- {roadmodel-0.2.71/docs → roadmodel-0.2.73/roadmodel/data}/catalog.json +2 -2
- roadmodel-0.2.73/roadmodel/data/context-windows.json +60 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/model-selector.txt +5 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/__init__.py +1 -1
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/recommend.py +77 -7
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/scoring.py +158 -28
- {roadmodel-0.2.71 → roadmodel-0.2.73}/.gitignore +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/LICENSE +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/NOTICE +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/benchmarks.json +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/settings-display.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/project-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/user-context.example.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/gcp-killswitch/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/supabase/README.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/benchmarks.json +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/project-roadmap-template.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/settings-display.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/user-context.example.md +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/agent_context.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/agent_rules.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/update_projects.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/updater_lock.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/upgrade_fleet.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/worktree_memory.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/__main__.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/cli.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/config.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/cost.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/errors.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/mcp_server.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/__init__.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/anthropic.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/google.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/openai.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/openai_compatible.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/registry.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/usage.py +0 -0
- {roadmodel-0.2.71 → roadmodel-0.2.73}/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.73
|
|
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-07T19:13:30Z",
|
|
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.73"
|
|
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-07T19:13:30Z",
|
|
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.73"
|
|
@@ -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,38 @@ 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. When the
|
|
1491
|
+
material is larger than every known window, or none left fits, the row
|
|
1492
|
+
stands and ``fits`` says so."""
|
|
1493
|
+
small = set(scoring.too_small_for(input_tokens))
|
|
1494
|
+
drawn = {p.candidate.model_id for p in [*row.frontier, *row.points]}
|
|
1495
|
+
excluded = sorted(small & drawn)
|
|
1496
|
+
report: dict[str, Any] = {"input_tokens": input_tokens, "excluded": excluded, "fits": True}
|
|
1497
|
+
if scoring.nothing_fits(input_tokens):
|
|
1498
|
+
# Larger than every known window: no pick holds it, and one whose
|
|
1499
|
+
# window is unknown would stand only by default.
|
|
1500
|
+
report["fits"] = False
|
|
1501
|
+
return row, report
|
|
1502
|
+
if not excluded:
|
|
1503
|
+
return row, report
|
|
1504
|
+
refit = scoring.ladder(
|
|
1505
|
+
row.task, scoring_text, unavailable_models=[*(unavailable_models or []), *sorted(small)]
|
|
1506
|
+
)
|
|
1507
|
+
if refit is None:
|
|
1508
|
+
report["fits"] = False
|
|
1509
|
+
return row, report
|
|
1510
|
+
return refit, report
|
|
1511
|
+
|
|
1512
|
+
|
|
1453
1513
|
def recommend_structured_ladder(
|
|
1454
1514
|
prompt: str,
|
|
1455
1515
|
config: Config,
|
|
@@ -1521,10 +1581,17 @@ def recommend_structured_ladder(
|
|
|
1521
1581
|
ladder_table=scoring.render_ladder_table(table) or None,
|
|
1522
1582
|
)
|
|
1523
1583
|
frontier_guard: dict[str, Any] | None = None
|
|
1584
|
+
fitted: scoring.Ladder | None = None
|
|
1524
1585
|
if table:
|
|
1525
1586
|
key, found = _table_row_key(ladder, table)
|
|
1526
1587
|
if key is not None:
|
|
1527
1588
|
row = table[key]
|
|
1589
|
+
context: dict[str, Any] | None = None
|
|
1590
|
+
tokens = next(
|
|
1591
|
+
(int(b["input_tokens"]) for b in ladder.values() if b.get("input_tokens")), None
|
|
1592
|
+
)
|
|
1593
|
+
if tokens is not None and scoring_text is not None:
|
|
1594
|
+
row, context = _fit_row_to_input(row, tokens, scoring_text, unavailable_models)
|
|
1528
1595
|
frontier_guard = {
|
|
1529
1596
|
"mode": "frontier",
|
|
1530
1597
|
"classification": key,
|
|
@@ -1538,11 +1605,14 @@ def recommend_structured_ladder(
|
|
|
1538
1605
|
if row.rungs["quality"].specialist
|
|
1539
1606
|
else None
|
|
1540
1607
|
),
|
|
1608
|
+
"context": context,
|
|
1541
1609
|
}
|
|
1610
|
+
fitted = row
|
|
1542
1611
|
else:
|
|
1543
1612
|
frontier_guard = {"mode": "frontier", "classification": None, "found": found}
|
|
1544
1613
|
for base in ladder.values():
|
|
1545
1614
|
base.pop("classification", None)
|
|
1615
|
+
base.pop("input_tokens", None)
|
|
1546
1616
|
picks = {
|
|
1547
1617
|
tier: _base_to_payload(
|
|
1548
1618
|
ladder[tier],
|
|
@@ -1551,9 +1621,9 @@ def recommend_structured_ladder(
|
|
|
1551
1621
|
)
|
|
1552
1622
|
for tier in _LADDER_TIERS
|
|
1553
1623
|
}
|
|
1554
|
-
if
|
|
1555
|
-
_attach_backup_plans(picks,
|
|
1556
|
-
_mark_specialist(picks,
|
|
1624
|
+
if fitted is not None:
|
|
1625
|
+
_attach_backup_plans(picks, fitted)
|
|
1626
|
+
_mark_specialist(picks, fitted)
|
|
1557
1627
|
guard = _ladder_tier_guard(picks)
|
|
1558
1628
|
if frontier_guard is not None:
|
|
1559
1629
|
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,36 @@ 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 nothing_fits(input_tokens: int, windows: dict[str, int] | None = None) -> bool:
|
|
334
|
+
"""True when no model with a known context window can hold
|
|
335
|
+
``input_tokens`` of material: the material is larger than any model
|
|
336
|
+
takes, and a model whose window is unknown is no answer."""
|
|
337
|
+
known = windows if windows is not None else context_windows()
|
|
338
|
+
return bool(known) and len(too_small_for(input_tokens, known)) == len(known)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def too_small_for(input_tokens: int, windows: dict[str, int] | None = None) -> list[str]:
|
|
342
|
+
"""The catalog ids whose context window cannot hold ``input_tokens`` of
|
|
343
|
+
material with CONTEXT_HEADROOM to spare. A model with no known window is
|
|
344
|
+
kept: nothing says it cannot."""
|
|
345
|
+
known = windows if windows is not None else context_windows()
|
|
346
|
+
need = input_tokens * CONTEXT_HEADROOM
|
|
347
|
+
return sorted(mid for mid, n in known.items() if n < need)
|
|
348
|
+
|
|
349
|
+
|
|
303
350
|
def with_composites(bench: dict[str, Any]) -> dict[str, Any]:
|
|
304
351
|
"""A copy of ``bench`` with each ``COMPOSITES`` key added to the
|
|
305
352
|
evaluations of every row measured on all its parts: the mean of the row's
|
|
@@ -770,20 +817,35 @@ def _quality(
|
|
|
770
817
|
tiers: dict[str, Any] = raw_tiers if isinstance(raw_tiers, dict) else {}
|
|
771
818
|
letter = str(tiers.get(task.category, "C")).upper()
|
|
772
819
|
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
820
|
if scale is None:
|
|
780
821
|
# No evidence exists for this category at all (multimodal): every model
|
|
781
822
|
# is on its letter, so there is nothing to discount against.
|
|
782
823
|
return letter_q, "letter", letter
|
|
783
|
-
|
|
824
|
+
points = _evidence_points(str(model.get("id", "")), task, bench, scale, level)
|
|
825
|
+
if points is None:
|
|
784
826
|
return max(0.0, letter_q - UNMEASURED_DISCOUNT), "letter", letter
|
|
785
|
-
q = EVIDENCE_WEIGHT *
|
|
786
|
-
return q, f"aa:{
|
|
827
|
+
q = EVIDENCE_WEIGHT * points + (1.0 - EVIDENCE_WEIGHT) * letter_q
|
|
828
|
+
return q, f"aa:{CATEGORY_EVIDENCE[task.category]}+letter", letter
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _evidence_points(
|
|
832
|
+
model_id: str,
|
|
833
|
+
task: Task,
|
|
834
|
+
bench: dict[str, Any],
|
|
835
|
+
scale: EvidenceScale | None,
|
|
836
|
+
level: str | None = None,
|
|
837
|
+
) -> float | None:
|
|
838
|
+
"""The category's evidence for ``model_id`` (at ``level`` when given) on
|
|
839
|
+
its 0–100 scale, or None when unmeasured or the category has none."""
|
|
840
|
+
if scale is None:
|
|
841
|
+
return None
|
|
842
|
+
key = CATEGORY_EVIDENCE[task.category]
|
|
843
|
+
# Throughput is the endpoint's at any effort (AA leaves it unmeasured on
|
|
844
|
+
# most of a model's other-effort rows, and its figures there are noise):
|
|
845
|
+
# speed reads the headline row's.
|
|
846
|
+
at = None if key == "median_output_tokens_per_second" else level
|
|
847
|
+
v = _evidence(bench, model_id, key, at)
|
|
848
|
+
return None if v is None else scale.points(v)
|
|
787
849
|
|
|
788
850
|
|
|
789
851
|
@dataclass(frozen=True)
|
|
@@ -959,6 +1021,7 @@ def rank(
|
|
|
959
1021
|
continue
|
|
960
1022
|
provider = _cost.model_provider(model_id) or str(model.get("provider", "unknown"))
|
|
961
1023
|
quality, source, letter = _quality(model, task, bench, scale)
|
|
1024
|
+
points = _evidence_points(model_id, task, bench, scale)
|
|
962
1025
|
shortfall = max(0.0, requirement - quality)
|
|
963
1026
|
penalty = SHORTFALL_SLOPE * shortfall
|
|
964
1027
|
price = blended_price(model)
|
|
@@ -1023,6 +1086,7 @@ def rank(
|
|
|
1023
1086
|
cost_penalty=round(cost_pen, 2),
|
|
1024
1087
|
score=round(score, 2),
|
|
1025
1088
|
notes=notes,
|
|
1089
|
+
evidence_points=None if points is None else round(points, 2),
|
|
1026
1090
|
)
|
|
1027
1091
|
# One row per model: its best reachable platform. A funded platform
|
|
1028
1092
|
# always beats an unfunded one regardless of score; equal scores go
|
|
@@ -1242,6 +1306,35 @@ def _nearest(p: FrontierPoint) -> tuple[float, float, float, float]:
|
|
|
1242
1306
|
return (-p.candidate.requirement_penalty, *_strength(p))
|
|
1243
1307
|
|
|
1244
1308
|
|
|
1309
|
+
def _level_with(a: Candidate, b: Candidate, category: str) -> bool:
|
|
1310
|
+
"""Whether ``a`` stands at least level with ``b`` in the category: its
|
|
1311
|
+
evidence no more than the noise band (``TIE_BAND``) below ``b``'s, or,
|
|
1312
|
+
where either is unmeasured there, its quality no lower."""
|
|
1313
|
+
if a.evidence_points is None or b.evidence_points is None:
|
|
1314
|
+
return a.quality >= b.quality
|
|
1315
|
+
return a.evidence_points >= b.evidence_points - TIE_BAND.get(category, DEFAULT_TIE_BAND)
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _tie_break(p: FrontierPoint, category: str) -> tuple[float, float, float]:
|
|
1319
|
+
"""Between points level in the category: the higher AA Index, then the
|
|
1320
|
+
cheaper. On speed work the lower effort comes first: throughput is the
|
|
1321
|
+
same at every effort, and more reasoning only delays the answer."""
|
|
1322
|
+
slower = 0.0
|
|
1323
|
+
if category == "speed":
|
|
1324
|
+
level = p.candidate.evidence_level or p.candidate.effort
|
|
1325
|
+
slower = -float(EFFORT_LADDER.index(level)) if level in EFFORT_LADDER else 0.0
|
|
1326
|
+
return (slower, -1.0 if p.aa_index is None else p.aa_index, -p.price_usd)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _top(points: list[FrontierPoint], category: str) -> FrontierPoint:
|
|
1330
|
+
"""The strongest point for the category: of those level with its leader
|
|
1331
|
+
there (:func:`_level_with`), the one ahead on :func:`_tie_break`. A
|
|
1332
|
+
difference within run-to-run noise does not decide it."""
|
|
1333
|
+
leader = max(points, key=_strength)
|
|
1334
|
+
level = [p for p in points if _level_with(p.candidate, leader.candidate, category)]
|
|
1335
|
+
return max(level, key=lambda p: _tie_break(p, category))
|
|
1336
|
+
|
|
1337
|
+
|
|
1245
1338
|
# Letters from weakest to strongest.
|
|
1246
1339
|
LETTER_ORDER: Final[str] = "DCBAS"
|
|
1247
1340
|
# Categories whose evidence is something other than the AA Intelligence Index,
|
|
@@ -1276,6 +1369,7 @@ def _specialist(
|
|
|
1276
1369
|
and c.requirement_penalty == 0
|
|
1277
1370
|
and rank(c.letter) > rank(t.letter)
|
|
1278
1371
|
and c.quality > t.quality
|
|
1372
|
+
and not _level_with(t, c, task.category)
|
|
1279
1373
|
]
|
|
1280
1374
|
if not stronger:
|
|
1281
1375
|
return None
|
|
@@ -1325,6 +1419,7 @@ def _at_level(
|
|
|
1325
1419
|
model is not up to it.
|
|
1326
1420
|
``scales`` are the category's evidence scale and the planning one."""
|
|
1327
1421
|
quality, source, letter = _quality(model, task, bench, scales[0], level)
|
|
1422
|
+
points = _evidence_points(c.model_id, task, bench, scales[0], level)
|
|
1328
1423
|
general, _, _ = _quality(model, replace(task, category="planning"), bench, scales[1], level)
|
|
1329
1424
|
penalty = SHORTFALL_SLOPE * max(0.0, c.requirement - min(quality, general))
|
|
1330
1425
|
if level is None:
|
|
@@ -1354,6 +1449,7 @@ def _at_level(
|
|
|
1354
1449
|
cost_penalty=round(cost_pen, 2),
|
|
1355
1450
|
score=round(quality - penalty - cost_pen + nudge, 2),
|
|
1356
1451
|
evidence_level=level,
|
|
1452
|
+
evidence_points=None if points is None else round(points, 2),
|
|
1357
1453
|
)
|
|
1358
1454
|
|
|
1359
1455
|
|
|
@@ -1448,8 +1544,9 @@ def ladder(
|
|
|
1448
1544
|
COST and BALANCED stay on the frontier, BALANCED then spanning every
|
|
1449
1545
|
adequate point above COST.
|
|
1450
1546
|
- Each rung carries a BACKUP from another maker (:func:`_backup_for`):
|
|
1451
|
-
the
|
|
1452
|
-
|
|
1547
|
+
the cheapest point on the other makers' frontier that does the job as
|
|
1548
|
+
well as the rung, else the nearest below it. ``backup_warning`` says why
|
|
1549
|
+
a rung has none.
|
|
1453
1550
|
|
|
1454
1551
|
None when no pool model carries an AA Index (no frontier to read)."""
|
|
1455
1552
|
cat = catalog if catalog is not None else _cost._load_catalog()
|
|
@@ -1478,7 +1575,7 @@ def ladder(
|
|
|
1478
1575
|
adequate = [p for p in points if p.candidate.requirement_penalty == 0]
|
|
1479
1576
|
if not adequate:
|
|
1480
1577
|
adequate = [max(points, key=_nearest)]
|
|
1481
|
-
top =
|
|
1578
|
+
top = _top(adequate, task.category)
|
|
1482
1579
|
span = adequate[: adequate.index(top) + 1]
|
|
1483
1580
|
specialist = _specialist(task, settings, points, top, bench)
|
|
1484
1581
|
by_level = {(c.model_id, c.evidence_level): c for c in settings if c.evidence_level}
|
|
@@ -1541,11 +1638,12 @@ def ladder(
|
|
|
1541
1638
|
)
|
|
1542
1639
|
# With a specialist on QUALITY, BALANCED spans every adequate frontier point
|
|
1543
1640
|
# 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.
|
|
1641
|
+
# higher price only by standing at least level with COST in the task's own
|
|
1642
|
+
# category: the frontier is drawn on the AA Index, and a point above COST
|
|
1643
|
+
# on that axis can still trail it in the category the task needs. A gap
|
|
1644
|
+
# within run-to-run noise is level, and the AA Index then decides.
|
|
1547
1645
|
between = span[lo + 1 :] if specialist is not None else span[lo + 1 : -1]
|
|
1548
|
-
between = [p for p in between if p.candidate
|
|
1646
|
+
between = [p for p in between if _level_with(p.candidate, cost_point.candidate, task.category)]
|
|
1549
1647
|
# Like COST, BALANCED draws on what is already paid for first: a per-token
|
|
1550
1648
|
# point stands between only when no prepaid one out-scores COST there.
|
|
1551
1649
|
between = [p for p in between if p.candidate.scarcity < 1.0] or between
|
|
@@ -1574,8 +1672,12 @@ def ladder(
|
|
|
1574
1672
|
)
|
|
1575
1673
|
balanced_rung = settle(_below(balanced_rung, quality_rung, cat))
|
|
1576
1674
|
cost_rung = settle(_below(cost_rung, balanced_rung, cat))
|
|
1675
|
+
# General capability is level within the same noise band, read on the
|
|
1676
|
+
# AA Index's own 0–100 scale (the planning evidence).
|
|
1677
|
+
gscale = _evidence_scale(cat, bench, FRONTIER_INDEX)
|
|
1678
|
+
aa_band = 0.0 if gscale is None else DEFAULT_TIE_BAND * (gscale.hi - gscale.lo) / 100.0
|
|
1577
1679
|
rungs = {
|
|
1578
|
-
r.tier: replace(r, backup=_backup_for(r, settings, task, headroom, bench, cat))
|
|
1680
|
+
r.tier: replace(r, backup=_backup_for(r, settings, task, headroom, bench, cat, aa_band))
|
|
1579
1681
|
for r in (cost_rung, balanced_rung, quality_rung)
|
|
1580
1682
|
}
|
|
1581
1683
|
warning: str | None = None
|
|
@@ -1603,23 +1705,51 @@ def _backup_for(
|
|
|
1603
1705
|
headroom: str,
|
|
1604
1706
|
bench: dict[str, Any],
|
|
1605
1707
|
catalog: dict[str, Any],
|
|
1708
|
+
aa_band: float = 0.0,
|
|
1606
1709
|
) -> 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
|
-
|
|
1710
|
+
"""The rung's backup: the substitute from another maker that does the job
|
|
1711
|
+
as well as the rung, for the least.
|
|
1712
|
+
|
|
1713
|
+
Over the pool's models from a maker other than the rung model's, each at
|
|
1714
|
+
its measured efforts, draw the frontier and keep its adequate points,
|
|
1715
|
+
those already paid for ahead of per-token ones. A point matches the rung
|
|
1716
|
+
when it stands level with it in the task's category
|
|
1717
|
+
(:func:`_level_with`) and in general capability (an AA Index no more than
|
|
1718
|
+
``aa_band`` below). The backup is the cheapest match; with none, the
|
|
1719
|
+
substitute the QUALITY rule would pick (:func:`_top`: strongest in the
|
|
1720
|
+
category, a near tie going to the higher AA Index); with no adequate
|
|
1721
|
+
point, the one that falls shortest of the bar. It runs at its
|
|
1722
|
+
point's measured effort, else at the rung's posture's effort, on its own
|
|
1723
|
+
platform. None when the pool holds no other
|
|
1724
|
+
maker's model."""
|
|
1616
1725
|
front = frontier([c for c in pool if c.provider != rung.candidate.provider], bench)
|
|
1617
1726
|
if not front:
|
|
1618
1727
|
return None
|
|
1619
1728
|
adequate = [p for p in front if p.candidate.requirement_penalty == 0]
|
|
1729
|
+
r = rung.point
|
|
1730
|
+
|
|
1731
|
+
def paid_first(points: list[FrontierPoint]) -> list[FrontierPoint]:
|
|
1732
|
+
return [p for p in points if p.candidate.scarcity < 1.0] or points
|
|
1733
|
+
|
|
1734
|
+
def aa(p: FrontierPoint) -> float:
|
|
1735
|
+
return -1.0 if p.aa_index is None else p.aa_index
|
|
1736
|
+
|
|
1620
1737
|
if adequate:
|
|
1621
|
-
|
|
1622
|
-
|
|
1738
|
+
# The cheapest substitute level with the rung in the category and in
|
|
1739
|
+
# general capability; with none, the one the QUALITY rule would pick
|
|
1740
|
+
# among the substitutes (:func:`_top`).
|
|
1741
|
+
near = paid_first(adequate)
|
|
1742
|
+
matches = [
|
|
1743
|
+
p
|
|
1744
|
+
for p in near
|
|
1745
|
+
if _level_with(p.candidate, r.candidate, task.category)
|
|
1746
|
+
and (r.aa_index is None or aa(p) >= r.aa_index - aa_band)
|
|
1747
|
+
]
|
|
1748
|
+
chosen = (
|
|
1749
|
+
min(matches, key=lambda p: (p.price_usd, -aa(p)))
|
|
1750
|
+
if matches
|
|
1751
|
+
else _top(near, task.category)
|
|
1752
|
+
)
|
|
1623
1753
|
else:
|
|
1624
1754
|
chosen = max(front, key=_nearest)
|
|
1625
1755
|
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
|