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-
|
| 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-
|
| 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.
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
| 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/
|
| 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;
|
| 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/
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
[Part 5](05-the-ledger-is-the-database.md): it's an *open*, format-validated string, not a
|
| 56 |
-
closed enum. A
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 *
|
| 77 |
-
always speaks before the scheduled rhythm resumes
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 89 |
-
>
|
| 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.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |

|
| 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*
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 201 |
-
|
| 202 |
-
a
|
|
|
|
| 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 |

|
| 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
|
| 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 |
-
#
|
| 107 |
-
#
|
| 108 |
-
#
|
| 109 |
LocalModel(
|
| 110 |
-
repo_id="nvidia/
|
| 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.
|
| 135 |
-
#
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 79 |
-
from transformers import AutoTokenizer
|
| 80 |
|
| 81 |
-
#
|
| 82 |
-
#
|
| 83 |
-
|
| 84 |
tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=trust_remote_code)
|
| 85 |
try:
|
| 86 |
-
model =
|
| 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 =
|
| 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,
|
| 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,
|
| 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
|
| 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()
|
| 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/
|
| 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 |
-
#
|
| 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/
|
| 66 |
-
assert binding["model"] == "nvidia/
|
| 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/
|
| 119 |
binding = inference.binding_for(key)
|
| 120 |
-
assert binding["model"] == "nvidia/
|
| 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/
|
| 140 |
assert isinstance(provider, LocalTransformersProvider)
|
| 141 |
-
assert provider.model == "nvidia/
|
| 142 |
-
assert provider.model_id == "nvidia/
|
| 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/
|
| 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/
|
| 159 |
-
assert provider.model_id == "nvidia/
|
| 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 |
|