uvの--lockedと--frozenを検証する:CIの同期成功は何を保証するか
uv sync --frozenが成功しても、pyproject.tomlへ追加した依存が入っているとは限りません。
小さなプロジェクトでlockfileの更新漏れを作ったところ、--lockedはエラーで止まり、--frozenは成功しました。ただし後者では、新しく追加したidnaをimportできる状態になっていませんでした。
この記事では、この差を再現し、CI(変更のたびにテストなどを実行する仕組み)で何を検証したいかから指定を選びます。実測環境はLinux、uv 0.11.19、Python 3.12.3です。GitHub Actions自体は実行していません。
lockfileが一致していない状態を作る
既存プロジェクトを壊さないよう、空の検証用ディレクトリで行います。uvと、次の条件を満たすPythonが使えることが前提です。
最初のpyproject.tomlは次の内容です。ビルド設定を持たない小さな検証用プロジェクトで、アプリの配布は行いません。
[project]
name = "uv-ci-example"
version = "0.1.0"
requires-python = ">=3.12,<3.13"
dependencies = ["packaging>=24,<26"]
[dependency-groups]
dev = ["pytest>=8,<9"]
比較対象のPython系統をそろえてから、最初のlockを作る
uv python pin 3.12
uv lock
uv sync --locked
ここではpyproject.tomlとuv.lockが整合しています。lockfileは、解決済みの依存関係を記録したファイルです。pyproject.tomlが求める条件と、実際に使う解決結果を別々に扱えるようになります。uvのプロジェクト構成
続いて、pyproject.tomlのdependencies行だけを次へ変更します。
lock更新漏れを再現するため、この段階ではuv addを使わない
dependencies = ["packaging>=24,<26", "idna==3.10"]
この行は既存の[project]内にある行を置き換えるものです。別の場所へ追記しないでください。通常の依存追加にはuv addを使えますが、今回は意図的にuv.lockを更新しない状態を作っています。uvの依存関係管理
--lockedでは更新漏れを検出できた
設定とlockの不整合を検出できるか確認する
uv sync --locked
実測では終了コード1になり、lockfileの更新が必要なのに--lockedが指定されている、というエラーになりました。uv.lockの内容は変更されていません。
これは想定した失敗です。CIに「コミットされた依存設定を、そのlockfileで扱えるか」を検証させたいなら、この段階で止まることに意味があります。
--frozenは成功したが、追加依存は入らなかった
同じ状態のまま、今度は次を実行しました。
不整合な設定を保ったまま、既存lockを使った場合を比較する
uv sync --frozen
通常のuv runによる自動更新を避け、未導入の状態を調べる
uv run --frozen python -c "import importlib.util; assert importlib.util.find_spec('idna') is None"
両コマンドとも終了コード0でした。2つ目のコマンドが成功したことは、この環境でidnaが見つからなかったことを意味します。uv.lockも変更されていません。
確認時にuv runへ--frozenを付けたのは、観測中にlockや環境が自動更新されるのを避けるためです。通常のuv runには、コマンドの起動前に必要なlockと同期を行う動作があります。uvのlockと同期
2つの指定は、確認する条件が違う
図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。
操作
今回の不整合への動作
成功から読み取れること
uv sync --locked
更新が必要なので停止
成功した場合、uvの判定でlockが現在の設定と整合している
uv sync --frozen
整合性確認を省き、古いlockで同期
選ばれたlockと同期対象のグループで環境を作れた。現在の設定との整合性は別途確認が必要
uv lockの後にuv sync --locked
lockを更新してから同期
更新した解決結果を使って環境を作れる
--frozenは「より厳しい固定」という意味ではなく、lockfileの最新性確認を省く指定です。仕様はLocking and syncingに記載されています。
また、--lockedもファイルの文字列が完全一致するかを検査するものではありません。たとえば依存の条件を変えても、既存lockが引き続き条件を満たす場合があります。新しいパッケージ版が公開されたことだけで、lockが不整合になるわけでもありません。
このため、通常のCIでは--lockedを出発点にし、別工程で整合性を保証しているなどの理由がある場合に--frozenを選ぶ、という整理ができます。
更新する工程と、検証する工程を分ける
先ほどの検証用プロジェクトでは、次の操作で不整合を解消できます。
依存変更をlockへ反映し、差分をレビューできる状態にする
uv lock
uv sync --locked
更新後には追加した依存が見つかることを確認する
uv run --locked python -c "import importlib.util; assert importlib.util.find_spec('idna') is not None"
CIへ渡すときは、pyproject.tomlとuv.lockの両方をGitで管理します。通常のテスト実行中にlockを作り直すより、依存を変更する作業で更新し、その差分を確認してからCIへ渡す方が、何をテストしたかを追いやすくなります。
pytestをdevグループに登録したプロジェクトなら、uvとPythonの準備後のテスト部分は次のようになります。これはjobs.<job_id>.steps内の断片です。
- name: Sync dependencies
CI内の暗黙のlock更新で、更新漏れが隠れるのを防ぐ
run: uv sync --locked
- name: Run tests
テスト起動時にも同じ整合性条件を保つ
run: uv run --locked pytest -q
uv runにも--lockedを付けることで、実行直前の自動更新に頼らず検証します。tests/にはプロジェクトのテストを用意してください。上の依存設定と同期だけでは、テストケースは生成されません。uvのGitHub Actions連携
lockfileだけで実行環境全体が固定されるわけではない
図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。
ローカル検証では、pytest 8.4.2とpackaging 25.0を導入し、バージョンの順序を比較するテスト3件を実行しました。検証用の.venvを削除して再同期した後も、同じ3件が成功しました。依存追加後にlockを更新した後も成功しています。
ただし、これはGitHubのrunner(ジョブを実行するマシン)でも同じ挙動になることをすべて確認した結果ではありません。
対象
そろえる方法
注意点
依存関係
uv.lockを共有し、--lockedで同期
OSやPython条件により導入対象が変わり得る
uv本体
setup-uvのversion入力などで指定
Actionの版だけではuv本体まで固定されない
Python
CIと手元で同じ版を選択
3.12指定ではパッチ版まで一致するとは限らない
OS
対象runnerを選ぶ
同じOSラベルでもイメージ内部は更新される
Pythonの選択とuv本体の導入方法は、uvのPythonバージョン管理とsetup-uvの入力設定を参照してください。
キャッシュヒットでもsyncは必要
キャッシュは取得・ビルド済みの成果物を再利用する仕組みです。今回必要なパッケージを決めるlockfileや、その内容から環境を作る同期処理の代わりにはなりません。uvのキャッシュ
キャッシュが復元できてもuv sync --lockedは実行し、今回の環境を整えます。逆にキャッシュがない初回でも、必要なパッケージを取得できればテストまで進める構成にします。
私は、CIでの再現性は「固定するファイルを増やす」だけでなく、どの不整合をエラーにするかを決めることが大事だと考えています。今回の例では、--frozenで同期できたことより、--lockedが更新漏れを検出したことの方が、依存変更をレビューする目的に合っていました。
公式資料の確認日は2026年9月11日です。ローカルでの同期・不整合検出・テストを確認し、GitHub Actionsのイベント発火、Actionの実行、キャッシュ復元と速度は未検証として区別しています。
uvのプロジェクト管理へ移るところから確認したい場合は、uvとpip・venvの違いと移行手順も参照してください。
GitHub Actionsへ導入するためのworkflow全体と、Python・uvの固定、キャッシュ、サブディレクトリの設定は、uvをGitHub Actionsで使う方法にまとめています。
@thsottiaux Yes
ruff#29852
20 likes · 17,440 views
Tinyworldformer corre en un Pico 2W de $6 con 520 KB de RAM
Un microcontrolador de 6 dólares acaba de ejecutar, en tiempo real, un modelo de mundo comprimido que inventa videojuegos cuadro por cuadro. El proyecto tinyworldformer corre dentro de 520 KB de RAM en una Raspberry Pi Pico 2W, sin GPU, sin nube y sin conexión a internet.
Su autor lo logró separando el trabajo en dos redes neuronales por fotograma. Una genera el siguiente cuadro del juego, la otra decide qué botón presionar, y ambas corren a la vez sobre el mismo chip.
TL;DR
- Int8 y pesos compartidos reducen el generador de frames a entre 26.000 y 131.000 parámetros según el juego.- Un bloque transformer aplicado de 1 a 4 veces sustituye a una pila de capas distintas y ahorra memoria.- La red actora, separada del generador, pesa solo entre 9.000 y 16.000 parámetros en float32.- El RP2350 reparte el trabajo: el núcleo 1 corre ambas redes, el núcleo 0 atiende USB y pantalla.- El repositorio SheepSeb/tinyworldformer deja jugar los cinco juegos reales desde la terminal con uv.
¿Qué es un modelo de mundo comprimido?
Un modelo de mundo comprimido es una red neuronal generativa reducida en tamaño y precisión para correr sin GPU: tinyworldformer la implementa como un transformer en bucle cuantizado a 8 bits que predice el siguiente fotograma de un videojuego y cabe en los 520 KB de RAM de un microcontrolador de 6 dólares.
La idea de partida de su autor fue simple. Los transformers en bucle reutilizan el mismo bloque de pesos varias veces en lugar de apilar capas distintas, así que ocupan mucha menos memoria por diseño. Si ese ahorro era suficiente, un modelo generativo completo podía caber en un chip de bolsillo.
Por qué importa
La mayoría de los modelos generativos de video o de juegos dependen de una GPU con varios gigabytes de memoria. Tinyworldformer invierte esa ecuación: corre en un chip pensado para termostatos y drones de juguete. Eso importa para el campo conocido como edge computing, donde el objetivo es mover la inferencia al dispositivo en lugar de depender de un servidor remoto.
Un modelo que corre en el borde no necesita conexión a internet, no paga latencia de red y no expone datos a un servidor externo. Acá el beneficio es además una prueba de concepto. Si una red generativa cabe en 520 KB, la pregunta deja de ser si el edge AI puede generar contenido y pasa a ser cuánto contenido puede generar con ese presupuesto de memoria.
El ángulo de costo pesa tanto como el técnico. Una Raspberry Pi Pico 2W cuesta una fracción de lo que cuesta cualquier GPU capaz de correr un transformer completo, y consume órdenes de magnitud menos energía porque no tiene memoria de video ni refrigeración activa. Para proyectos de hobby, educación o investigación en TinyML, esa diferencia de costo decide si un experimento se hace o no.
Cómo funciona tinyworldformer por dentro
Tinyworldformer separa el problema en dos redes con trabajos distintos. La primera, el generador de frames, recibe el cuadro actual y la acción del jugador y predice el cuadro siguiente junto con la recompensa. La segunda, la red actora, solo mira el cuadro y decide qué acción tomar. Ninguna de las dos ve jamás el juego real una vez que arranca la sesión: el generador solo recibe acciones de la actora, y la actora solo ve cuadros inventados por el generador.
sequenceDiagram
participant Actor as Red actora
participant Generador as Generador de frames
Actor->>Generador: envia accion
Generador-->>Actor: entrega frame generado y recompensa
Note over Actor,Generador: el generador nunca ve el juego real
Tokenización en parches
Antes de entrar al transformer, cada cuadro se recorta en parches cuadrados: 2x2 celdas en Breakout, 4x4 en Doom-lite, Flappy Bird y Pong, 6x6 en Pac-Man lite. Eso da entre 36 y 80 tokens por juego según el repositorio. Cada token suma un vector aprendido por celda, con una tabla distinta según la posición dentro del parche para no perder el orden, más un vector de posición y un vector de la acción tomada.
Un ejemplo simplificado en Python muestra la misma idea con un cuadro diminuto de 4x4 dividido en parches de 2x2:
import numpy as np
frame = np.array([
[1, 1, 2, 2],
[1, 1, 2, 2],
[3, 3, 4, 4],
[3, 3, 4, 4],
])
def a_parches(frame, parche=2):
h, w = frame.shape
tokens = []
for i in range(0, h, parche):
for j in range(0, w, parche):
tokens.append(frame[i:i+parche, j:j+parche].flatten())
return np.array(tokens)
print(a_parches(frame))
La salida literal es una matriz de 4 tokens, uno por cada región homogénea del cuadro:
[[1 1 1 1]
[2 2 2 2]
[3 3 3 3]
[4 4 4 4]]
El transformer en bucle
En lugar de apilar, por ejemplo, ocho bloques transformer distintos, tinyworldformer usa un único bloque (atención más una red feedforward con ReLU) y lo aplica entre 1 y 4 veces seguidas, sumando la entrada original antes de cada pasada. El resultado de cada pasada se entrena contra el cuadro siguiente real, así que el número de pasadas en tiempo de ejecución es un control directo entre velocidad y precisión. En el Pico 2W, para mantener una tasa de cuadros razonable, el proyecto usa dos pasadas.
flowchart TD
A["Frame t + accion"] --> B["Tokenizar en parches"]
B --> C["Bloque transformer (pesos compartidos)"]
C --> D{"Repetir de 1 a 4 veces"}
D -->|"otra pasada"| C
D -->|"pasada final"| E["Frame t+1 y recompensa"]
Cuantización y reparto entre núcleos
El generador de frames se cuantiza a int8, mientras que la red actora se queda en float32 porque ya es pequeña de por sí, entre 9.000 y 16.000 parámetros, y la precisión ahí importa más para decidir bien la acción. El RP2350 del Pico 2W tiene dos núcleos: el núcleo 1 corre el generador y la actora, el núcleo 0 se encarga del USB y de la pantalla, según documenta el repositorio.
Esa separación entre generador y actor permite optimizar cada red por separado. El generador, la parte pesada, recibe toda la cuantización, mientras que la actora, ya liviana, se queda intacta en float32.
El RP2350 integra FPU, pieza clave para ejecutar redes cuantizadas.
flowchart TD
A["RP2350 dual-core"] --> B["Nucleo 0: USB y pantalla"]
A --> C["Nucleo 1: inferencia"]
subgraph Inferencia["Nucleo de inferencia"]
C --> D["Generador de frames int8"]
C --> E["Red actora float32"]
end
Ejemplos prácticos y cómo empezar
El repositorio incluye cinco juegos reales escritos en unas pocas líneas de numpy, lo que le permite a su autor comparar cada frame generado contra la física real del juego. La siguiente tabla resume el costo de memoria y cómputo de cada uno, con datos publicados en el repositorio de tinyworldformer:
JuegoTamaño de frameParámetrosMACs por frameFrames exactosBreakout20x1626k4.3M99.99%Doom-lite32x3255k4.2M83.6%Flappy Bird24x2446k2.0M100%Pong20x3246k2.3M99.45%Pac-Man lite48x48131k8.2M86.4%
La columna de frames exactos mide el porcentaje de transiciones donde cada celda del cuadro predicho coincide con el cuadro real. Doom-lite tiene el número más bajo, 83.6%, pero no porque juegue mal: su vista en primera persona tiene 768 celdas, y un solo píxel de sombra mal pintado ya marca todo el cuadro como incorrecto en esta métrica.
Cómo probarlo en tu máquina
El proyecto usa uv como gestor de paquetes de Python, así que no hace falta instalar dependencias a mano una por una. En Linux o macOS:
curl -LsSf https://astral.sh/uv/install.sh | sh
git clone https://github.com/SheepSeb/tinyworldformer
cd tinyworldformer
uv run python -m tinyworldformer.games.play --game pacman
En Windows, el único paso que cambia es la instalación de uv, desde PowerShell: powershell -c "irm https://astral.sh/uv/install.ps1 | iex", y luego los mismos comandos de git clone y uv run.
El comando play acepta también breakout, doomlite, flappy o pong en lugar de pacman. No hay salida de texto que mostrar: uv descarga las dependencias declaradas en el pyproject.toml y abre una ventana gráfica con el juego real corriendo en numpy, el mismo que el generador de frames aprendió a imitar.
> 💡 Tip: correr primero el juego real con play antes de buscar los pesos entrenados ayuda a entender qué está tratando de imitar el generador cuantizado, celda por celda.
Casos de uso reales
Tinyworldformer no apunta a reemplazar un motor de videojuegos. Su valor está en lo que demuestra: un modelo generativo entrenado sobre partidas grabadas puede, cuantizado y comprimido, correr en el mismo chip que hoy maneja sensores de temperatura o drones de juguete baratos. El caso de uso inmediato es educativo y de investigación en TinyML, pero la receta (tokenizar en parches, compartir pesos entre pasadas, cuantizar solo la parte más pesada) es trasladable a otros generadores pequeños, como un simulador de sensor para probar un controlador embebido sin hardware real conectado.
El repositorio también suma un agente de refuerzo por streaming que aprende a jugar dentro del sueño generado por el propio modelo, en lugar de solo repetir una política ya entrenada. Cualquier botón del dispositivo le devuelve el control al usuario humano, así que la misma placa sirve para ver a la IA jugar sola o para jugar uno mismo dentro del mundo que ella inventa.
Breakout necesita solo 26.000 parámetros; Pac-Man lite necesita 131.000.
Errores comunes y buenas prácticas
- Confundir precisión de píxel con jugabilidad. Un 83.6% de frames exactos suena mal hasta que se entiende la métrica: un solo píxel de sombra distinto ya cuenta como fallo total del cuadro, aunque el estado del juego (posición del enemigo, vida, puntaje) siga siendo correcto.- Cuantizar todo por igual. Aplicar int8 a la red actora, que ya tiene menos de 16.000 parámetros, aportaría poco ahorro de memoria y arriesgaría la precisión de la política. La cuantización rinde donde el modelo es grande, no donde ya es pequeño.- Elegir mal el número de pasadas del bucle. Una sola pasada del transformer es más rápida pero deja artefactos visibles, un enemigo que no debería estar ahí, por ejemplo. Cuatro pasadas corrigen casi todo el error pero cuestan más tiempo de cómputo por cuadro. Dos pasadas fue el punto que eligió el proyecto para el Pico 2W.- Verificar contra la física real, no solo mirar el video generado. Tener la versión real del juego en numpy, como hace este repositorio, es lo que permite medir el error cuadro por cuadro en lugar de confiar en que se vea bien.
Comparativa con alternativas para generar contenido con IA
EnfoqueCuándo usarloVentajaLimitaciónTransformer completo en GPU o nubeCuando la latencia de red no importa y hay presupuesto de cómputoMáxima fidelidad del frame generadoDepende de conexión e infraestructura externaModelo de mundo comprimido en microcontrolador (tinyworldformer)Dispositivos aislados, sin GPU, con RAM y flash muy limitadosCorre offline, sin latencia de red, consumo mínimoSolo viable en juegos o dominios muy pequeñosDistillation genérica a modelos pequeños (TinyML clásico)Tareas de clasificación o detección simples en sensoresHerramientas y runtimes madurosNo pensado para generar secuencias completas de frames
La fila intermedia describe a tinyworldformer. No busca competir en fidelidad visual con un generador de video entrenado en GPU; busca que el juego siga siendo jugable dentro de un presupuesto de memoria minúsculo.
Profundizando: por qué el bucle ahorra memoria y dónde se nota el límite
La razón de fondo por la que un transformer en bucle pesa menos es aritmética simple: un transformer tradicional de N capas guarda N juegos de pesos distintos, mientras que el bucle guarda un solo juego de pesos y lo reusa N veces. El costo que se paga a cambio es tiempo de cómputo, cada pasada adicional cuesta los mismos multiplica-acumula que la anterior, en lugar de memoria, y en un microcontrolador la memoria suele ser el recurso más escaso de los dos.
La cuantización int8 agrega un segundo ahorro, independiente del anterior: cada peso pasa de ocupar 4 bytes en float32 a ocupar 1 byte, a costa de redondear cada valor a uno de 256 niveles posibles. El siguiente ejemplo en Python muestra ese redondeo con cuatro pesos cualquiera:
import numpy as np
pesos = np.array([0.42, -0.87, 1.35, -1.90], dtype=np.float32)
escala = np.max(np.abs(pesos)) / 127
cuantizados = np.round(pesos / escala).astype(np.int8)
reconstruidos = cuantizados.astype(np.float32) * escala
print(cuantizados)
print(np.round(reconstruidos, 4))
La salida literal es:
[ 28 -58 90 -127]
[ 0.4189 -0.8678 1.3465 -1.9 ]
El peso original de 1.90 se reconstruye exacto porque definió la escala; los otros tres cargan un pequeño error de redondeo. Ese error, multiplicado por miles de pesos y varias pasadas del bucle, ayuda a explicar por qué Pac-Man lite, el juego más complejo con 131.000 parámetros, tiene un porcentaje de frames exactos menor que Flappy Bird. No hay una forma directa de verificar desde afuera, sin acceso al checkpoint, cuánto de ese error viene de la cuantización y cuánto del tamaño del juego; lo más cercano que ofrece el propio repositorio es comparar el porcentaje de frames exactos por juego de la tabla anterior.
El FPU del RP2350 importa en este punto. Sin una unidad de punto flotante en hardware, la dequantización, volver de int8 a valores reales antes de cada operación de atención, sería mucho más lenta, porque el chip tendría que emular esas operaciones en software.
> 💭 Clave: el ahorro de memoria del bucle y el ahorro de la cuantización son independientes entre sí; tinyworldformer los combina, pero cada uno podría usarse por separado en otro proyecto.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: cloná el repositorio, corré uv run python -m tinyworldformer.games.play --game breakout y leé el archivo de ese juego dentro de src/tinyworldformer/games/ para ver cuántas líneas de numpy hacen falta para una física jugable completa.
Preguntas frecuentes
¿Qué microcontrolador corre el modelo de mundo comprimido de tinyworldformer?
Una Raspberry Pi Pico 2W, construida sobre el chip RP2350, que tiene dos núcleos y una unidad de punto flotante. El núcleo 1 ejecuta el generador de frames y la red actora; el núcleo 0 atiende USB y la pantalla.
¿Cuántos parámetros tiene el transformer en bucle de cada juego?
Entre 26.000 y 131.000, según la complejidad del juego: Breakout es el más liviano y Pac-Man lite el más pesado, de acuerdo con la tabla publicada en el repositorio.
¿Hace falta GPU para entrenar tinyworldformer antes de cuantizarlo?
El repositorio no detalla el hardware exacto de entrenamiento, pero el generador de frames se entrena sobre partidas grabadas del juego real, un proceso que normalmente se hace fuera del microcontrolador y antes de cuantizar los pesos para desplegarlos en el Pico 2W.
¿Qué diferencia a la red actora de la IA embebida en microcontroladores que genera los frames?
La red actora es una MLP mucho más chica, entre 9.000 y 16.000 parámetros, que se queda en float32 porque su tarea, decidir una acción a partir de un frame, tolera peor el error de cuantización que generar el frame en sí.
¿Se puede correr tinyworldformer en un microcontrolador distinto al Pico 2W?
El repositorio está construido específicamente para el RP2350 del Pico 2W, incluyendo una carpeta en Rust para el firmware del dispositivo. Portarlo a otro chip exigiría al menos una FPU y memoria suficiente para los buffers de atención, pero el propio proyecto no documenta otros objetivos probados.
Referencias
- GitHub: SheepSeb/tinyworldformer: repositorio original con el código, los juegos en numpy y los datos de tamaño y precisión de cada modelo.- Raspberry Pi: fabricante oficial de la Raspberry Pi Pico 2W y el chip RP2350 usado en el proyecto.- Wikipedia: Edge computing: contexto sobre mover la inferencia de IA al dispositivo en lugar de a un servidor remoto.- Wikipedia: Quantization: fundamento matemático de la cuantización usada para pasar los pesos de float32 a int8.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
It depends who you trust :slight_smile:
Astral/OpenAI build binaries at Python Build Standalone:
astral-sh/python-build-standalone
These are used by projects such as uv, Hatch and PDM.
1 like
> why this exists
docs.basedpyright.com
> The "type server protocol" is a desperate attempt from microsoft to prevent pylance from becoming obsolete by outsourcing its type checking to other language servers like basedpyright, ty or pyrefly -- while continuing to keep certain language server features exclusive to pylance.
> This protocol benefits nobody except microsoft. It adds needless complexity to any language server that chooses to support it. Basedpyright does not support it and strongly advises other language servers to avoid supporting it as well.
> For more info see here pylance-release#7180 and here ty#1190
DetachHead (maintainer of basedpyright) has criticized the TSP before:
- docs.basedpyright.com
- pylance-release#7180
Ty (the type checker maintained by astral) however will apparently support the TSP: ruff#23432
Rust Didn't Win Because It's Fast
GitHub rewrote Copilot's runtime in Rust. OpenAI's Codex is now almost all Rust. Bun, the runtime Claude Code ships on, moved 535,496 lines of Zig to Rust in 11 days. But it isn't about speed: C has been fast for half a century. We fly inside a real laptop, down to the CPU die and the memory cells, to show what Rust actually changed, and why AI coding agents love it.
0:00 Everyone is rewriting in Rust
0:49 This is Sidequest Explained
1:00 Inside the machine: instructions, bytes, stack and heap
1:53 Way one: freeing memory by hand (C and C++)
2:23 An elevator crash started Rust
2:40 Way two: the garbage collector (Discord's spikes)
3:12 Way three: ownership
3:36 Checked before it runs, and Android's results
4:07 Close to the metal: 28 bytes vs 8
4:21 Copilot: 5.25 s to 292 ms
4:41 Why AI agents love the Rust compiler
5:11 Is Python dead? What it means for you
Sources
GitHub Blog, migrating the Copilot runtime to Rust (Sep 2026): github.blog
Bun, Rewriting Bun in Rust (Jul 2026): bun.sh
OpenAI Codex repository: openai/codex
Codex native rewrite discussion: codex#1174
Grok Build repository: xai-org/grok-build
Dennis Ritchie, The Development of the C Language: bell-labs.com
Microsoft MSRC, 70% of security bugs are memory safety (2019): microsoft.com
Chromium memory safety: chromium.org
MIT Technology Review, Rust's origin (2023): technologyreview.com
Rust 1.0 announcement: blog.rust-lang.org
Discord, Why Discord is switching from Go to Rust (2020): discord.com
The Rust Book, ownership and borrowing: doc.rust-lang.org
Google, memory-safe languages in Android 13 (2022): security.googleblog.com
Google, Rust in Android (Nov 2025): blog.google
Python sys.getsizeof / CPython ints: docs.python.org
Hugging Face tokenizers: huggingface/tokenizers
OpenAI tiktoken: openai/tiktoken
pydantic-core: pydantic/pydantic-core
uv by Astral: astral-sh/uv
Numbers are the sources' own. The Copilot timings are client + session + one-turn overhead in the opt-in in-process mode, not counting the model or network. Android's 1000× is memory-safety vulnerability density. Code on screen is an illustration, not a run.
Credits
Die micrographs: Fritzchens Fritz (CC0). Memory-cell micrograph (Micron MT4C1024) and SEM plate (1886VE10): ZeptoBars, CC BY 3.0. Office and hardware photos: Pexels. HDRIs and materials: Poly Haven, ambientCG (CC0). Music: "Lofi Study" by FASSounds (Pixabay). Rust, GitHub, Copilot, OpenAI, Codex, Bun, Claude, xAI, Grok, Microsoft, Chrome, Android, Discord, Python, Hugging Face, Astral, pydantic and other names and logos belong to their owners, used for identification and commentary; Sidequest is not affiliated with any of them. Narration: an AI clone of my own voice. Animation: original, built in three.js.
#rust #programming #ai
50 likes · 14 comments · 6,610 views
uv Now Type-Checks Python with ty. How to Set Up uv check (Pin ty First)
uv now has a type-check command. uv check syncs your project environment, downloads the ty type checker and runs it, with no config and no separate pip install. It also checks PEP 723 scripts against their own inline dependencies, which is the part that got me: your type checker has probably been checking those scripts against the wrong venv for years, flagging import requests as unresolved or silently using whatever happened to be installed.
I've been running it and I like it. The "your laptop and CI use the same ty version" pitch needs a footnote, though, and one command fixes it.
I'll go through projects first, then workspaces, scripts, VS Code, pre-commit and GitHub Actions.
What uv check actually does
Per the announcement from October 13, 2026, uv check prepares the project environment, installs its dependencies and checks the project with ty. You need uv 0.12.3 or later and ty 0.0.76 or later. ty itself gets downloaded the first time you run the command, so you don't install it yourself.
It's a preview feature. Astral says so plainly, and if you never run uv check, nothing about your uv workflow changes.
The CLI reference describes it as "Run checks on the project," and adds: "Currently, this type checks Python code using ty." There's also a --fix flag to apply safe fixes. Ruff is on the roadmap but not in the command yet. Frank Chu tested this by adding unused import os and import sys: uv check didn't report either of them. So keep your ruff check step for now.
Step 1: type-check a project with uv check
uv self update # make sure you're on 0.12.3+
uv check
That's the whole setup. On the first run in Chu's test (uv 0.13.5, ty 0.0.91, a 2 vCPU VM), uv created .venv, pulled a managed CPython, built the project and downloaded ty, which was 13.5 MiB. The cold run took 5.53s and warm runs took 0.71s. Bare ty check on the same tiny project took 0.09s, so ty barely registers and most of the time goes to uv syncing the environment.
The exit codes are what CI expects: 1 when there are diagnostics, 0 when the code is clean.
Every run prints an experimental warning. Chu found you can silence it with --preview-features check-command.
Step 2: workspaces with --package and --all-packages
If you use a uv workspace, the flags work the same way they do in uv sync:
uv check --package api # one member: syncs it + its deps, checks only its files
uv check --all-packages # every member in the workspace
The reference docs say --package syncs the workspace environment with the selected members and their dependencies, and "only files owned by those members are checked." In a monorepo, that's what lets you run a quick per-package check in a pre-push hook and the full sweep in CI.
Step 3: check PEP 723 scripts against their own dependencies
This is the part I care about most. Say you have a script like this:
# /// script
# requires-python = ">=3.12"
# dependencies = ["requests"]
# ///
import requests
status: int = requests.get("https://example.com")
Run this:
uv check --script fetch.py
uv builds a separate environment from the script's own metadata and checks the script against it, even when it disagrees with the surrounding project. In Chu's test, plain ty check reported unresolved-import for requests because it was looking in the wrong venv. With the integration, it reported the real bug instead. I ran this exact file with uv 0.13.5, which pulled ty 0.0.92, and got the same thing, exit code 1:
error[invalid-assignment]: Object of type `Response` is not assignable to `int`
--> fetch.py:7:15
uv check on its own doesn't go looking for scripts, though. Chu had to name each one with --script. If you want ty to find and check the project's scripts in one pass, the blog gives an env var:
TY_UV=scripts ty check
That setting is opt-in for a good reason. Astral's explanation: "As dependency installation can require execution of third-party Python code, automatic environment setup is currently opt-in." The same applies to uv check itself, so running uv check on a repo you just cloned is not a read-only operation. Treat it like pip install.
Step 4: ty in VS Code with the uv setting
Install the ty extension, make sure uv is on your PATH, and add this to settings.json:
{
"ty.experimental.useUv": "on"
}
With that set, ty runs uv in the background so completions, hover and Go to Definition inside a script use the script's declared requirements and not your project venv. If you write a lot of throwaway uv run scripts, this alone makes the integration worth trying.
Step 5: the ty pre-commit hook
The ty-pre-commit hook now runs uv check under the hood, and the rev tracks the ty version:
repos:
- repo: https://github.com/astral-sh/ty-pre-commit
rev: v0.0.92 # ty version
hooks:
- id: ty
args: [--isolated]
I'd add --isolated right away. Without it, the hook can create or update your uv.lock and .venv during a commit, which is the kind of side effect that eventually costs you an afternoon. The README also suggests [--isolated, --no-python-downloads, --no-cache] if you want more idempotent runs.
Two other gotchas from the README:
- The hook always checks the whole project. Don't set pass_filenames: true, because the hook errors if it receives filenames.
- It doesn't work on pre-commit.ci, which blocks network access. Add ci: skip: [ty] there and run the check in your own CI.
Step 6: uv check in GitHub Actions
The ty-pre-commit README uses astral-sh/setup-uv@v10.3.2. Here's a minimal GitHub Actions job built on that:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: astral-sh/setup-uv@v10.3.2
- run: uv check --locked --all-packages
--locked makes the job fail if uv.lock is out of date, so CI can't silently re-resolve and check against different dependencies than the ones you committed.
The catch: uv check only pins ty if you add it
The announcement says uv check "automatically uses the ty version recorded in your project's lockfile," so laptops and CI agree. That only holds once ty is actually in your lockfile.
According to the --ty-version docs, the exact version from uv.lock is used "when ty is a project dependency or a dependency in the project's dev group." If it isn't, uv uses a constrained range like >=0.0,<0.1. ty is at 0.0.9x and ships often, so in that range your laptop and your CI runner can end up on different ty builds, which is exactly the drift the feature is supposed to prevent.
Chu checked this: after uv add --dev "ty==0.0.80", uv check -v used 0.0.80 even though 0.0.91 was out. So the fix is one command:
uv add --dev ty
I did this on a fresh uv init project and uv check -v logged Requirement already installed: ty==0.0.92, the version now in the lockfile.
Do this before you add uv check to CI. I'd argue it should be the default, or at least come with a loud warning.
Should ty replace mypy or Pyright yet?
Not yet, and Astral's post doesn't claim it should. The integration is experimental, ty is still on 0.0.x, and Astral says it is "continuing toward ty's stable release." ty's docs claim it's 10x to 100x faster than mypy and Pyright. I believe the speed claim. What I don't know yet is how many of your existing # type: ignore comments and plugin-dependent patterns (Django, SQLAlchemy, pydantic mypy plugins) will survive the switch.
What I'm doing for now:
| Situation | My call |
|---|---|
| PEP 723 scripts, one-off tools | Use uv check --script now. mypy never handled these well. |
| New project, no mypy plugins | Run uv check in CI next to your current checker for a few weeks |
| Large codebase on mypy plugins | Keep mypy. Try ty in the editor only. |
| Monorepo on uv workspaces | Trial --all-packages in a non-blocking CI job |
Running it next to your current checker costs you about a minute of CI setup. Dropping your current checker for a 0.0.x preview is a bet I'm not making on a production codebase.
So where are you on this? Would you replace mypy or pyright with ty today, or wait until it's out of preview? If you've already switched, tell me which false positives made you want to switch back.
Sources
- Bringing uv and ty together, Astral blog, Oct 13, 2026
- uv CLI reference: uv check
- astral-sh/ty-pre-commit README
- ty documentation
- Frank Chu: Astral wired ty into uv. I ran uv check on a project with four deliberate errors.
- Lobsters discussion
1 reaction · 1 comment
AI Agents Are Quietly Being Rewritten in Rust
OpenAI, Block, and Perplexity all rebuilt key parts of their AI stack in Rust. In this video I break down why the layer around the model (agents, sandboxes, and tooling) is moving to Rust, and the one catch most people skip.
I’m a fresh grad learning Rust and harness engineering in public, so every claim here comes from sources I checked, linked below.
Chapters
0:00 Elon’s Larp
00:36 Codex CLI: TypeScript to Rust
01:46 Goose: Python to Rust
02:22 Sandboxes: Firecracker and Perplexity SPACE
03:14 uv, Ruff, and OpenAI buying Astral
03:50 Hugging Face tokenizers and Candle
04:13 The catch
04:45 Advance
Sources
Elon post : x.com
openai/codex
block/builderbot
perplexityai/SPACE
x.com
codex#1174
news.ycombinator.com
reddit.com
codex#1174
news.ycombinator.com
reddit.com
github.com/aaif-goose/goose/blob/main/crates/goos…
goose-docs.ai
github.com/aaif-goose/goose/blob/main/crates/goos…
firecracker-microvm.github.io
x.com
anthropics/claude-code
reddit.com
huggingface.co
huggingface/candle
astral-sh/uv
astral.sh
github.com/perplexityai/SPACE/tree/main/crates/ap…
firecracker-microvm/firecracker
github.com/aaif-goose/goose/tree/main
Follow along
Rust playlist: youtube.com
Building Harness playlist: youtube.com
Portfolio: vkgeed.in
X post for this video: x.com
#rust #aiengineering #softwareengineering
2 likes · 1,199 views
Building a $0 VPS on Google Cloud: DietPi, Tailscale, IPv6, and the traps I hit
I wanted a small always-on Linux box: private SSH access, no public IPv4, and a bill of exactly zero. The job is modest — a dashboard for the box itself, a couple of small scripts, an SSH endpoint I can reach from anywhere — but renting a VPS for that is paying monthly for a machine that mostly idles. What I got was a working VM, after falling into five traps and several installer bugs that cost me an evening. The last step was not declaring victory: it was opening Cloud Billing to see what the platform actually charged.
This is the field report, including the parts where I was wrong.
The target
- Google Cloud e2-micro (2 shared vCPU, 964 MB RAM) in a free-tier region
- Debian 13 "Trixie" base, DietPi on top
- No public IPv4
- SSH over Tailscale only
- Budget-triggered billing shutoff as a safety net, not an absolute spending guarantee
The RAM number matters. On plain Debian the box idled at 389 MB used. On DietPi it idles at 248 MB — 36% less, which on a 964 MB machine is the difference between "can run one small service" and "cannot".
Trap 1: the DietPi conversion script will eat your Tailscale
DietPi ships PREP_SYSTEM_FOR_DIETPI.sh, which converts a running Debian install into DietPi. It looks like the obvious path on a cloud VM: no image juggling, just run it.
Don't — not on a cloud VM you only reach over the network.
Reading the script before running it:
grep -in "tailscale" prep.sh # no matches
grep -Ein 'google|gcloud' prep.sh # no matches
And the mechanism, at line 995:
G_DIETPI-NOTIFY 2 'Marking all packages as auto-installed first, to allow effective autoremove afterwards'
mapfile -t apackages < <(apt-mark showmanual)
G_EXEC apt-mark auto "${apackages[@]}"
It marks every manually-installed package as auto-installed, then autoremoves what nothing depends on. Unless explicitly protected, tailscaled and the GCP guest agents (google-guest-agent, google-compute-engine-oslogin) are candidates for removal when nothing else depends on them.
The script's own header says it plainly:
> # - Either Ethernet connection or local (non-SSH) terminal access
On a remote-only VM, that cleanup can cut off both Tailscale and the guest-agent path you expected to use for recovery. I did not want to discover that halfway through a conversion.
What I did instead: built a new VM from the official DietPi image, keeping the old one running until the new one answered. The old VM was the rollback plan, and it stayed untouched until the end.
Trap 2: GCP only accepts a file literally named disk.raw
Downloading DietPi_NativePC-UEFI-x86_64-Trixie.img.xz and repacking it naively fails:
ERROR: (gcloud.compute.images.create) Could not fetch resource:
- The file inside the tar archive was named 'dietpi.img'. It should be named 'disk.raw'.
The tar must contain exactly one file, named disk.raw. Here is the sequence used after downloading the named DietPi image (replace BUCKET with your bucket name):
mv DietPi_NativePC-UEFI-x86_64-Trixie.img.xz dietpi.img.xz
xz -dk dietpi.img.xz
cp dietpi.img disk.raw
tar -cSf disk.tar disk.raw # -S for sparse
gzip -1 disk.tar
gcloud storage cp disk.tar.gz gs://BUCKET/
gcloud compute images create dietpi-trixie \
--source-uri=gs://BUCKET/disk.tar.gz \
--guest-os-features=UEFI_COMPATIBLE
--guest-os-features=UEFI_COMPATIBLE is not optional — the DietPi Native PC image boots via UEFI, and the GCE firmware needs to know.
Trap 3: the VM came up with a public IP and a default password
I intended to create the instance without a public IPv4. My first verification showed an external address, an overly broad SSH firewall path, and Dropbear still accepting the vendor's default password. Whether the flag was omitted or the effective deployment settings differed, the lesson is to inspect the resulting instance instead of trusting intent or the command line.
That exposure was brief, but brief is not harmless. Rotate defaults immediately, use SSH keys rather than password authentication where possible, and explicitly inspect both IPv4 and IPv6 firewall rules. Always verify immediately after creation:
gcloud compute instances describe VM --zone=Z --format="value(networkInterfaces[0].accessConfigs[0].natIP)"
Don't trust the flag; check the result.
Trap 4: no egress, and Tailscale status misled me
This one cost the most time, and the symptom is genuinely misleading.
Removing the external IPv4 broke everything. tailscale status on the peer still showed the node as active, with tx/rx counters. But:
$ tailscale ping 100.x.y.z
ping "100.x.y.z" timed out
active in the tailnet is not proof of reachability. The node's tailscaled was registered with the coordination server through a stale path and kept reporting itself; data packets had nowhere to go. When you are debugging, an actual end-to-end probe is better evidence of reachability than a status label.
Two separate causes stacked here:
4a. The VM was on the wrong VPC
I created it on default, which is IPv4-only. The working VM was on a custom VPC with stackType: IPV4_IPV6. Same symptom, different root cause. Compare against a VM that already works before creating a new one — one describe would have saved an hour.
4b. GCE expects DHCPv6; my DietPi network configuration did not complete it
With the right VPC and an external IPv6 range assigned, the interface still had only a link-local address:
$ ip -6 addr show eth0
inet6 fe80::4001:aff:fe14:3/64 scope link proto kernel_ll
Google assigns a /96 IPv6 range to an interface, with the first address delivered using DHCPv6. The metadata server also provides router advertisements for the default route. My DietPi image, using ifupdown, did not complete that configuration automatically, while the earlier Debian install had handled it. This is an issue with the guest configuration, not proof that GCE lacks DHCPv6 or router advertisements. See the official IPv6 setup documentation.
The workaround I used, in /etc/network/interfaces.d/eth0.conf, with the address and gateway obtained from the actual instance configuration (the values below are placeholders):
allow-hotplug eth0
iface eth0 inet dhcp
iface eth0 inet6 static
address 2600:1900:4000:XXXX:0:0:0:0
netmask 96
gateway fe80::4001:aff:fe14:1
Validate before rebooting — ifupdown can still boot you into a networkless machine. Have a recovery path before editing remote network configuration:
ifquery eth0 # exit 0 and the parsed values mean the syntax is good
Then reboot for real. A systemctl restart networking is not the same test; the bug only shows on a cold boot.
Trap 5: IPv6-only egress cannot reach IPv4-only destinations
Getting IPv6 working is only half the story. The VM had no external IPv4 and no usable native IPv4 internet egress in this setup. (It still had an internal IPv4 address on its dual-stack VPC interface.) IPv4-only destinations remained out of reach.
The symptom looks exactly like a firewall problem:
$ curl -sSfL https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-unknown-linux-gnu.tar.gz
curl: (28) Failed to connect to github.com port 443 after 134331 ms: Could not connect to server
134 seconds of silence, then nothing. No connection refused and no DNS error: the hostname resolved, but our VM had no available native route to the IPv4 endpoint returned for that download.
The instinct is to go hunting through ip addr, ip route, and firewall rules. The explanation for this incident was one DNS query away: at the time of the test, github.com returned no usable AAAA record for the VM. DNS records and endpoint support change, so repeat the check rather than assuming the same answer forever.
$ getent ahostsv6 github.com
# empty
I did not trust the local resolver, so I asked a public one too:
curl -sS -H 'accept: application/dns-json' \
"https://cloudflare-dns.com/dns-query?name=github.com&type=AAAA"
# {"Status":0,"Answer":null} -> no AAAA
These were the hostname results captured during the incident, not a permanent current-status table:
| Host | AAAA |
|---|---|
| github.com | no |
| api.github.com | no |
| objects.githubusercontent.com | no |
| release-assets.githubusercontent.com | no |
| raw.githubusercontent.com | yes |
| github-releases.githubusercontent.com | yes |
| pypi.org, files.pythonhosted.org | yes |
| deb.debian.org, astral.sh | yes |
In these tests, PyPI and the Debian mirrors worked over native IPv6 while the tested GitHub release URL did not. That is a snapshot of this environment, not an eternal rule for every GitHub hostname. A network configuration cannot conjure an IPv6 endpoint where one is unavailable.
The fix: a proxy on a dual-stack peer, not an exit node
Two options, and I tried the reasoning for both.
Exit node. Route everything through a Tailscale peer that has IPv4. It works — and I rejected it. It sends 100% of the traffic through a home connection, including the PyPI and Debian traffic that is already fine over native IPv6. It adds latency to everything, and it turns that peer into a single point of failure for the entire VM: one machine reboots and the server loses the internet.
A proxy for only what needs it. What I built: a small HTTP CONNECT proxy on a dual-stack machine in the same tailnet, bound only to the Tailscale interface. Traffic splits in two:
┌──────────────────────┐
pypi.org ───► │ │ native IPv6 — direct
deb.debian.org │ marine (IPv6-only) │
│ │
github.com ───► │ apt / curl ────────┼──► dual-stack peer ──► IPv4 internet
(IPv4-only) └──────────────────────┘ 100.x.y.z:3128
The proxy itself is a single constant — the rest is deployment:
BIND = ("100.x.y.z", 3128) # Tailscale IP, never 0.0.0.0
with a systemd unit so it comes back after a reboot:
[Unit]
Description=CONNECT proxy for the IPv6-only VM
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/marine-proxy/proxy.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Binding to the Tailscale address rather than 0.0.0.0 is not a detail. An open proxy on a LAN is a gift to whoever scans it first; the tailnet is already the trust boundary.
On the VM side, the proxy has to be visible to processes that never read a shell profile — apt, systemd units, installer scripts:
# /etc/apt/apt.conf.d/99proxy
Acquire::http::Proxy "http://100.x.y.z:3128";
Acquire::https::Proxy "http://100.x.y.z:3128";
And because DietPi's installer shells out to curl from a non-interactive process that inherits neither /etc/profile.d nor /etc/environment:
# /root/.curlrc
proxy = "http://100.x.y.z:3128"
noproxy = "localhost,127.0.0.1,::1,100.64.0.0/10,.ts.net,.debian.org,pypi.org,.pythonhosted.org"
/root/.curlrc is read by curl itself when it runs as root, even if the calling shell did not load interactive profiles. The noproxy half is the important half for curl — without it, even IPv6-ready traffic goes to the proxy. Note that this .curlrc does not set bypass rules for APT: the APT proxy configuration above applies separately, and per-host APT exceptions require their own settings.
100.64.0.0/10 in noproxy is not optional — it is the Tailscale CGNAT range. Omit it and the proxy tries to reach the tailnet through itself.
The result, measured on the VM:
| Destination | Route | Result |
|---|---|---|
| pypi.org | direct, native IPv6 | 141 MB/s |
| github.com | through proxy | 200 |
| GitHub release tarball | through proxy | 16 MB in 18s (was a 134s timeout) |
The final proxy setup at a glance
| Piece | Where | What it does |
|---|---|---|
| proxy.py + systemd unit | dual-stack peer | HTTP CONNECT on 100.x.y.z:3128, bound to the tailnet address only |
| /etc/apt/apt.conf.d/99proxy | marine | APT sends http/https through the proxy |
| /root/.curlrc | marine | curl as root uses the proxy, with noproxy for the tailnet and IPv6-ready hosts |
| /etc/apt/apt.conf.d/99force-ipv6 | marine | deleted — forcing a family breaks the proxy connection |
| /etc/gai.conf (optional) | marine | prefer IPv6 through precedence, without forbidding IPv4 |
That last row is the whole next section in one line.
The trap inside the fix: prefer IPv6, never force it
Earlier, when apt kept stalling on IPv4, I had "fixed" it with:
# /etc/apt/apt.conf.d/99force-ipv6
Acquire::ForceIPv6 "true";
That was correct at the time and became wrong the moment the proxy existed:
Err:1 https://deb.debian.org/debian trixie InRelease
Something wicked happened resolving '100.x.y.z:3128'
(-9 - Address family for hostname not supported)
ForceIPv6 applies to the proxy connection as well, and the proxy is an IPv4 literal. Forcing a family is a landmine; preferring one is fine. For processes using glibc address selection, /etc/gai.conf can adjust precedence without globally forbidding IPv4. Test it against the applications you actually use:
precedence ::ffff:0:0/96 10
Delete the ForceIPv6 file. Prefer, never force.
The counterintuitive part: IPv6 is free, IPv4 is not
I initially assumed the external IPv6 was costing money and estimated ~$3.65/month. That was wrong, and the documentation says so in a sentence I read past:
> "You are not charged for external IPv6 address ranges that are assigned to subnets or for external IPv6 addresses that are assigned to VM instances."
The $0.005/hour line in the same table is for IPv4:
> "Static and ephemeral IP addresses in use on standard VM instances — $0.005 / 1 hour"
> Free tier: limited to one hour per month.
So on a free-tier VM:
| Address configuration | Address charge | Caveat |
|---|---|---|
| External IPv6 on a VM | $0 for the address | Data transfer has separate pricing and limits |
| External IPv4 on a VM | Charged hourly in normal use | Both static and ephemeral IPv4 can be charged |
| No external IP address | No external-address charge | Internet egress needs another path |
In this design, external IPv6 gave the VM outbound connectivity without paying for a public IPv4 address. It did not make all outbound traffic unlimited or free. See Google Cloud VPC pricing.
Cloud NAT is another way to provide IPv4 egress without a public IPv4 on the VM, but it has its own charges; verify the pricing before treating it as a Free Tier component.
What the image actually carries — and what I installed myself
The VM was provisioned from the DietPi Native PC image, built for bare metal. It does ship support for hardware a cloud VM will never have — but less than my first pass at this audit claimed, and the correction is the more useful lesson.
I went back to the package manifest of the image as imported, before anything was added to it. What it actually carries:
| Shipped by the image | Weight | Present on the VM |
|---|---|---|
| firmware-iwlwifi (Intel) | 111 MB | no wireless adapter |
| firmware-atheros | 95 MB | no wireless adapter |
| firmware-mediatek | 45 MB | no wireless adapter |
| firmware-realtek, firmware-brcm80211 and two smaller ones | 38 MB | no wireless adapter |
| Wi-Fi firmware, total | 288 MB | 41% of the 698 MB base system |
That is the vendor's real waste: 288 MB of firmware for three wireless vendors on a machine with no radio. It is removable with no loss of function, and it is the part of this cleanup I would apply to any DietPi cloud VM on sight.
Everything else that looked like image bloat was mine. Docker, the LEMP stack, Mesa/libllvm19, intel-microcode and ffmpeg were not in the imported image — they were installed on this box afterwards and then forgotten. An earlier version of this audit listed them as things "the image carries", and that was wrong. It changes the conclusion: the expensive part was not the vendor's defaults, it was what I had installed myself and never used.
So the cleanup had two different targets, and only one of them is a default-image problem:
- Vendor defaults — 288 MB of Wi-Fi firmware for hardware that is not there.
- My own installs — Docker with zero containers, a LEMP stack with no application, a graphics stack with no GPU. Reversible, but self-inflicted.
The starting point, counting both: 3.8 GB used of 9.8 GB (40%) and 13 running systemd units.
After removing what would never run — measured at the end of the cleanup run:
| | Before | After |
|---|---:|---:|
| Disk used | 3.8 GB (40%) | 2.7 GB (28%) |
| Running systemd units | 13 | 7 |
With ~721 MB of MemAvailable on a 964 MB machine, up from ~656 MB.
I am deliberately not quoting a package count here. The cleanup report I started from claimed 526 → 452, and when I went back to check it against the dpkg manifests, no snapshot on the box ever held 526 packages: the imported image had 223, the snapshot from the night before had 249. The disk and memory numbers are real — they come from df and free captured inside the run. The package count was not, and I would rather drop it than publish a number I cannot reproduce.
Those numbers describe the box at 12:21 that day. They are a snapshot, not a permanent state — part of the LEMP stack was reinstalled afterwards, on purpose, and the count moved again.
Two traps in that cleanup are worth more than the numbers themselves.
The dependency you cannot see
This one did not bite me on this box — I never had ffmpeg installed here, and I checked. But it is the check I now run before any purge, because the chain is real:
ffmpeg -> libavfilter10 -> libflite1
libflite1 is a speech-synthesis engine. On a headless server it looks like an obvious orphan. ffmpeg uses the flite filter to generate synthesized audio, so it depends on libavfilter, which depends on libflite1. Purging the speech library takes the video tool with it.
The way to see it before you commit is the simulation, and it only works if you read the lines you did not ask for:
apt-get -s purge libflite1 | grep '^Purg'
# Purg ffmpeg libavdevice61 libavfilter10 libflite1
Three names appear that were never in the list you typed. A dependency tree is not the list of names you chose; it is the list of names you are about to lose. The summary line tells you how many; only the Purg lines tell you which.
autoremove goes further than the plan
After the explicit purge, apt-get autoremove --purge removed packages that were not on my list — among them rsync and nftables. Both had been installed as dependencies and became orphans. I only noticed rsync was gone when a later install pulled it back in, hours afterwards; nftables I never reinstalled.
The impact is low — everything here reinstalls in one command — but the lesson is structural. autoremove is where a cleanup plan loses control. apt tells you how many packages it will remove, never which. Either read the full list, or mark what must survive before running it:
apt-mark manual rsync lsof socat nftables
Building a budget-triggered billing kill switch
Here is the part that surprises people: a standard GCP budget alert does not enforce a spending cap. It can email or publish notifications, but usage continues unless a separate action is taken. Google documents a possible automation to disable billing; it also warns of reporting delays.
To add an automated shutoff attempt, I chained three pieces:
1. A budget with a pubsubTopic in notificationsRule
2. A Cloud Function subscribed to that topic
3. A service account allowed to call projects.updateBillingInfo with an empty billingAccountName
Cloud Billing
│
▼
Budget: R$5 · alerts at 50 / 90 / 100%
│ notificationsRule.pubsubTopic
▼
Pub/Sub topic ──► Eventarc ──► Cloud Function (gen2)
│
│ costAmount / budgetAmount >= 1.0 ?
▼
projects.updateBillingInfo
billingAccountName = ""
Unlinking the billing account can stop billable services, but the trigger is not instantaneous. Charges incurred before notifications arrive may still post afterward. This is a safety mechanism, not a contractual hard spending cap. See Google’s own limitations.
The core of the function is one API call:
def disable_billing(project_id):
client = _billing_client()
name = f"projects/{project_id}"
current = client.projects().getBillingInfo(name=name).execute()
if not current.get("billingEnabled"):
return "already_disabled"
client.projects().updateBillingInfo(
name=name, body={"billingAccountName": ""}
).execute()
return "disabled"
Four things that bit me building it
The budget currency must match the billing account currency. Creating a USD budget on a BRL account returns INVALID_ARGUMENT with no useful detail. Read currencyCode from the billing account first.
pubsubTopic: "" is also INVALID_ARGUMENT. Omit the field entirely if you want email-only notifications.
ADC needs a quota project for the budgets API. A curl with a valid bearer token returns:
403: "Your application is authenticating by using local Application Default Credentials.
The billingbudgets.googleapis.com API requires a quota project"
Add the header x-goog-user-project: your-project.
Cloud Run returns 200 even when your function crashes. The gen2 runtime wraps your entry point in the background signature, so it calls function(event, context) — two arguments. I wrote def handler(cloud_event), and the only evidence was buried in stdout:
TypeError: budget_guard() takes 1 positional argument but 2 were given
while the high-level HTTP log line I had inspected said 200. Do not use an HTTP status alone to prove that the intended handler ran successfully. Read the function/container logs:
gcloud logging read 'resource.labels.service_name=MY_FN AND logName:"stdout"' --limit=20
Also: logging.info() did not appear in stdout for me. print(..., flush=True) did.
And one IAM detail: the Eventarc trigger invokes Cloud Run as a service account, and Cloud Run rejects it unless that account holds roles/run.invoker on the service. The trigger exists and looks healthy either way.
Testing without detonating
I tested only the safe branch — publish a message with a cost below the threshold and confirm the function logs its decision and stops:
[budget-guard] spend 1.50 of 27.00 (5.6%) — target project: my-project
[budget-guard] below the 100% threshold. No action taken.
The numbers in that test payload are arbitrary — I chose them to produce a ratio below 1.0, not to match the live budget. What the test proves is the branch, not the amounts.
Testing the real path would have interrupted the VM. The non-destructive test confirmed the safe branch; code review gave us partial confidence in the destructive branch, which remained untested end-to-end. A stronger approach is to test the destructive branch against a disposable project, not production.
Two design choices worth copying:
- Hardcode the target project in an env var. Never infer it from the Pub/Sub payload — a malformed message should not be able to shut down the wrong project.
- Abort if budgetAmount is missing or zero. A division by zero or a bad payload must fail closed, not open.
Proving the $0 bill: the Marine billing audit
This was the part that mattered most to me. I did not want to publish "free VPS" merely because the Compute Engine Free Tier page said e2-micro was eligible. A real project can still accumulate charges for surrounding services.
We audited a Google Cloud project (id withheld), covering October 1–24, 2026, using its Cloud Billing reports. A read-only review compared the service totals, individual SKUs, savings, and available promotional balances. The audit's observed outcome was R$0.00 in net usage cost for the project during that period.
> On the figures: the amounts below are the values Cloud Billing actually reported for the audited window, rounded to centavos the way the console displays them. They are small — cents, not dollars — and that is the point: the gross usage is real, the net is zero.
| Reported SKU or service | Gross usage | Savings / offsets | Net usage cost |
|---|---:|---:|---:|
| Network Analyzer Resource Hours | R$0.12 | −R$0.12 | R$0.00 |
| Internet to Google Cloud Performance Resource Hours | R$0.09 | −R$0.09 | R$0.00 |
| Topology and Google Cloud Performance Resource Hours | R$0.12 | −R$0.12 | R$0.00 |
| Cloud Run Functions CPU (us-central1; 124.72 vCPU-seconds) | R$0.02 | −R$0.02 | R$0.00 |
| Network Intelligence rounding / minor residual | ≈R$0.01 | ≈−R$0.01 | R$0.00 |
| Reported total | ≈R$0.36 | ≈−R$0.36 | R$0.00 |
The displayed SKU rows are rounded to centavos, hence the small difference between their sum and the reported service total.
Why gross usage showed up at all
Google explicitly documents a 100% discount for Network Analyzer, Network Topology, and Performance Dashboard. Their list-price usage can appear in billing details even when the final charge is zero. That is precisely why hiding or deleting network monitoring would have been the wrong first response. Official Network Intelligence Center pricing.
Cloud Run's Free Tier can also appear as gross compute usage followed by credits or savings. The 124.72 vCPU-seconds observed for this budget-related function were in us-central1 and showed R$0.00 net in the reviewed report. Official Cloud Run pricing.
What the audit confirmed — and what it did not
The comparison found no net charge on these lines, and no identifiable material drawdown of the Developer Program or other promotional balances attributable to them. The developer-credit balance was essentially unchanged; a roughly R$0.01 movement elsewhere in the balance display was too small and insufficiently attributed to use as proof either way.
There was one remaining limitation: the console grouped these abatements under "other savings" without conclusively exposing credit.type and credit.name for every current-period SKU. We could verify which resource accrued gross usage, and that the usage was fully offset, but not exactly which credit record paid for each centavo in the underlying billing export.
So the defensible claim is not "Google showed zero gross usage" or "we traced every cent of credit attribution." It is: the project's observed net usage cost was R$0.00 for the audited period; the relevant Network Intelligence Center services have a documented 100% discount; and the reviewed balances did not show a meaningful promotional-credit debit tied to those lines. A final attribution audit would require SKU-level credit.type / credit.name details, for example through detailed Cloud Billing export if enabled.
For anyone reproducing this, visit Cloud Billing → Reports, filter by project and date, group by SKU, and compare Usage cost, Other savings, and Subtotal. Then repeat the view with Promotional credits excluded. Google documents these filters in View and analyze savings.
What "$0" does not promise
Google Cloud's always-free Compute Engine allowance is not unlimited. At the time of this audit it covered an e2-micro within the eligible US regions, 30 GB-month of standard persistent disk, and 1 GB/month of qualifying North American outbound data transfer, aggregated under the program rules. External IPv6 addresses assigned to VMs do not carry an address charge, but outbound traffic, additional disks, custom images, snapshots, and adjacent services can have their own pricing. See Google Cloud Free Program, VPC pricing, and custom image storage pricing.
The audit found no separately identified nonzero net charge for the DietPi image, persistent storage, external IPv6, or data transfer in the reviewed window. That is a measurement of this period, not a promise about every future hour or every deployment. Verify your region, disk type, account-wide allowances, and future use; the project can become billable if conditions change.
In short: the Marine was a zero-net-cost VPS in the period we actually checked. The article is a blueprint for staying inside the free allowances — not an unlimited-use guarantee or a vendor billing warranty.
Final state
DietPi 10.8 / Debian 13 (trixie)
e2-micro, 964 MB RAM, 1.1 GB swap, 9.8 GB disk
Disk: 40% -> 28% Units: 13 -> 7 (at the end of the cleanup run)
External IPv4: none
External IPv6: yes (free)
SSH: Dropbear, restricted to the tailnet in tested setup; migrate to key-only auth
Firewall: verify effective IPv4 **and** IPv6 ingress policies; management via tailnet
IPv4-only destinations: CONNECT proxy over the tailnet, 100.64.0.0/10 excluded
IPv6-capable destinations: native, direct
Observed net GCP usage cost (1–24 Oct 2026): R$ 0.00 after savings
Plus a R$5 budget with alerts at 50/90/100% and a function intended to unlink billing if the threshold is reached. The controller is useful defense in depth; it does not guarantee zero future bills.
What I'd do differently
1. Read the conversion script before running it — four greps would have saved the whole rebuild.
2. describe a working VM before creating a new one. Network stack, image flags, disk type. Copy the working configuration instead of reconstructing it.
3. Treat tailscale status as a hint and tailscale ping as evidence.
4. Verify the external IP after creation, don't trust --no-address.
5. Check the pricing page's exceptions, not just its table. "You are not charged for..." sentences are easy to skip and change the answer completely.
6. Check the destination's AAAA before touching the network config. In this case, the 134-second timeout was about an IPv4-only destination, not a missing firewall rule.
7. Prefer a family, do not force it globally. ForceIPv6 broke APT once the proxy was configured with an IPv4 literal.
8. Test an installer's validation regex against the example the installer displays. If the example fails, the bug is not in your typing.
9. Read the Purg lines of a simulation, not its summary. The summary says how many packages will go; only the list says which ones — and a dependency tree can hide a tool you meant to keep behind a library you never think about.
10. Review removals and mark essential packages as manual before autoremove. Orphaned dependencies are not necessarily junk.
The traps were not exotic. They were hiding in a script header, cloud networking docs, a pricing footnote, function logs, a DNS answer, and finally a billing report. The lesson is not "GCP is hard"; it is that the evidence was often one command or one billing filter away, and I trusted the convenient summary instead of verifying the underlying data.
The one I keep thinking about is still the DNS check. I spent time inspecting routes and firewall rules for a destination that was unavailable over IPv6 in that test. getent ahostsv6 github.com was the useful next command. It took one second to run and an hour to think of.
The billing report delivered the same lesson in a different form: a cost line is not the same as a charge, and a zero net bill is not proof that every underlying credit was individually identified.
Appendix: four dietpi-dashboard installer bugs
dietpi-dashboard was the first service I put on the VM, and its setup dialog fought IPv6 the whole way. Four bugs, all in the installer:
1. The address validator cannot accept IPv6. The regex in dietpi-software:3778 ends in an alphanumeric class, so it takes a hostname or an IPv4 address — never a colon. The dialog's own example (fe80::4001:aff:fe14:1:5253) is rejected by the validator displaying it.
2. The two validators disagree. The regex accepts hostnames, but the binary is Rust and parses the field as SocketAddr, so a hostname passes the dialog and dies at startup with invalid socket address syntax for key frontend_addr. What works is a bracketed IPv6 literal — rejected by the regex, so it has to be set by editing config-backend.toml.
3. The 64-character secret is never checked against the frontend's. With mismatched secrets the network is correct, the port is open, the service reports active, and the handshake fails silently.
4. The installer overwrites secret with whatever was typed, with no validation at all. Mine ended up as an 8-character string and broke the config after a reboot.
One diagnostic trap belongs with them: the backend runs with no listening port on 5252 or 5253, which looks like a broken config and is not. In a backend-client architecture the node dials out and the frontend listens, so a node with no open port is correct behaviour. Confirm it from the frontend host instead:
ss -tnp | grep 5253
# ESTAB [fd7a:...:9c5d]:5253 [fd7a:...:b278]:45650 users:(("frontend",...))
The practical rule: after the installer finishes, verify the config file instead of trusting the dialog, and diff the secret between hosts before starting the backend.
1 reaction · 2 comments
Mentions
uvの--lockedと--frozenを検証する:CIの同期成功は何を保証するか uv sync --frozenが成功しても、pyproject.tomlへ追加した依存が入っているとは限りません。 小さなプロジェクトでlockfileの更新漏れを作ったところ、--lockedはエラーで止まり、--frozenは成功しました。ただし後者では、新しく追加したidnaをimportできる状態になっていませんでした。 この記事では、この差を再現し、CI(変更のたびにテストなどを実行する仕組み)で何を検証したいかから指定を選びます。実測環境はLinux、uv 0.11.19、Python 3.12.3です。GitHub Actions自体は実行していません。 lockfileが一致していない状態を作る 既存プロジェクトを壊さないよう、空の検証用ディレクトリで行います。uvと、次の条件を満たすPythonが使えることが前提です。 最初のpyproject.tomlは次の内容です。ビルド設定を持たない小さな検証用プロジェクトで、アプリの配布は行いません。 [project] name = "uv-ci-example" version = "0.1.0" requires-python = ">=3.12,<3.13" dependencies = ["packaging>=24,<26"] [dependency-groups] dev = ["pytest>=8,<9"] 比較対象のPython系統をそろえてから、最初のlockを作る uv python pin 3.12 uv lock uv sync --locked ここではpyproject.tomlとuv.lockが整合しています。lockfileは、解決済みの依存関係を記録したファイルです。pyproject.tomlが求める条件と、実際に使う解決結果を別々に扱えるようになります。uvのプロジェクト構成 続いて、pyproject.tomlのdependencies行だけを次へ変更します。 lock更新漏れを再現するため、この段階ではuv addを使わない dependencies = ["packaging>=24,<26", "idna==3.10"] この行は既存の[project]内にある行を置き換えるものです。別の場所へ追記しないでください。通常の依存追加にはuv addを使えますが、今回は意図的にuv.lockを更新しない状態を作っています。uvの依存関係管理 --lockedでは更新漏れを検出できた 設定とlockの不整合を検出できるか確認する uv sync --locked 実測では終了コード1になり、lockfileの更新が必要なのに--lockedが指定されている、というエラーになりました。uv.lockの内容は変更されていません。 これは想定した失敗です。CIに「コミットされた依存設定を、そのlockfileで扱えるか」を検証させたいなら、この段階で止まることに意味があります。 --frozenは成功したが、追加依存は入らなかった 同じ状態のまま、今度は次を実行しました。 不整合な設定を保ったまま、既存lockを使った場合を比較する uv sync --frozen 通常のuv runによる自動更新を避け、未導入の状態を調べる uv run --frozen python -c "import importlib.util; assert importlib.util.find_spec('idna') is None" 両コマンドとも終了コード0でした。2つ目のコマンドが成功したことは、この環境でidnaが見つからなかったことを意味します。uv.lockも変更されていません。 確認時にuv runへ--frozenを付けたのは、観測中にlockや環境が自動更新されるのを避けるためです。通常のuv runには、コマンドの起動前に必要なlockと同期を行う動作があります。uvのlockと同期 2つの指定は、確認する条件が違う 図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。 操作 今回の不整合への動作 成功から読み取れること uv sync --locked 更新が必要なので停止 成功した場合、uvの判定でlockが現在の設定と整合している uv sync --frozen 整合性確認を省き、古いlockで同期 選ばれたlockと同期対象のグループで環境を作れた。現在の設定との整合性は別途確認が必要 uv lockの後にuv sync --locked lockを更新してから同期 更新した解決結果を使って環境を作れる --frozenは「より厳しい固定」という意味ではなく、lockfileの最新性確認を省く指定です。仕様はLocking and syncingに記載されています。 また、--lockedもファイルの文字列が完全一致するかを検査するものではありません。たとえば依存の条件を変えても、既存lockが引き続き条件を満たす場合があります。新しいパッケージ版が公開されたことだけで、lockが不整合になるわけでもありません。 このため、通常のCIでは--lockedを出発点にし、別工程で整合性を保証しているなどの理由がある場合に--frozenを選ぶ、という整理ができます。 更新する工程と、検証する工程を分ける 先ほどの検証用プロジェクトでは、次の操作で不整合を解消できます。 依存変更をlockへ反映し、差分をレビューできる状態にする uv lock uv sync --locked 更新後には追加した依存が見つかることを確認する uv run --locked python -c "import importlib.util; assert importlib.util.find_spec('idna') is not None" CIへ渡すときは、pyproject.tomlとuv.lockの両方をGitで管理します。通常のテスト実行中にlockを作り直すより、依存を変更する作業で更新し、その差分を確認してからCIへ渡す方が、何をテストしたかを追いやすくなります。 pytestをdevグループに登録したプロジェクトなら、uvとPythonの準備後のテスト部分は次のようになります。これはjobs.<job_id>.steps内の断片です。 - name: Sync dependencies CI内の暗黙のlock更新で、更新漏れが隠れるのを防ぐ run: uv sync --locked - name: Run tests テスト起動時にも同じ整合性条件を保つ run: uv run --locked pytest -q uv runにも--lockedを付けることで、実行直前の自動更新に頼らず検証します。tests/にはプロジェクトのテストを用意してください。上の依存設定と同期だけでは、テストケースは生成されません。uvのGitHub Actions連携 lockfileだけで実行環境全体が固定されるわけではない 図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。 ローカル検証では、pytest 8.4.2とpackaging 25.0を導入し、バージョンの順序を比較するテスト3件を実行しました。検証用の.venvを削除して再同期した後も、同じ3件が成功しました。依存追加後にlockを更新した後も成功しています。 ただし、これはGitHubのrunner(ジョブを実行するマシン)でも同じ挙動になることをすべて確認した結果ではありません。 対象 そろえる方法 注意点 依存関係 uv.lockを共有し、--lockedで同期 OSやPython条件により導入対象が変わり得る uv本体 setup-uvのversion入力などで指定 Actionの版だけではuv本体まで固定されない Python CIと手元で同じ版を選択 3.12指定ではパッチ版まで一致するとは限らない OS 対象runnerを選ぶ 同じOSラベルでもイメージ内部は更新される Pythonの選択とuv本体の導入方法は、uvのPythonバージョン管理とsetup-uvの入力設定を参照してください。 キャッシュヒットでもsyncは必要 キャッシュは取得・ビルド済みの成果物を再利用する仕組みです。今回必要なパッケージを決めるlockfileや、その内容から環境を作る同期処理の代わりにはなりません。uvのキャッシュ キャッシュが復元できてもuv sync --lockedは実行し、今回の環境を整えます。逆にキャッシュがない初回でも、必要なパッケージを取得できればテストまで進める構成にします。 私は、CIでの再現性は「固定するファイルを増やす」だけでなく、どの不整合をエラーにするかを決めることが大事だと考えています。今回の例では、--frozenで同期できたことより、--lockedが更新漏れを検出したことの方が、依存変更をレビューする目的に合っていました。 公式資料の確認日は2026年9月11日です。ローカルでの同期・不整合検出・テストを確認し、GitHub Actionsのイベント発火、Actionの実行、キャッシュ復元と速度は未検証として区別しています。 uvのプロジェクト管理へ移るところから確認したい場合は、uvとpip・venvの違いと移行手順も参照してください。 GitHub Actionsへ導入するためのworkflow全体と、Python・uvの固定、キャッシュ、サブディレクトリの設定は、uvをGitHub Actionsで使う方法にまとめています。
@thsottiaux Yes ruff#29852
Tinyworldformer corre en un Pico 2W de $6 con 520 KB de RAM Un microcontrolador de 6 dólares acaba de ejecutar, en tiempo real, un modelo de mundo comprimido que inventa videojuegos cuadro por cuadro. El proyecto tinyworldformer corre dentro de 520 KB de RAM en una Raspberry Pi Pico 2W, sin GPU, sin nube y sin conexión a internet. Su autor lo logró separando el trabajo en dos redes neuronales por fotograma. Una genera el siguiente cuadro del juego, la otra decide qué botón presionar, y ambas corren a la vez sobre el mismo chip. TL;DR - Int8 y pesos compartidos reducen el generador de frames a entre 26.000 y 131.000 parámetros según el juego.- Un bloque transformer aplicado de 1 a 4 veces sustituye a una pila de capas distintas y ahorra memoria.- La red actora, separada del generador, pesa solo entre 9.000 y 16.000 parámetros en float32.- El RP2350 reparte el trabajo: el núcleo 1 corre ambas redes, el núcleo 0 atiende USB y pantalla.- El repositorio SheepSeb/tinyworldformer deja jugar los cinco juegos reales desde la terminal con uv. ¿Qué es un modelo de mundo comprimido? Un modelo de mundo comprimido es una red neuronal generativa reducida en tamaño y precisión para correr sin GPU: tinyworldformer la implementa como un transformer en bucle cuantizado a 8 bits que predice el siguiente fotograma de un videojuego y cabe en los 520 KB de RAM de un microcontrolador de 6 dólares. La idea de partida de su autor fue simple. Los transformers en bucle reutilizan el mismo bloque de pesos varias veces en lugar de apilar capas distintas, así que ocupan mucha menos memoria por diseño. Si ese ahorro era suficiente, un modelo generativo completo podía caber en un chip de bolsillo. Por qué importa La mayoría de los modelos generativos de video o de juegos dependen de una GPU con varios gigabytes de memoria. Tinyworldformer invierte esa ecuación: corre en un chip pensado para termostatos y drones de juguete. Eso importa para el campo conocido como edge computing, donde el objetivo es mover la inferencia al dispositivo en lugar de depender de un servidor remoto. Un modelo que corre en el borde no necesita conexión a internet, no paga latencia de red y no expone datos a un servidor externo. Acá el beneficio es además una prueba de concepto. Si una red generativa cabe en 520 KB, la pregunta deja de ser si el edge AI puede generar contenido y pasa a ser cuánto contenido puede generar con ese presupuesto de memoria. El ángulo de costo pesa tanto como el técnico. Una Raspberry Pi Pico 2W cuesta una fracción de lo que cuesta cualquier GPU capaz de correr un transformer completo, y consume órdenes de magnitud menos energía porque no tiene memoria de video ni refrigeración activa. Para proyectos de hobby, educación o investigación en TinyML, esa diferencia de costo decide si un experimento se hace o no. Cómo funciona tinyworldformer por dentro Tinyworldformer separa el problema en dos redes con trabajos distintos. La primera, el generador de frames, recibe el cuadro actual y la acción del jugador y predice el cuadro siguiente junto con la recompensa. La segunda, la red actora, solo mira el cuadro y decide qué acción tomar. Ninguna de las dos ve jamás el juego real una vez que arranca la sesión: el generador solo recibe acciones de la actora, y la actora solo ve cuadros inventados por el generador. sequenceDiagram participant Actor as Red actora participant Generador as Generador de frames Actor->>Generador: envia accion Generador-->>Actor: entrega frame generado y recompensa Note over Actor,Generador: el generador nunca ve el juego real Tokenización en parches Antes de entrar al transformer, cada cuadro se recorta en parches cuadrados: 2x2 celdas en Breakout, 4x4 en Doom-lite, Flappy Bird y Pong, 6x6 en Pac-Man lite. Eso da entre 36 y 80 tokens por juego según el repositorio. Cada token suma un vector aprendido por celda, con una tabla distinta según la posición dentro del parche para no perder el orden, más un vector de posición y un vector de la acción tomada. Un ejemplo simplificado en Python muestra la misma idea con un cuadro diminuto de 4x4 dividido en parches de 2x2: import numpy as np frame = np.array([ [1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4], ]) def a_parches(frame, parche=2): h, w = frame.shape tokens = [] for i in range(0, h, parche): for j in range(0, w, parche): tokens.append(frame[i:i+parche, j:j+parche].flatten()) return np.array(tokens) print(a_parches(frame)) La salida literal es una matriz de 4 tokens, uno por cada región homogénea del cuadro: [[1 1 1 1] [2 2 2 2] [3 3 3 3] [4 4 4 4]] El transformer en bucle En lugar de apilar, por ejemplo, ocho bloques transformer distintos, tinyworldformer usa un único bloque (atención más una red feedforward con ReLU) y lo aplica entre 1 y 4 veces seguidas, sumando la entrada original antes de cada pasada. El resultado de cada pasada se entrena contra el cuadro siguiente real, así que el número de pasadas en tiempo de ejecución es un control directo entre velocidad y precisión. En el Pico 2W, para mantener una tasa de cuadros razonable, el proyecto usa dos pasadas. flowchart TD A["Frame t + accion"] --> B["Tokenizar en parches"] B --> C["Bloque transformer (pesos compartidos)"] C --> D{"Repetir de 1 a 4 veces"} D -->|"otra pasada"| C D -->|"pasada final"| E["Frame t+1 y recompensa"] Cuantización y reparto entre núcleos El generador de frames se cuantiza a int8, mientras que la red actora se queda en float32 porque ya es pequeña de por sí, entre 9.000 y 16.000 parámetros, y la precisión ahí importa más para decidir bien la acción. El RP2350 del Pico 2W tiene dos núcleos: el núcleo 1 corre el generador y la actora, el núcleo 0 se encarga del USB y de la pantalla, según documenta el repositorio. Esa separación entre generador y actor permite optimizar cada red por separado. El generador, la parte pesada, recibe toda la cuantización, mientras que la actora, ya liviana, se queda intacta en float32. El RP2350 integra FPU, pieza clave para ejecutar redes cuantizadas. flowchart TD A["RP2350 dual-core"] --> B["Nucleo 0: USB y pantalla"] A --> C["Nucleo 1: inferencia"] subgraph Inferencia["Nucleo de inferencia"] C --> D["Generador de frames int8"] C --> E["Red actora float32"] end Ejemplos prácticos y cómo empezar El repositorio incluye cinco juegos reales escritos en unas pocas líneas de numpy, lo que le permite a su autor comparar cada frame generado contra la física real del juego. La siguiente tabla resume el costo de memoria y cómputo de cada uno, con datos publicados en el repositorio de tinyworldformer: JuegoTamaño de frameParámetrosMACs por frameFrames exactosBreakout20x1626k4.3M99.99%Doom-lite32x3255k4.2M83.6%Flappy Bird24x2446k2.0M100%Pong20x3246k2.3M99.45%Pac-Man lite48x48131k8.2M86.4% La columna de frames exactos mide el porcentaje de transiciones donde cada celda del cuadro predicho coincide con el cuadro real. Doom-lite tiene el número más bajo, 83.6%, pero no porque juegue mal: su vista en primera persona tiene 768 celdas, y un solo píxel de sombra mal pintado ya marca todo el cuadro como incorrecto en esta métrica. Cómo probarlo en tu máquina El proyecto usa uv como gestor de paquetes de Python, así que no hace falta instalar dependencias a mano una por una. En Linux o macOS: curl -LsSf https://astral.sh/uv/install.sh | sh git clone https://github.com/SheepSeb/tinyworldformer cd tinyworldformer uv run python -m tinyworldformer.games.play --game pacman En Windows, el único paso que cambia es la instalación de uv, desde PowerShell: powershell -c "irm https://astral.sh/uv/install.ps1 | iex", y luego los mismos comandos de git clone y uv run. El comando play acepta también breakout, doomlite, flappy o pong en lugar de pacman. No hay salida de texto que mostrar: uv descarga las dependencias declaradas en el pyproject.toml y abre una ventana gráfica con el juego real corriendo en numpy, el mismo que el generador de frames aprendió a imitar. > 💡 Tip: correr primero el juego real con play antes de buscar los pesos entrenados ayuda a entender qué está tratando de imitar el generador cuantizado, celda por celda. Casos de uso reales Tinyworldformer no apunta a reemplazar un motor de videojuegos. Su valor está en lo que demuestra: un modelo generativo entrenado sobre partidas grabadas puede, cuantizado y comprimido, correr en el mismo chip que hoy maneja sensores de temperatura o drones de juguete baratos. El caso de uso inmediato es educativo y de investigación en TinyML, pero la receta (tokenizar en parches, compartir pesos entre pasadas, cuantizar solo la parte más pesada) es trasladable a otros generadores pequeños, como un simulador de sensor para probar un controlador embebido sin hardware real conectado. El repositorio también suma un agente de refuerzo por streaming que aprende a jugar dentro del sueño generado por el propio modelo, en lugar de solo repetir una política ya entrenada. Cualquier botón del dispositivo le devuelve el control al usuario humano, así que la misma placa sirve para ver a la IA jugar sola o para jugar uno mismo dentro del mundo que ella inventa. Breakout necesita solo 26.000 parámetros; Pac-Man lite necesita 131.000. Errores comunes y buenas prácticas - Confundir precisión de píxel con jugabilidad. Un 83.6% de frames exactos suena mal hasta que se entiende la métrica: un solo píxel de sombra distinto ya cuenta como fallo total del cuadro, aunque el estado del juego (posición del enemigo, vida, puntaje) siga siendo correcto.- Cuantizar todo por igual. Aplicar int8 a la red actora, que ya tiene menos de 16.000 parámetros, aportaría poco ahorro de memoria y arriesgaría la precisión de la política. La cuantización rinde donde el modelo es grande, no donde ya es pequeño.- Elegir mal el número de pasadas del bucle. Una sola pasada del transformer es más rápida pero deja artefactos visibles, un enemigo que no debería estar ahí, por ejemplo. Cuatro pasadas corrigen casi todo el error pero cuestan más tiempo de cómputo por cuadro. Dos pasadas fue el punto que eligió el proyecto para el Pico 2W.- Verificar contra la física real, no solo mirar el video generado. Tener la versión real del juego en numpy, como hace este repositorio, es lo que permite medir el error cuadro por cuadro en lugar de confiar en que se vea bien. Comparativa con alternativas para generar contenido con IA EnfoqueCuándo usarloVentajaLimitaciónTransformer completo en GPU o nubeCuando la latencia de red no importa y hay presupuesto de cómputoMáxima fidelidad del frame generadoDepende de conexión e infraestructura externaModelo de mundo comprimido en microcontrolador (tinyworldformer)Dispositivos aislados, sin GPU, con RAM y flash muy limitadosCorre offline, sin latencia de red, consumo mínimoSolo viable en juegos o dominios muy pequeñosDistillation genérica a modelos pequeños (TinyML clásico)Tareas de clasificación o detección simples en sensoresHerramientas y runtimes madurosNo pensado para generar secuencias completas de frames La fila intermedia describe a tinyworldformer. No busca competir en fidelidad visual con un generador de video entrenado en GPU; busca que el juego siga siendo jugable dentro de un presupuesto de memoria minúsculo. Profundizando: por qué el bucle ahorra memoria y dónde se nota el límite La razón de fondo por la que un transformer en bucle pesa menos es aritmética simple: un transformer tradicional de N capas guarda N juegos de pesos distintos, mientras que el bucle guarda un solo juego de pesos y lo reusa N veces. El costo que se paga a cambio es tiempo de cómputo, cada pasada adicional cuesta los mismos multiplica-acumula que la anterior, en lugar de memoria, y en un microcontrolador la memoria suele ser el recurso más escaso de los dos. La cuantización int8 agrega un segundo ahorro, independiente del anterior: cada peso pasa de ocupar 4 bytes en float32 a ocupar 1 byte, a costa de redondear cada valor a uno de 256 niveles posibles. El siguiente ejemplo en Python muestra ese redondeo con cuatro pesos cualquiera: import numpy as np pesos = np.array([0.42, -0.87, 1.35, -1.90], dtype=np.float32) escala = np.max(np.abs(pesos)) / 127 cuantizados = np.round(pesos / escala).astype(np.int8) reconstruidos = cuantizados.astype(np.float32) * escala print(cuantizados) print(np.round(reconstruidos, 4)) La salida literal es: [ 28 -58 90 -127] [ 0.4189 -0.8678 1.3465 -1.9 ] El peso original de 1.90 se reconstruye exacto porque definió la escala; los otros tres cargan un pequeño error de redondeo. Ese error, multiplicado por miles de pesos y varias pasadas del bucle, ayuda a explicar por qué Pac-Man lite, el juego más complejo con 131.000 parámetros, tiene un porcentaje de frames exactos menor que Flappy Bird. No hay una forma directa de verificar desde afuera, sin acceso al checkpoint, cuánto de ese error viene de la cuantización y cuánto del tamaño del juego; lo más cercano que ofrece el propio repositorio es comparar el porcentaje de frames exactos por juego de la tabla anterior. El FPU del RP2350 importa en este punto. Sin una unidad de punto flotante en hardware, la dequantización, volver de int8 a valores reales antes de cada operación de atención, sería mucho más lenta, porque el chip tendría que emular esas operaciones en software. > 💭 Clave: el ahorro de memoria del bucle y el ahorro de la cuantización son independientes entre sí; tinyworldformer los combina, pero cada uno podría usarse por separado en otro proyecto. 📖 Resumen en Telegram: Ver resumen Tu próximo paso: cloná el repositorio, corré uv run python -m tinyworldformer.games.play --game breakout y leé el archivo de ese juego dentro de src/tinyworldformer/games/ para ver cuántas líneas de numpy hacen falta para una física jugable completa. Preguntas frecuentes ¿Qué microcontrolador corre el modelo de mundo comprimido de tinyworldformer? Una Raspberry Pi Pico 2W, construida sobre el chip RP2350, que tiene dos núcleos y una unidad de punto flotante. El núcleo 1 ejecuta el generador de frames y la red actora; el núcleo 0 atiende USB y la pantalla. ¿Cuántos parámetros tiene el transformer en bucle de cada juego? Entre 26.000 y 131.000, según la complejidad del juego: Breakout es el más liviano y Pac-Man lite el más pesado, de acuerdo con la tabla publicada en el repositorio. ¿Hace falta GPU para entrenar tinyworldformer antes de cuantizarlo? El repositorio no detalla el hardware exacto de entrenamiento, pero el generador de frames se entrena sobre partidas grabadas del juego real, un proceso que normalmente se hace fuera del microcontrolador y antes de cuantizar los pesos para desplegarlos en el Pico 2W. ¿Qué diferencia a la red actora de la IA embebida en microcontroladores que genera los frames? La red actora es una MLP mucho más chica, entre 9.000 y 16.000 parámetros, que se queda en float32 porque su tarea, decidir una acción a partir de un frame, tolera peor el error de cuantización que generar el frame en sí. ¿Se puede correr tinyworldformer en un microcontrolador distinto al Pico 2W? El repositorio está construido específicamente para el RP2350 del Pico 2W, incluyendo una carpeta en Rust para el firmware del dispositivo. Portarlo a otro chip exigiría al menos una FPU y memoria suficiente para los buffers de atención, pero el propio proyecto no documenta otros objetivos probados. Referencias - GitHub: SheepSeb/tinyworldformer: repositorio original con el código, los juegos en numpy y los datos de tamaño y precisión de cada modelo.- Raspberry Pi: fabricante oficial de la Raspberry Pi Pico 2W y el chip RP2350 usado en el proyecto.- Wikipedia: Edge computing: contexto sobre mover la inferencia de IA al dispositivo en lugar de a un servidor remoto.- Wikipedia: Quantization: fundamento matemático de la cuantización usada para pasar los pesos de float32 a int8. 📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
It depends who you trust :slight_smile: Astral/OpenAI build binaries at Python Build Standalone: astral-sh/python-build-standalone These are used by projects such as uv, Hatch and PDM.
> why this exists docs.basedpyright.com > The "type server protocol" is a desperate attempt from microsoft to prevent pylance from becoming obsolete by outsourcing its type checking to other language servers like basedpyright, ty or pyrefly -- while continuing to keep certain language server features exclusive to pylance. > This protocol benefits nobody except microsoft. It adds needless complexity to any language server that chooses to support it. Basedpyright does not support it and strongly advises other language servers to avoid supporting it as well. > For more info see here pylance-release#7180 and here ty#1190
DetachHead (maintainer of basedpyright) has criticized the TSP before: - docs.basedpyright.com - pylance-release#7180 Ty (the type checker maintained by astral) however will apparently support the TSP: ruff#23432
Rust Didn't Win Because It's Fast GitHub rewrote Copilot's runtime in Rust. OpenAI's Codex is now almost all Rust. Bun, the runtime Claude Code ships on, moved 535,496 lines of Zig to Rust in 11 days. But it isn't about speed: C has been fast for half a century. We fly inside a real laptop, down to the CPU die and the memory cells, to show what Rust actually changed, and why AI coding agents love it. 0:00 Everyone is rewriting in Rust 0:49 This is Sidequest Explained 1:00 Inside the machine: instructions, bytes, stack and heap 1:53 Way one: freeing memory by hand (C and C++) 2:23 An elevator crash started Rust 2:40 Way two: the garbage collector (Discord's spikes) 3:12 Way three: ownership 3:36 Checked before it runs, and Android's results 4:07 Close to the metal: 28 bytes vs 8 4:21 Copilot: 5.25 s to 292 ms 4:41 Why AI agents love the Rust compiler 5:11 Is Python dead? What it means for you Sources GitHub Blog, migrating the Copilot runtime to Rust (Sep 2026): github.blog Bun, Rewriting Bun in Rust (Jul 2026): bun.sh OpenAI Codex repository: openai/codex Codex native rewrite discussion: codex#1174 Grok Build repository: xai-org/grok-build Dennis Ritchie, The Development of the C Language: bell-labs.com Microsoft MSRC, 70% of security bugs are memory safety (2019): microsoft.com Chromium memory safety: chromium.org MIT Technology Review, Rust's origin (2023): technologyreview.com Rust 1.0 announcement: blog.rust-lang.org Discord, Why Discord is switching from Go to Rust (2020): discord.com The Rust Book, ownership and borrowing: doc.rust-lang.org Google, memory-safe languages in Android 13 (2022): security.googleblog.com Google, Rust in Android (Nov 2025): blog.google Python sys.getsizeof / CPython ints: docs.python.org Hugging Face tokenizers: huggingface/tokenizers OpenAI tiktoken: openai/tiktoken pydantic-core: pydantic/pydantic-core uv by Astral: astral-sh/uv Numbers are the sources' own. The Copilot timings are client + session + one-turn overhead in the opt-in in-process mode, not counting the model or network. Android's 1000× is memory-safety vulnerability density. Code on screen is an illustration, not a run. Credits Die micrographs: Fritzchens Fritz (CC0). Memory-cell micrograph (Micron MT4C1024) and SEM plate (1886VE10): ZeptoBars, CC BY 3.0. Office and hardware photos: Pexels. HDRIs and materials: Poly Haven, ambientCG (CC0). Music: "Lofi Study" by FASSounds (Pixabay). Rust, GitHub, Copilot, OpenAI, Codex, Bun, Claude, xAI, Grok, Microsoft, Chrome, Android, Discord, Python, Hugging Face, Astral, pydantic and other names and logos belong to their owners, used for identification and commentary; Sidequest is not affiliated with any of them. Narration: an AI clone of my own voice. Animation: original, built in three.js. #rust #programming #ai
uv Now Type-Checks Python with ty. How to Set Up uv check (Pin ty First) uv now has a type-check command. uv check syncs your project environment, downloads the ty type checker and runs it, with no config and no separate pip install. It also checks PEP 723 scripts against their own inline dependencies, which is the part that got me: your type checker has probably been checking those scripts against the wrong venv for years, flagging import requests as unresolved or silently using whatever happened to be installed. I've been running it and I like it. The "your laptop and CI use the same ty version" pitch needs a footnote, though, and one command fixes it. I'll go through projects first, then workspaces, scripts, VS Code, pre-commit and GitHub Actions. What uv check actually does Per the announcement from October 13, 2026, uv check prepares the project environment, installs its dependencies and checks the project with ty. You need uv 0.12.3 or later and ty 0.0.76 or later. ty itself gets downloaded the first time you run the command, so you don't install it yourself. It's a preview feature. Astral says so plainly, and if you never run uv check, nothing about your uv workflow changes. The CLI reference describes it as "Run checks on the project," and adds: "Currently, this type checks Python code using ty." There's also a --fix flag to apply safe fixes. Ruff is on the roadmap but not in the command yet. Frank Chu tested this by adding unused import os and import sys: uv check didn't report either of them. So keep your ruff check step for now. Step 1: type-check a project with uv check uv self update # make sure you're on 0.12.3+ uv check That's the whole setup. On the first run in Chu's test (uv 0.13.5, ty 0.0.91, a 2 vCPU VM), uv created .venv, pulled a managed CPython, built the project and downloaded ty, which was 13.5 MiB. The cold run took 5.53s and warm runs took 0.71s. Bare ty check on the same tiny project took 0.09s, so ty barely registers and most of the time goes to uv syncing the environment. The exit codes are what CI expects: 1 when there are diagnostics, 0 when the code is clean. Every run prints an experimental warning. Chu found you can silence it with --preview-features check-command. Step 2: workspaces with --package and --all-packages If you use a uv workspace, the flags work the same way they do in uv sync: uv check --package api # one member: syncs it + its deps, checks only its files uv check --all-packages # every member in the workspace The reference docs say --package syncs the workspace environment with the selected members and their dependencies, and "only files owned by those members are checked." In a monorepo, that's what lets you run a quick per-package check in a pre-push hook and the full sweep in CI. Step 3: check PEP 723 scripts against their own dependencies This is the part I care about most. Say you have a script like this: # /// script # requires-python = ">=3.12" # dependencies = ["requests"] # /// import requests status: int = requests.get("https://example.com") Run this: uv check --script fetch.py uv builds a separate environment from the script's own metadata and checks the script against it, even when it disagrees with the surrounding project. In Chu's test, plain ty check reported unresolved-import for requests because it was looking in the wrong venv. With the integration, it reported the real bug instead. I ran this exact file with uv 0.13.5, which pulled ty 0.0.92, and got the same thing, exit code 1: error[invalid-assignment]: Object of type `Response` is not assignable to `int` --> fetch.py:7:15 uv check on its own doesn't go looking for scripts, though. Chu had to name each one with --script. If you want ty to find and check the project's scripts in one pass, the blog gives an env var: TY_UV=scripts ty check That setting is opt-in for a good reason. Astral's explanation: "As dependency installation can require execution of third-party Python code, automatic environment setup is currently opt-in." The same applies to uv check itself, so running uv check on a repo you just cloned is not a read-only operation. Treat it like pip install. Step 4: ty in VS Code with the uv setting Install the ty extension, make sure uv is on your PATH, and add this to settings.json: { "ty.experimental.useUv": "on" } With that set, ty runs uv in the background so completions, hover and Go to Definition inside a script use the script's declared requirements and not your project venv. If you write a lot of throwaway uv run scripts, this alone makes the integration worth trying. Step 5: the ty pre-commit hook The ty-pre-commit hook now runs uv check under the hood, and the rev tracks the ty version: repos: - repo: https://github.com/astral-sh/ty-pre-commit rev: v0.0.92 # ty version hooks: - id: ty args: [--isolated] I'd add --isolated right away. Without it, the hook can create or update your uv.lock and .venv during a commit, which is the kind of side effect that eventually costs you an afternoon. The README also suggests [--isolated, --no-python-downloads, --no-cache] if you want more idempotent runs. Two other gotchas from the README: - The hook always checks the whole project. Don't set pass_filenames: true, because the hook errors if it receives filenames. - It doesn't work on pre-commit.ci, which blocks network access. Add ci: skip: [ty] there and run the check in your own CI. Step 6: uv check in GitHub Actions The ty-pre-commit README uses astral-sh/setup-uv@v10.3.2. Here's a minimal GitHub Actions job built on that: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 - uses: astral-sh/setup-uv@v10.3.2 - run: uv check --locked --all-packages --locked makes the job fail if uv.lock is out of date, so CI can't silently re-resolve and check against different dependencies than the ones you committed. The catch: uv check only pins ty if you add it The announcement says uv check "automatically uses the ty version recorded in your project's lockfile," so laptops and CI agree. That only holds once ty is actually in your lockfile. According to the --ty-version docs, the exact version from uv.lock is used "when ty is a project dependency or a dependency in the project's dev group." If it isn't, uv uses a constrained range like >=0.0,<0.1. ty is at 0.0.9x and ships often, so in that range your laptop and your CI runner can end up on different ty builds, which is exactly the drift the feature is supposed to prevent. Chu checked this: after uv add --dev "ty==0.0.80", uv check -v used 0.0.80 even though 0.0.91 was out. So the fix is one command: uv add --dev ty I did this on a fresh uv init project and uv check -v logged Requirement already installed: ty==0.0.92, the version now in the lockfile. Do this before you add uv check to CI. I'd argue it should be the default, or at least come with a loud warning. Should ty replace mypy or Pyright yet? Not yet, and Astral's post doesn't claim it should. The integration is experimental, ty is still on 0.0.x, and Astral says it is "continuing toward ty's stable release." ty's docs claim it's 10x to 100x faster than mypy and Pyright. I believe the speed claim. What I don't know yet is how many of your existing # type: ignore comments and plugin-dependent patterns (Django, SQLAlchemy, pydantic mypy plugins) will survive the switch. What I'm doing for now: | Situation | My call | |---|---| | PEP 723 scripts, one-off tools | Use uv check --script now. mypy never handled these well. | | New project, no mypy plugins | Run uv check in CI next to your current checker for a few weeks | | Large codebase on mypy plugins | Keep mypy. Try ty in the editor only. | | Monorepo on uv workspaces | Trial --all-packages in a non-blocking CI job | Running it next to your current checker costs you about a minute of CI setup. Dropping your current checker for a 0.0.x preview is a bet I'm not making on a production codebase. So where are you on this? Would you replace mypy or pyright with ty today, or wait until it's out of preview? If you've already switched, tell me which false positives made you want to switch back. Sources - Bringing uv and ty together, Astral blog, Oct 13, 2026 - uv CLI reference: uv check - astral-sh/ty-pre-commit README - ty documentation - Frank Chu: Astral wired ty into uv. I ran uv check on a project with four deliberate errors. - Lobsters discussion
AI Agents Are Quietly Being Rewritten in Rust OpenAI, Block, and Perplexity all rebuilt key parts of their AI stack in Rust. In this video I break down why the layer around the model (agents, sandboxes, and tooling) is moving to Rust, and the one catch most people skip. I’m a fresh grad learning Rust and harness engineering in public, so every claim here comes from sources I checked, linked below. Chapters 0:00 Elon’s Larp 00:36 Codex CLI: TypeScript to Rust 01:46 Goose: Python to Rust 02:22 Sandboxes: Firecracker and Perplexity SPACE 03:14 uv, Ruff, and OpenAI buying Astral 03:50 Hugging Face tokenizers and Candle 04:13 The catch 04:45 Advance Sources Elon post : x.com openai/codex block/builderbot perplexityai/SPACE x.com codex#1174 news.ycombinator.com reddit.com codex#1174 news.ycombinator.com reddit.com github.com/aaif-goose/goose/blob/main/crates/goos… goose-docs.ai github.com/aaif-goose/goose/blob/main/crates/goos… firecracker-microvm.github.io x.com anthropics/claude-code reddit.com huggingface.co huggingface/candle astral-sh/uv astral.sh github.com/perplexityai/SPACE/tree/main/crates/ap… firecracker-microvm/firecracker github.com/aaif-goose/goose/tree/main Follow along Rust playlist: youtube.com Building Harness playlist: youtube.com Portfolio: vkgeed.in X post for this video: x.com #rust #aiengineering #softwareengineering
Building a $0 VPS on Google Cloud: DietPi, Tailscale, IPv6, and the traps I hit I wanted a small always-on Linux box: private SSH access, no public IPv4, and a bill of exactly zero. The job is modest — a dashboard for the box itself, a couple of small scripts, an SSH endpoint I can reach from anywhere — but renting a VPS for that is paying monthly for a machine that mostly idles. What I got was a working VM, after falling into five traps and several installer bugs that cost me an evening. The last step was not declaring victory: it was opening Cloud Billing to see what the platform actually charged. This is the field report, including the parts where I was wrong. The target - Google Cloud e2-micro (2 shared vCPU, 964 MB RAM) in a free-tier region - Debian 13 "Trixie" base, DietPi on top - No public IPv4 - SSH over Tailscale only - Budget-triggered billing shutoff as a safety net, not an absolute spending guarantee The RAM number matters. On plain Debian the box idled at 389 MB used. On DietPi it idles at 248 MB — 36% less, which on a 964 MB machine is the difference between "can run one small service" and "cannot". Trap 1: the DietPi conversion script will eat your Tailscale DietPi ships PREP_SYSTEM_FOR_DIETPI.sh, which converts a running Debian install into DietPi. It looks like the obvious path on a cloud VM: no image juggling, just run it. Don't — not on a cloud VM you only reach over the network. Reading the script before running it: grep -in "tailscale" prep.sh # no matches grep -Ein 'google|gcloud' prep.sh # no matches And the mechanism, at line 995: G_DIETPI-NOTIFY 2 'Marking all packages as auto-installed first, to allow effective autoremove afterwards' mapfile -t apackages < <(apt-mark showmanual) G_EXEC apt-mark auto "${apackages[@]}" It marks every manually-installed package as auto-installed, then autoremoves what nothing depends on. Unless explicitly protected, tailscaled and the GCP guest agents (google-guest-agent, google-compute-engine-oslogin) are candidates for removal when nothing else depends on them. The script's own header says it plainly: > # - Either Ethernet connection or local (non-SSH) terminal access On a remote-only VM, that cleanup can cut off both Tailscale and the guest-agent path you expected to use for recovery. I did not want to discover that halfway through a conversion. What I did instead: built a new VM from the official DietPi image, keeping the old one running until the new one answered. The old VM was the rollback plan, and it stayed untouched until the end. Trap 2: GCP only accepts a file literally named disk.raw Downloading DietPi_NativePC-UEFI-x86_64-Trixie.img.xz and repacking it naively fails: ERROR: (gcloud.compute.images.create) Could not fetch resource: - The file inside the tar archive was named 'dietpi.img'. It should be named 'disk.raw'. The tar must contain exactly one file, named disk.raw. Here is the sequence used after downloading the named DietPi image (replace BUCKET with your bucket name): mv DietPi_NativePC-UEFI-x86_64-Trixie.img.xz dietpi.img.xz xz -dk dietpi.img.xz cp dietpi.img disk.raw tar -cSf disk.tar disk.raw # -S for sparse gzip -1 disk.tar gcloud storage cp disk.tar.gz gs://BUCKET/ gcloud compute images create dietpi-trixie \ --source-uri=gs://BUCKET/disk.tar.gz \ --guest-os-features=UEFI_COMPATIBLE --guest-os-features=UEFI_COMPATIBLE is not optional — the DietPi Native PC image boots via UEFI, and the GCE firmware needs to know. Trap 3: the VM came up with a public IP and a default password I intended to create the instance without a public IPv4. My first verification showed an external address, an overly broad SSH firewall path, and Dropbear still accepting the vendor's default password. Whether the flag was omitted or the effective deployment settings differed, the lesson is to inspect the resulting instance instead of trusting intent or the command line. That exposure was brief, but brief is not harmless. Rotate defaults immediately, use SSH keys rather than password authentication where possible, and explicitly inspect both IPv4 and IPv6 firewall rules. Always verify immediately after creation: gcloud compute instances describe VM --zone=Z --format="value(networkInterfaces[0].accessConfigs[0].natIP)" Don't trust the flag; check the result. Trap 4: no egress, and Tailscale status misled me This one cost the most time, and the symptom is genuinely misleading. Removing the external IPv4 broke everything. tailscale status on the peer still showed the node as active, with tx/rx counters. But: $ tailscale ping 100.x.y.z ping "100.x.y.z" timed out active in the tailnet is not proof of reachability. The node's tailscaled was registered with the coordination server through a stale path and kept reporting itself; data packets had nowhere to go. When you are debugging, an actual end-to-end probe is better evidence of reachability than a status label. Two separate causes stacked here: 4a. The VM was on the wrong VPC I created it on default, which is IPv4-only. The working VM was on a custom VPC with stackType: IPV4_IPV6. Same symptom, different root cause. Compare against a VM that already works before creating a new one — one describe would have saved an hour. 4b. GCE expects DHCPv6; my DietPi network configuration did not complete it With the right VPC and an external IPv6 range assigned, the interface still had only a link-local address: $ ip -6 addr show eth0 inet6 fe80::4001:aff:fe14:3/64 scope link proto kernel_ll Google assigns a /96 IPv6 range to an interface, with the first address delivered using DHCPv6. The metadata server also provides router advertisements for the default route. My DietPi image, using ifupdown, did not complete that configuration automatically, while the earlier Debian install had handled it. This is an issue with the guest configuration, not proof that GCE lacks DHCPv6 or router advertisements. See the official IPv6 setup documentation. The workaround I used, in /etc/network/interfaces.d/eth0.conf, with the address and gateway obtained from the actual instance configuration (the values below are placeholders): allow-hotplug eth0 iface eth0 inet dhcp iface eth0 inet6 static address 2600:1900:4000:XXXX:0:0:0:0 netmask 96 gateway fe80::4001:aff:fe14:1 Validate before rebooting — ifupdown can still boot you into a networkless machine. Have a recovery path before editing remote network configuration: ifquery eth0 # exit 0 and the parsed values mean the syntax is good Then reboot for real. A systemctl restart networking is not the same test; the bug only shows on a cold boot. Trap 5: IPv6-only egress cannot reach IPv4-only destinations Getting IPv6 working is only half the story. The VM had no external IPv4 and no usable native IPv4 internet egress in this setup. (It still had an internal IPv4 address on its dual-stack VPC interface.) IPv4-only destinations remained out of reach. The symptom looks exactly like a firewall problem: $ curl -sSfL https://github.com/astral-sh/uv/releases/download/0.13.4/uv-x86_64-unknown-linux-gnu.tar.gz curl: (28) Failed to connect to github.com port 443 after 134331 ms: Could not connect to server 134 seconds of silence, then nothing. No connection refused and no DNS error: the hostname resolved, but our VM had no available native route to the IPv4 endpoint returned for that download. The instinct is to go hunting through ip addr, ip route, and firewall rules. The explanation for this incident was one DNS query away: at the time of the test, github.com returned no usable AAAA record for the VM. DNS records and endpoint support change, so repeat the check rather than assuming the same answer forever. $ getent ahostsv6 github.com # empty I did not trust the local resolver, so I asked a public one too: curl -sS -H 'accept: application/dns-json' \ "https://cloudflare-dns.com/dns-query?name=github.com&type=AAAA" # {"Status":0,"Answer":null} -> no AAAA These were the hostname results captured during the incident, not a permanent current-status table: | Host | AAAA | |---|---| | github.com | no | | api.github.com | no | | objects.githubusercontent.com | no | | release-assets.githubusercontent.com | no | | raw.githubusercontent.com | yes | | github-releases.githubusercontent.com | yes | | pypi.org, files.pythonhosted.org | yes | | deb.debian.org, astral.sh | yes | In these tests, PyPI and the Debian mirrors worked over native IPv6 while the tested GitHub release URL did not. That is a snapshot of this environment, not an eternal rule for every GitHub hostname. A network configuration cannot conjure an IPv6 endpoint where one is unavailable. The fix: a proxy on a dual-stack peer, not an exit node Two options, and I tried the reasoning for both. Exit node. Route everything through a Tailscale peer that has IPv4. It works — and I rejected it. It sends 100% of the traffic through a home connection, including the PyPI and Debian traffic that is already fine over native IPv6. It adds latency to everything, and it turns that peer into a single point of failure for the entire VM: one machine reboots and the server loses the internet. A proxy for only what needs it. What I built: a small HTTP CONNECT proxy on a dual-stack machine in the same tailnet, bound only to the Tailscale interface. Traffic splits in two: ┌──────────────────────┐ pypi.org ───► │ │ native IPv6 — direct deb.debian.org │ marine (IPv6-only) │ │ │ github.com ───► │ apt / curl ────────┼──► dual-stack peer ──► IPv4 internet (IPv4-only) └──────────────────────┘ 100.x.y.z:3128 The proxy itself is a single constant — the rest is deployment: BIND = ("100.x.y.z", 3128) # Tailscale IP, never 0.0.0.0 with a systemd unit so it comes back after a reboot: [Unit] Description=CONNECT proxy for the IPv6-only VM After=network-online.target tailscaled.service Wants=network-online.target [Service] ExecStart=/usr/bin/python3 /opt/marine-proxy/proxy.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target Binding to the Tailscale address rather than 0.0.0.0 is not a detail. An open proxy on a LAN is a gift to whoever scans it first; the tailnet is already the trust boundary. On the VM side, the proxy has to be visible to processes that never read a shell profile — apt, systemd units, installer scripts: # /etc/apt/apt.conf.d/99proxy Acquire::http::Proxy "http://100.x.y.z:3128"; Acquire::https::Proxy "http://100.x.y.z:3128"; And because DietPi's installer shells out to curl from a non-interactive process that inherits neither /etc/profile.d nor /etc/environment: # /root/.curlrc proxy = "http://100.x.y.z:3128" noproxy = "localhost,127.0.0.1,::1,100.64.0.0/10,.ts.net,.debian.org,pypi.org,.pythonhosted.org" /root/.curlrc is read by curl itself when it runs as root, even if the calling shell did not load interactive profiles. The noproxy half is the important half for curl — without it, even IPv6-ready traffic goes to the proxy. Note that this .curlrc does not set bypass rules for APT: the APT proxy configuration above applies separately, and per-host APT exceptions require their own settings. 100.64.0.0/10 in noproxy is not optional — it is the Tailscale CGNAT range. Omit it and the proxy tries to reach the tailnet through itself. The result, measured on the VM: | Destination | Route | Result | |---|---|---| | pypi.org | direct, native IPv6 | 141 MB/s | | github.com | through proxy | 200 | | GitHub release tarball | through proxy | 16 MB in 18s (was a 134s timeout) | The final proxy setup at a glance | Piece | Where | What it does | |---|---|---| | proxy.py + systemd unit | dual-stack peer | HTTP CONNECT on 100.x.y.z:3128, bound to the tailnet address only | | /etc/apt/apt.conf.d/99proxy | marine | APT sends http/https through the proxy | | /root/.curlrc | marine | curl as root uses the proxy, with noproxy for the tailnet and IPv6-ready hosts | | /etc/apt/apt.conf.d/99force-ipv6 | marine | deleted — forcing a family breaks the proxy connection | | /etc/gai.conf (optional) | marine | prefer IPv6 through precedence, without forbidding IPv4 | That last row is the whole next section in one line. The trap inside the fix: prefer IPv6, never force it Earlier, when apt kept stalling on IPv4, I had "fixed" it with: # /etc/apt/apt.conf.d/99force-ipv6 Acquire::ForceIPv6 "true"; That was correct at the time and became wrong the moment the proxy existed: Err:1 https://deb.debian.org/debian trixie InRelease Something wicked happened resolving '100.x.y.z:3128' (-9 - Address family for hostname not supported) ForceIPv6 applies to the proxy connection as well, and the proxy is an IPv4 literal. Forcing a family is a landmine; preferring one is fine. For processes using glibc address selection, /etc/gai.conf can adjust precedence without globally forbidding IPv4. Test it against the applications you actually use: precedence ::ffff:0:0/96 10 Delete the ForceIPv6 file. Prefer, never force. The counterintuitive part: IPv6 is free, IPv4 is not I initially assumed the external IPv6 was costing money and estimated ~$3.65/month. That was wrong, and the documentation says so in a sentence I read past: > "You are not charged for external IPv6 address ranges that are assigned to subnets or for external IPv6 addresses that are assigned to VM instances." The $0.005/hour line in the same table is for IPv4: > "Static and ephemeral IP addresses in use on standard VM instances — $0.005 / 1 hour" > Free tier: limited to one hour per month. So on a free-tier VM: | Address configuration | Address charge | Caveat | |---|---|---| | External IPv6 on a VM | $0 for the address | Data transfer has separate pricing and limits | | External IPv4 on a VM | Charged hourly in normal use | Both static and ephemeral IPv4 can be charged | | No external IP address | No external-address charge | Internet egress needs another path | In this design, external IPv6 gave the VM outbound connectivity without paying for a public IPv4 address. It did not make all outbound traffic unlimited or free. See Google Cloud VPC pricing. Cloud NAT is another way to provide IPv4 egress without a public IPv4 on the VM, but it has its own charges; verify the pricing before treating it as a Free Tier component. What the image actually carries — and what I installed myself The VM was provisioned from the DietPi Native PC image, built for bare metal. It does ship support for hardware a cloud VM will never have — but less than my first pass at this audit claimed, and the correction is the more useful lesson. I went back to the package manifest of the image as imported, before anything was added to it. What it actually carries: | Shipped by the image | Weight | Present on the VM | |---|---|---| | firmware-iwlwifi (Intel) | 111 MB | no wireless adapter | | firmware-atheros | 95 MB | no wireless adapter | | firmware-mediatek | 45 MB | no wireless adapter | | firmware-realtek, firmware-brcm80211 and two smaller ones | 38 MB | no wireless adapter | | Wi-Fi firmware, total | 288 MB | 41% of the 698 MB base system | That is the vendor's real waste: 288 MB of firmware for three wireless vendors on a machine with no radio. It is removable with no loss of function, and it is the part of this cleanup I would apply to any DietPi cloud VM on sight. Everything else that looked like image bloat was mine. Docker, the LEMP stack, Mesa/libllvm19, intel-microcode and ffmpeg were not in the imported image — they were installed on this box afterwards and then forgotten. An earlier version of this audit listed them as things "the image carries", and that was wrong. It changes the conclusion: the expensive part was not the vendor's defaults, it was what I had installed myself and never used. So the cleanup had two different targets, and only one of them is a default-image problem: - Vendor defaults — 288 MB of Wi-Fi firmware for hardware that is not there. - My own installs — Docker with zero containers, a LEMP stack with no application, a graphics stack with no GPU. Reversible, but self-inflicted. The starting point, counting both: 3.8 GB used of 9.8 GB (40%) and 13 running systemd units. After removing what would never run — measured at the end of the cleanup run: | | Before | After | |---|---:|---:| | Disk used | 3.8 GB (40%) | 2.7 GB (28%) | | Running systemd units | 13 | 7 | With ~721 MB of MemAvailable on a 964 MB machine, up from ~656 MB. I am deliberately not quoting a package count here. The cleanup report I started from claimed 526 → 452, and when I went back to check it against the dpkg manifests, no snapshot on the box ever held 526 packages: the imported image had 223, the snapshot from the night before had 249. The disk and memory numbers are real — they come from df and free captured inside the run. The package count was not, and I would rather drop it than publish a number I cannot reproduce. Those numbers describe the box at 12:21 that day. They are a snapshot, not a permanent state — part of the LEMP stack was reinstalled afterwards, on purpose, and the count moved again. Two traps in that cleanup are worth more than the numbers themselves. The dependency you cannot see This one did not bite me on this box — I never had ffmpeg installed here, and I checked. But it is the check I now run before any purge, because the chain is real: ffmpeg -> libavfilter10 -> libflite1 libflite1 is a speech-synthesis engine. On a headless server it looks like an obvious orphan. ffmpeg uses the flite filter to generate synthesized audio, so it depends on libavfilter, which depends on libflite1. Purging the speech library takes the video tool with it. The way to see it before you commit is the simulation, and it only works if you read the lines you did not ask for: apt-get -s purge libflite1 | grep '^Purg' # Purg ffmpeg libavdevice61 libavfilter10 libflite1 Three names appear that were never in the list you typed. A dependency tree is not the list of names you chose; it is the list of names you are about to lose. The summary line tells you how many; only the Purg lines tell you which. autoremove goes further than the plan After the explicit purge, apt-get autoremove --purge removed packages that were not on my list — among them rsync and nftables. Both had been installed as dependencies and became orphans. I only noticed rsync was gone when a later install pulled it back in, hours afterwards; nftables I never reinstalled. The impact is low — everything here reinstalls in one command — but the lesson is structural. autoremove is where a cleanup plan loses control. apt tells you how many packages it will remove, never which. Either read the full list, or mark what must survive before running it: apt-mark manual rsync lsof socat nftables Building a budget-triggered billing kill switch Here is the part that surprises people: a standard GCP budget alert does not enforce a spending cap. It can email or publish notifications, but usage continues unless a separate action is taken. Google documents a possible automation to disable billing; it also warns of reporting delays. To add an automated shutoff attempt, I chained three pieces: 1. A budget with a pubsubTopic in notificationsRule 2. A Cloud Function subscribed to that topic 3. A service account allowed to call projects.updateBillingInfo with an empty billingAccountName Cloud Billing │ ▼ Budget: R$5 · alerts at 50 / 90 / 100% │ notificationsRule.pubsubTopic ▼ Pub/Sub topic ──► Eventarc ──► Cloud Function (gen2) │ │ costAmount / budgetAmount >= 1.0 ? ▼ projects.updateBillingInfo billingAccountName = "" Unlinking the billing account can stop billable services, but the trigger is not instantaneous. Charges incurred before notifications arrive may still post afterward. This is a safety mechanism, not a contractual hard spending cap. See Google’s own limitations. The core of the function is one API call: def disable_billing(project_id): client = _billing_client() name = f"projects/{project_id}" current = client.projects().getBillingInfo(name=name).execute() if not current.get("billingEnabled"): return "already_disabled" client.projects().updateBillingInfo( name=name, body={"billingAccountName": ""} ).execute() return "disabled" Four things that bit me building it The budget currency must match the billing account currency. Creating a USD budget on a BRL account returns INVALID_ARGUMENT with no useful detail. Read currencyCode from the billing account first. pubsubTopic: "" is also INVALID_ARGUMENT. Omit the field entirely if you want email-only notifications. ADC needs a quota project for the budgets API. A curl with a valid bearer token returns: 403: "Your application is authenticating by using local Application Default Credentials. The billingbudgets.googleapis.com API requires a quota project" Add the header x-goog-user-project: your-project. Cloud Run returns 200 even when your function crashes. The gen2 runtime wraps your entry point in the background signature, so it calls function(event, context) — two arguments. I wrote def handler(cloud_event), and the only evidence was buried in stdout: TypeError: budget_guard() takes 1 positional argument but 2 were given while the high-level HTTP log line I had inspected said 200. Do not use an HTTP status alone to prove that the intended handler ran successfully. Read the function/container logs: gcloud logging read 'resource.labels.service_name=MY_FN AND logName:"stdout"' --limit=20 Also: logging.info() did not appear in stdout for me. print(..., flush=True) did. And one IAM detail: the Eventarc trigger invokes Cloud Run as a service account, and Cloud Run rejects it unless that account holds roles/run.invoker on the service. The trigger exists and looks healthy either way. Testing without detonating I tested only the safe branch — publish a message with a cost below the threshold and confirm the function logs its decision and stops: [budget-guard] spend 1.50 of 27.00 (5.6%) — target project: my-project [budget-guard] below the 100% threshold. No action taken. The numbers in that test payload are arbitrary — I chose them to produce a ratio below 1.0, not to match the live budget. What the test proves is the branch, not the amounts. Testing the real path would have interrupted the VM. The non-destructive test confirmed the safe branch; code review gave us partial confidence in the destructive branch, which remained untested end-to-end. A stronger approach is to test the destructive branch against a disposable project, not production. Two design choices worth copying: - Hardcode the target project in an env var. Never infer it from the Pub/Sub payload — a malformed message should not be able to shut down the wrong project. - Abort if budgetAmount is missing or zero. A division by zero or a bad payload must fail closed, not open. Proving the $0 bill: the Marine billing audit This was the part that mattered most to me. I did not want to publish "free VPS" merely because the Compute Engine Free Tier page said e2-micro was eligible. A real project can still accumulate charges for surrounding services. We audited a Google Cloud project (id withheld), covering October 1–24, 2026, using its Cloud Billing reports. A read-only review compared the service totals, individual SKUs, savings, and available promotional balances. The audit's observed outcome was R$0.00 in net usage cost for the project during that period. > On the figures: the amounts below are the values Cloud Billing actually reported for the audited window, rounded to centavos the way the console displays them. They are small — cents, not dollars — and that is the point: the gross usage is real, the net is zero. | Reported SKU or service | Gross usage | Savings / offsets | Net usage cost | |---|---:|---:|---:| | Network Analyzer Resource Hours | R$0.12 | −R$0.12 | R$0.00 | | Internet to Google Cloud Performance Resource Hours | R$0.09 | −R$0.09 | R$0.00 | | Topology and Google Cloud Performance Resource Hours | R$0.12 | −R$0.12 | R$0.00 | | Cloud Run Functions CPU (us-central1; 124.72 vCPU-seconds) | R$0.02 | −R$0.02 | R$0.00 | | Network Intelligence rounding / minor residual | ≈R$0.01 | ≈−R$0.01 | R$0.00 | | Reported total | ≈R$0.36 | ≈−R$0.36 | R$0.00 | The displayed SKU rows are rounded to centavos, hence the small difference between their sum and the reported service total. Why gross usage showed up at all Google explicitly documents a 100% discount for Network Analyzer, Network Topology, and Performance Dashboard. Their list-price usage can appear in billing details even when the final charge is zero. That is precisely why hiding or deleting network monitoring would have been the wrong first response. Official Network Intelligence Center pricing. Cloud Run's Free Tier can also appear as gross compute usage followed by credits or savings. The 124.72 vCPU-seconds observed for this budget-related function were in us-central1 and showed R$0.00 net in the reviewed report. Official Cloud Run pricing. What the audit confirmed — and what it did not The comparison found no net charge on these lines, and no identifiable material drawdown of the Developer Program or other promotional balances attributable to them. The developer-credit balance was essentially unchanged; a roughly R$0.01 movement elsewhere in the balance display was too small and insufficiently attributed to use as proof either way. There was one remaining limitation: the console grouped these abatements under "other savings" without conclusively exposing credit.type and credit.name for every current-period SKU. We could verify which resource accrued gross usage, and that the usage was fully offset, but not exactly which credit record paid for each centavo in the underlying billing export. So the defensible claim is not "Google showed zero gross usage" or "we traced every cent of credit attribution." It is: the project's observed net usage cost was R$0.00 for the audited period; the relevant Network Intelligence Center services have a documented 100% discount; and the reviewed balances did not show a meaningful promotional-credit debit tied to those lines. A final attribution audit would require SKU-level credit.type / credit.name details, for example through detailed Cloud Billing export if enabled. For anyone reproducing this, visit Cloud Billing → Reports, filter by project and date, group by SKU, and compare Usage cost, Other savings, and Subtotal. Then repeat the view with Promotional credits excluded. Google documents these filters in View and analyze savings. What "$0" does not promise Google Cloud's always-free Compute Engine allowance is not unlimited. At the time of this audit it covered an e2-micro within the eligible US regions, 30 GB-month of standard persistent disk, and 1 GB/month of qualifying North American outbound data transfer, aggregated under the program rules. External IPv6 addresses assigned to VMs do not carry an address charge, but outbound traffic, additional disks, custom images, snapshots, and adjacent services can have their own pricing. See Google Cloud Free Program, VPC pricing, and custom image storage pricing. The audit found no separately identified nonzero net charge for the DietPi image, persistent storage, external IPv6, or data transfer in the reviewed window. That is a measurement of this period, not a promise about every future hour or every deployment. Verify your region, disk type, account-wide allowances, and future use; the project can become billable if conditions change. In short: the Marine was a zero-net-cost VPS in the period we actually checked. The article is a blueprint for staying inside the free allowances — not an unlimited-use guarantee or a vendor billing warranty. Final state DietPi 10.8 / Debian 13 (trixie) e2-micro, 964 MB RAM, 1.1 GB swap, 9.8 GB disk Disk: 40% -> 28% Units: 13 -> 7 (at the end of the cleanup run) External IPv4: none External IPv6: yes (free) SSH: Dropbear, restricted to the tailnet in tested setup; migrate to key-only auth Firewall: verify effective IPv4 **and** IPv6 ingress policies; management via tailnet IPv4-only destinations: CONNECT proxy over the tailnet, 100.64.0.0/10 excluded IPv6-capable destinations: native, direct Observed net GCP usage cost (1–24 Oct 2026): R$ 0.00 after savings Plus a R$5 budget with alerts at 50/90/100% and a function intended to unlink billing if the threshold is reached. The controller is useful defense in depth; it does not guarantee zero future bills. What I'd do differently 1. Read the conversion script before running it — four greps would have saved the whole rebuild. 2. describe a working VM before creating a new one. Network stack, image flags, disk type. Copy the working configuration instead of reconstructing it. 3. Treat tailscale status as a hint and tailscale ping as evidence. 4. Verify the external IP after creation, don't trust --no-address. 5. Check the pricing page's exceptions, not just its table. "You are not charged for..." sentences are easy to skip and change the answer completely. 6. Check the destination's AAAA before touching the network config. In this case, the 134-second timeout was about an IPv4-only destination, not a missing firewall rule. 7. Prefer a family, do not force it globally. ForceIPv6 broke APT once the proxy was configured with an IPv4 literal. 8. Test an installer's validation regex against the example the installer displays. If the example fails, the bug is not in your typing. 9. Read the Purg lines of a simulation, not its summary. The summary says how many packages will go; only the list says which ones — and a dependency tree can hide a tool you meant to keep behind a library you never think about. 10. Review removals and mark essential packages as manual before autoremove. Orphaned dependencies are not necessarily junk. The traps were not exotic. They were hiding in a script header, cloud networking docs, a pricing footnote, function logs, a DNS answer, and finally a billing report. The lesson is not "GCP is hard"; it is that the evidence was often one command or one billing filter away, and I trusted the convenient summary instead of verifying the underlying data. The one I keep thinking about is still the DNS check. I spent time inspecting routes and firewall rules for a destination that was unavailable over IPv6 in that test. getent ahostsv6 github.com was the useful next command. It took one second to run and an hour to think of. The billing report delivered the same lesson in a different form: a cost line is not the same as a charge, and a zero net bill is not proof that every underlying credit was individually identified. Appendix: four dietpi-dashboard installer bugs dietpi-dashboard was the first service I put on the VM, and its setup dialog fought IPv6 the whole way. Four bugs, all in the installer: 1. The address validator cannot accept IPv6. The regex in dietpi-software:3778 ends in an alphanumeric class, so it takes a hostname or an IPv4 address — never a colon. The dialog's own example (fe80::4001:aff:fe14:1:5253) is rejected by the validator displaying it. 2. The two validators disagree. The regex accepts hostnames, but the binary is Rust and parses the field as SocketAddr, so a hostname passes the dialog and dies at startup with invalid socket address syntax for key frontend_addr. What works is a bracketed IPv6 literal — rejected by the regex, so it has to be set by editing config-backend.toml. 3. The 64-character secret is never checked against the frontend's. With mismatched secrets the network is correct, the port is open, the service reports active, and the handshake fails silently. 4. The installer overwrites secret with whatever was typed, with no validation at all. Mine ended up as an 8-character string and broke the config after a reboot. One diagnostic trap belongs with them: the backend runs with no listening port on 5252 or 5253, which looks like a broken config and is not. In a backend-client architecture the node dials out and the frontend listens, so a node with no open port is correct behaviour. Confirm it from the frontend host instead: ss -tnp | grep 5253 # ESTAB [fd7a:...:9c5d]:5253 [fd7a:...:b278]:45650 users:(("frontend",...)) The practical rule: after the installer finishes, verify the config file instead of trusting the dialog, and diff the secret between hosts before starting the backend.