shiftgate 0.1.1__tar.gz → 0.1.2__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 (26) hide show
  1. {shiftgate-0.1.1 → shiftgate-0.1.2}/PKG-INFO +33 -31
  2. {shiftgate-0.1.1 → shiftgate-0.1.2}/README.md +32 -30
  3. {shiftgate-0.1.1 → shiftgate-0.1.2}/pyproject.toml +1 -1
  4. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/cli.py +15 -3
  5. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/schemas.py +19 -1
  6. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/matcher.py +41 -49
  7. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/router.py +11 -5
  8. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/utils/display.py +41 -14
  9. {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_registry.py +65 -0
  10. {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_router.py +21 -24
  11. {shiftgate-0.1.1 → shiftgate-0.1.2}/.github/workflows/release.yml +0 -0
  12. {shiftgate-0.1.1 → shiftgate-0.1.2}/.gitignore +0 -0
  13. {shiftgate-0.1.1 → shiftgate-0.1.2}/data/default_tasks.json +0 -0
  14. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/__init__.py +0 -0
  15. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/feedback/__init__.py +0 -0
  16. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/feedback/loop.py +0 -0
  17. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/__init__.py +0 -0
  18. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/adapter_registry.py +0 -0
  19. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/task_registry.py +0 -0
  20. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/__init__.py +0 -0
  21. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/embedder.py +0 -0
  22. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/runtime/__init__.py +0 -0
  23. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/runtime/backend.py +0 -0
  24. {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/utils/__init__.py +0 -0
  25. {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/__init__.py +0 -0
  26. {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_feedback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shiftgate
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop.
5
5
  Project-URL: Homepage, https://github.com/shiftgate-ai/shiftgate
6
6
  Project-URL: Repository, https://github.com/shiftgate-ai/shiftgate
@@ -41,15 +41,13 @@ Your inference backend (Ollama, vLLM) is responsible for loading the weights; sh
41
41
 
42
42
  Instead of hardcoding which adapter to use, shiftgate embeds your query and matches it against a catalog of task clusters using cosine similarity — then routes inference to the best-fit LoRA adapter on your running Ollama or vLLM instance.
43
43
 
44
- Inspired by the [LORAUTER paper](https://arxiv.org/abs/2406.08213) (EPFL, 2026).
45
-
46
44
  ---
47
45
 
48
46
  ## Quickstart
49
47
 
50
48
  ```bash
51
49
  # 1. Install (requires Python 3.10+, uv recommended)
52
- uv add shiftgate
50
+ uv tool install shiftgate (or pip install shiftgate)
53
51
 
54
52
  # 2. Initialise: sets up ~/.shiftgate/ and computes task embeddings
55
53
  shiftgate init
@@ -126,24 +124,26 @@ User query
126
124
 
127
125
  ## Commands
128
126
 
129
- | Command | Description |
130
- |---|---|
131
- | `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
132
- | `shiftgate route "<query>"` | Route a query and show the decision — no inference |
133
- | `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
134
- | `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
135
- | `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
136
- | `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
137
- | `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
138
- | `shiftgate adapter list` | Table of all registered adapters |
139
- | `shiftgate adapter remove <id>` | Remove an adapter |
140
- | `shiftgate task list` | Table of all task clusters |
141
- | `shiftgate task add` | Interactively add a new task cluster |
142
- | `shiftgate feedback accept` | Mark last routing as good |
143
- | `shiftgate feedback reject` | Mark last routing as bad |
144
- | `shiftgate feedback stats` | Adapter acceptance rate table |
145
- | `shiftgate status` | Backend connectivity + registry summary |
146
- | `shiftgate demo` | Animated demo with fake routing traces |
127
+
128
+ | Command | Description |
129
+ | -------------------------------------------------------- | --------------------------------------------------------------------- |
130
+ | `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
131
+ | `shiftgate route "<query>"` | Route a query and show the decision no inference |
132
+ | `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
133
+ | `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
134
+ | `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
135
+ | `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
136
+ | `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
137
+ | `shiftgate adapter list` | Table of all registered adapters |
138
+ | `shiftgate adapter remove <id>` | Remove an adapter |
139
+ | `shiftgate task list` | Table of all task clusters |
140
+ | `shiftgate task add` | Interactively add a new task cluster |
141
+ | `shiftgate feedback accept` | Mark last routing as good |
142
+ | `shiftgate feedback reject` | Mark last routing as bad |
143
+ | `shiftgate feedback stats` | Adapter acceptance rate table |
144
+ | `shiftgate status` | Backend connectivity + registry summary |
145
+ | `shiftgate demo` | Animated demo with fake routing traces |
146
+
147
147
 
148
148
  ---
149
149
 
@@ -231,12 +231,14 @@ To add a task cluster that better matches your domain, edit `data/default_tasks.
231
231
 
232
232
  ## Roadmap
233
233
 
234
- | Version | Focus |
235
- |---|---|
236
- | **v0.1** | Single base model, multi-adapter routing ← _current_ |
237
- | v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
238
- | v0.3 | Multi-model routing (route to different base models per task) |
239
- | v1.0 | Community registry + web UI |
234
+
235
+ | Version | Focus |
236
+ | -------- | ------------------------------------------------------------- |
237
+ | **v0.1** | Single base model, multi-adapter routing *current* |
238
+ | v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
239
+ | v0.3 | Multi-model routing (route to different base models per task) |
240
+ | v1.0 | Community registry + web UI |
241
+
240
242
 
241
243
  ---
242
244
 
@@ -270,8 +272,8 @@ The recommended flow:
270
272
  1. Bump the version in `pyproject.toml` (`version = "x.y.z"`).
271
273
  2. Open a PR, get it reviewed and merged.
272
274
  3. Tag the commit: `git tag vx.y.z && git push origin vx.y.z`.
273
- 4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
274
- using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
275
+ 4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
276
+ using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
275
277
  — no stored API token needed.
276
278
 
277
279
  For a one-off manual publish (maintainers only):
@@ -306,4 +308,4 @@ shiftgate/
306
308
 
307
309
  ## License
308
310
 
309
- MIT. See [LICENSE](LICENSE).
311
+ MIT. See [LICENSE](LICENSE).
@@ -8,15 +8,13 @@ Your inference backend (Ollama, vLLM) is responsible for loading the weights; sh
8
8
 
9
9
  Instead of hardcoding which adapter to use, shiftgate embeds your query and matches it against a catalog of task clusters using cosine similarity — then routes inference to the best-fit LoRA adapter on your running Ollama or vLLM instance.
10
10
 
11
- Inspired by the [LORAUTER paper](https://arxiv.org/abs/2406.08213) (EPFL, 2026).
12
-
13
11
  ---
14
12
 
15
13
  ## Quickstart
16
14
 
17
15
  ```bash
18
16
  # 1. Install (requires Python 3.10+, uv recommended)
19
- uv add shiftgate
17
+ uv tool install shiftgate (or pip install shiftgate)
20
18
 
21
19
  # 2. Initialise: sets up ~/.shiftgate/ and computes task embeddings
22
20
  shiftgate init
@@ -93,24 +91,26 @@ User query
93
91
 
94
92
  ## Commands
95
93
 
96
- | Command | Description |
97
- |---|---|
98
- | `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
99
- | `shiftgate route "<query>"` | Route a query and show the decision — no inference |
100
- | `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
101
- | `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
102
- | `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
103
- | `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
104
- | `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
105
- | `shiftgate adapter list` | Table of all registered adapters |
106
- | `shiftgate adapter remove <id>` | Remove an adapter |
107
- | `shiftgate task list` | Table of all task clusters |
108
- | `shiftgate task add` | Interactively add a new task cluster |
109
- | `shiftgate feedback accept` | Mark last routing as good |
110
- | `shiftgate feedback reject` | Mark last routing as bad |
111
- | `shiftgate feedback stats` | Adapter acceptance rate table |
112
- | `shiftgate status` | Backend connectivity + registry summary |
113
- | `shiftgate demo` | Animated demo with fake routing traces |
94
+
95
+ | Command | Description |
96
+ | -------------------------------------------------------- | --------------------------------------------------------------------- |
97
+ | `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
98
+ | `shiftgate route "<query>"` | Route a query and show the decision no inference |
99
+ | `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
100
+ | `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
101
+ | `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
102
+ | `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
103
+ | `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
104
+ | `shiftgate adapter list` | Table of all registered adapters |
105
+ | `shiftgate adapter remove <id>` | Remove an adapter |
106
+ | `shiftgate task list` | Table of all task clusters |
107
+ | `shiftgate task add` | Interactively add a new task cluster |
108
+ | `shiftgate feedback accept` | Mark last routing as good |
109
+ | `shiftgate feedback reject` | Mark last routing as bad |
110
+ | `shiftgate feedback stats` | Adapter acceptance rate table |
111
+ | `shiftgate status` | Backend connectivity + registry summary |
112
+ | `shiftgate demo` | Animated demo with fake routing traces |
113
+
114
114
 
115
115
  ---
116
116
 
@@ -198,12 +198,14 @@ To add a task cluster that better matches your domain, edit `data/default_tasks.
198
198
 
199
199
  ## Roadmap
200
200
 
201
- | Version | Focus |
202
- |---|---|
203
- | **v0.1** | Single base model, multi-adapter routing ← _current_ |
204
- | v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
205
- | v0.3 | Multi-model routing (route to different base models per task) |
206
- | v1.0 | Community registry + web UI |
201
+
202
+ | Version | Focus |
203
+ | -------- | ------------------------------------------------------------- |
204
+ | **v0.1** | Single base model, multi-adapter routing *current* |
205
+ | v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
206
+ | v0.3 | Multi-model routing (route to different base models per task) |
207
+ | v1.0 | Community registry + web UI |
208
+
207
209
 
208
210
  ---
209
211
 
@@ -237,8 +239,8 @@ The recommended flow:
237
239
  1. Bump the version in `pyproject.toml` (`version = "x.y.z"`).
238
240
  2. Open a PR, get it reviewed and merged.
239
241
  3. Tag the commit: `git tag vx.y.z && git push origin vx.y.z`.
240
- 4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
241
- using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
242
+ 4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
243
+ using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
242
244
  — no stored API token needed.
243
245
 
244
246
  For a one-off manual publish (maintainers only):
@@ -273,4 +275,4 @@ shiftgate/
273
275
 
274
276
  ## License
275
277
 
276
- MIT. See [LICENSE](LICENSE).
278
+ MIT. See [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shiftgate"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -104,11 +104,15 @@ def _finish_adapter_add(adapter: AdapterEntry, task_reg, adapter_reg) -> None:
104
104
 
105
105
  linked = _auto_link_adapter(adapter, task_reg)
106
106
  if linked:
107
+ # Successfully wired into at least one task cluster → mark routable.
108
+ adapter.status = "linked"
109
+ adapter_reg.save()
107
110
  task_reg.save()
108
- console.print(f" [dim]Linked to task cluster(s): {', '.join(linked)}[/dim]")
111
+ console.print(f" [green]Status: linked[/green] {', '.join(linked)}")
109
112
  else:
110
113
  console.print(
111
- " [dim]No task clusters auto-linked (no tag overlap). "
114
+ " [yellow]Status: unassigned[/yellow] — no task clusters matched these tags.\n"
115
+ " [dim]This adapter will NOT be selected by the router until it is linked. "
112
116
  "Use `shiftgate task list` to see clusters.[/dim]"
113
117
  )
114
118
 
@@ -479,7 +483,15 @@ def run(
479
483
  )
480
484
 
481
485
  if adapter is None:
482
- console.print(f"[red]Adapter '{trace.selected_adapter_id}' not found in registry.[/red]")
486
+ # Either the matched task has no linked adapter, or the linked ID is
487
+ # missing from the registry. In both cases: never guess, never run.
488
+ console.print(
489
+ "[red]No adapter available for this query — not running inference.[/red]\n"
490
+ f" Matched task: [bold]{trace.matched_task_id}[/bold]\n"
491
+ " Add one with: "
492
+ f"[cyan]shiftgate adapter add <hf_repo> --tags {trace.matched_task_id}[/cyan]"
493
+ )
494
+ feedback_loop.record_trace(trace)
483
495
  raise typer.Exit(1)
484
496
 
485
497
  if backend_name is None:
@@ -15,6 +15,8 @@ weights available to the backend (Ollama, vLLM, etc.) before running inference.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from typing import Literal
19
+
18
20
  from pydantic import BaseModel, Field, model_validator
19
21
 
20
22
 
@@ -81,6 +83,16 @@ class AdapterEntry(BaseModel):
81
83
  description="Optional benchmark score (0–1) reported by the adapter author.",
82
84
  )
83
85
 
86
+ # --- routing wiring status ---
87
+ status: Literal["linked", "unassigned"] = Field(
88
+ default="unassigned",
89
+ description=(
90
+ "Whether this adapter is wired into at least one task cluster's "
91
+ "preferred_adapters list. 'linked' = reachable by the router; "
92
+ "'unassigned' = registered but not yet routable to any task."
93
+ ),
94
+ )
95
+
84
96
  @model_validator(mode="after")
85
97
  def _at_least_one_source(self) -> "AdapterEntry":
86
98
  """Warn (not error) when no source field is set.
@@ -155,7 +167,13 @@ class RoutingTrace(BaseModel):
155
167
  similarity_score: float = Field(
156
168
  description="Cosine similarity between the query embedding and the winning centroid (0–1)."
157
169
  )
158
- selected_adapter_id: str = Field(description="ID of the adapter that was selected for inference.")
170
+ selected_adapter_id: str | None = Field(
171
+ default=None,
172
+ description=(
173
+ "ID of the adapter selected for inference, or None when the matched "
174
+ "task has no linked adapter in the registry."
175
+ ),
176
+ )
159
177
  accepted: bool | None = Field(
160
178
  default=None,
161
179
  description="User feedback: True = good routing, False = bad routing, None = not yet rated.",
@@ -43,16 +43,25 @@ class MatchResult:
43
43
 
44
44
  Returned by ``select_adapter`` so that both the router and the
45
45
  ``--explain`` display have access to the complete decision tree.
46
+
47
+ ``selected_adapter`` is ``None`` when the matched task has no adapter
48
+ linked in the registry. In that case ``selection_method`` is
49
+ ``"no_adapter_for_task"`` and the router must NOT run inference.
46
50
  """
47
51
 
48
- selected_adapter: AdapterEntry
52
+ selected_adapter: AdapterEntry | None
49
53
  matched_task: TaskCluster
50
54
  similarity_score: float
51
55
  # All top-K task matches including their candidate adapter lists
52
56
  all_task_matches: list[TaskMatch] = field(default_factory=list)
53
- # How the adapter was ultimately found: "preferred", "fallback", or "tag_overlap"
57
+ # How the adapter was found: "preferred", "fallback", or "no_adapter_for_task"
54
58
  selection_method: str = "preferred"
55
59
 
60
+ @property
61
+ def has_adapter(self) -> bool:
62
+ """True when an adapter was successfully selected."""
63
+ return self.selected_adapter is not None
64
+
56
65
 
57
66
  # ---------------------------------------------------------------------------
58
67
  # Core functions
@@ -109,40 +118,38 @@ def select_adapter(
109
118
  top_tasks: list[TaskMatch],
110
119
  adapter_registry, # AdapterRegistry — avoid circular import with string hint
111
120
  ) -> MatchResult:
112
- """Select the best adapter given the ranked task list.
121
+ """Select the adapter linked to the best-matching task.
113
122
 
114
123
  Strategy
115
124
  --------
116
- Pass 1 explicit preferred/fallback lists
117
- For each top task (highest score first), walk ``preferred_adapters``
118
- then ``fallback_adapters``. Return the first adapter found in the
119
- registry. Also populates ``TaskMatch.candidate_adapters`` for every
120
- task so the ``--explain`` view can show all candidates.
121
-
122
- Pass 2 — tag-overlap fallback
123
- If no task had a registered preferred/fallback adapter (e.g. the user
124
- just added an adapter without re-linking), score every registered
125
- adapter by how many of its ``task_tags`` appear as tokens in the top
126
- task's ID (e.g. tag ``"sql"`` overlaps ``"code_sql"``). Return the
127
- highest-scoring adapter. This means ``adapter add`` works immediately
128
- without a separate linking step.
129
-
130
- Pass 3 — empty registry
131
- Raise ``NoAdapterError`` only when there are literally no adapters.
125
+ For each top task (highest score first), walk ``preferred_adapters`` then
126
+ ``fallback_adapters`` and collect the adapters that exist in the registry
127
+ (populating ``TaskMatch.candidate_adapters`` for the ``--explain`` view).
128
+ The first such adapter found, on the highest-scoring task, is selected.
129
+
130
+ No silent fallback
131
+ ------------------
132
+ If the matched (top) task has **no** linked adapter in the registry, the
133
+ router must NOT substitute an arbitrary adapter doing so silently routes,
134
+ e.g., a music query to a SQL adapter and destroys trust. Instead this
135
+ returns a ``MatchResult`` with ``selected_adapter=None`` and
136
+ ``selection_method="no_adapter_for_task"``.
132
137
 
133
138
  Parameters
134
139
  ----------
135
140
  top_tasks:
136
- Output of ``top_k_tasks``.
141
+ Output of ``top_k_tasks`` (sorted by score descending).
137
142
  adapter_registry:
138
143
  ``AdapterRegistry`` instance to look up adapter IDs.
139
144
 
140
145
  Returns
141
146
  -------
142
- ``MatchResult`` containing the selected adapter, winning task, score, and
143
- the full ranked task list annotated with their candidate adapters.
147
+ ``MatchResult``. ``selected_adapter`` is ``None`` when no adapter is
148
+ linked to any of the ranked tasks. The ``matched_task`` is always the
149
+ top-scoring task so callers can still report what was matched.
144
150
  """
145
- # Pass 1: populate candidate lists and find the first explicit match.
151
+ # Populate candidate lists for every task (for the --explain view) and
152
+ # find the first explicit match in score order.
146
153
  explicit_result: MatchResult | None = None
147
154
 
148
155
  for tm in top_tasks:
@@ -178,40 +185,25 @@ def select_adapter(
178
185
  )
179
186
  return explicit_result
180
187
 
181
- # Pass 2: tag-overlap fallback.
182
- all_adapters = adapter_registry.list_adapters()
183
- if not all_adapters:
184
- raise NoAdapterError(
185
- "No adapters registered. Add one with `shiftgate adapter add`."
186
- )
187
-
188
+ # No adapter linked to any ranked task — do NOT guess. Report the matched
189
+ # task with no adapter so the caller can prompt the user to add one.
188
190
  top_task = top_tasks[0]
189
- task_vocab: set[str] = set()
190
- for tm in top_tasks:
191
- task_vocab.update(tm.task.id.lower().split("_"))
192
-
193
- best_adapter = max(
194
- all_adapters,
195
- key=lambda a: len({t.lower() for t in a.task_tags} & task_vocab),
196
- )
197
-
198
- # Add the fallback adapter as a candidate on the top task for the explain view.
199
- if best_adapter not in top_task.candidate_adapters:
200
- top_task.candidate_adapters.append(best_adapter)
201
-
202
- logger.debug(
203
- "Tag-overlap fallback selected adapter '%s' for task '%s'",
204
- best_adapter.id,
191
+ logger.info(
192
+ "No linked adapter for matched task '%s' — refusing to guess.",
205
193
  top_task.task.id,
206
194
  )
207
195
  return MatchResult(
208
- selected_adapter=best_adapter,
196
+ selected_adapter=None,
209
197
  matched_task=top_task.task,
210
198
  similarity_score=top_task.score,
211
199
  all_task_matches=top_tasks,
212
- selection_method="tag_overlap",
200
+ selection_method="no_adapter_for_task",
213
201
  )
214
202
 
215
203
 
216
204
  class NoAdapterError(RuntimeError):
217
- """Raised when the matcher cannot find any registered adapter for a query."""
205
+ """Raised when the matcher cannot find any registered adapter for a query.
206
+
207
+ Retained for backward compatibility; ``select_adapter`` no longer raises it
208
+ (it returns a ``MatchResult`` with ``selected_adapter=None`` instead).
209
+ """
@@ -15,7 +15,7 @@ from shiftgate.registry.adapter_registry import AdapterRegistry
15
15
  from shiftgate.registry.schemas import RoutingTrace
16
16
  from shiftgate.registry.task_registry import TaskRegistry
17
17
  from shiftgate.router.embedder import Embedder
18
- from shiftgate.router.matcher import MatchResult, NoAdapterError, select_adapter, top_k_tasks
18
+ from shiftgate.router.matcher import MatchResult, select_adapter, top_k_tasks
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -54,10 +54,14 @@ def route(
54
54
  ``(RoutingTrace, MatchResult)`` — the trace for persistence/feedback and
55
55
  the full match result for detailed display (e.g. ``--explain``).
56
56
 
57
+ Note
58
+ ----
59
+ When the matched task has no linked adapter, ``MatchResult.selected_adapter``
60
+ is ``None`` and the trace's ``selected_adapter_id`` is ``None``. The router
61
+ never substitutes an arbitrary adapter.
62
+
57
63
  Raises
58
64
  ------
59
- NoAdapterError
60
- If no registered adapter matches any of the top-K tasks.
61
65
  ValueError
62
66
  If embeddings have not been computed (missing centroids).
63
67
  """
@@ -71,12 +75,14 @@ def route(
71
75
  ranked = top_k_tasks(query_embedding, all_tasks, k=top_k)
72
76
  result = select_adapter(ranked, adapter_registry)
73
77
 
78
+ selected_id = result.selected_adapter.id if result.selected_adapter else None
79
+
74
80
  trace = RoutingTrace(
75
81
  id=uuid.uuid4().hex,
76
82
  query=query,
77
83
  matched_task_id=result.matched_task.id,
78
84
  similarity_score=result.similarity_score,
79
- selected_adapter_id=result.selected_adapter.id,
85
+ selected_adapter_id=selected_id,
80
86
  timestamp=datetime.now(timezone.utc).isoformat(),
81
87
  )
82
88
 
@@ -85,7 +91,7 @@ def route(
85
91
  query[:60],
86
92
  result.matched_task.id,
87
93
  result.similarity_score * 100,
88
- result.selected_adapter.id,
94
+ selected_id or "<none>",
89
95
  result.selection_method,
90
96
  )
91
97
  return trace, result
@@ -83,7 +83,10 @@ def show_routing_decision(
83
83
  backend_name:
84
84
  Active backend name ('ollama', 'vllm', or None).
85
85
  """
86
- colour = _similarity_colour(trace.similarity_score)
86
+ # When no adapter was selected the decision is unactionable — render red
87
+ # regardless of how confident the task match was.
88
+ no_adapter = adapter is None and trace.selected_adapter_id is None
89
+ colour = "red" if no_adapter else _similarity_colour(trace.similarity_score)
87
90
 
88
91
  grid = Table.grid(padding=(0, 2))
89
92
  grid.add_column(style="dim", min_width=16)
@@ -98,7 +101,18 @@ def show_routing_decision(
98
101
  task_text.append_text(_similarity_bar(trace.similarity_score))
99
102
  grid.add_row("Matched Task", task_text)
100
103
 
101
- if adapter:
104
+ if no_adapter:
105
+ # Never silently substitute an adapter. Tell the user how to fix it.
106
+ adapter_text = Text("No adapter available", style="bold red")
107
+ grid.add_row("Adapter", adapter_text)
108
+ suggestion = Text()
109
+ suggestion.append("Add one with: ", style="dim")
110
+ suggestion.append(
111
+ f"shiftgate adapter add <hf_repo> --tags {trace.matched_task_id}",
112
+ style="cyan",
113
+ )
114
+ grid.add_row("Suggestion", suggestion)
115
+ elif adapter:
102
116
  adapter_text = Text()
103
117
  adapter_text.append(adapter.name, style="bold magenta")
104
118
  adapter_text.append(f" [{adapter.base_model}]", style="dim")
@@ -107,7 +121,7 @@ def show_routing_decision(
107
121
  adapter_text.append(f"\n {source}", style="dim blue")
108
122
  grid.add_row("Adapter", adapter_text)
109
123
  else:
110
- grid.add_row("Adapter", Text(trace.selected_adapter_id, style="bold magenta"))
124
+ grid.add_row("Adapter", Text(str(trace.selected_adapter_id), style="bold magenta"))
111
125
 
112
126
  backend_text = Text(backend_name or "—", style="green" if backend_name else "dim")
113
127
  grid.add_row("Backend", backend_text)
@@ -115,12 +129,12 @@ def show_routing_decision(
115
129
  if trace.latency_ms is not None:
116
130
  grid.add_row("Latency", Text(f"{trace.latency_ms:.0f} ms", style="dim"))
117
131
 
118
- panel = Panel(
119
- grid,
120
- title=f"[bold {colour}] shiftgate routing decision [/bold {colour}]",
121
- border_style=colour,
122
- expand=False,
132
+ title = (
133
+ "[bold red] no adapter available [/bold red]"
134
+ if no_adapter
135
+ else f"[bold {colour}] shiftgate routing decision [/bold {colour}]"
123
136
  )
137
+ panel = Panel(grid, title=title, border_style=colour, expand=False)
124
138
  console.print()
125
139
  console.print(panel)
126
140
  console.print()
@@ -192,15 +206,23 @@ def show_explain_decision(
192
206
  method_labels = {
193
207
  "preferred": "[green]preferred_adapters list[/green]",
194
208
  "fallback": "[yellow]fallback_adapters list[/yellow]",
195
- "tag_overlap": "[yellow]tag-overlap fallback[/yellow] (adapter not in any task list yet — run `shiftgate adapter add` with matching tags)",
209
+ "no_adapter_for_task": "[red]no adapter linked to the matched task[/red]",
196
210
  }
197
211
  method_display = method_labels.get(match_result.selection_method, match_result.selection_method)
198
212
 
199
213
  selected = match_result.selected_adapter
200
- console.print(f" [bold]Selected adapter:[/bold] [bold magenta]{selected.id}[/bold magenta]")
201
- console.print(f" [bold]Base model:[/bold] {selected.base_model}")
202
- console.print(f" [bold]Source:[/bold] {_adapter_source_label(selected)}")
203
- console.print(f" [bold]Selection method:[/bold] {method_display}")
214
+ if selected is None:
215
+ console.print(" [bold]Selected adapter:[/bold] [bold red]No adapter available[/bold red]")
216
+ console.print(f" [bold]Selection method:[/bold] {method_display}")
217
+ console.print(
218
+ " [dim]Add one with:[/dim] "
219
+ f"[cyan]shiftgate adapter add <hf_repo> --tags {match_result.matched_task.id}[/cyan]"
220
+ )
221
+ else:
222
+ console.print(f" [bold]Selected adapter:[/bold] [bold magenta]{selected.id}[/bold magenta]")
223
+ console.print(f" [bold]Base model:[/bold] {selected.base_model}")
224
+ console.print(f" [bold]Source:[/bold] {_adapter_source_label(selected)}")
225
+ console.print(f" [bold]Selection method:[/bold] {method_display}")
204
226
  console.print()
205
227
  console.rule()
206
228
  console.print()
@@ -233,13 +255,18 @@ def show_adapter_table(adapters: list[AdapterEntry]) -> None:
233
255
  table.add_column("Base Model", style="dim")
234
256
  table.add_column("Tags", style="green")
235
257
  table.add_column("Source", style="blue")
258
+ table.add_column("Status", justify="center")
236
259
  table.add_column("Score", justify="right")
237
260
 
238
261
  for a in adapters:
239
262
  source = _adapter_source_label(a)
240
263
  score = f"{a.benchmark_score:.2f}" if a.benchmark_score is not None else "—"
241
264
  tags = ", ".join(a.task_tags) if a.task_tags else "—"
242
- table.add_row(a.id, a.name, a.base_model, tags, source, score)
265
+ if a.status == "linked":
266
+ status = "[green]linked[/green]"
267
+ else:
268
+ status = "[yellow]unassigned[/yellow]"
269
+ table.add_row(a.id, a.name, a.base_model, tags, source, status, score)
243
270
 
244
271
  console.print(table)
245
272
 
@@ -381,3 +381,68 @@ class TestAutoLinkAdapter:
381
381
 
382
382
  linked = _auto_link_adapter(adapter, task_reg)
383
383
  assert linked == []
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Adapter status field
388
+ # ---------------------------------------------------------------------------
389
+
390
+ class TestAdapterStatus:
391
+ def test_default_status_is_unassigned(self):
392
+ adapter = AdapterEntry(id="x", name="X", base_model="b")
393
+ assert adapter.status == "unassigned"
394
+
395
+ def test_status_persists_round_trip(self, tmp_shiftgate, monkeypatch):
396
+ import shiftgate.registry.adapter_registry as ar_mod
397
+
398
+ save_path = tmp_shiftgate / "adapters.json"
399
+ monkeypatch.setattr(ar_mod, "_USER_ADAPTERS_PATH", save_path)
400
+
401
+ adapter = AdapterEntry(
402
+ id="sql-lora", name="SQL", base_model="llama3",
403
+ task_tags=["sql"], hf_repo="org/sql-lora", status="linked",
404
+ )
405
+ AdapterRegistry(adapters=[adapter], source_path=save_path).save()
406
+
407
+ reloaded = AdapterRegistry.load().get_adapter("sql-lora")
408
+ assert reloaded.status == "linked"
409
+
410
+ def test_finish_adapter_add_marks_linked(self, tmp_shiftgate, monkeypatch):
411
+ """_finish_adapter_add should set status='linked' when a task is linked."""
412
+ import shiftgate.cli as cli_mod
413
+
414
+ # Quiet console output during the test.
415
+ monkeypatch.setattr(cli_mod.console, "print", lambda *a, **k: None)
416
+
417
+ task = TaskCluster(
418
+ id="code_sql", name="SQL", description="",
419
+ validation_examples=["x"], preferred_adapters=[],
420
+ )
421
+ task_reg = TaskRegistry(tasks=[task], source_path=tmp_shiftgate / "tasks.json")
422
+ adapter = AdapterEntry(
423
+ id="sql-lora", name="SQL", base_model="llama3",
424
+ task_tags=["sql"], hf_repo="org/sql-lora",
425
+ )
426
+ adapter_reg = AdapterRegistry(adapters=[adapter], source_path=tmp_shiftgate / "adapters.json")
427
+
428
+ cli_mod._finish_adapter_add(adapter, task_reg, adapter_reg)
429
+ assert adapter.status == "linked"
430
+
431
+ def test_finish_adapter_add_stays_unassigned_without_match(self, tmp_shiftgate, monkeypatch):
432
+ import shiftgate.cli as cli_mod
433
+
434
+ monkeypatch.setattr(cli_mod.console, "print", lambda *a, **k: None)
435
+
436
+ task = TaskCluster(
437
+ id="code_sql", name="SQL", description="",
438
+ validation_examples=["x"], preferred_adapters=[],
439
+ )
440
+ task_reg = TaskRegistry(tasks=[task], source_path=tmp_shiftgate / "tasks.json")
441
+ adapter = AdapterEntry(
442
+ id="music-lora", name="Music", base_model="llama3",
443
+ task_tags=["music"], hf_repo="org/music-lora",
444
+ )
445
+ adapter_reg = AdapterRegistry(adapters=[adapter], source_path=tmp_shiftgate / "adapters.json")
446
+
447
+ cli_mod._finish_adapter_add(adapter, task_reg, adapter_reg)
448
+ assert adapter.status == "unassigned"
@@ -172,17 +172,26 @@ class TestSelectAdapter:
172
172
  result = select_adapter(ranked, adapter_reg_partial)
173
173
  assert result.selected_adapter.id in {"adapter-y", "adapter-z"}
174
174
 
175
- def test_raises_no_adapter_error_when_registry_empty(self, synthetic_tasks, tmp_path):
175
+ def test_no_adapter_when_registry_empty(self, synthetic_tasks, tmp_path):
176
+ """An empty registry yields a None adapter, never an exception or a guess."""
176
177
  empty_reg = AdapterRegistry(adapters=[], source_path=tmp_path / "adapters.json")
177
178
  query_emb = np.array([1.0, 0.0, 0.0], dtype=np.float32)
178
179
  ranked = top_k_tasks(query_emb, synthetic_tasks, k=3)
179
- with pytest.raises(NoAdapterError):
180
- select_adapter(ranked, empty_reg)
180
+ result = select_adapter(ranked, empty_reg)
181
+ assert result.selected_adapter is None
182
+ assert result.has_adapter is False
183
+ assert result.selection_method == "no_adapter_for_task"
184
+ # The matched task is still reported so the caller can prompt the user.
185
+ assert result.matched_task.id == "task_x"
186
+
187
+ def test_no_silent_fallback_when_task_has_no_linked_adapter(self, tmp_path):
188
+ """The bug fix: an unrelated adapter must NOT be picked for the matched task.
181
189
 
182
- def test_tag_overlap_fallback_when_preferred_lists_empty(self, tmp_path):
183
- """If preferred_adapters is empty for all tasks, adapter tags are used as fallback."""
184
- task_sql = _make_task("code_sql", [0, 1, 0], adapter_ids=[])
185
- task_py = _make_task("code_python", [1, 0, 0], adapter_ids=[])
190
+ A music-style query matches a task that has no linked adapter; even
191
+ though a sql-lora exists in the registry, it must not be selected.
192
+ """
193
+ task_music = _make_task("audio_music", [0, 1, 0], adapter_ids=[])
194
+ task_py = _make_task("code_python", [1, 0, 0], adapter_ids=[])
186
195
 
187
196
  sql_adapter = AdapterEntry(
188
197
  id="sql-lora",
@@ -192,25 +201,13 @@ class TestSelectAdapter:
192
201
  )
193
202
  reg = AdapterRegistry(adapters=[sql_adapter], source_path=tmp_path / "adapters.json")
194
203
 
195
- query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32)
196
- ranked = top_k_tasks(query_emb, [task_sql, task_py], k=2)
197
- result = select_adapter(ranked, reg)
198
-
199
- assert result.selected_adapter.id == "sql-lora"
200
- assert result.selection_method == "tag_overlap"
201
-
202
- def test_tag_overlap_selects_best_matching_adapter(self, tmp_path):
203
- task_sql = _make_task("code_sql", [0, 1, 0], adapter_ids=[])
204
-
205
- sql_adapter = AdapterEntry(id="sql-lora", name="SQL", base_model="x", task_tags=["sql", "code"])
206
- py_adapter = AdapterEntry(id="py-lora", name="Py", base_model="x", task_tags=["python"])
207
-
208
- reg = AdapterRegistry(adapters=[sql_adapter, py_adapter], source_path=tmp_path / "adapters.json")
209
- query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32)
210
- ranked = top_k_tasks(query_emb, [task_sql], k=1)
204
+ query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32) # matches audio_music
205
+ ranked = top_k_tasks(query_emb, [task_music, task_py], k=2)
211
206
  result = select_adapter(ranked, reg)
212
207
 
213
- assert result.selected_adapter.id == "sql-lora"
208
+ assert result.selected_adapter is None
209
+ assert result.selection_method == "no_adapter_for_task"
210
+ assert result.matched_task.id == "audio_music"
214
211
 
215
212
  def test_fallback_adapters_are_tried(self, tmp_path):
216
213
  task_with_fallback = TaskCluster(
File without changes
File without changes