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.
Files changed (57) hide show
  1. {roadmodel-0.2.71 → roadmodel-0.2.73}/PKG-INFO +1 -1
  2. {roadmodel-0.2.71/roadmodel/data → roadmodel-0.2.73/docs}/catalog.json +2 -2
  3. roadmodel-0.2.73/docs/context-windows.json +60 -0
  4. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/model-selector.txt +5 -0
  5. {roadmodel-0.2.71 → roadmodel-0.2.73}/hatch_build.py +1 -0
  6. {roadmodel-0.2.71 → roadmodel-0.2.73}/pyproject.toml +2 -1
  7. {roadmodel-0.2.71/docs → roadmodel-0.2.73/roadmodel/data}/catalog.json +2 -2
  8. roadmodel-0.2.73/roadmodel/data/context-windows.json +60 -0
  9. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/model-selector.txt +5 -0
  10. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/__init__.py +1 -1
  11. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/recommend.py +77 -7
  12. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/scoring.py +158 -28
  13. {roadmodel-0.2.71 → roadmodel-0.2.73}/.gitignore +0 -0
  14. {roadmodel-0.2.71 → roadmodel-0.2.73}/LICENSE +0 -0
  15. {roadmodel-0.2.71 → roadmodel-0.2.73}/NOTICE +0 -0
  16. {roadmodel-0.2.71 → roadmodel-0.2.73}/README.md +0 -0
  17. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/benchmarks.json +0 -0
  18. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/model-tier-cost-scale.md +0 -0
  19. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/settings-display.md +0 -0
  20. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/phase-roadmap-template.md +0 -0
  21. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/planning-kit-how-to-use.md +0 -0
  22. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/project-roadmap-template.md +0 -0
  23. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/prompt-phase-roadmap.md +0 -0
  24. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/templates/prompt-project-roadmap.md +0 -0
  25. {roadmodel-0.2.71 → roadmodel-0.2.73}/docs/user-context.example.md +0 -0
  26. {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/README.md +0 -0
  27. {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/gcp-killswitch/README.md +0 -0
  28. {roadmodel-0.2.71 → roadmodel-0.2.73}/infra/supabase/README.md +0 -0
  29. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/benchmarks.json +0 -0
  30. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/model-tier-cost-scale.md +0 -0
  31. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/phase-roadmap-template.md +0 -0
  32. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  33. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/project-roadmap-template.md +0 -0
  34. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  35. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/prompt-project-roadmap.md +0 -0
  36. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/settings-display.md +0 -0
  37. {roadmodel-0.2.71 → roadmodel-0.2.73}/roadmodel/data/user-context.example.md +0 -0
  38. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/agent_context.py +0 -0
  39. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/agent_rules.py +0 -0
  40. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/update_projects.py +0 -0
  41. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/updater_lock.py +0 -0
  42. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/upgrade_fleet.py +0 -0
  43. {roadmodel-0.2.71 → roadmodel-0.2.73}/scripts/worktree_memory.py +0 -0
  44. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/__main__.py +0 -0
  45. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/cli.py +0 -0
  46. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/config.py +0 -0
  47. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/cost.py +0 -0
  48. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/errors.py +0 -0
  49. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/mcp_server.py +0 -0
  50. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/__init__.py +0 -0
  51. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/anthropic.py +0 -0
  52. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/google.py +0 -0
  53. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/openai.py +0 -0
  54. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/openai_compatible.py +0 -0
  55. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/providers/registry.py +0 -0
  56. {roadmodel-0.2.71 → roadmodel-0.2.73}/src/roadmodel/usage.py +0 -0
  57. {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.71
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-07T15:54:22Z",
4
+ "generated_at_utc": "2026-10-07T19:13:30Z",
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.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-07T15:54:22Z",
4
+ "generated_at_utc": "2026-10-07T19:13:30Z",
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.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.\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,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 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"]])
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
- if v is None:
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 * scale.points(v) + (1.0 - EVIDENCE_WEIGHT) * letter_q
786
- return q, f"aa:{key}+letter", letter
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 best adequate point on the other makers' frontier at no higher list
1452
- price. ``backup_warning`` says why a rung has none.
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 = max(adequate, key=_strength)
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 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.
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.quality > cost_point.candidate.quality]
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 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."""
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
- within = [p for p in adequate if p.price_usd <= rung.point.price_usd]
1622
- chosen = within[-1] if within else adequate[0]
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