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.
Files changed (57) hide show
  1. {roadmodel-0.2.71 → roadmodel-0.2.72}/PKG-INFO +1 -1
  2. {roadmodel-0.2.71/roadmodel/data → roadmodel-0.2.72/docs}/catalog.json +2 -2
  3. roadmodel-0.2.72/docs/context-windows.json +60 -0
  4. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/model-selector.txt +5 -0
  5. {roadmodel-0.2.71 → roadmodel-0.2.72}/hatch_build.py +1 -0
  6. {roadmodel-0.2.71 → roadmodel-0.2.72}/pyproject.toml +2 -1
  7. {roadmodel-0.2.71/docs → roadmodel-0.2.72/roadmodel/data}/catalog.json +2 -2
  8. roadmodel-0.2.72/roadmodel/data/context-windows.json +60 -0
  9. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/model-selector.txt +5 -0
  10. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/__init__.py +1 -1
  11. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/recommend.py +71 -7
  12. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/scoring.py +150 -28
  13. {roadmodel-0.2.71 → roadmodel-0.2.72}/.gitignore +0 -0
  14. {roadmodel-0.2.71 → roadmodel-0.2.72}/LICENSE +0 -0
  15. {roadmodel-0.2.71 → roadmodel-0.2.72}/NOTICE +0 -0
  16. {roadmodel-0.2.71 → roadmodel-0.2.72}/README.md +0 -0
  17. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/benchmarks.json +0 -0
  18. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/model-tier-cost-scale.md +0 -0
  19. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/settings-display.md +0 -0
  20. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/phase-roadmap-template.md +0 -0
  21. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/planning-kit-how-to-use.md +0 -0
  22. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/project-roadmap-template.md +0 -0
  23. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/prompt-phase-roadmap.md +0 -0
  24. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/templates/prompt-project-roadmap.md +0 -0
  25. {roadmodel-0.2.71 → roadmodel-0.2.72}/docs/user-context.example.md +0 -0
  26. {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/README.md +0 -0
  27. {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/gcp-killswitch/README.md +0 -0
  28. {roadmodel-0.2.71 → roadmodel-0.2.72}/infra/supabase/README.md +0 -0
  29. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/benchmarks.json +0 -0
  30. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/model-tier-cost-scale.md +0 -0
  31. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/phase-roadmap-template.md +0 -0
  32. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  33. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/project-roadmap-template.md +0 -0
  34. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  35. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/prompt-project-roadmap.md +0 -0
  36. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/settings-display.md +0 -0
  37. {roadmodel-0.2.71 → roadmodel-0.2.72}/roadmodel/data/user-context.example.md +0 -0
  38. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/agent_context.py +0 -0
  39. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/agent_rules.py +0 -0
  40. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/update_projects.py +0 -0
  41. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/updater_lock.py +0 -0
  42. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/upgrade_fleet.py +0 -0
  43. {roadmodel-0.2.71 → roadmodel-0.2.72}/scripts/worktree_memory.py +0 -0
  44. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/__main__.py +0 -0
  45. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/cli.py +0 -0
  46. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/config.py +0 -0
  47. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/cost.py +0 -0
  48. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/errors.py +0 -0
  49. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/mcp_server.py +0 -0
  50. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/__init__.py +0 -0
  51. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/anthropic.py +0 -0
  52. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/google.py +0 -0
  53. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/openai.py +0 -0
  54. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/openai_compatible.py +0 -0
  55. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/providers/registry.py +0 -0
  56. {roadmodel-0.2.71 → roadmodel-0.2.72}/src/roadmodel/usage.py +0 -0
  57. {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.71
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-07T15:54:22Z",
4
+ "generated_at_utc": "2026-10-07T18:08:11Z",
5
5
  "source_doc_sha256": {
6
- "model-selector.txt": "1608924122bce175e39dcf85f17cb371738164d5c5e171de2568a87ecc794395",
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.71"
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-07T15:54:22Z",
4
+ "generated_at_utc": "2026-10-07T18:08:11Z",
5
5
  "source_doc_sha256": {
6
- "model-selector.txt": "1608924122bce175e39dcf85f17cb371738164d5c5e171de2568a87ecc794395",
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.71"
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.\n"
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 classification:
811
- for base in parsed.values():
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 frontier_guard is not None and frontier_guard["classification"] is not None:
1555
- _attach_backup_plans(picks, table[frontier_guard["classification"]])
1556
- _mark_specialist(picks, table[frontier_guard["classification"]])
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
- if v is None:
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 * scale.points(v) + (1.0 - EVIDENCE_WEIGHT) * letter_q
786
- return q, f"aa:{key}+letter", letter
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 best adequate point on the other makers' frontier at no higher list
1452
- price. ``backup_warning`` says why a rung has none.
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 = max(adequate, key=_strength)
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 beating COST in the task's own category: the
1545
- # frontier is drawn on the AA Index, and a point above COST on that axis
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.quality > cost_point.candidate.quality]
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 best substitute from another maker the operator
1608
- can run, at no higher list price.
1609
-
1610
- Over the pool's models from a maker other than the rung model's, draw the
1611
- frontier and keep its adequate points. The backup is the adequate point
1612
- with the highest list price at or below the rung's; else the cheapest
1613
- adequate point above it; else, with no adequate point, the frontier's
1614
- strongest for the category. It runs at the rung's posture's effort on its
1615
- own platform. None when the pool holds no other maker's model."""
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
- within = [p for p in adequate if p.price_usd <= rung.point.price_usd]
1622
- chosen = within[-1] if within else adequate[0]
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