letify 2.6.1__tar.gz → 2.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. {letify-2.6.1 → letify-2.6.3}/PKG-INFO +1 -1
  2. {letify-2.6.1 → letify-2.6.3}/docs/INTENT.md +23 -1
  3. {letify-2.6.1 → letify-2.6.3}/docs/SPEC.md +27 -1
  4. {letify-2.6.1 → letify-2.6.3}/letify/__init__.py +1 -1
  5. {letify-2.6.1 → letify-2.6.3}/letify/declare/reduce.py +164 -18
  6. {letify-2.6.1 → letify-2.6.3}/letify/protocol/worker.py +37 -2
  7. {letify-2.6.1 → letify-2.6.3}/letify/runtime/broker.py +24 -3
  8. {letify-2.6.1 → letify-2.6.3}/letify/runtime/session.py +22 -3
  9. {letify-2.6.1 → letify-2.6.3}/letify-core/Cargo.lock +3 -3
  10. {letify-2.6.1 → letify-2.6.3}/letify-core/Cargo.toml +1 -1
  11. {letify-2.6.1 → letify-2.6.3}/letify-ext/package-lock.json +2 -2
  12. {letify-2.6.1 → letify-2.6.3}/letify-ext/package.json +1 -1
  13. {letify-2.6.1 → letify-2.6.3}/pyproject.toml +1 -1
  14. {letify-2.6.1 → letify-2.6.3}/tests/test_data_streaming.py +73 -0
  15. {letify-2.6.1 → letify-2.6.3}/tests/test_distributed.py +75 -0
  16. {letify-2.6.1 → letify-2.6.3}/tests/test_runtime.py +29 -0
  17. {letify-2.6.1 → letify-2.6.3}/.github/workflows/ci.yml +0 -0
  18. {letify-2.6.1 → letify-2.6.3}/.github/workflows/docs.yml +0 -0
  19. {letify-2.6.1 → letify-2.6.3}/.github/workflows/publish.yml +0 -0
  20. {letify-2.6.1 → letify-2.6.3}/.gitignore +0 -0
  21. {letify-2.6.1 → letify-2.6.3}/.researchtree +0 -0
  22. {letify-2.6.1 → letify-2.6.3}/CLAUDE.md +0 -0
  23. {letify-2.6.1 → letify-2.6.3}/LICENSE +0 -0
  24. {letify-2.6.1 → letify-2.6.3}/PROJECT.md +0 -0
  25. {letify-2.6.1 → letify-2.6.3}/README.md +0 -0
  26. {letify-2.6.1 → letify-2.6.3}/docs/COMPONENT.md +0 -0
  27. {letify-2.6.1 → letify-2.6.3}/docs/NETWORK.md +0 -0
  28. {letify-2.6.1 → letify-2.6.3}/docs/guide/01-getting-started.md +0 -0
  29. {letify-2.6.1 → letify-2.6.3}/docs/guide/02-providers.md +0 -0
  30. {letify-2.6.1 → letify-2.6.3}/docs/guide/03-execution-modes.md +0 -0
  31. {letify-2.6.1 → letify-2.6.3}/docs/guide/04-environments-and-data.md +0 -0
  32. {letify-2.6.1 → letify-2.6.3}/docs/guide/05-concurrency.md +0 -0
  33. {letify-2.6.1 → letify-2.6.3}/docs/guide/06-cost.md +0 -0
  34. {letify-2.6.1 → letify-2.6.3}/docs/guide/07-troubleshooting.md +0 -0
  35. {letify-2.6.1 → letify-2.6.3}/docs/guide/08-releasing.md +0 -0
  36. {letify-2.6.1 → letify-2.6.3}/docs/guide/README.md +0 -0
  37. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  38. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  39. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  40. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  41. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  42. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  43. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  44. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  45. {letify-2.6.1 → letify-2.6.3}/docs/guide/ko/README.md +0 -0
  46. {letify-2.6.1 → letify-2.6.3}/docs/locales/README_ko.md +0 -0
  47. {letify-2.6.1 → letify-2.6.3}/docs/mkdocs.yml +0 -0
  48. {letify-2.6.1 → letify-2.6.3}/examples/README.md +0 -0
  49. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/.letify.example +0 -0
  50. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/00_smoke.py +0 -0
  51. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/01_sweep.py +0 -0
  52. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/02_resume.py +0 -0
  53. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/03_watch.py +0 -0
  54. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/README.md +0 -0
  55. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/README_ko.md +0 -0
  56. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/common.py +0 -0
  57. {letify-2.6.1 → letify-2.6.3}/examples/nvfp4-lora/recipe.py +0 -0
  58. {letify-2.6.1 → letify-2.6.3}/letify/cli.py +0 -0
  59. {letify-2.6.1 → letify-2.6.3}/letify/config/__init__.py +0 -0
  60. {letify-2.6.1 → letify-2.6.3}/letify/config/inventory.py +0 -0
  61. {letify-2.6.1 → letify-2.6.3}/letify/config/login.py +0 -0
  62. {letify-2.6.1 → letify-2.6.3}/letify/config/schema.py +0 -0
  63. {letify-2.6.1 → letify-2.6.3}/letify/config/secrets.py +0 -0
  64. {letify-2.6.1 → letify-2.6.3}/letify/config/writer.py +0 -0
  65. {letify-2.6.1 → letify-2.6.3}/letify/declare/__init__.py +0 -0
  66. {letify-2.6.1 → letify-2.6.3}/letify/declare/cache.py +0 -0
  67. {letify-2.6.1 → letify-2.6.3}/letify/declare/env.py +0 -0
  68. {letify-2.6.1 → letify-2.6.3}/letify/declare/function.py +0 -0
  69. {letify-2.6.1 → letify-2.6.3}/letify/declare/instance.py +0 -0
  70. {letify-2.6.1 → letify-2.6.3}/letify/declare/replicas.py +0 -0
  71. {letify-2.6.1 → letify-2.6.3}/letify/errors.py +0 -0
  72. {letify-2.6.1 → letify-2.6.3}/letify/install.py +0 -0
  73. {letify-2.6.1 → letify-2.6.3}/letify/launcher.py +0 -0
  74. {letify-2.6.1 → letify-2.6.3}/letify/paths.py +0 -0
  75. {letify-2.6.1 → letify-2.6.3}/letify/protocol/__init__.py +0 -0
  76. {letify-2.6.1 → letify-2.6.3}/letify/protocol/codec.py +0 -0
  77. {letify-2.6.1 → letify-2.6.3}/letify/protocol/driver.py +0 -0
  78. {letify-2.6.1 → letify-2.6.3}/letify/protocol/handle.py +0 -0
  79. {letify-2.6.1 → letify-2.6.3}/letify/protocol/wire.py +0 -0
  80. {letify-2.6.1 → letify-2.6.3}/letify/providers/__init__.py +0 -0
  81. {letify-2.6.1 → letify-2.6.3}/letify/providers/base.py +0 -0
  82. {letify-2.6.1 → letify-2.6.3}/letify/providers/colab.py +0 -0
  83. {letify-2.6.1 → letify-2.6.3}/letify/providers/colab_files.py +0 -0
  84. {letify-2.6.1 → letify-2.6.3}/letify/providers/colab_keepalive.py +0 -0
  85. {letify-2.6.1 → letify-2.6.3}/letify/providers/elice.py +0 -0
  86. {letify-2.6.1 → letify-2.6.3}/letify/providers/kaggle.py +0 -0
  87. {letify-2.6.1 → letify-2.6.3}/letify/providers/kaggle_adapter.py +0 -0
  88. {letify-2.6.1 → letify-2.6.3}/letify/providers/local.py +0 -0
  89. {letify-2.6.1 → letify-2.6.3}/letify/providers/modal.py +0 -0
  90. {letify-2.6.1 → letify-2.6.3}/letify/providers/modal_adapter.py +0 -0
  91. {letify-2.6.1 → letify-2.6.3}/letify/providers/naming.py +0 -0
  92. {letify-2.6.1 → letify-2.6.3}/letify/providers/shell.py +0 -0
  93. {letify-2.6.1 → letify-2.6.3}/letify/providers/tunnel.py +0 -0
  94. {letify-2.6.1 → letify-2.6.3}/letify/providers/usage.py +0 -0
  95. {letify-2.6.1 → letify-2.6.3}/letify/remoting/__init__.py +0 -0
  96. {letify-2.6.1 → letify-2.6.3}/letify/remoting/capability.py +0 -0
  97. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/__init__.py +0 -0
  98. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/client.py +0 -0
  99. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/cuda.py +0 -0
  100. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/executor.py +0 -0
  101. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/frames.py +0 -0
  102. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/guard.py +0 -0
  103. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/tensor.py +0 -0
  104. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/trace.py +0 -0
  105. {letify-2.6.1 → letify-2.6.3}/letify/remoting/device/value.py +0 -0
  106. {letify-2.6.1 → letify-2.6.3}/letify/remoting/loader.py +0 -0
  107. {letify-2.6.1 → letify-2.6.3}/letify/remoting/probe.py +0 -0
  108. {letify-2.6.1 → letify-2.6.3}/letify/render.py +0 -0
  109. {letify-2.6.1 → letify-2.6.3}/letify/runtime/__init__.py +0 -0
  110. {letify-2.6.1 → letify-2.6.3}/letify/runtime/bootstrap.py +0 -0
  111. {letify-2.6.1 → letify-2.6.3}/letify/runtime/channel.py +0 -0
  112. {letify-2.6.1 → letify-2.6.3}/letify/runtime/lease.py +0 -0
  113. {letify-2.6.1 → letify-2.6.3}/letify/runtime/pool.py +0 -0
  114. {letify-2.6.1 → letify-2.6.3}/letify/runtime/telemetry.py +0 -0
  115. {letify-2.6.1 → letify-2.6.3}/letify/store/__init__.py +0 -0
  116. {letify-2.6.1 → letify-2.6.3}/letify/store/backends/__init__.py +0 -0
  117. {letify-2.6.1 → letify-2.6.3}/letify/store/backends/filesystem.py +0 -0
  118. {letify-2.6.1 → letify-2.6.3}/letify/store/backends/google_auth.py +0 -0
  119. {letify-2.6.1 → letify-2.6.3}/letify/store/backends/layout.py +0 -0
  120. {letify-2.6.1 → letify-2.6.3}/letify/store/backends/objects.py +0 -0
  121. {letify-2.6.1 → letify-2.6.3}/letify/store/cas.py +0 -0
  122. {letify-2.6.1 → letify-2.6.3}/letify/store/pathdata.py +0 -0
  123. {letify-2.6.1 → letify-2.6.3}/letify/store/sendorder.py +0 -0
  124. {letify-2.6.1 → letify-2.6.3}/letify/store/volume.py +0 -0
  125. {letify-2.6.1 → letify-2.6.3}/letify/stubs.py +0 -0
  126. {letify-2.6.1 → letify-2.6.3}/letify/tools.py +0 -0
  127. {letify-2.6.1 → letify-2.6.3}/letify/transport/__init__.py +0 -0
  128. {letify-2.6.1 → letify-2.6.3}/letify/transport/agent.py +0 -0
  129. {letify-2.6.1 → letify-2.6.3}/letify/transport/announce.py +0 -0
  130. {letify-2.6.1 → letify-2.6.3}/letify/transport/link.py +0 -0
  131. {letify-2.6.1 → letify-2.6.3}/letify/transport/nat.py +0 -0
  132. {letify-2.6.1 → letify-2.6.3}/letify/transport/pipeline.py +0 -0
  133. {letify-2.6.1 → letify-2.6.3}/letify/transport/portmap.py +0 -0
  134. {letify-2.6.1 → letify-2.6.3}/letify/transport/probe.py +0 -0
  135. {letify-2.6.1 → letify-2.6.3}/letify/transport/quic.py +0 -0
  136. {letify-2.6.1 → letify-2.6.3}/letify/transport/rendezvous.py +0 -0
  137. {letify-2.6.1 → letify-2.6.3}/letify/transport/setup.py +0 -0
  138. {letify-2.6.1 → letify-2.6.3}/letify/transport/sshopts.py +0 -0
  139. {letify-2.6.1 → letify-2.6.3}/letify/transport/strategies.py +0 -0
  140. {letify-2.6.1 → letify-2.6.3}/letify-core/.gitignore +0 -0
  141. {letify-2.6.1 → letify-2.6.3}/letify-core/build.py +0 -0
  142. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-agent/Cargo.toml +0 -0
  143. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-agent/src/driver.rs +0 -0
  144. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-agent/src/main.rs +0 -0
  145. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/Cargo.toml +0 -0
  146. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/client.rs +0 -0
  147. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/exports.rs +0 -0
  148. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/image.rs +0 -0
  149. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/lib.rs +0 -0
  150. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/status.rs +0 -0
  151. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-driver/src/table.rs +0 -0
  152. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-quic/Cargo.toml +0 -0
  153. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-quic/src/main.rs +0 -0
  154. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-wire/Cargo.toml +0 -0
  155. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-wire/src/lib.rs +0 -0
  156. {letify-2.6.1 → letify-2.6.3}/letify-core/letify-wire/tests/throughput.rs +0 -0
  157. {letify-2.6.1 → letify-2.6.3}/letify-core/rust-toolchain.toml +0 -0
  158. {letify-2.6.1 → letify-2.6.3}/letify-ext/.vscodeignore +0 -0
  159. {letify-2.6.1 → letify-2.6.3}/letify-ext/LICENSE +0 -0
  160. {letify-2.6.1 → letify-2.6.3}/letify-ext/README.md +0 -0
  161. {letify-2.6.1 → letify-2.6.3}/letify-ext/media/icon.svg +0 -0
  162. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/cli.ts +0 -0
  163. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/extension.ts +0 -0
  164. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/history.ts +0 -0
  165. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/model.ts +0 -0
  166. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/poll.ts +0 -0
  167. {letify-2.6.1 → letify-2.6.3}/letify-ext/src/render.ts +0 -0
  168. {letify-2.6.1 → letify-2.6.3}/letify-ext/test/cli.test.ts +0 -0
  169. {letify-2.6.1 → letify-2.6.3}/letify-ext/test/manifest.test.ts +0 -0
  170. {letify-2.6.1 → letify-2.6.3}/letify-ext/test/model.test.ts +0 -0
  171. {letify-2.6.1 → letify-2.6.3}/letify-ext/test/regressions.test.ts +0 -0
  172. {letify-2.6.1 → letify-2.6.3}/letify-ext/tsconfig.json +0 -0
  173. {letify-2.6.1 → letify-2.6.3}/letify_providers.py +0 -0
  174. {letify-2.6.1 → letify-2.6.3}/scripts/sync-main.sh +0 -0
  175. {letify-2.6.1 → letify-2.6.3}/scripts/version.py +0 -0
  176. {letify-2.6.1 → letify-2.6.3}/tests/conftest.py +0 -0
  177. {letify-2.6.1 → letify-2.6.3}/tests/fake_kaggle_adapter.py +0 -0
  178. {letify-2.6.1 → letify-2.6.3}/tests/fake_modal_adapter.py +0 -0
  179. {letify-2.6.1 → letify-2.6.3}/tests/test_cli.py +0 -0
  180. {letify-2.6.1 → letify-2.6.3}/tests/test_cli_output.py +0 -0
  181. {letify-2.6.1 → letify-2.6.3}/tests/test_colab_keepalive.py +0 -0
  182. {letify-2.6.1 → letify-2.6.3}/tests/test_config.py +0 -0
  183. {letify-2.6.1 → letify-2.6.3}/tests/test_core.py +0 -0
  184. {letify-2.6.1 → letify-2.6.3}/tests/test_data.py +0 -0
  185. {letify-2.6.1 → letify-2.6.3}/tests/test_declare.py +0 -0
  186. {letify-2.6.1 → letify-2.6.3}/tests/test_device.py +0 -0
  187. {letify-2.6.1 → letify-2.6.3}/tests/test_editor_sessions.py +0 -0
  188. {letify-2.6.1 → letify-2.6.3}/tests/test_elice.py +0 -0
  189. {letify-2.6.1 → letify-2.6.3}/tests/test_frames.py +0 -0
  190. {letify-2.6.1 → letify-2.6.3}/tests/test_install.py +0 -0
  191. {letify-2.6.1 → letify-2.6.3}/tests/test_kaggle.py +0 -0
  192. {letify-2.6.1 → letify-2.6.3}/tests/test_kaggle_cookie.py +0 -0
  193. {letify-2.6.1 → letify-2.6.3}/tests/test_kaggle_link.py +0 -0
  194. {letify-2.6.1 → letify-2.6.3}/tests/test_link_log.py +0 -0
  195. {letify-2.6.1 → letify-2.6.3}/tests/test_notebook.py +0 -0
  196. {letify-2.6.1 → letify-2.6.3}/tests/test_parallel_placement.py +0 -0
  197. {letify-2.6.1 → letify-2.6.3}/tests/test_paths.py +0 -0
  198. {letify-2.6.1 → letify-2.6.3}/tests/test_portmap.py +0 -0
  199. {letify-2.6.1 → letify-2.6.3}/tests/test_protocol.py +0 -0
  200. {letify-2.6.1 → letify-2.6.3}/tests/test_providers.py +0 -0
  201. {letify-2.6.1 → letify-2.6.3}/tests/test_quic.py +0 -0
  202. {letify-2.6.1 → letify-2.6.3}/tests/test_remoting.py +0 -0
  203. {letify-2.6.1 → letify-2.6.3}/tests/test_render.py +0 -0
  204. {letify-2.6.1 → letify-2.6.3}/tests/test_ship.py +0 -0
  205. {letify-2.6.1 → letify-2.6.3}/tests/test_store.py +0 -0
  206. {letify-2.6.1 → letify-2.6.3}/tests/test_strategies.py +0 -0
  207. {letify-2.6.1 → letify-2.6.3}/tests/test_stubs.py +0 -0
  208. {letify-2.6.1 → letify-2.6.3}/tests/test_transport.py +0 -0
  209. {letify-2.6.1 → letify-2.6.3}/tests/test_tunnel_setup.py +0 -0
  210. {letify-2.6.1 → letify-2.6.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.6.1
3
+ Version: 2.6.3
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -74,6 +74,28 @@ cross cloud to cloud and the client carries the mappings and nothing else. Measu
74
74
  inbound on the network this was designed on is capped near 0.25 MiB/s, against 21 MiB/s
75
75
  outbound to a near runtime, which is why this is a claim rather than an optimization.
76
76
 
77
+ **TCP hole punching does not reach between two Kaggle runtimes.** Measured on 2026-10-13 between two
78
+ Google Cloud VMs on two Kaggle accounts: both NATs preserved the port, 45117 on each, so the
79
+ signalling and the mappings were right, and every SYN was dropped for the whole 45 second window from
80
+ both sides at once. So the mechanism this claim names works client to runtime, where it was measured,
81
+ and not runtime to runtime on that network. The claim is not refuted, because what it asserts is that
82
+ the reduction must not pass through the client, and that still holds. What is refuted is that a TCP
83
+ punch is the way to arrange it on Kaggle.
84
+
85
+ **A UDP punch does not reach either.** Measured the same way on the same pair of accounts: both NATs
86
+ preserved the port, 45223 on each, both sides sent datagrams for 30 seconds, and neither received
87
+ one. So neither protocol reaches between two Kaggle runtimes with a mapping learned from a third
88
+ party, and the distributed placement has no runtime to runtime transport on that pair today. A pair
89
+ that cannot connect refuses the reduction, which is what letify does, so the behaviour is right and
90
+ the placement is unusable there rather than slow.
91
+
92
+ Two paths remain, and which one letify takes is an open decision rather than something to assume.
93
+ Either both runtimes dial outward to a third host that is not the client, which is a relay and has to
94
+ be argued against the constraint that a slow link is a failure, or they exchange parameters through
95
+ object storage in one of their clouds, which letify already reaches with the `bucket` account option
96
+ and the GCS backend. The second needs no inbound anywhere, which is the property the measurements
97
+ above say is missing.
98
+
77
99
  ## How an experiment reports efficiency
78
100
 
79
101
  This standard sits here rather than in `docs/SPEC.md` because it governs how a claim is tested, not what the system does. The spec records the design; this records what a report has to show before a claim above may be called supported. Every experiment pull request follows it.
@@ -108,7 +130,7 @@ Each of these would change a claim or a default. Answering one is a good first e
108
130
 
109
131
  1. **How many concurrent sessions does one Colab account allow?** Undocumented, and it moves with tier, credit balance and demand. Until it is measured, a `devices` count in the provider entry is where the answer goes, so a user who has measured their own account is not overruled by a number letify guessed.
110
132
  2. **What is the real host synchronization count per step, `k`, for the target workload?** Measurable with `torch.cuda.set_sync_debug_mode("warn")`. This sets whether PyTorch forwarding is worth using for that workload.
111
- 3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path.
133
+ 3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. It failed between two Google Cloud VMs on two Kaggle accounts, where both NATs also preserved the port and every SYN was dropped, so a preserved port is not sufficient. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path, and runtime to runtime it decides whether the distributed placement has a transport on a given pair of providers.
112
134
  4. **Is NVFP4 reachable in a stock Colab runtime?** Needs the CUDA version, the compute capability and whether the quantization stack installs.
113
135
  5. **Is the Elice SSH port stable across a restart?** If it is not, the configuration needs a command that resolves the current port.
114
136
  6. **What does Elice spot pricing cost?** The API exposes a pricing id, which suggests preemptible instances are available. This is a direct cost lever.
@@ -111,6 +111,30 @@ parameters into the weak direction every round. A pair of replicas that cannot p
111
111
  fails the reduction and says so; it does not fall back to the client, for the reason
112
112
  [Link floor](#link-floor) gives.
113
113
 
114
+ A replica joins a round with two addresses for the listener it opened: the one its own interface
115
+ reports, and the one a STUN server says its NAT gives that port. The broker passes both to every
116
+ other joined replica, with a token the pair agrees a connection on and the time both start dialing,
117
+ which is what [TCP hole punching](#tcp-punch) already needs from the client when the client is one
118
+ of the two ends. A replica then tries the address its peer reported for itself first, because two
119
+ replicas on one machine or one network reach each other without a punch, and punches to the mapped
120
+ address when that connection is refused. The punch is the same `punch` call a strategy makes. The
121
+ replica that would have dialed anyway, the one that is not the leader, is the initiator, so the two
122
+ sides never both take that role whatever their ranks are.
123
+
124
+ The distributed placement is supported on a pair of runtimes that can reach each other, and nowhere
125
+ else. Two Kaggle runtimes cannot: measured on 2026-10-13, both NATs preserved the port and every TCP
126
+ SYN was dropped for 45 s, and a UDP punch on the same pair sent for 30 s each way without a datagram
127
+ arriving. letify therefore refuses such a pair rather than routing the reduction through the client
128
+ or a relay, which is the decision `docs/INTENT.md` records against claim N10. A declaration naming
129
+ two runtimes that cannot connect fails, and the measurement to make before using a provider pair is
130
+ whether they punch at all.
131
+
132
+ Punching is between a pair, because one punched connection takes one local port on each side and
133
+ the two sides have to agree which pair that port belongs to. So a round of more than two replicas
134
+ whose members cannot reach each other directly is refused, naming the ranks that could not connect,
135
+ rather than punching a mesh letify has not measured. Two replicas is the case claim N9's boundary is
136
+ measured on, and more than two over direct addresses already works.
137
+
114
138
  ### How often to synchronize <!-- id: distributed-local-steps -->
115
139
 
116
140
  > `letify.local_steps(param_bytes, step_seconds)` answers how many steps to take between
@@ -1078,6 +1102,8 @@ Before a blob is written to a destination that is not unpacked, the local proces
1078
1102
 
1079
1103
  A persistent provider builds its environment with `uv sync` in `<workspace root>/project/<env key>` every session, and never packs the project directory into a volume or unpacks an archive from one. A sync over an existing `.venv` checks it and installs nothing, so it is faster than any archive transfer, and the `.venv` is already on the disk the archive would be unpacked to.
1080
1104
 
1105
+ That holds only while the project directory is under the workspace root. A provider that keeps its workspace root and still builds the `.venv` elsewhere archives like an ephemeral one, because the directory the sync writes does not survive the session however durable the workspace root is. Modal is that provider: its workspace root is a Modal volume and its `env_root` is the sandbox's own disk, chosen because a cold import from the volume reads thousands of small files over the network. Without this, Modal rebuilt 230 packages on every session, measured at 428 s and 534 s on an A10, and had neither the persistence its workspace root offers nor the archive an ephemeral provider gets. Whether the archive is then worth packing is the measurement Materializing into a runtime already makes.
1106
+
1081
1107
  An ephemeral provider keeps the behaviour of Materializing into a runtime: every file is sent each session, and the environment archive is packed and restored.
1082
1108
 
1083
1109
  Nothing hands a session to the caller. There is no call that returns one, no argument that takes one, and no way to hold the wrong one, because which session serves a call is the pool's answer to work out from the declaration.
@@ -1261,7 +1287,7 @@ A `stat` cannot take that snapshot first, because it is on a data loader's hot p
1261
1287
 
1262
1288
  #### When a blob does not arrive <!-- id: project-data-streaming-failures -->
1263
1289
 
1264
- - **A blob that never arrives.** A `data_want` unanswered for `data_wait_timeout` seconds on the account, 600 by default, fails that open with `RuntimeFailure` naming the relative path and the digest. The call is not killed, because the body may handle it; the failure is infrastructure, so a retry of the call is allowed.
1290
+ - **A blob that never arrives.** A `data_want` whose blob has not grown for `data_wait_timeout` seconds on the account, 600 by default, fails that open with `RuntimeFailure` naming the relative path, the digest, and how much of the blob had arrived. The call is not killed, because the body may handle it; the failure is infrastructure, so a retry of the call is allowed. The timeout counts time without progress rather than total time, because a blob large enough to need longer than the timeout at the link's rate would otherwise fail however healthy the transfer was: 11.3 GB needs 19.3 MiB/s to land inside 600 s, and a Modal path measured at 45.3 MiB/s for one blob alone did not reach that behind 37 other files. The worker reads the size of the partial it is writing, so progress is the bytes on its own disk and not an estimate.
1265
1291
  - **A sender that stops.** The background sender raising leaves every blob still queued unsendable. letify says so once, `letify: data sending stopped after <n> of <n> files, <n> not sent: <error>`, and tells the runtime, which wakes every reader waiting on one of those blobs with `RuntimeFailure` naming the file and that reason, and fails a reader that arrives later at once. Waiting it out instead costs `data_wait_timeout` once for every blob still queued, which is how a hundred queued shards become an hour of silence.
1266
1292
  - **A client that disconnects mid stream.** The session ends as any lost channel ends it. Every blocked reader is woken with `RuntimeFailure`, the call ends with it, and no partial file is ever placed or read. A `.partial` file is removed when the worker next starts.
1267
1293
  - **A call that finishes before the background transfer.** The transfer is cancelled, both the channel sender and any bucket upload not yet started. A blob already committed to the cache stays and counts as held for the next call, and a partial one is removed. The write-back and the `data_evict` request follow the cancellation, in that order.
@@ -66,7 +66,7 @@ distributed: Final = Host.distributed
66
66
  # The enum class stays importable from letify.declare.instance for letify's own use.
67
67
  del Host
68
68
 
69
- __version__ = "2.6.1"
69
+ __version__ = "2.6.3"
70
70
 
71
71
 
72
72
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -68,6 +68,7 @@ def all_reduce(
68
68
  op: Callable[[list], Any] | None = None,
69
69
  timeout: float = DEFAULT_TIMEOUT,
70
70
  min_peers: int | None = None,
71
+ _watch: dict | None = None,
71
72
  ) -> int:
72
73
  """Replace ``tensor``'s contents with the reduction of every replica's, in place.
73
74
 
@@ -111,19 +112,16 @@ def all_reduce(
111
112
  round_id = _ROUND
112
113
 
113
114
  deadline = time.monotonic() + timeout
114
- listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
+ listener, port, mapped = _listener(world)
116
+ host = _local_address()
115
117
  try:
116
- listener.bind(("0.0.0.0", 0))
117
- listener.listen(world)
118
- port = listener.getsockname()[1]
119
- host = _local_address()
120
-
121
118
  main._reduce_join(
122
119
  {
123
120
  "op": "reduce_join",
124
121
  "round": round_id,
125
122
  "rank": my_rank,
126
123
  "address": [host, port],
124
+ "mapped": list(mapped) if mapped is not None else None,
127
125
  "timeout": timeout,
128
126
  "min_peers": needed,
129
127
  }
@@ -138,22 +136,58 @@ def all_reduce(
138
136
 
139
137
  leader = int(table["leader"])
140
138
  count = int(table["count"])
141
- peers = {int(r): tuple(address) for r, address in table["peers"].items()}
142
- header = {"rank": my_rank, "nbytes": len(raw)}
139
+ token = str(table.get("token", ""))
140
+ start_at = float(table.get("start_at", time.time()))
141
+ peers = {
142
+ int(r): {
143
+ "address": tuple(entry["address"]),
144
+ "mapped": tuple(entry["mapped"]) if entry.get("mapped") else None,
145
+ }
146
+ for r, entry in table["peers"].items()
147
+ }
148
+ header = {"rank": my_rank, "round": round_id, "nbytes": len(raw)}
149
+
150
+ if _watch is not None:
151
+ _watch["peers"] = table.get("peers")
152
+ _watch["token"] = token
153
+ _watch["start_at"] = start_at
143
154
 
144
155
  if my_rank == leader:
145
- result = _lead(listener, deadline, my_rank, tensor, header, raw, count, op)
156
+ result = _lead(
157
+ listener, deadline, my_rank, tensor, header, raw, count, op, peers, token,
158
+ start_at, port, round_id,
159
+ )
146
160
  else:
147
- result = _follow(peers[leader], deadline, header, raw)
161
+ result = _follow(leader, peers, deadline, header, raw, token, start_at, port, count)
148
162
  except OSError as exc:
149
163
  raise ReductionFailed(f"all_reduce round {round_id} failed: {exc}") from exc
150
- finally:
151
- listener.close()
152
164
 
153
165
  _write_back(tensor, result)
154
166
  return count
155
167
 
156
168
 
169
+ #: The replica's listener, its port and its STUN mapping, opened once and kept.
170
+ #: Spec "Reducing between replicas": a loop that reduces every H steps would otherwise
171
+ #: pay a STUN round trip and a fresh punch on every round.
172
+ _LISTENER: list = []
173
+ _LISTENER_LOCK = threading.Lock()
174
+
175
+
176
+ def _listener(world: int) -> tuple[Any, int, tuple[str, int] | None]:
177
+ """This replica's listening socket, its port and its mapped address, made once."""
178
+ with _LISTENER_LOCK:
179
+ if not _LISTENER:
180
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
181
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
182
+ if hasattr(socket, "SO_REUSEPORT"):
183
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
184
+ sock.bind(("0.0.0.0", 0))
185
+ sock.listen(max(1, world))
186
+ port = sock.getsockname()[1]
187
+ _LISTENER.append((sock, port, _mapped_address(port)))
188
+ return _LISTENER[0]
189
+
190
+
157
191
  def _local_address() -> str:
158
192
  """The host peers can reach this one on. No configuration knob: it is the machine's
159
193
  own address, as spec "Reducing between replicas" scopes the local transport."""
@@ -163,6 +197,40 @@ def _local_address() -> str:
163
197
  return "127.0.0.1"
164
198
 
165
199
 
200
+ def _mapped_address(port: int) -> tuple[str, int] | None:
201
+ """The STUN mapping of the listener's own ``port``, or None when it cannot be had.
202
+
203
+ Spec "Reducing between replicas": a replica joins a round with two addresses, its
204
+ own and this one. Never raises: a replica with no mapping still joins, with
205
+ ``mapped`` absent, and is still reachable on its own address.
206
+ """
207
+ try:
208
+ from ..transport import nat
209
+ except ImportError:
210
+ return None
211
+ try:
212
+ return nat.stun_mapping(port)
213
+ except Exception:
214
+ return None
215
+
216
+
217
+ def _pair_for_punching(*, count: int, ranks: list[int]) -> tuple[int, int]:
218
+ """The two ranks a punch is between, or a refusal naming every rank in the round.
219
+
220
+ Spec "Reducing between replicas": one punched connection takes one local port on
221
+ each side, so punching a round of more than two replicas is refused rather than
222
+ attempted as a mesh letify has not measured.
223
+ """
224
+ ordered = sorted(ranks)
225
+ if count == 2 and len(ordered) == 2:
226
+ return ordered[0], ordered[1]
227
+ named = ", ".join(str(r) for r in ordered)
228
+ raise ReductionFailed(
229
+ f"a round of {count} replicas over ranks {named} cannot punch to reach each "
230
+ "other directly: punching is between a pair, not a mesh of more than two"
231
+ )
232
+
233
+
166
234
  def _await_table(main: Any, round_id: int, deadline: float) -> dict:
167
235
  """Block until the client answers this round's join, or the deadline passes."""
168
236
  cond = main._REDUCE_LOCK
@@ -187,18 +255,62 @@ def _lead(
187
255
  raw: bytes,
188
256
  count: int,
189
257
  op: Callable[[list], Any],
258
+ peers: dict[int, dict],
259
+ token: str,
260
+ start_at: float,
261
+ port: int,
262
+ round_id: int,
190
263
  ) -> Any:
191
- """Accept every other joined peer, reduce, and send the result back to each."""
264
+ """Accept every other joined peer, punching for one that never connects, then reduce
265
+ and send the result back to each.
266
+
267
+ Spec "Reducing between replicas": the leader accepts on its own listener first,
268
+ because two replicas on one machine or one network reach each other without a
269
+ punch, and punches with ``initiator=False`` only for a peer that never arrives.
270
+ """
192
271
  collected: dict[int, Any] = {my_rank: tensor}
193
272
  conns: list[socket.socket] = []
194
273
  try:
274
+ window_deadline = min(deadline, start_at + _window())
195
275
  while len(collected) < count:
196
- listener.settimeout(_remaining(deadline))
197
- conn, _addr = listener.accept()
276
+ remaining_window = window_deadline - time.monotonic()
277
+ if remaining_window <= 0:
278
+ break
279
+ listener.settimeout(max(0.01, min(remaining_window, _remaining(deadline))))
280
+ try:
281
+ conn, _addr = listener.accept()
282
+ except TimeoutError:
283
+ break
198
284
  conn.settimeout(_remaining(deadline))
199
285
  peer_header, payload = _recv_message(conn)
286
+ if int(peer_header.get("round", round_id)) != round_id:
287
+ # The listener outlives a round, so a connection a previous one left
288
+ # behind can be accepted here. Spec "Reducing between replicas".
289
+ conn.close()
290
+ continue
200
291
  collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
201
292
  conns.append(conn)
293
+
294
+ missing = sorted(r for r in peers if r not in collected)
295
+ if missing:
296
+ _pair_for_punching(count=count, ranks=sorted(peers))
297
+ from ..transport import nat
298
+
299
+ for peer_rank in missing:
300
+ mapped = peers[peer_rank]["mapped"]
301
+ if mapped is None:
302
+ raise ReductionFailed(
303
+ f"replica {peer_rank} never connected directly and reported no "
304
+ "mapped address to punch to"
305
+ )
306
+ conn = nat.punch(
307
+ port, mapped, bytes.fromhex(token), initiator=False, start_at=start_at
308
+ )
309
+ conn.settimeout(_remaining(deadline))
310
+ peer_header, payload = _recv_message(conn)
311
+ collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
312
+ conns.append(conn)
313
+
202
314
  arrays = [collected[r] for r in sorted(collected)]
203
315
  result = op(arrays)
204
316
  result_header, result_raw = _encode(result)
@@ -211,9 +323,34 @@ def _lead(
211
323
  conn.close()
212
324
 
213
325
 
214
- def _follow(leader_address: tuple, deadline: float, header: dict, raw: bytes) -> bytes:
215
- """Send this replica's array to the leader, and wait for the reduced result's bytes."""
216
- conn = socket.create_connection(leader_address, timeout=_remaining(deadline))
326
+ def _follow(
327
+ leader_rank: int,
328
+ peers: dict[int, dict],
329
+ deadline: float,
330
+ header: dict,
331
+ raw: bytes,
332
+ token: str,
333
+ start_at: float,
334
+ port: int,
335
+ count: int,
336
+ ) -> bytes:
337
+ """Send this replica's array to the leader, and wait for the reduced result's bytes.
338
+
339
+ Spec "Reducing between replicas": the address the leader reported for itself is
340
+ tried first, and the mapped address is punched to with ``initiator=True`` only when
341
+ that direct connection fails.
342
+ """
343
+ peer = peers[leader_rank]
344
+ try:
345
+ conn = socket.create_connection(peer["address"], timeout=_remaining(deadline))
346
+ except OSError:
347
+ _pair_for_punching(count=count, ranks=sorted(peers))
348
+ mapped = peer["mapped"]
349
+ if mapped is None:
350
+ raise
351
+ from ..transport import nat
352
+
353
+ conn = nat.punch(port, mapped, bytes.fromhex(token), initiator=True, start_at=start_at)
217
354
  try:
218
355
  conn.settimeout(_remaining(deadline))
219
356
  _send_message(conn, header, raw)
@@ -224,6 +361,15 @@ def _follow(leader_address: tuple, deadline: float, header: dict, raw: bytes) ->
224
361
  conn.close()
225
362
 
226
363
 
364
+ def _window() -> float:
365
+ """The punch window, from ``letify.transport.nat`` when it can be imported."""
366
+ try:
367
+ from ..transport import nat
368
+ except ImportError:
369
+ return 45.0
370
+ return nat.DEFAULT_WINDOW
371
+
372
+
227
373
  def _remaining(deadline: float) -> float:
228
374
  left = deadline - time.monotonic()
229
375
  if left <= 0:
@@ -1269,6 +1269,31 @@ def _op_data_failed(request):
1269
1269
  return {"ok": True, "value": True}
1270
1270
 
1271
1271
 
1272
+ def _data_received(blobs, digest):
1273
+ """Bytes of this blob that are on the runtime's disk, however they are arriving.
1274
+
1275
+ Spec "When a blob does not arrive": the wait counts time without progress, and this is
1276
+ what progress means. Reads the partial a single put writes and the one a parallel put
1277
+ fills, so neither shape looks stalled while it grows.
1278
+ """
1279
+ total = 0
1280
+ final = _data_file(blobs, digest)
1281
+ directory = os.path.dirname(final)
1282
+ name = os.path.basename(final)
1283
+ try:
1284
+ entries = os.listdir(directory)
1285
+ except OSError:
1286
+ return 0
1287
+ for entry in entries:
1288
+ if entry != name and not entry.startswith(name + "."):
1289
+ continue
1290
+ try:
1291
+ total = max(total, os.stat(os.path.join(directory, entry)).st_size)
1292
+ except OSError:
1293
+ continue
1294
+ return total
1295
+
1296
+
1272
1297
  def _data_want(digests, blocking=False):
1273
1298
  """Ask the local process for these blobs, in this order. Nothing replies to it."""
1274
1299
  if not digests:
@@ -1353,6 +1378,7 @@ def _data_wait(path):
1353
1378
  state["first"] = os.path.basename(path)
1354
1379
  deadline = started + state["wait"]
1355
1380
  _data_want([digest], True)
1381
+ received = _data_received(state["blobs"], digest)
1356
1382
  try:
1357
1383
  with _DATA_READY:
1358
1384
  while path in state["pending"] and state["dir"] in _DATA_CALLS:
@@ -1361,11 +1387,20 @@ def _data_wait(path):
1361
1387
  "%s was never sent: %s"
1362
1388
  % (os.path.basename(path), state["failed"])
1363
1389
  )
1390
+ # Spec "When a blob does not arrive": the wait counts time without
1391
+ # progress, because a blob needing longer than the timeout at the link's
1392
+ # rate would otherwise fail however healthy its transfer was.
1393
+ landed = _data_received(state["blobs"], digest)
1394
+ if landed > received:
1395
+ received = landed
1396
+ deadline = time.monotonic() + state["wait"]
1364
1397
  left = deadline - time.monotonic()
1365
1398
  if left <= 0:
1366
1399
  raise _DataFailure(
1367
- "the file blob %s of %s did not arrive within %.0f s"
1368
- % (digest, os.path.basename(path), state["wait"])
1400
+ "the file blob %s of %s did not arrive within %.0f s, "
1401
+ "%.1f MiB of %.1f MiB arrived"
1402
+ % (digest, os.path.basename(path), state["wait"],
1403
+ received / 1048576.0, size / 1048576.0)
1369
1404
  )
1370
1405
  _DATA_READY.wait(min(left, 0.5))
1371
1406
  finally:
@@ -10,6 +10,7 @@ claim N10.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import secrets
13
14
  import threading
14
15
  import time
15
16
  from typing import Any
@@ -24,6 +25,10 @@ _TOTAL_BYTES = 0
24
25
  #: Seconds between polls of a replica's joins, and between sweeps for an expired round.
25
26
  _POLL_S = 0.02
26
27
 
28
+ #: Seconds ahead of now a round's agreed start moment is set, so every replica's
29
+ #: answer arrives before dialing is meant to begin.
30
+ _START_AHEAD_S = 1.0
31
+
27
32
 
28
33
  def _account(payload: dict) -> None:
29
34
  """Add the size of one brokered payload to the running total."""
@@ -134,10 +139,26 @@ class Broker:
134
139
  self._fail(round_id, joins)
135
140
 
136
141
  def _answer(self, round_id: int, joins: dict[int, dict]) -> None:
137
- """Send every joined replica the peer table, naming the lowest rank leader."""
142
+ """Send every joined replica the peer table, naming the lowest rank leader.
143
+
144
+ Spec "Reducing between replicas": each entry carries both addresses a replica
145
+ joined with, and the table carries one token and one start moment, the same
146
+ for every replica of this round, so a pair can agree a punch without the client
147
+ relaying anything more than this.
148
+ """
138
149
  leader = min(joins)
139
- peers = {str(r): list(join["address"]) for r, join in joins.items()}
140
- payload = {"round": round_id, "peers": peers, "leader": leader, "count": len(joins)}
150
+ peers = {
151
+ str(r): {"address": list(join["address"]), "mapped": join.get("mapped")}
152
+ for r, join in joins.items()
153
+ }
154
+ payload = {
155
+ "round": round_id,
156
+ "peers": peers,
157
+ "leader": leader,
158
+ "count": len(joins),
159
+ "token": secrets.token_hex(16),
160
+ "start_at": time.time() + _START_AHEAD_S,
161
+ }
141
162
  self._send_to(joins, payload)
142
163
 
143
164
  def _fail(self, round_id: int, joins: dict[int, dict]) -> None:
@@ -174,7 +174,11 @@ class Runtime:
174
174
  # Not retried, so nothing else would end this session.
175
175
  self.shutdown(reason="interpreter_mismatch")
176
176
  raise
177
- if self.env_source == "sync" and not self.provider.persistent:
177
+ # The same rule as _environment_volumes: a project directory that does not
178
+ # survive the session is worth archiving whatever the provider calls itself.
179
+ if self.env_source == "sync" and not (
180
+ self.provider.persistent and self._project_survives()
181
+ ):
178
182
  from . import bootstrap
179
183
 
180
184
  root = bootstrap.project_dir(
@@ -643,8 +647,15 @@ class Runtime:
643
647
  self.workspace = self.eval(bootstrap.workspace_source(root), timeout=120)
644
648
 
645
649
  def _environment_volumes(self) -> tuple[Volume, ...]:
646
- """Explicit archive volumes, or the automatic cache for an ephemeral provider."""
647
- if self.provider.persistent:
650
+ """Explicit archive volumes, or the automatic cache where the project does not survive.
651
+
652
+ Spec "Volumes on a persistent runtime": a persistent provider needs no archive
653
+ because its project directory is on a disk that lasts, which holds only while that
654
+ directory is under the workspace root. One that keeps its workspace and builds the
655
+ .venv elsewhere archives like an ephemeral one, because the sync's output dies with
656
+ the session however durable the root is.
657
+ """
658
+ if self.provider.persistent and self._project_survives():
648
659
  return ()
649
660
  if self.volumes:
650
661
  return self.volumes
@@ -652,6 +663,14 @@ class Runtime:
652
663
 
653
664
  return (environment_volume(self.provider),)
654
665
 
666
+ def _project_survives(self) -> bool:
667
+ """Whether the directory the sync writes lives on the disk the workspace root is on."""
668
+ root = (self.provider.env_root or "").rstrip("/")
669
+ if not root:
670
+ return True
671
+ workspace = (self.workspace or self.provider.workspace_root or "").rstrip("/")
672
+ return bool(workspace) and (root == workspace or root.startswith(workspace + "/"))
673
+
655
674
  def _measured_rate(self) -> float | None:
656
675
  """This session's measured upload rate in bytes per second, or None where none was.
657
676
 
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.6.1"
141
+ version = "2.6.3"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.6.1"
149
+ version = "2.6.3"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.6.1"
167
+ version = "2.6.3"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.6.1"
11
+ version = "2.6.3"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.6.1",
3
+ "version": "2.6.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.6.1",
9
+ "version": "2.6.3",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.6.1",
5
+ "version": "2.6.3",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.6.1"
3
+ version = "2.6.3"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"