puremethod 0.1.0__py3-none-any.whl

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 (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
llm/prices.py ADDED
@@ -0,0 +1,98 @@
1
+ """Versioned price table (07-llm.md §4) and cost arithmetic.
2
+
3
+ Prices are USD per million tokens. A model with no entry is refused (INV-3). A local model
4
+ needs an explicit entry, which may be 0.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, replace
10
+ from typing import Any
11
+
12
+ from .types import PriceUnknown, Usage
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelPrice:
17
+ input: float
18
+ output: float
19
+ cache_read: float | None = None # None: charged at the input price (upper bound)
20
+ cache_write: float | None = None # None: charged at the input price
21
+ # Optional long-context tier: when input tokens exceed the threshold, the whole request
22
+ # is charged at these prices.
23
+ long_threshold: int | None = None
24
+ long_input: float | None = None
25
+ long_output: float | None = None
26
+ # A self-hosted model whose cost is not known per token (billed by the hour, or not
27
+ # known): calls count 0 against the money limit, are recorded `metered: false`, and need
28
+ # the run's unmetered-compute consent (INV-3, KB-036). Its input and output are 0.
29
+ unmetered: bool = False
30
+
31
+ @staticmethod
32
+ def unmetered_price() -> ModelPrice:
33
+ return ModelPrice(0.0, 0.0, unmetered=True)
34
+
35
+ def tier(self, input_tokens: int) -> ModelPrice:
36
+ if self.long_threshold is not None and input_tokens > self.long_threshold:
37
+ return replace(self, input=self.long_input or self.input,
38
+ output=self.long_output or self.output, long_threshold=None)
39
+ return self
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PriceTable:
44
+ version: str
45
+ source: str
46
+ prices: dict[tuple[str, str], ModelPrice] # (provider, model) -> price
47
+
48
+ def get(self, provider: str, model: str) -> ModelPrice:
49
+ price = self.prices.get((provider, model))
50
+ if price is None:
51
+ raise PriceUnknown(f"no price for {provider}/{model}; add one to the run config")
52
+ return price
53
+
54
+ def with_overrides(self, overrides: dict[str, dict[str, Any]]) -> PriceTable:
55
+ """Overrides keyed `provider/model`, values are ModelPrice fields."""
56
+ prices = dict(self.prices)
57
+ for key, fields in overrides.items():
58
+ provider, _, model = key.partition("/")
59
+ if not model:
60
+ raise ValueError(f"price override key {key!r} must be provider/model")
61
+ prices[(provider, model)] = ModelPrice(**fields)
62
+ version = f"{self.version}+overrides" if overrides else self.version
63
+ return PriceTable(version=version, source=self.source, prices=prices)
64
+
65
+
66
+ def cost_usd(price: ModelPrice, usage: Usage) -> float:
67
+ p = price.tier(usage.input_tokens + usage.cache_read_tokens + usage.cache_write_tokens)
68
+ cache_read = p.cache_read if p.cache_read is not None else p.input
69
+ cache_write = p.cache_write if p.cache_write is not None else p.input
70
+ return (usage.input_tokens * p.input + usage.output_tokens * p.output
71
+ + usage.cache_read_tokens * cache_read
72
+ + usage.cache_write_tokens * cache_write) / 1_000_000
73
+
74
+
75
+ def bound_usd(price: ModelPrice, input_tokens: int, max_tokens: int) -> float:
76
+ """Upper bound for one call: every input token at the highest input-side price."""
77
+ p = price.tier(input_tokens)
78
+ input_side = max(p.input, p.cache_read or 0.0, p.cache_write or 0.0)
79
+ return (input_tokens * input_side + max_tokens * p.output) / 1_000_000
80
+
81
+
82
+ # Source: Anthropic first-party API list prices for current models, as given in the
83
+ # claude-api reference (model table cached 2026-10-06), read 2026-10-27. Cache writes at
84
+ # 1.25x input (5-minute TTL). Claude Haiku 5.5: listed prices apply up to 100K prompt
85
+ # tokens; above that, $0.50 / $2.50. No OpenAI prices are shipped: they were not checked
86
+ # against a current source, so OpenAI models need a price in the run config (INV-3).
87
+ DEFAULT_PRICES = PriceTable(
88
+ version="2026-10-27.1",
89
+ source="Anthropic list prices via the claude-api reference, cached 2026-10-06",
90
+ prices={
91
+ ("anthropic", "claude-fable-5-1"): ModelPrice(10.00, 50.00, 0.25, 12.50),
92
+ ("anthropic", "claude-opus-5-5"): ModelPrice(4.00, 20.00, 0.20, 5.00),
93
+ ("anthropic", "claude-sonnet-5-5"): ModelPrice(2.00, 10.00, 0.20, 2.50),
94
+ ("anthropic", "claude-haiku-5-5"): ModelPrice(0.10, 0.50, None, 0.125,
95
+ long_threshold=100_000,
96
+ long_input=0.50, long_output=2.50),
97
+ },
98
+ )
llm/tokens.py ADDED
@@ -0,0 +1,18 @@
1
+ """Fallback token estimate (07-llm.md §5 step 2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ # Per-message allowance for role markers and framing, on top of the content estimate.
8
+ MESSAGE_OVERHEAD = 8
9
+ REQUEST_OVERHEAD = 16
10
+
11
+
12
+ def estimate_tokens(text: str) -> int:
13
+ """4 tokens per 3 characters, plus 10%, rounded up."""
14
+ return math.ceil(len(text) * 4 / 3 * 1.1)
15
+
16
+
17
+ def estimate_request(texts: list[str]) -> int:
18
+ return sum(estimate_tokens(t) + MESSAGE_OVERHEAD for t in texts) + REQUEST_OVERHEAD
llm/types.py ADDED
@@ -0,0 +1,106 @@
1
+ """Messages, results, call records and errors (07-llm.md §3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal, TypedDict
7
+
8
+ Role = Literal["planner", "coder", "analyst", "writer"]
9
+ ROLES: tuple[Role, ...] = ("planner", "coder", "analyst", "writer")
10
+
11
+
12
+ class Message(TypedDict):
13
+ role: Literal["system", "user", "assistant"]
14
+ content: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Usage:
19
+ input_tokens: int = 0
20
+ output_tokens: int = 0
21
+ cache_read_tokens: int = 0
22
+ cache_write_tokens: int = 0
23
+
24
+
25
+ @dataclass
26
+ class CallRecord:
27
+ """One provider call, successful or not (INV-1). Same shape as the adapters' records.
28
+
29
+ `request` and `response` hold the full text for `loop` to store as ledger artifacts.
30
+ """
31
+
32
+ source: str
33
+ role: str
34
+ provider: str
35
+ model: str
36
+ model_reported: str | None
37
+ fallback: bool
38
+ attempt: int
39
+ parameters: dict[str, Any]
40
+ started_at: str
41
+ ended_at: str
42
+ status: Literal["ok", "error"]
43
+ usage: Usage
44
+ cost_usd: float
45
+ price_table_version: str
46
+ bound_usd: float
47
+ bound_exceeded: bool
48
+ error_type: str | None
49
+ error_message: str | None
50
+ request: dict[str, Any]
51
+ response: str | None
52
+ metered: bool = True # False for a model priced as unmetered (INV-3, KB-036)
53
+ model_digest: str | None = None # as the server reports it, e.g. Ollama (§3, KB-036)
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ d = dict(self.__dict__)
57
+ d["usage"] = dict(self.usage.__dict__)
58
+ return d
59
+
60
+
61
+ @dataclass
62
+ class LLMResult:
63
+ text: str
64
+ parsed: Any
65
+ usage: Usage
66
+ cost_usd: float
67
+ model_reported: str | None
68
+ stop_reason: str | None
69
+ record: CallRecord
70
+ records: list[CallRecord] = field(default_factory=list)
71
+
72
+
73
+ class LLMError(Exception):
74
+ """Base error. `records` holds every call made before the failure (INV-1)."""
75
+
76
+ def __init__(self, message: str, records: list[CallRecord] | None = None) -> None:
77
+ super().__init__(message)
78
+ self.records: list[CallRecord] = records or []
79
+
80
+
81
+ class ProviderUnavailable(LLMError):
82
+ """Outage or persistent transient failure. The caller pauses the run (SYS-INV-7)."""
83
+
84
+
85
+ class RateLimited(LLMError):
86
+ """Rate limited after the retries ran out."""
87
+
88
+
89
+ class QuotaExhausted(LLMError):
90
+ """The account has no quota or credit left."""
91
+
92
+
93
+ class AuthError(LLMError):
94
+ """Credentials missing, invalid, or not allowed."""
95
+
96
+
97
+ class InvalidOutput(LLMError):
98
+ """The output still failed its schema after the retries."""
99
+
100
+
101
+ class PriceUnknown(LLMError):
102
+ """The model has no price; it is refused rather than treated as free (INV-3)."""
103
+
104
+
105
+ class RequestRejected(LLMError):
106
+ """The provider refused the request as malformed (HTTP 400, 404, 413, 422)."""
loop/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """loop: the research cycle (docs/knowledge_base/03-loop.md)."""
2
+
3
+ from .config import (
4
+ Autonomy,
5
+ ExecutorChoice,
6
+ LadderConfig,
7
+ Limits,
8
+ PaperConfig,
9
+ PureTraceUse,
10
+ RunConfig,
11
+ next_stage,
12
+ stage_budget_s,
13
+ )
14
+ from .core import LimitReached, Loop, RunStatus
15
+ from .decide import RULES, Verdict, evaluate
16
+
17
+ __all__ = ["RULES", "Autonomy", "ExecutorChoice", "LadderConfig", "LimitReached", "Limits",
18
+ "Loop", "PaperConfig", "PureTraceUse", "RunConfig", "RunStatus", "Verdict",
19
+ "evaluate", "next_stage", "stage_budget_s"]
loop/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """RunConfig (03-loop.md §3): validated once, then fixed for the run (INV-6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+ CHECKPOINTS = ("C1", "C2")
10
+
11
+
12
+ class _Strict(BaseModel):
13
+ model_config = ConfigDict(extra="forbid", frozen=True)
14
+
15
+
16
+ class Limits(_Strict):
17
+ wall_clock: float = Field(gt=0, description="hours")
18
+ money: float = Field(gt=0, description="USD")
19
+ writing_reserve: float = Field(default=0.15, ge=0.05, le=0.5)
20
+
21
+
22
+ class ExecutorChoice(_Strict):
23
+ id: str = Field(min_length=1)
24
+ allow_unisolated: bool = False
25
+
26
+
27
+ class LadderConfig(_Strict):
28
+ pilot_minutes: float = Field(default=5.0, gt=0)
29
+ growth_factor: float = Field(default=3.0, gt=1)
30
+ max_stages: int = Field(default=3, ge=1, le=6)
31
+ confirmation_seeds: int = Field(default=3, ge=1, le=10)
32
+
33
+
34
+ class PureTraceUse(_Strict):
35
+ screening: bool = False
36
+ selfcheck: bool = False
37
+
38
+
39
+ class PaperConfig(_Strict):
40
+ template: Literal["article", "ieeetran"] = "article"
41
+ disclosure: bool = True
42
+
43
+
44
+ class Autonomy(_Strict):
45
+ mode: Literal["autonomous", "checkpoint"] = "checkpoint"
46
+ checkpoints: tuple[str, ...] = CHECKPOINTS
47
+
48
+ @model_validator(mode="after")
49
+ def _known(self) -> Autonomy:
50
+ unknown = set(self.checkpoints) - set(CHECKPOINTS)
51
+ if unknown:
52
+ raise ValueError(f"unknown checkpoints: {sorted(unknown)}")
53
+ return self
54
+
55
+
56
+ class RunConfig(_Strict):
57
+ idea: str = Field(min_length=1)
58
+ domain: Literal["ml", "simulation"]
59
+ autonomy: Autonomy = Autonomy()
60
+ limits: Limits
61
+ executor: ExecutorChoice
62
+ accept_unmetered_compute: bool = False
63
+ llm: dict[str, dict[str, Any]]
64
+ budget_ladder: LadderConfig = LadderConfig()
65
+ puretrace: PureTraceUse = PureTraceUse()
66
+ paper: PaperConfig = PaperConfig()
67
+ data_hints: tuple[str, ...] = ()
68
+ job_credentials: tuple[str, ...] = () # names the experiments may receive
69
+
70
+ @model_validator(mode="after")
71
+ def _roles(self) -> RunConfig:
72
+ missing = {"planner", "coder", "analyst"} - set(self.llm)
73
+ if missing:
74
+ raise ValueError(f"llm must map roles {sorted(missing)}")
75
+ return self
76
+
77
+ def checkpoint_on(self, cid: str) -> bool:
78
+ return self.autonomy.mode == "checkpoint" and cid in self.autonomy.checkpoints
79
+
80
+ def credential_names(self) -> list[str]:
81
+ """Every credential the run uses, so its values are registered as secrets."""
82
+ names = {str(r["credential"]) for r in self.llm.values() if r.get("credential")}
83
+ for r in self.llm.values():
84
+ fb = r.get("fallback")
85
+ if isinstance(fb, dict) and fb.get("credential"):
86
+ names.add(str(fb["credential"]))
87
+ names.update(self.job_credentials)
88
+ return sorted(names)
89
+
90
+
91
+ def stage_budget_s(ladder: LadderConfig, stage: str) -> float:
92
+ """Stage k gets pilot × growth^k; `confirm` gets the last stage's budget; `smoke` a
93
+ tenth of the pilot (at least 30 s)."""
94
+ pilot = ladder.pilot_minutes * 60
95
+ if stage == "smoke":
96
+ return max(pilot / 10, 30.0)
97
+ if stage == "confirm":
98
+ return pilot * ladder.growth_factor ** (ladder.max_stages - 1)
99
+ return pilot * ladder.growth_factor ** int(stage)
100
+
101
+
102
+ def next_stage(ladder: LadderConfig, stage: str) -> str:
103
+ if stage == "smoke":
104
+ return "0"
105
+ if stage == "confirm":
106
+ raise ValueError("confirm is the last stage")
107
+ k = int(stage) + 1
108
+ return "confirm" if k >= ladder.max_stages else str(k)