roadmodel 0.2.96__tar.gz → 0.2.97__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 (74) hide show
  1. {roadmodel-0.2.96 → roadmodel-0.2.97}/PKG-INFO +1 -1
  2. {roadmodel-0.2.96/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-refresh.md +9 -1
  3. {roadmodel-0.2.96/roadmodel/data → roadmodel-0.2.97/docs/claude-commands}/roadmap-step.md +7 -2
  4. {roadmodel-0.2.96/roadmodel/data → roadmodel-0.2.97/docs/templates}/phase-roadmap-template.md +12 -5
  5. {roadmodel-0.2.96 → roadmodel-0.2.97}/pyproject.toml +1 -1
  6. {roadmodel-0.2.96/docs/templates → roadmodel-0.2.97/roadmodel/data}/phase-roadmap-template.md +12 -5
  7. {roadmodel-0.2.96/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-refresh.md +9 -1
  8. {roadmodel-0.2.96/docs/claude-commands → roadmodel-0.2.97/roadmodel/data}/roadmap-step.md +7 -2
  9. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/__init__.py +1 -1
  10. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/cli.py +174 -0
  11. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/ladder_layout.py +156 -3
  12. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/scoring.py +78 -81
  13. {roadmodel-0.2.96 → roadmodel-0.2.97}/.github/README.md +0 -0
  14. {roadmodel-0.2.96 → roadmodel-0.2.97}/.gitignore +0 -0
  15. {roadmodel-0.2.96 → roadmodel-0.2.97}/LICENSE +0 -0
  16. {roadmodel-0.2.96 → roadmodel-0.2.97}/NOTICE +0 -0
  17. {roadmodel-0.2.96 → roadmodel-0.2.97}/README.md +0 -0
  18. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/benchmarks.json +0 -0
  19. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/catalog.json +0 -0
  20. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/claude-commands/roadmap-phase.md +0 -0
  21. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/claude-commands/roadmap-project.md +0 -0
  22. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/claude-commands/roadmodel-upgrade.md +0 -0
  23. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/context-windows.json +0 -0
  24. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/effort-tokens.json +0 -0
  25. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/model-selector.txt +0 -0
  26. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/model-tier-cost-scale.md +0 -0
  27. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/settings-display.md +0 -0
  28. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/templates/planning-kit-how-to-use.md +0 -0
  29. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/templates/project-roadmap-template.md +0 -0
  30. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/templates/prompt-phase-roadmap.md +0 -0
  31. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/templates/prompt-project-roadmap.md +0 -0
  32. {roadmodel-0.2.96 → roadmodel-0.2.97}/docs/user-context.example.md +0 -0
  33. {roadmodel-0.2.96 → roadmodel-0.2.97}/hatch_build.py +0 -0
  34. {roadmodel-0.2.96 → roadmodel-0.2.97}/infra/README.md +0 -0
  35. {roadmodel-0.2.96 → roadmodel-0.2.97}/infra/gcp-killswitch/README.md +0 -0
  36. {roadmodel-0.2.96 → roadmodel-0.2.97}/infra/supabase/README.md +0 -0
  37. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/benchmarks.json +0 -0
  38. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/catalog.json +0 -0
  39. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/context-windows.json +0 -0
  40. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/effort-tokens.json +0 -0
  41. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/model-selector.txt +0 -0
  42. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/model-tier-cost-scale.md +0 -0
  43. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  44. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/project-roadmap-template.md +0 -0
  45. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  46. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/prompt-project-roadmap.md +0 -0
  47. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/roadmap-phase.md +0 -0
  48. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/roadmap-project.md +0 -0
  49. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/roadmodel-upgrade.md +0 -0
  50. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/settings-display.md +0 -0
  51. {roadmodel-0.2.96 → roadmodel-0.2.97}/roadmodel/data/user-context.example.md +0 -0
  52. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/agent_context.py +0 -0
  53. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/agent_rules.py +0 -0
  54. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/update_projects.py +0 -0
  55. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/updater_lock.py +0 -0
  56. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/upgrade_fleet.py +0 -0
  57. {roadmodel-0.2.96 → roadmodel-0.2.97}/scripts/worktree_memory.py +0 -0
  58. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/__main__.py +0 -0
  59. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/config.py +0 -0
  60. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/cost.py +0 -0
  61. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/errors.py +0 -0
  62. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/mcp_server.py +0 -0
  63. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/private_files.py +0 -0
  64. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/__init__.py +0 -0
  65. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/_safety.py +0 -0
  66. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/anthropic.py +0 -0
  67. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/google.py +0 -0
  68. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/openai.py +0 -0
  69. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/openai_compatible.py +0 -0
  70. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/providers/registry.py +0 -0
  71. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/recommend.py +0 -0
  72. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/series.py +0 -0
  73. {roadmodel-0.2.96 → roadmodel-0.2.97}/src/roadmodel/usage.py +0 -0
  74. {roadmodel-0.2.96 → roadmodel-0.2.97}/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.96
3
+ Version: 0.2.97
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
@@ -221,7 +221,15 @@ complexity, novel or not; its Model rationale names them), then:
221
221
  not exist: a Complete step's ladder is the record of what ran, an `In
222
222
  progress` step's is what an agent is running, and a written phase's
223
223
  records what wrote its roadmap, so those keep their layout. That is a
224
- layout fix, not a new pick, and takes no `Settings updated` line. A
224
+ layout fix, not a new pick, and takes no `Settings updated` line. Then
225
+ run `roadmodel rerank <every roadmap file>` (roadmodel 0.2.97 on): it
226
+ gives each of those ladder tables the ranks this release computes, so a
227
+ change to how roadmodel ranks reaches every step not yet run. It reads
228
+ the classification from the ladder's note (`Ranked for …`), else from the
229
+ only classification whose ladder has the same Picks. It adds a `Settings
230
+ updated` line where a rank changed. It reports any ladder it cannot
231
+ place: classify that step from its Model rationale and re-run with
232
+ `--hint FILE:SECTION=category/complexity[/novel]`. A
225
233
  ladder written before roadmodel 0.2.93 has Model / Backup / Platform /
226
234
  Effort (/ AA Index) / Thinking rows and no Runner-up: on a step that has
227
235
  not shipped, or a phase not yet written, replace it with the ladder
@@ -115,7 +115,11 @@ the dials and make sure it is set.
115
115
  Price rows. Level is the bare level of that platform's own dial (`High`
116
116
  is Intelligence High on Codex, Effort High on Claude Code and
117
117
  Antigravity). The ✓ in a column's header marks the one its **Runs:**
118
- line names. A 0.2.94 ladder is three lists instead (`- Pick:`,
118
+ line names. From roadmodel 0.2.97 the three ranks of a column come from
119
+ three makers (a tight pool is a maker's); where too few are funded, a
120
+ missing rank is the Pick one level lower, its Level marked ↓, and the
121
+ note line says which classification the ladder was ranked for. A 0.2.94
122
+ ladder is three lists instead (`- Pick:`,
119
123
  `- Runner-up:`, `- Backup:` under each column's bold heading), and a
120
124
  0.2.93 one a table with rows `1 · Pick`, `2 · Runner-up` and
121
125
  `3 · Backup`, each followed by Platform, Effort, Thinking and AA Index
@@ -129,7 +133,8 @@ the dials and make sure it is set.
129
133
  subscription that pays for that column's Pick's platform:
130
134
  - `headroom`: run that column's Pick.
131
135
  - `tight`: run the first of that column's Runner-up and Backup whose
132
- pool reads `headroom`, else the Cost column's Pick.
136
+ pool reads `headroom` or that is marked ↓ (the Pick one level lower,
137
+ which uses less of the tight pool), else the Cost column's Pick.
133
138
  - `exhausted`: run the first of that column's Runner-up and Backup
134
139
  whose pool reads `headroom`, else its Backup.
135
140
 
@@ -774,11 +774,18 @@ that owns it.}}
774
774
  platform has the toggle, and every AA Index and Price
775
775
  already assumes it. A note line under the table explains
776
776
  ✓ and Price, and a `**Conversation:**` line closes the
777
- ladder. The ranks follow the column's own rule, not the
778
- AA Index: Cost ranks the cheapest first, so its AA Index
779
- may rise down the column. The Backup reads `none` when
780
- the Runner-up already holds the
781
- only other maker's models. Every model is the latest
777
+ ladder. Each rank comes from its own maker (roadmodel
778
+ 0.2.97 on), because a tight usage pool is a maker's: the
779
+ Runner-up is the column's recommendation with the Pick's
780
+ maker left out, and the Backup leaves out both makers
781
+ above it. Where the user-context funds too few makers, a
782
+ missing rank is the Pick's own model one level lower,
783
+ its Level marked ↓ (it uses less of a tight pool), and
784
+ `none` below the dial's lowest level. The note line
785
+ names the classification the ladder was ranked for. The
786
+ ranks follow the column's own rule, not the AA Index:
787
+ Cost ranks each maker's cheapest adequate option, so its
788
+ AA Index may rise down the column. Every model is the latest
782
789
  version of its series, never an older one (GPT-6 Sol
783
790
  where GPT-6.1 Sol runs, Opus 5 where Opus 5.5 does).
784
791
  Set the Conversation line to **Continue** only when the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roadmodel"
7
- version = "0.2.96"
7
+ version = "0.2.97"
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"
@@ -774,11 +774,18 @@ that owns it.}}
774
774
  platform has the toggle, and every AA Index and Price
775
775
  already assumes it. A note line under the table explains
776
776
  ✓ and Price, and a `**Conversation:**` line closes the
777
- ladder. The ranks follow the column's own rule, not the
778
- AA Index: Cost ranks the cheapest first, so its AA Index
779
- may rise down the column. The Backup reads `none` when
780
- the Runner-up already holds the
781
- only other maker's models. Every model is the latest
777
+ ladder. Each rank comes from its own maker (roadmodel
778
+ 0.2.97 on), because a tight usage pool is a maker's: the
779
+ Runner-up is the column's recommendation with the Pick's
780
+ maker left out, and the Backup leaves out both makers
781
+ above it. Where the user-context funds too few makers, a
782
+ missing rank is the Pick's own model one level lower,
783
+ its Level marked ↓ (it uses less of a tight pool), and
784
+ `none` below the dial's lowest level. The note line
785
+ names the classification the ladder was ranked for. The
786
+ ranks follow the column's own rule, not the AA Index:
787
+ Cost ranks each maker's cheapest adequate option, so its
788
+ AA Index may rise down the column. Every model is the latest
782
789
  version of its series, never an older one (GPT-6 Sol
783
790
  where GPT-6.1 Sol runs, Opus 5 where Opus 5.5 does).
784
791
  Set the Conversation line to **Continue** only when the
@@ -221,7 +221,15 @@ complexity, novel or not; its Model rationale names them), then:
221
221
  not exist: a Complete step's ladder is the record of what ran, an `In
222
222
  progress` step's is what an agent is running, and a written phase's
223
223
  records what wrote its roadmap, so those keep their layout. That is a
224
- layout fix, not a new pick, and takes no `Settings updated` line. A
224
+ layout fix, not a new pick, and takes no `Settings updated` line. Then
225
+ run `roadmodel rerank <every roadmap file>` (roadmodel 0.2.97 on): it
226
+ gives each of those ladder tables the ranks this release computes, so a
227
+ change to how roadmodel ranks reaches every step not yet run. It reads
228
+ the classification from the ladder's note (`Ranked for …`), else from the
229
+ only classification whose ladder has the same Picks. It adds a `Settings
230
+ updated` line where a rank changed. It reports any ladder it cannot
231
+ place: classify that step from its Model rationale and re-run with
232
+ `--hint FILE:SECTION=category/complexity[/novel]`. A
225
233
  ladder written before roadmodel 0.2.93 has Model / Backup / Platform /
226
234
  Effort (/ AA Index) / Thinking rows and no Runner-up: on a step that has
227
235
  not shipped, or a phase not yet written, replace it with the ladder
@@ -115,7 +115,11 @@ the dials and make sure it is set.
115
115
  Price rows. Level is the bare level of that platform's own dial (`High`
116
116
  is Intelligence High on Codex, Effort High on Claude Code and
117
117
  Antigravity). The ✓ in a column's header marks the one its **Runs:**
118
- line names. A 0.2.94 ladder is three lists instead (`- Pick:`,
118
+ line names. From roadmodel 0.2.97 the three ranks of a column come from
119
+ three makers (a tight pool is a maker's); where too few are funded, a
120
+ missing rank is the Pick one level lower, its Level marked ↓, and the
121
+ note line says which classification the ladder was ranked for. A 0.2.94
122
+ ladder is three lists instead (`- Pick:`,
119
123
  `- Runner-up:`, `- Backup:` under each column's bold heading), and a
120
124
  0.2.93 one a table with rows `1 · Pick`, `2 · Runner-up` and
121
125
  `3 · Backup`, each followed by Platform, Effort, Thinking and AA Index
@@ -129,7 +133,8 @@ the dials and make sure it is set.
129
133
  subscription that pays for that column's Pick's platform:
130
134
  - `headroom`: run that column's Pick.
131
135
  - `tight`: run the first of that column's Runner-up and Backup whose
132
- pool reads `headroom`, else the Cost column's Pick.
136
+ pool reads `headroom` or that is marked ↓ (the Pick one level lower,
137
+ which uses less of the tight pool), else the Cost column's Pick.
133
138
  - `exhausted`: run the first of that column's Runner-up and Backup
134
139
  whose pool reads `headroom`, else its Backup.
135
140
 
@@ -1,2 +1,2 @@
1
1
  # src/roadmodel/__init__.py
2
- __version__ = "0.2.96"
2
+ __version__ = "0.2.97"
@@ -730,6 +730,180 @@ def relayout_command(paths: tuple[Path, ...], check: bool) -> None:
730
730
  raise SystemExit(1)
731
731
 
732
732
 
733
+ @cli.command("rerank")
734
+ @click.argument(
735
+ "paths",
736
+ nargs=-1,
737
+ required=True,
738
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
739
+ )
740
+ @click.option(
741
+ "--check",
742
+ is_flag=True,
743
+ default=False,
744
+ help="Report the ladders without rewriting them; exit 1 if any would change.",
745
+ )
746
+ @click.option(
747
+ "--hint",
748
+ "hints",
749
+ multiple=True,
750
+ metavar="FILE:SECTION=CATEGORY/COMPLEXITY[/novel]",
751
+ help="Classification for a ladder whose note names none and whose Picks match "
752
+ "several (e.g. 'phase31.6-roadmap.md:Step 5=coding/high').",
753
+ )
754
+ @click.option(
755
+ "--user-context",
756
+ "user_context_path",
757
+ type=click.Path(path_type=Path),
758
+ default=None,
759
+ help="Path to user-context.md override.",
760
+ )
761
+ def rerank_command(
762
+ paths: tuple[Path, ...], check: bool, hints: tuple[str, ...], user_context_path: Path | None
763
+ ) -> None:
764
+ """Re-rank each roadmap's ladder tables by this release's rules.
765
+
766
+ For every ladder table under a step that has not started, or a phase whose
767
+ roadmap does not exist, prints the ladder this release ranks for the same
768
+ classification: the one its note names, else the one a --hint gives, else
769
+ the only one whose Picks match the table's. A ladder whose Runner-up or
770
+ Backup changes gains a `> Settings updated` line beneath it. Complete and
771
+ In progress steps, and written phases, keep their ladder; a ladder whose
772
+ classification stays unclear is reported and left as it is.
773
+ """
774
+ import datetime as _dt
775
+ from collections import Counter
776
+
777
+ from roadmodel import ladder_layout
778
+
779
+ text_ctx = _scoring_user_context(user_context_path)
780
+ budget = scoring.declared_budget(text_ctx).lower()
781
+ cache: dict[tuple[tuple[str, str, bool], bool], dict[str, Any] | None] = {}
782
+
783
+ def ladder_for(key: tuple[str, str, bool], phase: bool) -> dict[str, Any] | None:
784
+ if (key, phase) not in cache:
785
+ task = scoring.Task(key[0], key[1], key[2], budget)
786
+ cache[(key, phase)] = scoring.settings_ladder(task, text_ctx, phase=phase)
787
+ return cache[(key, phase)]
788
+
789
+ def picks(got: dict[str, Any]) -> tuple[tuple[str, str], ...]:
790
+ return tuple(
791
+ (str(got["columns"][t]["model"]), str(got["columns"][t]["level_cell"]))
792
+ for t in scoring.LADDER_TIERS
793
+ )
794
+
795
+ def ranks(got: dict[str, Any]) -> tuple[tuple[str, ...], ...]:
796
+ cols = [got["columns"][t] for t in scoring.LADDER_TIERS]
797
+ return tuple(
798
+ tuple("none" if o is None else str(o["model"]) for o in opts)
799
+ for opts in (cols, [c["runner_up"] for c in cols], [c["backup"] for c in cols])
800
+ )
801
+
802
+ hinted: dict[tuple[str, str], tuple[str, str, bool]] = {}
803
+ for hint in hints:
804
+ where, _, what = hint.partition("=")
805
+ name, _, section = where.partition(":")
806
+ parts = what.strip().lower().split("/")
807
+ if len(parts) not in (2, 3) or not name or not section:
808
+ raise click.ClickException(f"--hint {hint!r}: use FILE:SECTION=CATEGORY/COMPLEXITY")
809
+ hinted[(name.strip(), section.strip())] = (parts[0], parts[1], parts[2:] == ["novel"])
810
+ keys = scoring.table_keys()
811
+ today = _dt.date.today().isoformat()
812
+ pending = 0
813
+ unresolved: list[str] = []
814
+ for path in paths:
815
+ text = path.read_text(encoding="utf-8")
816
+ lines = text.split("\n")
817
+ counts: Counter[str] = Counter()
818
+ replacements: dict[int, tuple[int, str]] = {}
819
+ for block in ladder_layout.table_ladders(text, phase_written=_phase_written(path.parent)):
820
+ if block.done:
821
+ counts["kept"] += 1
822
+ continue
823
+ key = block.ranked_for or hinted.get((path.name, block.section))
824
+ if key is None:
825
+ matches = [
826
+ k for k in keys if (g := ladder_for(k, block.phase)) and picks(g) == block.picks
827
+ ]
828
+ tables = {
829
+ str((ladder_for(k, block.phase) or {})["markdown"]).split("\n\n")[0]
830
+ for k in matches
831
+ }
832
+ if len(tables) != 1:
833
+ counts["unresolved"] += 1
834
+ found = ", ".join(
835
+ "/".join(map(str, k[:2])) + ("/novel" if k[2] else "") for k in matches
836
+ )
837
+ unresolved.append(
838
+ f"{path}: {block.section or 'ladder'} — "
839
+ + (
840
+ f"Picks match {found}"
841
+ if matches
842
+ else "no classification reproduces its Picks"
843
+ )
844
+ )
845
+ continue
846
+ key = matches[0]
847
+ guessed = len(matches) > 1
848
+ else:
849
+ guessed = False
850
+ got = ladder_for(key, block.phase)
851
+ if got is None:
852
+ counts["unresolved"] += 1
853
+ unresolved.append(f"{path}: {block.section or 'ladder'} — no ladder for {key}")
854
+ continue
855
+ md = str(got["markdown"])
856
+ if guessed:
857
+ # Several classifications rank this ladder alike: the table is
858
+ # right, but naming one of them in the note would be a guess.
859
+ md = _drop_ranked_for(md)
860
+ if block.conversation and block.conversation != "New":
861
+ md = md.replace(
862
+ "**Conversation:** New", f"**Conversation:** {block.conversation}", 1
863
+ )
864
+ if md == "\n".join(lines[block.start : block.end]):
865
+ counts["unchanged"] += 1
866
+ continue
867
+ if picks(got) != block.picks:
868
+ runs = scoring.LADDER_TIERS.index(str(got["runs"]))
869
+ was = " · ".join(block.picks[runs]) if len(block.picks) > runs else "—"
870
+ md += (
871
+ f"\n\n> Settings updated {today} (rerank): was {was}. Re-ranked for "
872
+ f"{key[0]}, {key[1]} complexity, each column's ranks from three makers."
873
+ )
874
+ elif ranks(got) != block.ranks:
875
+ md += (
876
+ f"\n\n> Settings updated {today} (rerank): each column's Runner-up and "
877
+ "Backup now come from makers other than its Pick; every Pick is unchanged."
878
+ )
879
+ replacements[block.start] = (block.end, md)
880
+ counts["reranked"] += 1
881
+ if replacements and not check:
882
+ path.write_text(ladder_layout.replace_ladders(text, replacements), encoding="utf-8")
883
+ pending += len(replacements)
884
+ if counts:
885
+ click.echo(
886
+ f"{path}: {counts['reranked']} re-ranked, {counts['unchanged']} unchanged, "
887
+ f"{counts['kept']} kept, {counts['unresolved']} unresolved"
888
+ )
889
+ for line in unresolved:
890
+ click.echo(f" unresolved {line}; pass --hint or run /roadmap-refresh")
891
+ if check and pending:
892
+ raise SystemExit(1)
893
+
894
+
895
+ def _drop_ranked_for(markdown: str) -> str:
896
+ """``markdown`` with its note's "Ranked for …" sentence removed and the
897
+ note re-wrapped at 80 columns."""
898
+ parts = markdown.split("\n\n")
899
+ for i, part in enumerate(parts):
900
+ flat = " ".join(part.split())
901
+ if flat.startswith("Ranked for "):
902
+ rest = re.sub(r"^Ranked for [^.]*\.\s*", "", flat)
903
+ parts[i] = textwrap.fill(rest, width=80, break_long_words=False)
904
+ return "\n\n".join(p for p in parts if p)
905
+
906
+
733
907
  def _phase_written(directory: Path) -> Callable[[str], bool]:
734
908
  """Whether a phase's roadmap exists beside the project roadmap:
735
909
  ``phase<id>-roadmap.md`` or ``phase<id>-<slug>-roadmap.md``, the id as the
@@ -42,6 +42,7 @@ RANKS: Final[tuple[tuple[str, str], ...]] = (
42
42
  FIELDS: Final[tuple[str, ...]] = ("Model", "Platform", "Level", "AA", "Price")
43
43
  INDENT: Final[str] = "&nbsp;" * 4
44
44
  RUNS_MARK: Final[str] = "✓"
45
+ STEP_MARK: Final[str] = "↓"
45
46
  WIDTH: Final[int] = 80
46
47
  NONE: Final[str] = "—"
47
48
  _SEP: Final[str] = " · "
@@ -81,6 +82,9 @@ class Option:
81
82
  price: float | None = None
82
83
  # False when the level's token use is a typical model's, not measured.
83
84
  price_measured: bool = True
85
+ # The Pick's own model a level or two lower, filling a rank no other
86
+ # maker could (marked ↓).
87
+ stepped_down: bool = False
84
88
 
85
89
 
86
90
  def level_key(word: str) -> str | None:
@@ -100,7 +104,7 @@ def _cells(option: Option | None) -> list[str]:
100
104
  return [
101
105
  f"**{option.model}**",
102
106
  option.platform,
103
- option.level,
107
+ option.level + (f" {STEP_MARK}" if option.stepped_down else ""),
104
108
  NONE if option.aa is None else option.aa,
105
109
  price_cell(option),
106
110
  ]
@@ -120,6 +124,7 @@ def ladder_markdown(
120
124
  *,
121
125
  runs: str | None,
122
126
  conversation: str | None,
127
+ ranked_for: str | None = None,
123
128
  ) -> str:
124
129
  """The ladder's table, its note, then its Conversation line. A rank is
125
130
  printed when any column carries it (a ladder older than the Runner-up has
@@ -128,7 +133,7 @@ def ladder_markdown(
128
133
  column reads `none` alone)."""
129
134
  head = ["", *(t.capitalize() + (f" {RUNS_MARK}" if t == runs else "") for t in TIERS)]
130
135
  rows = [head]
131
- priced = unmeasured = False
136
+ priced = unmeasured = stepped = False
132
137
  for label, key in RANKS:
133
138
  if not any(key in columns[t] for t in TIERS):
134
139
  continue
@@ -144,7 +149,15 @@ def ladder_markdown(
144
149
  unmeasured |= any(
145
150
  o is not None and o.price is not None and not o.price_measured for o in opts
146
151
  )
147
- notes = [f"{RUNS_MARK} runs by default."] if runs else []
152
+ stepped |= any(o is not None and o.stepped_down for o in opts)
153
+ notes = [f"Ranked for {ranked_for}."] if ranked_for else []
154
+ if runs:
155
+ notes.append(f"{RUNS_MARK} runs by default.")
156
+ if stepped:
157
+ notes.append(
158
+ f"{STEP_MARK} the Pick one level lower: no other maker is funded, and a "
159
+ "lower level uses less of a tight pool."
160
+ )
148
161
  if priced:
149
162
  notes.append(
150
163
  "Price: list $ per 1M tokens (3 in : 1 out) × the tokens the model "
@@ -420,3 +433,143 @@ def relayout(
420
433
  converted += 1
421
434
  i = end
422
435
  return "\n".join(out), converted, kept
436
+
437
+
438
+ # --------------------------------------------------------------------------- #
439
+ # Re-ranking the tables this release writes
440
+ # --------------------------------------------------------------------------- #
441
+
442
+ _TABLE_RE: Final = re.compile(r"^\|\s*\|\s*Cost\b")
443
+ _RANKED_FOR_RE: Final = re.compile(
444
+ r"Ranked for ([a-z-]+), (low|medium|high) complexity(, novel)?\."
445
+ )
446
+ _SECTION_RE: Final = re.compile(r"^(?:## (Step \S+)|### (Phase [0-9A-Za-z.]+)|### (\S+))")
447
+
448
+
449
+ @dataclass(frozen=True)
450
+ class TableLadder:
451
+ """A ladder table (0.2.95 on) in a roadmap, with the lines it spans: the
452
+ table, its note, its Conversation line and its Runs paragraph."""
453
+
454
+ start: int
455
+ end: int
456
+ section: str
457
+ phase: bool
458
+ done: bool
459
+ picks: tuple[tuple[str, str], ...]
460
+ ranks: tuple[tuple[str, ...], ...]
461
+ ranked_for: tuple[str, str, bool] | None
462
+ conversation: str | None
463
+
464
+
465
+ def _skip_blank(lines: list[str], i: int) -> int:
466
+ while i < len(lines) and not lines[i].strip():
467
+ i += 1
468
+ return i
469
+
470
+
471
+ def _block_end(lines: list[str], i: int) -> int:
472
+ """The index past a ladder's note paragraph, Conversation line and Runs
473
+ paragraph (each optional, in that order), from just past its table."""
474
+ k = _skip_blank(lines, i)
475
+ if k < len(lines) and lines[k].startswith(
476
+ ("Ranked for ", f"{RUNS_MARK} ", "Price: ", f"{STEP_MARK} ")
477
+ ):
478
+ while k < len(lines) and lines[k].strip():
479
+ k += 1
480
+ i = k
481
+ k = _skip_blank(lines, i)
482
+ if k < len(lines) and lines[k].startswith("**Conversation:**"):
483
+ i = k + 1
484
+ k = _skip_blank(lines, i)
485
+ if k < len(lines) and lines[k].startswith("**Runs:**"):
486
+ while k < len(lines) and lines[k].strip():
487
+ k += 1
488
+ i = k
489
+ return i
490
+
491
+
492
+ def table_ladders(
493
+ text: str, *, phase_written: Callable[[str], bool] | None = None
494
+ ) -> list[TableLadder]:
495
+ """Every ladder table in ``text``, each with its section (`Step 5`,
496
+ `Phase 32`), whether it sits under a phase heading, whether its step or
497
+ phase is done (Complete, In progress, or a written phase), its Picks and
498
+ every rank's models, the classification its note names, and its
499
+ Conversation value."""
500
+ lines = text.split("\n")
501
+ out: list[TableLadder] = []
502
+ section, phase, done = "", False, False
503
+ i = 0
504
+ while i < len(lines):
505
+ line = lines[i]
506
+ if m := _SECTION_RE.match(line):
507
+ section = m.group(1) or m.group(2) or m.group(3)
508
+ phase = m.group(2) is not None
509
+ done = "✅" in line or bool(
510
+ phase and phase_written and phase_written(section.split(" ", 1)[1])
511
+ )
512
+ elif re.match(r"^#{1,2} ", line):
513
+ section, phase, done = "", False, False
514
+ elif status := _STATUS_RE.match(line):
515
+ done = done or status.group(1).startswith(("Complete", "In progress"))
516
+ elif line.startswith("**Phase roadmap settings**"):
517
+ # The session that writes a phase roadmap, under any heading
518
+ # (pyfinlab's `### F1 —` workstreams): a phase ladder.
519
+ phase = True
520
+ if not (_TABLE_RE.match(line) and i + 1 < len(lines) and _RULE_RE.match(lines[i + 1])):
521
+ i += 1
522
+ continue
523
+ j = i + 2
524
+ cells: dict[str, dict[str, list[str]]] = {}
525
+ rank = ""
526
+ while j < len(lines) and lines[j].startswith("|"):
527
+ label, *values = _table_cells(lines[j])
528
+ name = label.replace("&nbsp;", "").strip()
529
+ if name.startswith("**"):
530
+ rank = name.strip("*")
531
+ cells[rank] = {}
532
+ elif rank:
533
+ cells[rank][name] = [v.strip("*").replace(f" {STEP_MARK}", "") for v in values]
534
+ j += 1
535
+ end = _block_end(lines, j)
536
+ block = "\n".join(lines[j:end])
537
+ found = _RANKED_FOR_RE.search(" ".join(block.split()))
538
+ conv = next((m.group(1) for ln in lines[j:end] if (m := _CONVERSATION_RE.match(ln))), None)
539
+ pick = cells.get("Pick", {})
540
+ picks = tuple(zip(pick.get("Model", []), pick.get("Level", []), strict=False))
541
+ ranks = tuple(tuple(cells[r].get("Model", [])) for r in cells)
542
+ out.append(
543
+ TableLadder(
544
+ start=i,
545
+ end=end,
546
+ section=section,
547
+ phase=phase,
548
+ done=done,
549
+ picks=picks,
550
+ ranks=ranks,
551
+ ranked_for=(
552
+ (found.group(1), found.group(2), bool(found.group(3))) if found else None
553
+ ),
554
+ conversation=conv,
555
+ )
556
+ )
557
+ i = end
558
+ return out
559
+
560
+
561
+ def replace_ladders(text: str, replacements: Mapping[int, tuple[int, str]]) -> str:
562
+ """``text`` with each ladder starting at line ``start`` (a key) replaced,
563
+ through its ``end`` line, by the given Markdown."""
564
+ lines = text.split("\n")
565
+ out: list[str] = []
566
+ i = 0
567
+ while i < len(lines):
568
+ if i in replacements:
569
+ end, md = replacements[i]
570
+ out.extend(md.split("\n"))
571
+ i = end
572
+ continue
573
+ out.append(lines[i])
574
+ i += 1
575
+ return "\n".join(out)
@@ -1981,7 +1981,7 @@ def _backup_for(
1981
1981
  ) -> Backup | None:
1982
1982
  """The rung's backup: the substitute from another maker that does the job
1983
1983
  as well as the rung, for the least. ``avoid`` leaves out further
1984
- candidates (a Settings ladder's runner-up, :func:`_ranked_backup`).
1984
+ candidates.
1985
1985
 
1986
1986
  Over the pool's models from a maker other than the rung model's, each at
1987
1987
  its measured efforts, draw the frontier and keep its adequate points,
@@ -2304,8 +2304,9 @@ RUN_COLUMN: Final[dict[str, str]] = {"cheap": "cost", "balanced": "balanced", "b
2304
2304
  # The rule a step's runner applies to the pools on the day (roadmap-step §3).
2305
2305
  RUN_RULE: Final[str] = (
2306
2306
  "If that column's usage pool reads `tight` or `exhausted`, run the first of its "
2307
- "Runner-up and Backup whose pool has headroom; with neither, a `tight` column runs "
2308
- "the Cost column and an `exhausted` one its Backup."
2307
+ "Runner-up and Backup whose pool has headroom; on a `tight` pool an option marked ↓ "
2308
+ "(the Pick one level lower) also runs. With none, a `tight` column runs the Cost "
2309
+ "column and an `exhausted` one its Backup."
2309
2310
  )
2310
2311
  # Dial levels a roadmap never pins: Codex's `ultra` and Claude Code's
2311
2312
  # Ultracode add parallel agents one planning document does not need.
@@ -2387,10 +2388,17 @@ def ladder_price_by_name(
2387
2388
 
2388
2389
 
2389
2390
  def _option(
2390
- c: Candidate, level: str, bench: dict[str, Any], catalog: dict[str, Any]
2391
+ c: Candidate,
2392
+ level: str,
2393
+ bench: dict[str, Any],
2394
+ catalog: dict[str, Any],
2395
+ *,
2396
+ stepped_down: bool = False,
2391
2397
  ) -> dict[str, Any]:
2392
2398
  """One ranked option of a ladder column: ``c`` run at ``level``, with its
2393
- dial and level, its Thinking toggle, its AA Index and its price there."""
2399
+ dial and level, its Thinking toggle, its AA Index and its price there;
2400
+ ``stepped_down`` when it is the Pick one or two levels lower, filling a
2401
+ rank no other maker could."""
2394
2402
  dial = _DIAL_NAMES.get(c.platform_id, "Effort")
2395
2403
  price = ladder_price(c.model_id, level, catalog)
2396
2404
  return {
@@ -2412,6 +2420,7 @@ def _option(
2412
2420
  "price_usd_per_1m": None if price is None else price[0],
2413
2421
  "price_measured": None if price is None else price[1],
2414
2422
  "thinking_cell": _thinking_cell(c, catalog),
2423
+ "stepped_down": stepped_down,
2415
2424
  }
2416
2425
 
2417
2426
 
@@ -2426,57 +2435,22 @@ def _layout_option(o: dict[str, Any] | None) -> _layout.Option | None:
2426
2435
  aa=_aa_cell(o["aa_index"]),
2427
2436
  price=o["price_usd_per_1m"],
2428
2437
  price_measured=bool(o["price_measured"]),
2438
+ stepped_down=bool(o.get("stepped_down")),
2429
2439
  )
2430
2440
 
2431
2441
 
2432
- def _series_ids(model_id: str, catalog: dict[str, Any]) -> list[str]:
2433
- """``model_id`` and every catalog model in its series
2434
- (:mod:`roadmodel.series`): GPT-6 Sol beside GPT-6.1 Sol."""
2435
- own = _series.series(model_id)
2436
- ids = (str(m.get("id", "")) for m in catalog.get("models", []) if isinstance(m, dict))
2437
- return [model_id, *(i for i in ids if own and i != model_id and _series.series(i) == own)]
2438
-
2439
-
2440
- def _ranked_backup(
2441
- rung: Rung,
2442
- second: Rung | None,
2443
- pool: list[Candidate],
2444
- task: Task,
2445
- headroom: str,
2446
- bench: dict[str, Any],
2447
- catalog: dict[str, Any],
2448
- aa_band: float,
2449
- ) -> Backup | None:
2450
- """A Settings column's Backup, ranked under its pick and runner-up
2451
- (``second``): the pick's backup (:func:`_backup_for`, another maker's)
2452
- that is not the runner-up's series, from a maker neither row above uses
2453
- where one meets the task's bar, so the three rows span as many makers as
2454
- the pool holds. A runner-up from the pick's own maker leaves the pick's
2455
- backup as it is. None when the runner-up holds the only other maker's
2456
- series: it already stands in for an outage or an exhausted pool."""
2457
- if second is None or second.candidate.provider == rung.candidate.provider:
2458
- return rung.backup
2459
- taken = second.candidate
2460
- own = frozenset(_series_ids(taken.model_id, catalog))
2461
-
2462
- def same_series(c: Candidate) -> bool:
2463
- return c.model_id in own
2464
-
2465
- third = _backup_for(
2466
- rung,
2467
- pool,
2468
- task,
2469
- headroom,
2470
- bench,
2471
- catalog,
2472
- aa_band,
2473
- avoid=lambda c: c.provider == taken.provider or same_series(c),
2474
- )
2475
- if third is not None and third.candidate.requirement_penalty == 0:
2476
- return third
2477
- return (
2478
- _backup_for(rung, pool, task, headroom, bench, catalog, aa_band, avoid=same_series) or third
2479
- )
2442
+ def _step_down(c: Candidate, level: str, steps: int, catalog: dict[str, Any]) -> str | None:
2443
+ """``level`` on ``c``'s own dial, ``steps`` rungs lower (High → Medium);
2444
+ None below the dial's lowest rung, or on a surface with no dial."""
2445
+ if c.platform_id in _DIAL_NAMES and _DIAL_NAMES[c.platform_id] is None:
2446
+ return None
2447
+ levels = native_levels(c.platform_id, c.model_id, catalog=catalog) or list(EFFORT_LADDER)
2448
+ levels = [lv for lv in levels if normalize_level(lv) not in _NEVER_PINNED]
2449
+ names = [normalize_level(lv) for lv in levels]
2450
+ here = normalize_level(level)
2451
+ if here not in names or names.index(here) < steps:
2452
+ return None
2453
+ return levels[names.index(here) - steps]
2480
2454
 
2481
2455
 
2482
2456
  def _deepest_level(c: Candidate, catalog: dict[str, Any]) -> str:
@@ -2503,21 +2477,27 @@ def settings_ladder(
2503
2477
  day instead of the roadmap being refreshed. ``phase`` is a phase-roadmap
2504
2478
  session: it runs Quality, at the deepest rung its dial allows.
2505
2479
 
2506
- Each column ranks three options, each with its effort and its AA
2507
- Intelligence Index there (:func:`ladder_aa_index`):
2480
+ Each column ranks three options from three makers, each with its effort,
2481
+ its AA Intelligence Index (:func:`ladder_aa_index`) and its price there.
2482
+ A tight pool is a maker's (for this operator a maker is a subscription),
2483
+ so a fallback from the Pick's own maker would draw on the pool that just
2484
+ ran short (2026-10-25: Quality ranked Opus 5.5, then Fable 5.1):
2508
2485
 
2509
2486
  - Pick: the column's rung (:func:`ladder`).
2510
- - Runner-up: the column's rung with the pick's whole series off the
2511
- table, so it is never an older version of the pick (GPT-6 Sol under
2512
- GPT-6.1 Sol), from any maker.
2513
- - Backup: another maker's substitute for the pick, never the runner-up's
2514
- series (:func:`_ranked_backup`).
2515
-
2516
- The ranks follow the column's own rule, not the AA Index: Cost ranks the
2517
- cheapest first, so its AA Index may rise down the column. Returns the
2518
- columns and the ladder's Markdown (:mod:`roadmodel.ladder_layout`: a
2519
- list per column, one option a line); None when no pool model carries an
2520
- AA Index."""
2487
+ - Runner-up: the column's rung with every model of the Pick's maker off
2488
+ the table.
2489
+ - Backup: the column's rung with both makers above off the table.
2490
+
2491
+ Where the pool holds too few makers, a missing rank is the Pick's own
2492
+ model one level lower on its dial (``stepped_down``; ↓ in the table),
2493
+ which uses less of a tight pool; with one maker, the Runner-up and Backup
2494
+ step down one and two levels. None below the dial's lowest level.
2495
+
2496
+ The ranks follow the column's own rule, not the AA Index: Cost ranks each
2497
+ maker's cheapest adequate option, so its AA Index may rise down the
2498
+ column. Returns the columns and the ladder table's Markdown
2499
+ (:mod:`roadmodel.ladder_layout`); None when no pool model carries an AA
2500
+ Index."""
2521
2501
  cat = catalog if catalog is not None else _cost._load_catalog()
2522
2502
  bench = benchmarks if benchmarks is not None else _load_benchmarks()
2523
2503
  off = list(unavailable_models or [])
@@ -2539,8 +2519,14 @@ def settings_ladder(
2539
2519
  lad = read([])
2540
2520
  if lad is None:
2541
2521
  return None
2542
- headroom = consumption_headroom(user_context_text or "")
2543
- aa_band = _aa_band(bench, cat)
2522
+ # Every catalog model, by its maker: a rank leaves out the makers above
2523
+ # it. The whole catalog, not the pool: with GPT-6.1 Sol off the table the
2524
+ # pool takes GPT-6 Sol back, the same maker.
2525
+ by_maker: dict[str, set[str]] = {}
2526
+ for m in cat.get("models", []):
2527
+ mid = str(m.get("id", ""))
2528
+ maker = _cost.model_provider(mid, cat) or str(m.get("provider", "unknown"))
2529
+ by_maker.setdefault(maker, set()).add(mid)
2544
2530
 
2545
2531
  def level_of(tier: str, r: Rung) -> str:
2546
2532
  return _deepest_level(r.candidate, cat) if phase and tier == "quality" else r.level
@@ -2548,20 +2534,27 @@ def settings_ladder(
2548
2534
  columns: dict[str, dict[str, Any]] = {}
2549
2535
  for tier in LADDER_TIERS:
2550
2536
  rung = lad.rungs[tier]
2551
- rest = read(_series_ids(rung.candidate.model_id, cat))
2552
- second = None if rest is None else rest.rungs[tier]
2553
- backup = _ranked_backup(rung, second, lad.settings, task, headroom, bench, cat, aa_band)
2554
- columns[tier] = {
2555
- **_option(rung.candidate, level_of(tier, rung), bench, cat),
2556
- "runner_up": (
2537
+ pick_level = level_of(tier, rung)
2538
+ pick = _option(rung.candidate, pick_level, bench, cat)
2539
+ ranked: list[dict[str, Any] | None] = [pick]
2540
+ makers = [rung.candidate.provider]
2541
+ while len(ranked) < 3:
2542
+ rest = read(sorted(m for maker in makers for m in by_maker.get(maker, ())))
2543
+ nxt = None if rest is None else rest.rungs[tier]
2544
+ if nxt is None or nxt.candidate.provider in makers:
2545
+ break
2546
+ ranked.append(_option(nxt.candidate, level_of(tier, nxt), bench, cat))
2547
+ makers.append(nxt.candidate.provider)
2548
+ steps = 1
2549
+ while len(ranked) < 3:
2550
+ lower = _step_down(rung.candidate, pick_level, steps, cat)
2551
+ ranked.append(
2557
2552
  None
2558
- if second is None
2559
- else _option(second.candidate, level_of(tier, second), bench, cat)
2560
- ),
2561
- "backup": (
2562
- None if backup is None else _option(backup.candidate, backup.level, bench, cat)
2563
- ),
2564
- }
2553
+ if lower is None
2554
+ else _option(rung.candidate, lower, bench, cat, stepped_down=True)
2555
+ )
2556
+ steps += 1
2557
+ columns[tier] = {**pick, "runner_up": ranked[1], "backup": ranked[2]}
2565
2558
  runs = "quality" if phase else RUN_COLUMN.get(task.budget, "balanced")
2566
2559
  lists = _layout.ladder_markdown(
2567
2560
  {
@@ -2574,6 +2567,10 @@ def settings_ladder(
2574
2567
  },
2575
2568
  runs=runs,
2576
2569
  conversation="New",
2570
+ ranked_for=(
2571
+ f"{task.category}, {task.complexity} complexity"
2572
+ + (", novel" if task.novel and task.complexity == "high" else "")
2573
+ ),
2577
2574
  )
2578
2575
  why = (
2579
2576
  "a phase-roadmap session runs Quality (quality first)"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes