agharsallah commited on
Commit
572f5aa
Β·
1 Parent(s): 7a13e4e

Refactor local model configurations and update tests

Browse files

- Updated local model configurations to use Nemotron-Mini-4B-Instruct instead of NVIDIA-Nemotron-3-Nano-4B-BF16.
- Adjusted tests to reflect changes in model keys and trust_remote_code settings.
- Removed auto_class attribute from LocalModel as it is no longer necessary.
- Implemented compatibility shims for removed predicates in transformers 5.x to ensure backward compatibility with remote code imports.
- Enhanced test coverage for the new compatibility shims and ensured all tests pass.

docs/adr/0033-local-inproc-transformers-backend.md CHANGED
@@ -96,8 +96,8 @@ backend stays inactive and the deterministic stub owns the no-config demo path.
96
  within a session.
97
  - **Prize-lane impact:** one sponsor family per tier, so a single in-process cast spans
98
  four tracks at once β€” **NVIDIA Nemotron** (`tiny`, also the **Tiny Titan** ≀4B lane via
99
- Nemotron-3-Nano-4B), **OpenBMB / MiniCPM** (`fast`), **Cohere / Aya** (`balanced`), and
100
- **JetBrains / Mellum** (`strong`) β€” plus the **Community Choice** on-device-inference
101
  story for the HF Space demo.
102
 
103
  **Negative / Risks:**
@@ -119,13 +119,15 @@ backend stays inactive and the deterministic stub owns the no-config demo path.
119
  - `llamacpp_catalogue.py` and `llamacpp_server.py` are deleted; `app.py`'s
120
  `gpu_selftest` `@spaces.GPU` guard is retained β€” it detects ZeroGPU availability at
121
  startup and is unrelated to the inference path.
122
- - Each tier is tagged with a distinct sponsor model (NVIDIA Nemotron-3-Nano-4B-BF16 Β·
123
  OpenBMB MiniCPM4.1-8B Β· Cohere Aya-Expanse-8B Β· JetBrains Mellum2-12B-A2.5B-Instruct), so
124
  a cross-sponsor cast runs on the Space's own GPU. This trades ZeroGPU quota/RAM headroom
125
  (several multi-GB loads per show) for multi-track coverage; the `tiny` model is listed
126
- first so any untagged fallback lands on the cheapest tier. Two deployment notes: Aya is a
127
- **gated** repo (needs licence acceptance + `HF_TOKEN`), and Mellum loads via
128
- `AutoModelForMultimodalLM` (a per-model `auto_class` on `LocalModel`).
 
 
129
  - Tests live in `tests/test_local_backend.py`. All 676 tests pass; the capability-gate
130
  logic is fully covered without a GPU or torch import in test processes.
131
 
 
96
  within a session.
97
  - **Prize-lane impact:** one sponsor family per tier, so a single in-process cast spans
98
  four tracks at once β€” **NVIDIA Nemotron** (`tiny`, also the **Tiny Titan** ≀4B lane via
99
+ Nemotron-Mini-4B-Instruct), **OpenBMB / MiniCPM** (`fast`), **Cohere / Aya** (`balanced`),
100
+ and **JetBrains / Mellum** (`strong`) β€” plus the **Community Choice** on-device-inference
101
  story for the HF Space demo.
102
 
103
  **Negative / Risks:**
 
119
  - `llamacpp_catalogue.py` and `llamacpp_server.py` are deleted; `app.py`'s
120
  `gpu_selftest` `@spaces.GPU` guard is retained β€” it detects ZeroGPU availability at
121
  startup and is unrelated to the inference path.
122
+ - Each tier is tagged with a distinct sponsor model (NVIDIA Nemotron-Mini-4B-Instruct Β·
123
  OpenBMB MiniCPM4.1-8B Β· Cohere Aya-Expanse-8B Β· JetBrains Mellum2-12B-A2.5B-Instruct), so
124
  a cross-sponsor cast runs on the Space's own GPU. This trades ZeroGPU quota/RAM headroom
125
  (several multi-GB loads per show) for multi-track coverage; the `tiny` model is listed
126
+ first so any untagged fallback lands on the cheapest tier. Notes: Aya is a **gated** repo
127
+ (needs licence acceptance + `HF_TOKEN`); MiniCPM ships v4-era custom code, so the provider
128
+ back-fills the `is_torch_fx_available`/`is_torch_sdpa_available` symbols transformers 5.x
129
+ removed; and the NVIDIA tier uses Nemotron-**Mini** (a plain transformer), not the
130
+ Nemotron-Nano hybrid, which hard-requires the mamba-ssm kernel that will not build on a Space.
131
  - Tests live in `tests/test_local_backend.py`. All 676 tests pass; the capability-gate
132
  logic is fully covered without a GPU or torch import in test processes.
133
 
docs/architecture/model-routing.md CHANGED
@@ -111,10 +111,10 @@ a single cast spans four sponsors at once (the multi-track strategy):
111
 
112
  | Key | Model | Tier | Notes |
113
  |---|---|---|---|
114
- | `local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | Nemotron Nano 4B | **tiny** | NVIDIA lane; Tiny-Titan ≀4B band; `trust_remote_code`; Mamba-2 hybrid (BF16, not GGUF) |
115
- | `local:openbmb/MiniCPM4.1-8B` | MiniCPM 4.1 8B | fast | OpenBMB lane; `trust_remote_code` |
116
  | `local:CohereLabs/aya-expanse-8b` | Aya Expanse 8B | balanced | Cohere lane; **gated repo** β€” needs licence acceptance + `HF_TOKEN` |
117
- | `local:JetBrains/Mellum2-12B-A2.5B-Instruct` | Mellum 2 (12B MoE, ~2.5B active) | strong | JetBrains lane; loads via `AutoModelForMultimodalLM` |
118
 
119
  Each tier is tagged, so a cast's `fast`/`balanced`/`strong` seats route to different sponsor
120
  models. That cross-sponsor cast loads several multi-GB models per show β€” heavy on the free
@@ -126,7 +126,7 @@ Bind a tier to a local model with a qualified key:
126
 
127
  ```yaml
128
  profiles:
129
- tiny: { endpoint: "local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16", temperature: 0.7, max_tokens: 192 }
130
  ```
131
 
132
  ### Real cost β†’ Governor
 
111
 
112
  | Key | Model | Tier | Notes |
113
  |---|---|---|---|
114
+ | `local:nvidia/Nemotron-Mini-4B-Instruct` | Nemotron Mini 4B | **tiny** | NVIDIA lane; Tiny-Titan ≀4B band; plain Nemotron-4 transformer (native, no kernels) |
115
+ | `local:openbmb/MiniCPM4.1-8B` | MiniCPM 4.1 8B | fast | OpenBMB lane; `trust_remote_code` (v4-symbol shim applied) |
116
  | `local:CohereLabs/aya-expanse-8b` | Aya Expanse 8B | balanced | Cohere lane; **gated repo** β€” needs licence acceptance + `HF_TOKEN` |
117
+ | `local:JetBrains/Mellum2-12B-A2.5B-Instruct` | Mellum 2 (12B MoE, ~2.5B active) | strong | JetBrains lane; native `MellumConfig` (Instruct, not Base) |
118
 
119
  Each tier is tagged, so a cast's `fast`/`balanced`/`strong` seats route to different sponsor
120
  models. That cross-sponsor cast loads several multi-GB models per show β€” heavy on the free
 
126
 
127
  ```yaml
128
  profiles:
129
+ tiny: { endpoint: "local:nvidia/Nemotron-Mini-4B-Instruct", temperature: 0.7, max_tokens: 192 }
130
  ```
131
 
132
  ### Real cost β†’ Governor
docs/blog/03-one-engine-three-costumes.md CHANGED
@@ -19,7 +19,35 @@ Here are the four.
19
  3. **Agents** β€” near-stateless functions that read context and emit a single typed event.
20
  4. **Projections** β€” pure functions that fold the event stream into any view you need.
21
 
22
- Everything else is configuration. Let's take them one at a time.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  ---
25
 
@@ -49,12 +77,66 @@ one log**. Three properties fall out of that, for free:
49
  - **The system is observable by default.** The ledger *is* the audit trail. What you'd
50
  normally bolt on as logging is the primary data structure.
51
 
52
- An event is a small, strictly-validated Pydantic record β€” an `id`, a `run_id`, a `turn`, a
53
- `kind`, an `actor`, and a `payload`, with `extra="forbid"` so a typo'd field is a loud
54
- error, not a silent one. The `kind` is the interesting part, and it's the subject of
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  [Part 5](05-the-ledger-is-the-database.md): it's an *open*, format-validated string, not a
56
- closed enum. A new scenario mints `clue.found` or `episode.published` without editing a
57
- single core file. That openness is what lets the engine stay still while the worlds move.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  ---
60
 
@@ -73,36 +155,119 @@ It schedules on two tracks, and most scenarios use both:
73
  regardless of what anyone said. This is how a judge synthesises every few turns, or a
74
  narrator keeps the world drifting even when the table goes quiet.
75
 
76
- Reactive agents drain *before* the tick batch, so an agent that should answer a disturbance
77
- always speaks before the scheduled rhythm resumes. And both tracks run under the
78
- **governor** β€” the runtime safety valve. Many tiny models posting to a shared board is
79
- exactly the topology that produces a surprise bill, so the governor caps `max_turns`,
80
- `max_calls_per_turn`, and `max_total_calls` (with optional token and spend ceilings on top).
81
- When a bound trips it raises a named `BudgetExceeded` that the UI surfaces as a graceful
82
- end-of-show, not a hung process. The governor gets its own treatment in
83
- [Part 5](05-the-ledger-is-the-database.md); here it's enough to know the conductor checks it
84
- before *every* scheduled agent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  > **What broke, and what it taught us.** An early agent that both *subscribed to* and
87
  > *emitted* `agent.spoke` re-triggered itself on its own event β€” a one-agent feedback loop
88
- > that burned the per-turn call cap before the judge ever fired. The fix is one line of
89
- > principle in the conductor: never queue an agent for its own event. Self-cascade is the
90
- > first bug a subscription model invites; name it and guard it early.
91
 
92
  ---
93
 
94
  ## 3. Agents: stateless functions that emit one event
95
 
96
- An agent is deliberately thin. It owns two things: a **persona string** and **the single
97
- typed event it emits** this turn. It does not own its prompt layout, its memory, or any
98
- knowledge of the other agents. It reads a context the engine assembled for it, calls its
99
- model, and posts one event back to the ledger.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- That thinness is what makes the cast swappable. Because an agent declares only a logical
102
- model *profile* (`tiny`, `fast`, `balanced`, `strong`) rather than a concrete model, the
103
- router can place a different small model behind each one β€” a ≀4B worker next to a stronger
104
- judge β€” without the agent knowing or caring. How that routing works, and why it's the whole
105
- prize strategy, is [Part 5](05-the-ledger-is-the-database.md).
106
 
107
  The "agents never call each other" rule isn't decoration; it's the originality hook. The
108
  cast of a four-player bluff game are four agents that have *never exchanged a line*. They
@@ -119,12 +284,55 @@ shared memory store.
119
  > decoupling agents is the goal, but *decoupled is not the same as deaf*. They must still
120
  > hear each other through the ledger.
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  ---
123
 
124
  ## 4. Projections: every view is a pure fold
125
 
126
- A projection is a pure function from the event list to some view. The stage projection folds
127
- `world.observed` into a `current_scene` and `agent.spoke` into the running list of notes.
 
 
 
 
 
 
 
 
 
 
 
128
  The stats panel folds calls and tokens. Each agent's memory is a filtered fold over the
129
  events it's allowed to see. The Fishbowl's scrub-anywhere replay is the same fold over a
130
  *prefix* of the log β€” `rebuild_stage(events[:k])` β€” which is why scrubbing back through a
@@ -147,19 +355,69 @@ flowchart LR
147
  ![The Fishbowl Show rendering Thousand Token Wood: MindCards around a scene, narrator feed, and meters β€” all derived from the event log.](images/show-thousand-token-wood.png)
148
  *The Show is just one read side: the stage, the cards, the feed, and the meters are all pure projections of the same append-only log.*
149
 
 
 
150
  The **observer** is the cleanest expression of the rule. It consumes events read-only and
151
- computes a `ViewDiff` β€” the delta to render β€” and it *never appends*. Rendering is a camera
152
- crew, not an actor. The world runs identically whether or not anyone is watching, you can
153
- attach several observers to one ledger at once (a stage view and a feed and a split table),
154
- and post-hoc analysis is just another observer fed a saved log. Cognition and presentation
155
- never touch.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  ---
158
 
159
  ## Two scenarios, zero engine edits
160
 
161
  The proof that the abstraction holds is that wildly different *cognitive shapes* need no
162
- engine changes β€” only config.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
  **Thousand Token Wood** is divergent. The scene gets stranger turn by turn; a seedkeeper
165
  narrates, a pocket actor wants impossible things, an echo transforms visitor disturbances, a
@@ -168,11 +426,25 @@ winner β€” the ledger *is* the story.
168
 
169
  **Mystery Roots** is convergent. A mystery is stated, a clue-gatherer extracts evidence, a
170
  hypothesis-former proposes, a devil's advocate attacks, and a judge rules. Scheduling is a
171
- tight multi-phase cycle that narrows toward an answer.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  Same conductor. Same ledger. Same governor. Same context builder. Same memory. **Different
174
  cast, different schedule, different cognitive shape** β€” and the difference is entirely in
175
- two YAML files. The engine is plumbing; the scenario is data.
176
 
177
  This isn't an aspiration we're trusting ourselves to honour. `tests/test_modularity.py`
178
  builds every scenario config and asserts the invariants hold, so the *first* time someone
@@ -187,9 +459,13 @@ scenarios standing on one engine.
187
  |---|---|---|
188
  | UI | Gradio (custom-themed Fishbowl) | Hackathon-required; the theater is built on the ledger's read surface, so it's swappable |
189
  | Event schema | Pydantic v2, `extra="forbid"` | Strict validation; a stray field is a loud error |
190
- | Event `kind` | Open, format-validated string | A scenario mints new kinds with zero engine edits (ADR-0009) |
 
 
 
191
  | Models | Small models (≀32B) behind a profile router | One cast can run several sponsor models at once β€” see Part 5 |
192
  | Memory | Ledger view, no separate store | Consistency, crash recovery, and testability for free β€” see Part 4 |
 
193
  | Orchestration | In-process, synchronous conductor | Right size for a live demo; durable execution is available when a run needs it |
194
 
195
  ---
@@ -197,9 +473,10 @@ scenarios standing on one engine.
197
  ## The throughline
198
 
199
  Four abstractions. A log you only append to, a manager who schedules and meters, thin agents
200
- that emit one event each, and pure folds that turn the log into every view. Stack them and
201
- you get a system that is reproducible, recoverable, testable, and β€” the part that matters for
202
- a hackathon β€” *extensible by writing YAML instead of Python*.
 
203
 
204
  The next two parts go deeper into the two abstractions that do the most quiet work. Part 4
205
  opens the agent's-eye view: how the context builder and the three-layer memory decide what a
 
19
  3. **Agents** β€” near-stateless functions that read context and emit a single typed event.
20
  4. **Projections** β€” pure functions that fold the event stream into any view you need.
21
 
22
+ Everything else is configuration. And the shape of how they fit together is the whole
23
+ design β€” one write side, one scheduler, thin emitters, and many pure reads:
24
+
25
+ ```mermaid
26
+ flowchart LR
27
+ C["<b>Conductor</b><br/>schedules Β· meters Β· drives the loop"]
28
+ G["<b>Governor</b><br/>calls Β· tokens Β· spend"]
29
+ A["<b>Agents</b><br/>thin Β· stateless<br/>one typed event each"]
30
+ L[("<b>Append-only Ledger</b><br/>single source of truth")]
31
+
32
+ C -->|"act(projection, recent_events)"| A
33
+ A -->|"append one Event"| L
34
+ L -->|"rebuild_stage(events)"| C
35
+ G -.->|"check() before every act"| C
36
+
37
+ subgraph reads["READ SIDE β€” every view is a pure fold of the log"]
38
+ direction LR
39
+ P1["stage view"]
40
+ P2["each agent's memory"]
41
+ P3["stats Β· governor panel"]
42
+ P4["judge's transcript"]
43
+ P5["exported JSONL trace"]
44
+ end
45
+ L --> P1 & P2 & P3 & P4 & P5
46
+ ```
47
+
48
+ The arrows only ever point one way into the ledger: **agents append, everything else reads.**
49
+ There is no arrow from an agent to another agent. Hold that picture; the rest of this part is
50
+ the four boxes, one at a time.
51
 
52
  ---
53
 
 
77
  - **The system is observable by default.** The ledger *is* the audit trail. What you'd
78
  normally bolt on as logging is the primary data structure.
79
 
80
+ ### The event envelope
81
+
82
+ An event is a small, strictly-validated Pydantic v2 record with `extra="forbid"`, so a
83
+ typo'd field is a loud error, not a silent one. The whole envelope is flat β€” no nesting, no
84
+ inheritance:
85
+
86
+ ```mermaid
87
+ classDiagram
88
+ class Event {
89
+ +str id Β«UUID β€” idempotency keyΒ»
90
+ +str run_id Β«which runΒ»
91
+ +int turn Β«sim-clock tickΒ»
92
+ +EventKind kind Β«regex-validated open stringΒ»
93
+ +str actor Β«who emitted itΒ»
94
+ +dict payload Β«shape is per-kind conventionΒ»
95
+ +datetime created_at
96
+ +int schema_version = 1
97
+ +str? session_id Β«which browser drove the runΒ»
98
+ +str? model_profile Β«route key requestedΒ»
99
+ +str? model_id Β«concrete model that ranΒ»
100
+ }
101
+ ```
102
+
103
+ The `kind` is the interesting part, and it's the subject of
104
  [Part 5](05-the-ledger-is-the-database.md): it's an *open*, format-validated string, not a
105
+ closed enum. A field validator enforces a lowercase, dot-namespaced shape β€”
106
+
107
+ ```python
108
+ _KIND_RE = re.compile(r"^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$") # "agent.spoke", "clue.found"
109
+
110
+ @field_validator("kind")
111
+ @classmethod
112
+ def _validate_kind(cls, value: str) -> str:
113
+ if not is_valid_kind(value):
114
+ raise ValueError(f"invalid event kind {value!r}")
115
+ return value
116
+ ```
117
+
118
+ β€” but the *set* of kinds is open. A new scenario mints `clue.found` or `episode.published`
119
+ without editing a single core file; the schema validates the *shape* of a kind, while each
120
+ agent's `manifest.may_emit` governs the *authority* to emit one. That split β€” open shape,
121
+ scoped authority β€” is what lets the engine stay still while the worlds move.
122
+
123
+ ### One write path, idempotent by id
124
+
125
+ There is exactly one mutation in the system: `append`. It's a list and a set, and it's
126
+ idempotent against the event's UUID so a retried conductor step can't double-write:
127
+
128
+ ```python
129
+ def append(self, event: Event) -> Event:
130
+ if event.id in self._seen_ids: # already have it β€” no-op
131
+ return event
132
+ self._events.append(event)
133
+ self._seen_ids.add(event.id)
134
+ return event
135
+ ```
136
+
137
+ The in-memory version above is the offline default; a `SqlAlchemyLedger` implements the
138
+ *same* contract over SQLite or Postgres. How one contract drives two backends, and why
139
+ ordering uses a server-assigned offset rather than the id, is [Part 5](05-the-ledger-is-the-database.md).
140
 
141
  ---
142
 
 
155
  regardless of what anyone said. This is how a judge synthesises every few turns, or a
156
  narrator keeps the world drifting even when the table goes quiet.
157
 
158
+ The two tracks run in a fixed order every step. Reactive agents drain *first*, so an agent
159
+ that should answer a disturbance always speaks before the scheduled rhythm resumes:
160
+
161
+ ```mermaid
162
+ flowchart TD
163
+ S(["step() β†’ _tick(): turn += 1"]) --> Gov["governor.begin_turn(turn)<br/>governor.check(turn)"]
164
+ Gov --> P1["<b>Track 1 β€” drain trigger queue (FIFO)</b><br/>agents whose subscribes_to matched<br/>last step's events"]
165
+ P1 --> P2["<b>Track 2 β€” run tick agents</b><br/>schedule.tick_every fires this turn"]
166
+ P2 --> Snap["maybe snapshot every N"]
167
+ P1 -.->|"each act() appends an Event"| N
168
+ P2 -.->|"each act() appends an Event"| N["_notify_subscribers(event)<br/>queue matching agents"]
169
+ N -.->|"feeds the NEXT step's Track 1"| P1
170
+ ```
171
+
172
+ The queueing rule is one method, and it carries the single most important guardrail in the
173
+ whole scheduler β€” *never queue an agent for its own event*:
174
+
175
+ ```python
176
+ def _notify_subscribers(self, event: Event) -> None:
177
+ for agent in self.scenario.agents:
178
+ if event.actor == agent.name: # never react to yourself
179
+ continue
180
+ if event.kind in agent.manifest.subscribes_to:
181
+ self._trigger_queue.append((agent, event))
182
+ ```
183
+
184
+ Both tracks run under the **governor** β€” the runtime safety valve. Many tiny models posting
185
+ to a shared board is exactly the topology that produces a surprise bill, so the governor caps
186
+ calls and spend on five axes, checked before *every* scheduled agent:
187
+
188
+ ```python
189
+ @dataclass
190
+ class Governor:
191
+ max_turns: int = 100 # the show ends after N turns
192
+ max_calls_per_turn: int = 8 # no single turn fires more than N model calls
193
+ max_total_calls: int = 500 # whole-run call cap
194
+ max_total_tokens: int | None = None # optional token ceiling
195
+ hourly_budget_usd: float | None = None # optional spend ceiling
196
+ ```
197
+
198
+ When a bound trips it raises a named `BudgetExceeded(reason=...)` that the conductor turns
199
+ into a graceful `run.finished` rather than a hung process. The governor gets its own
200
+ treatment in [Part 5](05-the-ledger-is-the-database.md); here it's enough to see it sits on
201
+ the control path, not beside it β€” `max_calls_per_turn` is also what makes the subscription
202
+ model safe, because it bounds the fan-out a single cascade can produce within one turn.
203
 
204
  > **What broke, and what it taught us.** An early agent that both *subscribed to* and
205
  > *emitted* `agent.spoke` re-triggered itself on its own event β€” a one-agent feedback loop
206
+ > that burned the per-turn call cap before the judge ever fired. The fix is the
207
+ > `event.actor == agent.name` line above: never queue an agent for its own event.
208
+ > Self-cascade is the first bug a subscription model invites; name it and guard it early.
209
 
210
  ---
211
 
212
  ## 3. Agents: stateless functions that emit one event
213
 
214
+ An agent is deliberately thin. The base interface is a single method β€” read the context the
215
+ engine assembled, emit one typed event:
216
+
217
+ ```python
218
+ class Agent(ABC):
219
+ name: str
220
+
221
+ @abstractmethod
222
+ def act(
223
+ self,
224
+ run_id: str,
225
+ turn: int,
226
+ projection: StageProjection, # the current stage, folded from the log
227
+ recent_events: tuple[Event, ...], # the window it's allowed to see
228
+ ) -> Event: # exactly one event back
229
+ ...
230
+ ```
231
+
232
+ It owns two things: a **persona string** and **the single typed event it emits** this turn.
233
+ It does not own its prompt layout, its memory, or any knowledge of the other agents. The
234
+ concrete `ManifestAgent` is driven entirely by a YAML manifest β€” persona, what it subscribes
235
+ to, what it may emit, its schedule, and crucially, a *logical model profile* rather than a
236
+ concrete model:
237
+
238
+ ```yaml
239
+ name: clue-gatherer
240
+ role: worker
241
+ persona: >
242
+ You are a careful Clue Gatherer. Extract exactly one new, concrete clue
243
+ from the current scene.
244
+ subscribes_to: []
245
+ may_emit: [agent.spoke]
246
+ schedule:
247
+ tick_every: 1
248
+ model_profile: fast # tiny ≀4B Β· fast ≀7B Β· balanced ≀13B Β· strong ≀32B
249
+ memory:
250
+ window: 8
251
+ ```
252
+
253
+ That `model_profile` is the swap point. Because an agent declares only a *profile*, the
254
+ `ModelRouter` can place a different small model behind each one β€” a ≀4B worker next to a
255
+ stronger judge β€” without the agent knowing or caring:
256
+
257
+ ```mermaid
258
+ flowchart LR
259
+ M["AgentManifest<br/>model_profile: fast"] --> R{"ModelRouter<br/>.for_profile('fast')"}
260
+ R -->|"offline (no key)"| Stub["DeterministicTinyModel<br/>fixed structured output"]
261
+ R -->|"live"| GW["OpenAI-compatible gateway"]
262
+ GW --> Cat["concrete ≀32B model<br/>gpt-oss Β· MiniCPM Β· Nemotron Β· gemma"]
263
+ M -. "model_endpoint: …" .-> Pin["pin one specific catalogue model<br/>(bypasses the tier)"]
264
+ Pin --> GW
265
+ ```
266
 
267
+ The router is the single place a model is ever named β€” which is why one cast can legitimately
268
+ run several different sponsor models in the same show, and why offline every profile resolves
269
+ to a deterministic stub so the demo runs with no key. The routing tiers, the decoding configs,
270
+ and why that's the whole prize strategy are [Part 5](05-the-ledger-is-the-database.md).
 
271
 
272
  The "agents never call each other" rule isn't decoration; it's the originality hook. The
273
  cast of a four-player bluff game are four agents that have *never exchanged a line*. They
 
284
  > decoupling agents is the goal, but *decoupled is not the same as deaf*. They must still
285
  > hear each other through the ledger.
286
 
287
+ ### One turn, end to end
288
+
289
+ The four abstractions only become a *show* when they run in sequence. Here is a single
290
+ acting agent, from the conductor's `check()` to the rendered diff β€” the loop that repeats
291
+ until the governor calls time:
292
+
293
+ ```mermaid
294
+ sequenceDiagram
295
+ participant C as Conductor
296
+ participant G as Governor
297
+ participant A as Agent
298
+ participant CB as ContextBuilder
299
+ participant R as ModelRouter
300
+ participant L as Ledger
301
+ participant O as Observer
302
+ C->>G: check(turn) Β«trip BudgetExceeded if overΒ»
303
+ C->>A: act(projection, recent_events)
304
+ A->>CB: build(persona, projection, memory, role)
305
+ CB-->>A: layered prompt string
306
+ A->>R: for_profile(model_profile)
307
+ R-->>A: small model β†’ one typed Event
308
+ A-->>C: Event Β«kind must be in may_emitΒ»
309
+ C->>G: record_call(tokens, cost)
310
+ C->>L: append(event) Β«idempotent on idΒ»
311
+ C->>O: consume(event) β†’ ViewDiff
312
+ Note over C,L: _notify_subscribers queues agents for the NEXT step
313
+ ```
314
+
315
+ Cognition (the agent + router) and bookkeeping (governor + ledger) and presentation (the
316
+ observer) are three separate concerns strung on one append. None of them know about the
317
+ others; they only know the event.
318
+
319
  ---
320
 
321
  ## 4. Projections: every view is a pure fold
322
 
323
+ A projection is a pure function from the event list to some view. The whole stage is one
324
+ small reducer β€” fold each event into a mutable snapshot:
325
+
326
+ ```python
327
+ def rebuild_stage(events: tuple[Event, ...], run_id: str | None = None) -> StageProjection:
328
+ projection = StageProjection()
329
+ if run_id is not None:
330
+ events = tuple(e for e in events if e.run_id == run_id)
331
+ for event in events:
332
+ projection.apply(event) # world.observed β†’ current_scene; agent.spoke β†’ notes; …
333
+ return projection
334
+ ```
335
+
336
  The stats panel folds calls and tokens. Each agent's memory is a filtered fold over the
337
  events it's allowed to see. The Fishbowl's scrub-anywhere replay is the same fold over a
338
  *prefix* of the log β€” `rebuild_stage(events[:k])` β€” which is why scrubbing back through a
 
355
  ![The Fishbowl Show rendering Thousand Token Wood: MindCards around a scene, narrator feed, and meters β€” all derived from the event log.](images/show-thousand-token-wood.png)
356
  *The Show is just one read side: the stage, the cards, the feed, and the meters are all pure projections of the same append-only log.*
357
 
358
+ ### The observer: a camera crew, never an actor
359
+
360
  The **observer** is the cleanest expression of the rule. It consumes events read-only and
361
+ computes a `ViewDiff` β€” the delta to render β€” and it *never appends*:
362
+
363
+ ```python
364
+ def consume(self, event: Event) -> ViewDiff:
365
+ prev_scene = self._view.current_scene
366
+ prev_notes = list(self._view.agent_notes)
367
+ self._view.apply(event) # advance the read-side snapshot
368
+ diff = ViewDiff(
369
+ scene_changed=self._view.current_scene != prev_scene,
370
+ new_agent_notes=[n for n in self._view.agent_notes if n not in prev_notes],
371
+ # … new_judge_notes, new_user_artifacts
372
+ )
373
+ if diff.has_changes:
374
+ for cb in self._callbacks: # push to UI / SSE / WebSocket
375
+ cb(diff)
376
+ return diff
377
+ ```
378
+
379
+ ```mermaid
380
+ flowchart LR
381
+ Conductor --> Agents
382
+ Agents -->|append| L[("Ledger")]
383
+ L -->|read only| Obs["Observer.consume(event)"]
384
+ Obs --> Diff["ViewDiff β€” scene_changed,<br/>new notes, new artifacts"]
385
+ Diff --> Out1["Gradio stage"]
386
+ Diff --> Out2["cognition graph"]
387
+ Diff --> Out3["plain chat log"]
388
+ ```
389
+
390
+ Rendering is a camera crew, not an actor. The world runs identically whether or not anyone
391
+ is watching, you can attach several observers to one ledger at once (a stage view and a feed
392
+ and a split table), and post-hoc analysis is just another observer fed a saved log. Cognition
393
+ and presentation never touch.
394
 
395
  ---
396
 
397
  ## Two scenarios, zero engine edits
398
 
399
  The proof that the abstraction holds is that wildly different *cognitive shapes* need no
400
+ engine changes β€” only config. Two scenarios, two YAML files, opposite scheduling topologies
401
+ on the *same* conductor:
402
+
403
+ ```mermaid
404
+ flowchart TB
405
+ subgraph TTW["thousand-token-wood.yaml β€” divergent"]
406
+ direction LR
407
+ sk["seedkeeper"] -.-> b1[("ledger")]
408
+ pa["pocket-actor"] -.-> b1
409
+ ec["echo"] -.-> b1
410
+ cr["critic"] -.-> b1
411
+ b1 -.-> sk & pa & ec & cr
412
+ end
413
+ subgraph MR["mystery-roots.yaml β€” convergent"]
414
+ direction LR
415
+ cg["clue-gatherer"] --> hf["hypothesis-former"]
416
+ hf --> da["devil's-advocate"]
417
+ da --> mj["mystery-judge"]
418
+ mj -->|narrows| cg
419
+ end
420
+ ```
421
 
422
  **Thousand Token Wood** is divergent. The scene gets stranger turn by turn; a seedkeeper
423
  narrates, a pocket actor wants impossible things, an echo transforms visitor disturbances, a
 
426
 
427
  **Mystery Roots** is convergent. A mystery is stated, a clue-gatherer extracts evidence, a
428
  hypothesis-former proposes, a devil's advocate attacks, and a judge rules. Scheduling is a
429
+ tight multi-phase cycle that narrows toward an answer. The difference between the two lives
430
+ entirely in their config β€” cast list, schedule, and a `competition` block:
431
+
432
+ ```yaml
433
+ # mystery-roots.yaml # twenty-sprouts.yaml
434
+ cast: cast:
435
+ - clue-gatherer - sprout-guesser
436
+ - hypothesis-former - secret-keeper
437
+ - devils-advocate - sprout-judge
438
+ - mystery-judge competition:
439
+ competition: kind: versus
440
+ kind: judged teams:
441
+ guesser: [sprout-guesser]
442
+ keeper: [secret-keeper]
443
+ ```
444
 
445
  Same conductor. Same ledger. Same governor. Same context builder. Same memory. **Different
446
  cast, different schedule, different cognitive shape** β€” and the difference is entirely in
447
+ those YAML files. The engine is plumbing; the scenario is data.
448
 
449
  This isn't an aspiration we're trusting ourselves to honour. `tests/test_modularity.py`
450
  builds every scenario config and asserts the invariants hold, so the *first* time someone
 
459
  |---|---|---|
460
  | UI | Gradio (custom-themed Fishbowl) | Hackathon-required; the theater is built on the ledger's read surface, so it's swappable |
461
  | Event schema | Pydantic v2, `extra="forbid"` | Strict validation; a stray field is a loud error |
462
+ | Event `kind` | Open, regex-validated string | A scenario mints new kinds with zero engine edits (ADR-0009) |
463
+ | Authority to emit | Per-agent `manifest.may_emit` | Open *shape*, scoped *authority* β€” shape and permission are decoupled |
464
+ | Scheduling | Two-track: subscriptions + ticks | Reaction *and* heartbeat; reactive drains before the tick batch |
465
+ | Safety | `Governor`, checked before every act | Many tiny models on a shared board is a surprise-bill topology; cap it on five axes |
466
  | Models | Small models (≀32B) behind a profile router | One cast can run several sponsor models at once β€” see Part 5 |
467
  | Memory | Ledger view, no separate store | Consistency, crash recovery, and testability for free β€” see Part 4 |
468
+ | Rendering | Read-only `Observer` β†’ `ViewDiff` | Cognition and presentation never touch; N observers per ledger |
469
  | Orchestration | In-process, synchronous conductor | Right size for a live demo; durable execution is available when a run needs it |
470
 
471
  ---
 
473
  ## The throughline
474
 
475
  Four abstractions. A log you only append to, a manager who schedules and meters, thin agents
476
+ that emit one event each, and pure folds that turn the log into every view. The arrows only
477
+ point one way β€” agents append, everything else reads β€” and that single constraint is what
478
+ buys a system that is reproducible, recoverable, testable, and, the part that matters for a
479
+ hackathon, *extensible by writing YAML instead of Python*.
480
 
481
  The next two parts go deeper into the two abstractions that do the most quiet work. Part 4
482
  opens the agent's-eye view: how the context builder and the three-layer memory decide what a
src/models/local_catalogue.py CHANGED
@@ -65,10 +65,7 @@ class LocalModel:
65
  ``transformers``). ``profile`` is the tier this model is the default casting for, or
66
  None for an alternate the cast can still pin explicitly. ``source`` is a friendly
67
  family/org label for the picker. ``trust_remote_code`` is forwarded to
68
- ``from_pretrained`` for repos that ship custom modelling code (e.g. MiniCPM, Nemotron).
69
- ``auto_class`` is the ``transformers`` auto-class the provider loads the repo with β€”
70
- ``AutoModelForCausalLM`` for an ordinary LM, overridden where a model card calls for a
71
- different one (e.g. JetBrains Mellum loads with ``AutoModelForMultimodalLM``).
72
  """
73
 
74
  repo_id: str
@@ -76,7 +73,6 @@ class LocalModel:
76
  params_b: float | None = None
77
  source: str = "Hugging Face"
78
  trust_remote_code: bool = False
79
- auto_class: str = "AutoModelForCausalLM"
80
 
81
  @property
82
  def key(self) -> str:
@@ -103,15 +99,14 @@ class LocalModel:
103
 
104
  LOCAL_MODELS: tuple[LocalModel, ...] = (
105
  # Tiny tier (≀4B, Tiny-Titan band) β€” the cast-wide fallback default. NVIDIA Nemotron
106
- # Nano is a Mamba-2/Transformer hybrid; load the BF16 (safetensors) sibling, not the
107
- # GGUF, since the in-process path runs transformers. Ships custom modelling code, so
108
- # trust_remote_code is required.
109
  LocalModel(
110
- repo_id="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
111
  profile="tiny",
112
  params_b=4.0,
113
  source="NVIDIA Nemotron",
114
- trust_remote_code=True,
115
  ),
116
  # Fast tier β€” OpenBMB MiniCPM 4.1 8B. Ships custom modelling code (trust_remote_code).
117
  LocalModel(
@@ -131,14 +126,14 @@ LOCAL_MODELS: tuple[LocalModel, ...] = (
131
  source="Cohere Labs Aya",
132
  ),
133
  # Strong tier β€” JetBrains Mellum 2 (12B MoE, ~2.5B active). The Instruct variant (a
134
- # post-trained assistant with a chat template), not the Base completion model. Its card
135
- # loads it with AutoModelForMultimodalLM, so we pin that auto-class.
 
136
  LocalModel(
137
  repo_id="JetBrains/Mellum2-12B-A2.5B-Instruct",
138
  profile="strong",
139
  params_b=12.0,
140
  source="JetBrains Mellum",
141
- auto_class="AutoModelForMultimodalLM",
142
  ),
143
  )
144
 
 
65
  ``transformers``). ``profile`` is the tier this model is the default casting for, or
66
  None for an alternate the cast can still pin explicitly. ``source`` is a friendly
67
  family/org label for the picker. ``trust_remote_code`` is forwarded to
68
+ ``from_pretrained`` for repos that ship custom modelling code (e.g. MiniCPM).
 
 
 
69
  """
70
 
71
  repo_id: str
 
73
  params_b: float | None = None
74
  source: str = "Hugging Face"
75
  trust_remote_code: bool = False
 
76
 
77
  @property
78
  def key(self) -> str:
 
99
 
100
  LOCAL_MODELS: tuple[LocalModel, ...] = (
101
  # Tiny tier (≀4B, Tiny-Titan band) β€” the cast-wide fallback default. NVIDIA Nemotron
102
+ # Mini 4B is a plain Nemotron-4 transformer (native in transformers, no custom code and
103
+ # no Mamba kernels), so it loads in-process cleanly β€” unlike the Nemotron-Nano hybrid,
104
+ # which hard-requires the mamba-ssm CUDA kernel that will not build on a Space.
105
  LocalModel(
106
+ repo_id="nvidia/Nemotron-Mini-4B-Instruct",
107
  profile="tiny",
108
  params_b=4.0,
109
  source="NVIDIA Nemotron",
 
110
  ),
111
  # Fast tier β€” OpenBMB MiniCPM 4.1 8B. Ships custom modelling code (trust_remote_code).
112
  LocalModel(
 
126
  source="Cohere Labs Aya",
127
  ),
128
  # Strong tier β€” JetBrains Mellum 2 (12B MoE, ~2.5B active). The Instruct variant (a
129
+ # post-trained assistant with a chat template), not the Base completion model. MellumConfig
130
+ # is native in transformers, so it loads with the default AutoModelForCausalLM (the card's
131
+ # AutoModelForMultimodalLM is wrong for this arch) and needs no custom code.
132
  LocalModel(
133
  repo_id="JetBrains/Mellum2-12B-A2.5B-Instruct",
134
  profile="strong",
135
  params_b=12.0,
136
  source="JetBrains Mellum",
 
137
  ),
138
  )
139
 
src/models/local_provider.py CHANGED
@@ -50,7 +50,35 @@ from src.models.provider import ModelProvider, estimate_tokens, model_error
50
  _LOADED: dict[str, tuple] = {}
51
 
52
 
53
- def _ensure_loaded(repo_id: str, trust_remote_code: bool, auto_class: str = "AutoModelForCausalLM") -> tuple:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  """Load (once, cached) the tokenizer + model for *repo_id* **on CPU**.
55
 
56
  Called from :meth:`LocalTransformersProvider.complete` in the parent process to warm
@@ -75,19 +103,18 @@ def _ensure_loaded(repo_id: str, trust_remote_code: bool, auto_class: str = "Aut
75
  """
76
  if repo_id in _LOADED:
77
  return _LOADED[repo_id]
78
- import transformers
79
- from transformers import AutoTokenizer
80
 
81
- # The auto-class is per-model (most are AutoModelForCausalLM; some cards call for another,
82
- # e.g. Mellum's AutoModelForMultimodalLM) β€” resolve it by name off the transformers module.
83
- model_cls = getattr(transformers, auto_class)
84
  tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
85
  try:
86
- model = model_cls.from_pretrained(
87
  repo_id, dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
88
  )
89
  except TypeError: # pragma: no cover - older transformers use the torch_dtype kwarg name
90
- model = model_cls.from_pretrained(
91
  repo_id, torch_dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
92
  )
93
  # Re-tie the output head to the (materialized) input embeddings so no parameter is left
@@ -98,7 +125,7 @@ def _ensure_loaded(repo_id: str, trust_remote_code: bool, auto_class: str = "Aut
98
  return _LOADED[repo_id]
99
 
100
 
101
- def _gpu_duration(repo_id, trust_remote_code, auto_class, system, prompt, max_new_tokens, temperature, top_p) -> int:
102
  """Dynamic ``@spaces.GPU`` duration (seconds) for one generation.
103
 
104
  Scales with the token budget and stays short so the Space keeps high queue priority on
@@ -109,7 +136,7 @@ def _gpu_duration(repo_id, trust_remote_code, auto_class, system, prompt, max_ne
109
 
110
 
111
  @spaces.GPU(duration=_gpu_duration)
112
- def _generate(repo_id, trust_remote_code, auto_class, system, prompt, max_new_tokens, temperature, top_p):
113
  """Run one chat completion on the GPU; return ``(text, prompt_tokens, completion_tokens)``.
114
 
115
  Module-level and decorated so ZeroGPU registers it and grants a GPU for the call. The
@@ -122,7 +149,7 @@ def _generate(repo_id, trust_remote_code, auto_class, system, prompt, max_new_to
122
  """
123
  import torch
124
 
125
- tokenizer, model = _ensure_loaded(repo_id, trust_remote_code, auto_class)
126
  if torch.cuda.is_available():
127
  model = model.to("cuda")
128
  device = next(model.parameters()).device
@@ -181,12 +208,11 @@ class LocalTransformersProvider(ModelProvider):
181
  # Warm the weights in the PARENT first so the forked @spaces.GPU call
182
  # inherits them (see module docstring); this is a cache hit after the
183
  # first use of this model in the process.
184
- _ensure_loaded(self.model, self._trust_remote_code(), self._auto_class())
185
  system = OpenAICompatProvider._system_for_role(role)
186
  text, prompt_tokens, completion_tokens = _generate(
187
  self.model,
188
  self._trust_remote_code(),
189
- self._auto_class(),
190
  system,
191
  prompt,
192
  self.max_tokens,
@@ -214,18 +240,6 @@ class LocalTransformersProvider(ModelProvider):
214
  entry = local_catalogue.model_by_key(self.model)
215
  return bool(entry.trust_remote_code) if entry is not None else False
216
 
217
- def _auto_class(self) -> str:
218
- """The ``transformers`` auto-class to load this repo with (from the catalogue).
219
-
220
- Most models load with ``AutoModelForCausalLM``; a few cards call for another (e.g.
221
- JetBrains Mellum β†’ ``AutoModelForMultimodalLM``). An off-catalogue id defaults to
222
- ``AutoModelForCausalLM`` β€” the ordinary case for a hand-pinned chat model.
223
- """
224
- from src.models import local_catalogue
225
-
226
- entry = local_catalogue.model_by_key(self.model)
227
- return entry.auto_class if entry is not None else "AutoModelForCausalLM"
228
-
229
  def _record_usage(self, prompt_tokens: int, completion_tokens: int, prompt: str, text: str) -> None:
230
  # Generation returns exact token counts; fall back to an estimate only if a count
231
  # came back as zero (e.g. an empty decode), so the Governor always sees a budget hit.
 
50
  _LOADED: dict[str, tuple] = {}
51
 
52
 
53
+ def _always_true(*_args, **_kwargs) -> bool:
54
+ return True
55
+
56
+
57
+ # v4-era capability predicates that transformers 5.x removed but Hub ``trust_remote_code``
58
+ # modelling files still import (e.g. MiniCPM's modeling_minicpm.py does
59
+ # ``from transformers.utils.import_utils import is_torch_fx_available``). All of these are
60
+ # unconditionally True at this project's torch>=2.8 floor β€” exactly the value the
61
+ # transformers maintainers say is now correct (transformers#44561) β€” so back-filling them
62
+ # lets such remote code import instead of crashing with ``cannot import name '…'``.
63
+ _REMOVED_TORCH_PREDICATES = ("is_torch_fx_available", "is_torch_sdpa_available")
64
+
65
+
66
+ def _ensure_transformers_v4_symbols() -> None:
67
+ """Restore removed v4-era predicates onto ``transformers.utils`` so older Hub remote
68
+ code (loaded via ``trust_remote_code``) imports cleanly. Idempotent β€” only fills a name
69
+ that is genuinely absent, so it never shadows a function transformers still ships."""
70
+ try:
71
+ import transformers.utils as tu
72
+ from transformers.utils import import_utils
73
+ except Exception: # pragma: no cover - transformers absent β†’ offline path, nothing to do
74
+ return
75
+ for mod in (import_utils, tu):
76
+ for name in _REMOVED_TORCH_PREDICATES:
77
+ if not hasattr(mod, name):
78
+ setattr(mod, name, _always_true)
79
+
80
+
81
+ def _ensure_loaded(repo_id: str, trust_remote_code: bool) -> tuple:
82
  """Load (once, cached) the tokenizer + model for *repo_id* **on CPU**.
83
 
84
  Called from :meth:`LocalTransformersProvider.complete` in the parent process to warm
 
103
  """
104
  if repo_id in _LOADED:
105
  return _LOADED[repo_id]
106
+ from transformers import AutoModelForCausalLM, AutoTokenizer
 
107
 
108
+ # Back-fill v4-era symbols removed in transformers 5.x before any trust_remote_code
109
+ # modelling file is imported (tokenizer or model), or it crashes at import time.
110
+ _ensure_transformers_v4_symbols()
111
  tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
112
  try:
113
+ model = AutoModelForCausalLM.from_pretrained(
114
  repo_id, dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
115
  )
116
  except TypeError: # pragma: no cover - older transformers use the torch_dtype kwarg name
117
+ model = AutoModelForCausalLM.from_pretrained(
118
  repo_id, torch_dtype="auto", low_cpu_mem_usage=False, trust_remote_code=trust_remote_code
119
  )
120
  # Re-tie the output head to the (materialized) input embeddings so no parameter is left
 
125
  return _LOADED[repo_id]
126
 
127
 
128
+ def _gpu_duration(repo_id, trust_remote_code, system, prompt, max_new_tokens, temperature, top_p) -> int:
129
  """Dynamic ``@spaces.GPU`` duration (seconds) for one generation.
130
 
131
  Scales with the token budget and stays short so the Space keeps high queue priority on
 
136
 
137
 
138
  @spaces.GPU(duration=_gpu_duration)
139
+ def _generate(repo_id, trust_remote_code, system, prompt, max_new_tokens, temperature, top_p):
140
  """Run one chat completion on the GPU; return ``(text, prompt_tokens, completion_tokens)``.
141
 
142
  Module-level and decorated so ZeroGPU registers it and grants a GPU for the call. The
 
149
  """
150
  import torch
151
 
152
+ tokenizer, model = _ensure_loaded(repo_id, trust_remote_code)
153
  if torch.cuda.is_available():
154
  model = model.to("cuda")
155
  device = next(model.parameters()).device
 
208
  # Warm the weights in the PARENT first so the forked @spaces.GPU call
209
  # inherits them (see module docstring); this is a cache hit after the
210
  # first use of this model in the process.
211
+ _ensure_loaded(self.model, self._trust_remote_code())
212
  system = OpenAICompatProvider._system_for_role(role)
213
  text, prompt_tokens, completion_tokens = _generate(
214
  self.model,
215
  self._trust_remote_code(),
 
216
  system,
217
  prompt,
218
  self.max_tokens,
 
240
  entry = local_catalogue.model_by_key(self.model)
241
  return bool(entry.trust_remote_code) if entry is not None else False
242
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  def _record_usage(self, prompt_tokens: int, completion_tokens: int, prompt: str, text: str) -> None:
244
  # Generation returns exact token counts; fall back to an estimate only if a count
245
  # came back as zero (e.g. an empty decode), so the Governor always sees a budget hit.
tests/test_local_backend.py CHANGED
@@ -36,7 +36,7 @@ def test_one_sponsor_model_per_tier_and_sizes_stay_small():
36
 
37
 
38
  def test_every_tier_resolves_to_its_sponsor_model():
39
- assert local_catalogue.default_key_for_profile("tiny") == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
40
  assert local_catalogue.default_key_for_profile("fast") == "openbmb/MiniCPM4.1-8B"
41
  assert local_catalogue.default_key_for_profile("balanced") == "CohereLabs/aya-expanse-8b"
42
  assert local_catalogue.default_key_for_profile("strong") == "JetBrains/Mellum2-12B-A2.5B-Instruct"
@@ -45,25 +45,19 @@ def test_every_tier_resolves_to_its_sponsor_model():
45
 
46
 
47
  def test_model_by_key_carries_trust_remote_code():
48
- # Nemotron + MiniCPM ship custom modelling code; Aya (native Command arch) does not; an
49
- # off-catalogue id is unknown.
50
- assert local_catalogue.model_by_key("nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16").trust_remote_code is True
51
  assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").trust_remote_code is True
 
52
  assert local_catalogue.model_by_key("CohereLabs/aya-expanse-8b").trust_remote_code is False
53
  assert local_catalogue.model_by_key("does/not-exist") is None
54
 
55
 
56
- def test_model_by_key_carries_auto_class():
57
- # Mellum's card loads it with AutoModelForMultimodalLM; the rest use the default class.
58
- assert local_catalogue.model_by_key("JetBrains/Mellum2-12B-A2.5B-Instruct").auto_class == "AutoModelForMultimodalLM"
59
- assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").auto_class == "AutoModelForCausalLM"
60
-
61
-
62
  def test_binding_is_a_bare_repo_id_with_no_endpoint():
63
  # In-process: the binding carries the raw transformers repo id (no openai/ prefix) and
64
  # neither a base_url nor an api_key β€” the router builds the in-process provider from it.
65
- binding = local_catalogue.binding_for("nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
66
- assert binding["model"] == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
67
  assert binding["base_url"] == ""
68
  assert binding["api_key"] == ""
69
 
@@ -115,9 +109,9 @@ def test_local_backend_is_registered_and_qualified():
115
 
116
  def test_registry_default_and_binding_round_trip():
117
  key = inference.default_key_for_profile("tiny", "local")
118
- assert key == "local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
119
  binding = inference.binding_for(key)
120
- assert binding["model"] == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
121
  assert binding["base_url"] == ""
122
 
123
 
@@ -136,15 +130,15 @@ def test_router_dispatches_local_key_to_in_process_provider():
136
  # A live router resolving a local: key must build the in-process provider (not LiteLLM),
137
  # bound to the bare repo id. Construction only β€” no GPU is touched.
138
  router = ModelRouter(offline=False)
139
- provider = router.for_profile("local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
140
  assert isinstance(provider, LocalTransformersProvider)
141
- assert provider.model == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
142
- assert provider.model_id == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
143
 
144
 
145
  def test_catalogue_spec_tags_local_kind_and_others_litellm():
146
  router = ModelRouter(offline=False)
147
- local_spec = router._catalogue_spec("local:nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
148
  assert local_spec is not None and local_spec.kind == "local"
149
  # An HF key resolves through the same path but stays on the HTTP transport.
150
  hf_spec = router._catalogue_spec("hf:katanemo/Arch-Router-1.5B")
@@ -155,8 +149,8 @@ def test_catalogue_spec_tags_local_kind_and_others_litellm():
155
 
156
 
157
  def test_provider_reports_model_id_and_zeroed_usage_before_any_call():
158
- provider = LocalTransformersProvider(model="nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16")
159
- assert provider.model_id == "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
160
  assert provider.last_usage == {} # no call yet β€” matches the sibling providers
161
  provider._zero_usage()
162
  assert provider.last_usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
@@ -169,16 +163,6 @@ def test_provider_resolves_trust_remote_code_from_catalogue():
169
  assert LocalTransformersProvider(model="some/random-repo")._trust_remote_code() is False
170
 
171
 
172
- def test_provider_resolves_auto_class_from_catalogue():
173
- # Mellum loads with a non-default auto-class; ordinary and off-catalogue repos use CausalLM.
174
- assert (
175
- LocalTransformersProvider(model="JetBrains/Mellum2-12B-A2.5B-Instruct")._auto_class()
176
- == "AutoModelForMultimodalLM"
177
- )
178
- assert LocalTransformersProvider(model="openbmb/MiniCPM4.1-8B")._auto_class() == "AutoModelForCausalLM"
179
- assert LocalTransformersProvider(model="some/random-repo")._auto_class() == "AutoModelForCausalLM"
180
-
181
-
182
  # ── ZeroGPU contract: CUDA only inside @spaces.GPU, never in the parent ───────────────
183
  # Regression guard for the production crash "Low-level CUDA init (torch._C._cuda_init)
184
  # reached … ZeroGPU's emulation did not intercept": the parent process gets no GPU, so any
@@ -231,6 +215,26 @@ def test_parent_loader_fully_materialises_weights_no_meta_tensors():
231
  assert "tie_weights()" in code
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def test_gpu_transfer_lives_inside_the_spaces_gpu_function():
235
  from pathlib import Path
236
 
 
36
 
37
 
38
  def test_every_tier_resolves_to_its_sponsor_model():
39
+ assert local_catalogue.default_key_for_profile("tiny") == "nvidia/Nemotron-Mini-4B-Instruct"
40
  assert local_catalogue.default_key_for_profile("fast") == "openbmb/MiniCPM4.1-8B"
41
  assert local_catalogue.default_key_for_profile("balanced") == "CohereLabs/aya-expanse-8b"
42
  assert local_catalogue.default_key_for_profile("strong") == "JetBrains/Mellum2-12B-A2.5B-Instruct"
 
45
 
46
 
47
  def test_model_by_key_carries_trust_remote_code():
48
+ # MiniCPM ships custom modelling code; the native-arch models (Nemotron-Mini, Aya) do
49
+ # not; an off-catalogue id is unknown.
 
50
  assert local_catalogue.model_by_key("openbmb/MiniCPM4.1-8B").trust_remote_code is True
51
+ assert local_catalogue.model_by_key("nvidia/Nemotron-Mini-4B-Instruct").trust_remote_code is False
52
  assert local_catalogue.model_by_key("CohereLabs/aya-expanse-8b").trust_remote_code is False
53
  assert local_catalogue.model_by_key("does/not-exist") is None
54
 
55
 
 
 
 
 
 
 
56
  def test_binding_is_a_bare_repo_id_with_no_endpoint():
57
  # In-process: the binding carries the raw transformers repo id (no openai/ prefix) and
58
  # neither a base_url nor an api_key β€” the router builds the in-process provider from it.
59
+ binding = local_catalogue.binding_for("nvidia/Nemotron-Mini-4B-Instruct")
60
+ assert binding["model"] == "nvidia/Nemotron-Mini-4B-Instruct"
61
  assert binding["base_url"] == ""
62
  assert binding["api_key"] == ""
63
 
 
109
 
110
  def test_registry_default_and_binding_round_trip():
111
  key = inference.default_key_for_profile("tiny", "local")
112
+ assert key == "local:nvidia/Nemotron-Mini-4B-Instruct"
113
  binding = inference.binding_for(key)
114
+ assert binding["model"] == "nvidia/Nemotron-Mini-4B-Instruct"
115
  assert binding["base_url"] == ""
116
 
117
 
 
130
  # A live router resolving a local: key must build the in-process provider (not LiteLLM),
131
  # bound to the bare repo id. Construction only β€” no GPU is touched.
132
  router = ModelRouter(offline=False)
133
+ provider = router.for_profile("local:nvidia/Nemotron-Mini-4B-Instruct")
134
  assert isinstance(provider, LocalTransformersProvider)
135
+ assert provider.model == "nvidia/Nemotron-Mini-4B-Instruct"
136
+ assert provider.model_id == "nvidia/Nemotron-Mini-4B-Instruct"
137
 
138
 
139
  def test_catalogue_spec_tags_local_kind_and_others_litellm():
140
  router = ModelRouter(offline=False)
141
+ local_spec = router._catalogue_spec("local:nvidia/Nemotron-Mini-4B-Instruct")
142
  assert local_spec is not None and local_spec.kind == "local"
143
  # An HF key resolves through the same path but stays on the HTTP transport.
144
  hf_spec = router._catalogue_spec("hf:katanemo/Arch-Router-1.5B")
 
149
 
150
 
151
  def test_provider_reports_model_id_and_zeroed_usage_before_any_call():
152
+ provider = LocalTransformersProvider(model="nvidia/Nemotron-Mini-4B-Instruct")
153
+ assert provider.model_id == "nvidia/Nemotron-Mini-4B-Instruct"
154
  assert provider.last_usage == {} # no call yet β€” matches the sibling providers
155
  provider._zero_usage()
156
  assert provider.last_usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
 
163
  assert LocalTransformersProvider(model="some/random-repo")._trust_remote_code() is False
164
 
165
 
 
 
 
 
 
 
 
 
 
 
166
  # ── ZeroGPU contract: CUDA only inside @spaces.GPU, never in the parent ───────────────
167
  # Regression guard for the production crash "Low-level CUDA init (torch._C._cuda_init)
168
  # reached … ZeroGPU's emulation did not intercept": the parent process gets no GPU, so any
 
215
  assert "tie_weights()" in code
216
 
217
 
218
+ def test_v4_compat_shim_backfills_removed_remote_code_predicates():
219
+ # Regression guard for the ZeroGPU error "cannot import name 'is_torch_fx_available'
220
+ # from transformers.utils.import_utils": transformers 5.x removed these predicates, but
221
+ # MiniCPM's (and other) trust_remote_code modelling files still import them. The provider
222
+ # back-fills them (all True at our torch floor) so the remote import succeeds.
223
+ from src.models import local_provider
224
+
225
+ local_provider._ensure_transformers_v4_symbols()
226
+ from transformers.utils import import_utils
227
+
228
+ # Every name the shim covers is importable from transformers.utils.import_utils and True.
229
+ for name in local_provider._REMOVED_TORCH_PREDICATES:
230
+ fn = getattr(import_utils, name)
231
+ assert fn() is True
232
+ # And _ensure_loaded runs the shim before touching any remote code.
233
+ import inspect
234
+
235
+ assert "_ensure_transformers_v4_symbols()" in inspect.getsource(local_provider._ensure_loaded)
236
+
237
+
238
  def test_gpu_transfer_lives_inside_the_spaces_gpu_function():
239
  from pathlib import Path
240