cdx-manager 0.12.4 → 0.14.0

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.
@@ -0,0 +1,259 @@
1
+ """The single rule for ordering sessions by desirability.
2
+
3
+ cdx used to answer "which session should this work go to" twice, with two
4
+ algorithms that disagreed: one behind `cdx select` and `cdx run --provider`,
5
+ another behind `cdx next`, the `cdx status` recommendation, and `cdx ready`.
6
+ Each knew something the other did not — the first honored the user's
7
+ `--priority` and filtered by provider, the second understood credits and reset
8
+ scheduling — so the answer depended on which command you asked.
9
+
10
+ This module is the merge. Every selector calls `rank_sessions`; the callers
11
+ differ only in the options they pass and what they do with the result.
12
+ """
13
+
14
+ from .config import REASONING_EFFORT_VALUES
15
+
16
+ # Below this, a session has nothing useful left in its window.
17
+ USABLE_AVAILABLE_THRESHOLD = 5
18
+
19
+ # Usability classes, best first.
20
+ USABLE_TIER = 3
21
+ BLOCKED_WITH_RESET_TIER = 2
22
+ RESET_KNOWN_TIER = 1
23
+ UNKNOWN_TIER = 0
24
+
25
+ # Compared in order, best first. `cdx select` publishes this as its
26
+ # `selection_policy`, built from these tuples, so the published policy cannot
27
+ # describe an order the code does not apply.
28
+ #
29
+ # The order after the first two factors depends on whether the session can be
30
+ # used right now, which the recommendation ranking already did and which is
31
+ # worth keeping: for a session you can use, what matters is cost and headroom;
32
+ # for one you cannot, what matters is when it comes back.
33
+ LEADING_FACTORS = ("usability", "priority")
34
+ USABLE_FACTORS = ("credits", "availability", "reset")
35
+ BLOCKED_FACTORS = ("reset", "credits", "availability")
36
+ TRAILING_FACTORS = ("reasoning_effort", "name")
37
+
38
+ RANKING_FACTORS = LEADING_FACTORS + USABLE_FACTORS + TRAILING_FACTORS
39
+
40
+
41
+ def factor_order(usable):
42
+ """The factors, in the order they are compared, for one usability class."""
43
+ return LEADING_FACTORS + (USABLE_FACTORS if usable else BLOCKED_FACTORS) + TRAILING_FACTORS
44
+
45
+ FACTOR_DESCRIPTIONS = {
46
+ "usability": "usable now, then blocked with a known reset, then reset known, then unknown",
47
+ "priority": "higher configured --priority first",
48
+ "credits": "sessions without credits first, to spend included quota before paid credits",
49
+ "availability": "higher remaining availability first",
50
+ "reset": "earlier known reset first",
51
+ "reasoning_effort": "lower configured reasoning effort first, to leave stronger sessions free",
52
+ "name": "session name, so the order is deterministic",
53
+ }
54
+
55
+ # Candidate filters, which exclude rather than order. Kept separate because
56
+ # describing them as ranking stages is what made the old published policy wrong.
57
+ FILTER_DESCRIPTIONS = {
58
+ "enabled": "disabled sessions are never candidates",
59
+ "authenticated": "logged-out sessions are never candidates; they cannot serve work",
60
+ "provider": "only sessions for the requested provider (when a provider is given)",
61
+ "min_reasoning_effort": "only sessions configured at or above the requested effort (when given)",
62
+ "require_ready": "only sessions confirmed authenticated with availability left (when readiness is required)",
63
+ }
64
+
65
+
66
+ def reasoning_rank(value):
67
+ try:
68
+ return REASONING_EFFORT_VALUES.index(str(value or "low").lower())
69
+ except ValueError:
70
+ return -1
71
+
72
+
73
+ def _is_usable_now(row):
74
+ available = row.get("available_pct")
75
+ return available is not None and available > USABLE_AVAILABLE_THRESHOLD
76
+
77
+
78
+ def _usability_tier(row, now_ts, reset_timestamp_fn):
79
+ """Higher is better.
80
+
81
+ Preserved from the recommendation ranking, which distinguished "blocked but
82
+ coming back at a known time" from "blocked with no information". The
83
+ headless ranking flattened both into "unavailable", losing the distinction
84
+ that makes `cdx next` useful.
85
+ """
86
+ reset_ts = reset_timestamp_fn(row)
87
+ if _is_usable_now(row):
88
+ return USABLE_TIER
89
+ if reset_ts is not None and reset_ts >= now_ts:
90
+ return BLOCKED_WITH_RESET_TIER
91
+ if reset_ts is not None:
92
+ return RESET_KNOWN_TIER
93
+ return UNKNOWN_TIER
94
+
95
+
96
+ def _factor_values(row, now_ts, reset_timestamp_fn):
97
+ """This row's value for each factor, already oriented so higher is better."""
98
+ reset_ts = reset_timestamp_fn(row)
99
+ available = row.get("available_pct")
100
+ try:
101
+ priority = int(row.get("priority") or 0)
102
+ except (TypeError, ValueError):
103
+ priority = 0
104
+ return {
105
+ "usability": _usability_tier(row, now_ts, reset_timestamp_fn),
106
+ # Ranked inside a usability tier, never across one: a session that
107
+ # cannot serve the request does not become the right choice by being
108
+ # preferred. Priority expresses which usable session to favour.
109
+ "priority": priority,
110
+ # Sessions *without* credits first: spend the included quota before
111
+ # burning paid credits. Inverting this is an easy mistake — the factor
112
+ # is named for what it looks at, not for what it prefers.
113
+ "credits": 0 if row.get("credits") is not None else 1,
114
+ "availability": float(available) if available is not None else -1.0,
115
+ "reset": -reset_ts if reset_ts is not None else float("-inf"),
116
+ # Negated: the weakest configured session that still clears the floor
117
+ # wins, so a stronger one stays free for work that needs it. This was
118
+ # previously implied by an un-negated term in a sort tuple; it is a
119
+ # deliberate cost preference and is stated here rather than inferred.
120
+ "reasoning_effort": -reasoning_rank(row.get("reasoning_effort")),
121
+ "name": row.get("session_name") or "",
122
+ }
123
+
124
+
125
+ def _sort_key(values):
126
+ # Sessions are bucketed by usability first, so every candidate compared
127
+ # beyond that point shares a usability class and therefore a factor order.
128
+ key = []
129
+ for factor in factor_order(values["usability"] == USABLE_TIER):
130
+ value = values[factor]
131
+ # Names sort ascending; every other factor is "higher is better", so
132
+ # they are negated to sort descending under one ascending sort.
133
+ key.append(value if factor == "name" else _negate(value))
134
+ return tuple(key)
135
+
136
+
137
+ def _negate(value):
138
+ return -value if isinstance(value, (int, float)) else value
139
+
140
+
141
+ def deciding_factor(winner_values, runner_up_values):
142
+ """The first factor that separated the winner from the runner-up.
143
+
144
+ Returns None when there was nobody to compare against, so a caller can say
145
+ "only candidate" instead of naming a factor that decided nothing.
146
+ """
147
+ if runner_up_values is None:
148
+ return None
149
+ for factor in factor_order(winner_values["usability"] == USABLE_TIER):
150
+ if winner_values[factor] != runner_up_values[factor]:
151
+ return factor
152
+ return None
153
+
154
+
155
+ def candidate_rows(rows, provider=None, require_ready=False, min_reasoning_effort=None):
156
+ """Rows eligible for selection, applying the filters described above.
157
+
158
+ Logged-out sessions are excluded unconditionally. The headless path used to
159
+ admit them unless `--require-ready` was passed, so `cdx select` could return
160
+ a session that could not possibly run anything.
161
+ """
162
+ minimum = reasoning_rank(min_reasoning_effort) if min_reasoning_effort else None
163
+ candidates = []
164
+ for row in rows:
165
+ if row.get("enabled", True) is False:
166
+ continue
167
+ if provider is not None and row.get("provider") != provider:
168
+ continue
169
+ if _row_is_logged_out(row):
170
+ continue
171
+ if minimum is not None and reasoning_rank(row.get("reasoning_effort")) < minimum:
172
+ continue
173
+ if require_ready and not _row_is_ready(row):
174
+ continue
175
+ candidates.append(row)
176
+ return candidates
177
+
178
+
179
+ def _row_is_logged_out(row):
180
+ if row.get("provider") in ("antigravity", "ollama"):
181
+ return False
182
+ return str(row.get("auth_status") or "").strip().lower() == "logged_out"
183
+
184
+
185
+ def _row_is_ready(row):
186
+ """Ready to receive a run right now.
187
+
188
+ Stricter than "not logged out": an unknown auth state is not readiness.
189
+ `cdx run --provider` asks for readiness precisely so it does not hand work
190
+ to a session that will fail at launch, and the headless ranking required a
191
+ confirmed `authenticated` here before the two rankings were merged.
192
+ Sessions that are only recommended, rather than selected to run, do not
193
+ pass `require_ready` and so are still surfaced with an unknown state.
194
+ """
195
+ if row.get("provider") not in ("antigravity", "ollama"):
196
+ if str(row.get("auth_status") or "").strip().lower() != "authenticated":
197
+ return False
198
+ return _row_has_availability(row)
199
+
200
+
201
+ def _row_has_availability(row):
202
+ available = row.get("available_pct")
203
+ if available is None:
204
+ # Unknown is not the same as empty. Excluding it here would make a
205
+ # never-probed session permanently unselectable; the caller warns
206
+ # instead, so the decision is visible rather than silent.
207
+ return True
208
+ try:
209
+ return float(available) > 0
210
+ except (TypeError, ValueError):
211
+ return False
212
+
213
+
214
+ def rank_sessions(rows, now_ts, reset_timestamp_fn, provider=None, require_ready=False,
215
+ min_reasoning_effort=None):
216
+ """Candidate rows ordered best-first, with why the winner won.
217
+
218
+ Returns (ordered_rows, decision) where decision names the deciding factor,
219
+ or None when there was only one candidate.
220
+ """
221
+ candidates = candidate_rows(
222
+ rows,
223
+ provider=provider,
224
+ require_ready=require_ready,
225
+ min_reasoning_effort=min_reasoning_effort,
226
+ )
227
+ scored = [
228
+ (row, _factor_values(row, now_ts, reset_timestamp_fn))
229
+ for row in candidates
230
+ ]
231
+ scored.sort(key=lambda item: _sort_key(item[1]))
232
+ ordered = [row for row, _values in scored]
233
+ if not scored:
234
+ return ordered, None
235
+ runner_up = scored[1][1] if len(scored) > 1 else None
236
+ return ordered, deciding_factor(scored[0][1], runner_up)
237
+
238
+
239
+ def selection_policy():
240
+ """The published description of the ranking, built from the ranking itself.
241
+
242
+ Derived rather than written, so a change to `RANKING_FACTORS` changes what
243
+ `cdx select` reports without anyone remembering to edit a string. The
244
+ previous hand-written policy omitted the reasoning-effort tie-break and
245
+ described a candidate filter as a sort stage.
246
+ """
247
+ return {
248
+ "factors": [
249
+ {"name": factor, "description": FACTOR_DESCRIPTIONS[factor]}
250
+ for factor in RANKING_FACTORS
251
+ ],
252
+ "filters": [
253
+ {"name": name, "description": description}
254
+ for name, description in FILTER_DESCRIPTIONS.items()
255
+ ],
256
+ "usable_order": list(factor_order(True)),
257
+ "blocked_order": list(factor_order(False)),
258
+ "summary": "_then_".join(factor_order(True)),
259
+ }