feat(observatory): ship modular AI inference labs
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# Observatory: граница с Legacy и постраничный каталог
|
||||
|
||||
Session: `observatory-catalog-cleanup-tpit2O`, 2026-09-03.
|
||||
Изменение поверх `eff60e4`; прежние незакоммиченные viewer/source-reuse правки сохранены.
|
||||
|
||||
## Решение и результат
|
||||
|
||||
По уточнению владельца Обсерватория показывает результаты переносимых профилей,
|
||||
а не весь архив исследований, связанный с выбранной исходной записью.
|
||||
Legacy «Лабораторные контуры» и их данные остаются нетронутыми. Разделение —
|
||||
типизированная проекция, не удаление LAB, не миграция исходников и не список
|
||||
запрещённых имён E24/E25/E26.
|
||||
|
||||
В текущем каталоге RAVNOVES004TREE теперь показывает один опубликованный M4.9T5
|
||||
от3 сентября. Старый M49 без replay capability и исторический canonical LAB V1
|
||||
не попадают в Обсерваторию. У RAVNOVES00 вместо14 исторических карточек —0.
|
||||
Нерассчитанные portable profiles по-прежнему доступны для расчёта. Существующие
|
||||
версии полноценных portable результатов не скрываются по возрасту или несовпадению
|
||||
с текущей установленной версией профиля.
|
||||
|
||||
## Реализация
|
||||
|
||||
- `core/observatory/catalog.ts`: принимает только `portable-result-review` через
|
||||
существующий строгий `observatoryRecordedRunBinding`: source/result/definition
|
||||
identity и publication provenance. Неверная portable identity вызывает ошибку,
|
||||
не переименовывается в Legacy и не скрывается как успешная очистка.
|
||||
- Старые canonical/experimental adapters и их endpoints не удалены. Их наличие
|
||||
в коде не означает включение в новый продуктовый список. Общий session catalog
|
||||
продолжает возвращать исторические записи своему прежнему потребителю.
|
||||
- Backend opt-in `pagination=cursor-v1` добавляет versioned page schema и
|
||||
`next_cursor` из существующей keyset pagination `SessionStore`. Без opt-in
|
||||
форма ответа остаётся ровно прежней: `items`. `scope`, `lab_contract` и
|
||||
правила публикации/скрытия проекций не менялись.
|
||||
- `core/observation/sessionCatalogPage.ts`: отдельный строгий page adapter.
|
||||
Observatory дочитывает страницы по100 metadata entries, проверяет уникальность
|
||||
записей, повторные/небезопасные cursors, версию/размер ответа, HTTP errors и
|
||||
отмену. При ошибке отменяется второй обход; существующий hook сохраняет
|
||||
последний успешный каталог и показывает ошибку обновления.
|
||||
- Предел обхода —256 страниц на scope (по умолчанию до25600 строк). Достижение
|
||||
предела оставляет явный partial-window флаг; отсутствие записи в таком срезе
|
||||
не подтверждает удаление. Исторические provenance не накапливаются при обходе.
|
||||
Список хранит только метаданные, не видео, облака или result artifacts.
|
||||
- Текущий поиск `Select` работает по всем загруженным источникам. Отдельного
|
||||
server-side текстового поиска нет. Это keyset traversal, не транзакционный
|
||||
снимок всего каталога: новая запись во время обхода появляется при следующем
|
||||
Refresh; удалённый cursor вызывает ошибку/повтор, не выдуманную полноту.
|
||||
- Из карточек убран внутренний `result_kind`, пояснение над списком описывает
|
||||
операторскую задачу. Новых controls, визуальных паттернов, CSS или per-LAB
|
||||
branches нет; скилл Mission Core UI сохранил канонические controls и разделение
|
||||
`core → workspace`.
|
||||
|
||||
## Проверки
|
||||
|
||||
-24 focused frontend/architecture tests PASS; полный последовательный frontend
|
||||
suite747 PASS; full typecheck PASS. Backend41 tests PASS; Ruff/mypy PASS.
|
||||
- Fixture500 источников и102 LAB entries проверяет связь portable результата
|
||||
со source499 после первой сотни legacy rows. Отдельно: точная полная страница
|
||||
без ложного limit flag, дубли/cursor cycle, abort, небезопасный cursor,
|
||||
неизвестная версия, превышение страницы и явный предел обхода.
|
||||
- Backend102 sources +102 LABs: старый ответ неизменён; страницы100+2, без
|
||||
потери/дубликатов; scope-invalid cursor404, неизвестная pagination422.
|
||||
- На действующем8000 production adapter с `limit=1` сделал20 GET:3 источника,
|
||||
17 исходных LAB rows,1 admitted result,0 unresolved, оба has-more false.
|
||||
UTC13:24:52.491–13:24:52.921; monotonic `performance.now()`
|
||||
130.567750–559.498167ms в одном Node-процессе. Это metadata proof, не FPS.
|
||||
- Browser: normal/expanded composition, поиск и выбор RAVNOVES00/004TREE,
|
||||
обе нерассчитанные версии у00, один текущий M49 у004TREE, Refresh и Escape
|
||||
dropdown проверены. Вне dropdown Escape не изменил modeless окно; закрытие
|
||||
полного viewer и memory lifecycle в этом проходе не проверялись.
|
||||
- Legacy API до и после установки совпал побайтно. Worker queue сохранила
|
||||
10failed/2succeeded,0 live leases и1 v3 grant. Новых model jobs —0.
|
||||
|
||||
## Установка и эксплуатация
|
||||
|
||||
Сборка `app-9nK8VtbK.js` обслуживается единственным Core на8000, PID1425 после
|
||||
контролируемого перезапуска idle-сервиса;8765 пуст. Предыдущая frontend-сборка
|
||||
сохранена в private evidence `previous-dist`, а не удалена. Backend declarations,
|
||||
порты и LaunchAgent не изменены.
|
||||
|
||||
Первый build с искусственным V8 heap limit1024MiB завершился heap-limit OOM
|
||||
до установки; прежний UI оставался рабочим. Последовательный build с2048MiB
|
||||
прошёл за10.84с, maximum RSS2253422592 bytes. Это ограничение build-процесса,
|
||||
не доказательство нехватки всей RAM Mac. Swap2133.69→2301.06MiB после сборки,
|
||||
pressure2; временные build/test процессы завершены. Heavy replay не запускался,
|
||||
прежний browser gate не обходился. Little Snitch, чужие приложения, Docker
|
||||
limits, GPU и модельные контейнеры не менялись. Оставлена одна лёгкая вкладка
|
||||
Обсерватории для владельца, без viewer/inference.
|
||||
|
||||
## Остаток маршрута
|
||||
|
||||
Выполнена часть2D: product/Legacy separation и постраничный metadata catalog.
|
||||
Это не закрытие всего этапа2. Далее остаются2A — versioned recorded-analysis и
|
||||
cold incremental input;2B — синхронный replay/ручной ракурс/ресурсный lifecycle;
|
||||
2C — последовательные LAB V1×004TREE и оба профиля×RAVNOVES00;2D — оставшиеся
|
||||
recovery/negative cases. Standalone полного графа3 и реальный борт4 отдельно.
|
||||
Ops-карточка в этом инкременте не обновлялась.
|
||||
|
||||
## Evidence
|
||||
|
||||
Private root: `.runtime/observatory-catalog-cleanup-tpit2O`. API metadata,
|
||||
screenshots и runtime output не добавлены в Git.
|
||||
|
||||
| Артефакт | SHA-256 |
|
||||
| --- | --- |
|
||||
| `legacy-before.json`, `legacy-after.json` | `4b5a19d83569c598e9936f1a65ef445d65eccc4bdc2731823bbbdebbf746cabc` |
|
||||
| `live-pagination-proof.json` | `1a5c65b04b34f2f797c927f3e29094b399f9c51c717786a1975344e6cc975dcf` |
|
||||
| `frontend-tests.log` | `674745c5a008cdb39a5d5a0b360ddff304da855dca2ba899ca2bd341a415dee0` |
|
||||
| `backend-tests.log` | `d8b26fab3621b44e79ae5555ccd2de5447fb1efac9c7c2737b97bf8563e7e2e1` |
|
||||
| `build-2g.log` | `f1b7d14266cee959bc69b271cffa17a4d1483c2af7330301fbc7261a01519a7f` |
|
||||
| `worker-readiness-after.json` | `26db0da5261bae8b1b85cb06c93f633dcaa9d732a543c6fca24326a6831a7758` |
|
||||
| `normal.png` | `c45fe93e56a1edaf6580bcbef5848d09a34e5bdc84903103567a58e47191d5db` |
|
||||
| `rav00-results.png` | `63cfbbbc6e89b2bb17ddd68c83dddb89a235e8d9eb54709955a70139dd2c76f2` |
|
||||
| `rav004-results.png` | `28520a65a459b6736697cfd82527df9ba2eb3ebacfd1888a70f914b1e4f43809` |
|
||||
@@ -0,0 +1,118 @@
|
||||
# Observatory: возврат к основному лабораторному сценарию
|
||||
|
||||
Дата: 2026-09-03. Session: `observatory-mainline-FZrkOa`.
|
||||
Checkout: `codex/m5-1-observatory`, инкремент поверх `eff60e4`.
|
||||
Handoff snapshot: UTC `2026-09-03T12:22:38.948Z`, Node `process.hrtime.bigint()`
|
||||
`2222283739491791` ns. Catalog snapshot имеет отдельные UTC/monotonic в evidence;
|
||||
часы разных механизмов не вычитаются друг из друга. Для тестов/build сохранено
|
||||
измерение elapsed через `/usr/bin/time -l`, не per-stage trace приложения.
|
||||
|
||||
## Решение и граница
|
||||
|
||||
Владелец потребовал вернуться к основному плану и не превращать общую занятость
|
||||
RAM Mac в бессрочный блокер. Активные процессы могут потреблять нужную память;
|
||||
после завершения временные ресурсы освобождаются. Это правило не отменяет
|
||||
зафиксированный дефект закрытого viewer и не означает, что лабы уже приняты.
|
||||
|
||||
Только локальный Core8000, без Worker/model runs, внешнего deployment, изменений
|
||||
Little Snitch/пользовательских служб/Docker limits, удаления записей/cache.
|
||||
UI skill сохранил один renderer/clock, прежние controls и последовательные gates.
|
||||
|
||||
## Объективный статус четырёх этапов
|
||||
|
||||
| Этап | Что доказано | Что остаётся |
|
||||
| --- | --- | --- |
|
||||
| 1. Основа | Admission, очередь/claim, защита от дублей, exact cache/version/selector | Не повторять уже выполненный ремонт |
|
||||
| 2. Полные лабы | M49×004TREE рассчитан, sealed/published, документ/cache пережили restart; общий saved replay реализован | Versioned recorded-analysis/input, visual acceptance, LAB V1 и вторая запись, recovery и большой каталог |
|
||||
| 3. Полный Docker-профиль | Существует инженерный граф DDRNet/RF-DETR/LiDAR/distance/motion/TGS/costmap/policy-shadow | Самостоятельный image/cold start, полный run и сравнение конфигураций |
|
||||
| 4. Борт | Цель определена | Реальное целевое оборудование и отдельная квалификация; сейчас не переносим |
|
||||
|
||||
**Лабы целиком не закрыты.** M4.9T5 сейчас CPU TGS; LAB V1 — последовательная
|
||||
EoMT+DDRNet segmentation, а не готовый полный профиль рига. Старые FPS и
|
||||
красивые overlays не доказывают целый вычислительный граф в одном Docker.
|
||||
|
||||
## Сверка незакрытого кода
|
||||
|
||||
- `core/observatory/catalog.ts:fetchObservatoryCatalog` запрашивает два окна
|
||||
по100 metadata entries (source/laboratory), не продолжает cursor. API
|
||||
`/api/v1/observation-sessions` cursor уже принимает. Нужно довести постраничные
|
||||
source/result связи и поиск, не грузить записи целиком и не скрывать результаты.
|
||||
- `compute/lidar_replay.py:build_lidar_replay_pack_v2` вызывает `_capture_arrays()`
|
||||
до cache lookup. Это не инкрементальный вход. Source/logical/producer hashes
|
||||
участвуют в identity, поэтому новый путь требует версионирования и сохранения
|
||||
старых exact-cache результатов, не механической перестановки строк.
|
||||
- `viewer/recorded.py:_RecordedBlueprintStream` активирует clone при обновлении
|
||||
слоёв; сохранение ручного eye не решено. Нужен поддержанный upstream-механизм,
|
||||
не локальный патч SDK/WASM и не второй renderer. Initial camera frame и
|
||||
полная visual coverage тоже ещё не приняты.
|
||||
|
||||
## Выполненный инкремент
|
||||
|
||||
Накопленный primitive-only owner v2 проверен и собран. Дополнительно исправлена
|
||||
потеря input между native iframe и существующими внешними controls: Escape и
|
||||
pointer fields копируются в parent-owned events с переводом координат через
|
||||
bounds iframe. Слежение за native divider снова получает события. Исходный
|
||||
canvas не получает синтетических действий; native gestures не отменяются.
|
||||
Все5 listeners удаляются при dispose. Это минимальная поддержка прежней
|
||||
композиции, а не новый UI control или новый clock.
|
||||
|
||||
## Итоговая последовательная проверка
|
||||
|
||||
| Проверка | Результат | Elapsed | Maximum RSS bytes |
|
||||
| --- | --- | ---: | ---: |
|
||||
| Lifecycle/input + architecture | 16 PASS | — | — |
|
||||
| Полный TypeScript | PASS | 4.87с | 904871936 |
|
||||
| Полный frontend suite | 740 PASS | 21.73с | 264798208 |
|
||||
| Lifecycle/session API/RRD backend | 64 PASS | 3.81с | 190988288 |
|
||||
| Production build | PASS | 15.14с | 1994817536 |
|
||||
|
||||
Первые739 frontend tests и первая сборка сохранены отдельно; после input relay
|
||||
повторены итоговые gates740. Число16 входит в740, суммы не уникальные тесты.
|
||||
RSS относится к измерению команды, не всей физической памяти Mac. В backend
|
||||
сохранилось deprecation warning Starlette/httpx; в build — warning больших chunks.
|
||||
|
||||
Serving: `app-DHVEqf5x.js`, runtime `rerun-Dy4Vuq0s.js`, upstream SDK0.36.3 без
|
||||
патча; WASM `re_viewer_bg-BO4B44yr.wasm` 50428810 bytes. `/`, `/rerun-runtime.html`
|
||||
и `/api/health` отвечают успешно, PID89747 на127.0.0.1:8000;8765 пуст.
|
||||
Нового backend restart не было.
|
||||
|
||||
## Browser и ресурсы: точная граница доказательства
|
||||
|
||||
Каталог нового v2 был открыт обычным путём LAB→Обсерватория до input relay patch;
|
||||
renderer92266=136MB, shared GPU72641=408MB, Core89747=164MB. Это не heavy replay.
|
||||
Auto-review отклонил открытие готового M49 при pressure2 из-за риска прежнего
|
||||
остаточного потребления. Запрошено явное разрешение на короткий просмотр;
|
||||
нет ответа на момент этой сводки. Обхода не было. Следовательно, normal/expanded/
|
||||
Escape/divider и open/close именно последней сборки **не приняты в браузере**.
|
||||
Временная проверочная вкладка закрыта; canonical Core оставлен работающим.
|
||||
|
||||
OOM не произошло. До финальной сборки swap1934.69MiB, после2173.69MiB,
|
||||
pressure2. Нельзя назвать RAM неограниченной или заявить memory-fix PASS по
|
||||
успешному build. Docker — только три durable telemetry containers примерно
|
||||
56/292/13MiB. Посторонние приложения и VM limits не менялись.
|
||||
|
||||
## Продолжение и приёмка
|
||||
|
||||
Продолжать оставшийся2A — versioned полный анализ/incremental input; при
|
||||
разрешённом просмотре завершить2B. Далее LAB V1×004TREE, оба профиля×RAVNOVES00,
|
||||
затем recovery/negative cases и пагинация. Подготовка metadata fixtures2D не
|
||||
зависит от heavy browser gate. Старые данные/результаты остаются immutable.
|
||||
Отдельный memory gate не подменяет эти задачи. Этап3 следует за принятым
|
||||
лабораторным циклом; борт, моторы и сетевой realtime PASS сейчас не требуются.
|
||||
|
||||
## Evidence manifest
|
||||
|
||||
Private directory: `.runtime/observatory-mainline-FZrkOa`.
|
||||
Исходные записи/скриншоты/секреты в Git не добавлялись.
|
||||
|
||||
| Файл | SHA-256 |
|
||||
| --- | --- |
|
||||
| `input-bridge-tests.log` | `b007e29a4b828b0982347dee267c0498424048abd7441efa0a1abc6ccdc54592` |
|
||||
| `input-bridge-typecheck.log` | `7c66ca38e202d5c34eaf2509723281e558aadb8fd8716ca38095d31cac877098` |
|
||||
| `input-bridge-full-tests.log` | `e8a103bb84f7412706f29661ca32a63c3b9f8a34b4e9d83a1a1bf468a009242e` |
|
||||
| `input-bridge-build.log` | `a437b38a1d6873707193e742c8719942b0842034adbe6168e5e507771b13226c` |
|
||||
| `backend-tests.log` | `3ccd6d31b1de48f0506557ab5cb83324121fdd80ca143a2d72a5dc7080bfed56` |
|
||||
| `01-catalog.txt` | `f226d322bd9749245a6d436806829b015038fa829ccb96979544ccb7d0518066` |
|
||||
|
||||
Ops ранее не ответил на direct read; карточка в этом инкременте не менялась.
|
||||
Актуализированы локальный ExecPlan и Desktop final-status.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Mission Core: освобождение ресурсов после просмотра — 2026-09-03
|
||||
|
||||
## Решение и граница
|
||||
|
||||
По уточнению владельца активная операция использует необходимую ей память.
|
||||
После завершения/закрытия/отмены/ошибки освобождаются её временные буферы,
|
||||
визуализатор, каналы, таймеры и серверные сессии. Новые ограничения качества,
|
||||
разрешения, числа точек или размера активной записи не вводятся. Дисковые
|
||||
записи, результаты и replay-cache сохраняются. Little Snitch, пользовательские
|
||||
приложения и настройки Docker VM не менялись.
|
||||
|
||||
Это исправление найденного lifecycle записанного просмотра, не приёмка памяти
|
||||
всех операций Core/Worker. Холодную подготовку, расчёт полного профиля,
|
||||
публикацию и live receiver нужно проверять отдельно по стадиям и PID.
|
||||
|
||||
## Доказанная исходная проблема
|
||||
|
||||
На `eff60e4` сохранённый M49 RRD размером 193622178 bytes открывался без compute.
|
||||
Renderer: 111 MB до → 1104 MB открыто → 567 MB спустя 74 секунды после закрытия.
|
||||
Reload снижал его до 133 MB. GPU-процесс: 372 → 838 → 825 → 363 MB.
|
||||
Core оставался около 168–170 MB; Docker VM — 2613 MB.
|
||||
|
||||
В upstream 0.36.3 `stop()` уже вызывает native destroy/free/deinit, но цикл
|
||||
`check_for_panic` продолжает планировать таймер. Наличие этого дефекта установлено
|
||||
по коду; он не доказан как единственный владелец всего остатка памяти. По ADR0045
|
||||
SDK не патчится. В backend также отсутствовало освобождение blueprint-сессии
|
||||
при закрытии UI: ограничение 32 владельцами само по себе не завершало lifecycle.
|
||||
|
||||
## Реализация
|
||||
|
||||
- `components/rerun/`: disposable iframe и фасад публичного upstream API.
|
||||
Уничтожение контекста с очисткой ссылок, включая подписки и каналы, даже при
|
||||
ошибке SDK stop. Ни родительская Promise, ни сохранённый callback не должны
|
||||
держать foreign DOM/SDK object после dispose. Один native clock сохраняется.
|
||||
- `RerunViewport`: cleanup на unmount/pagehide; новое owner-id при повторном
|
||||
запуске; восстановление из browser page cache через новый lifecycle.
|
||||
Активный live path не переведён в iframe.
|
||||
- `recordedBlueprintLifecycle`: renewal раз в 30с и bounded keepalive release.
|
||||
- `recorded_blueprint_lifecycle.py`: explicit release, idle TTL 300с,
|
||||
render/release exclusion, временный запрет запоздалого повторного открытия
|
||||
released owner, очистка failed render и shutdown. Reaper раз в 30с.
|
||||
Потеря renewal удаляет только восстанавливаемый ephemeral blueprint.
|
||||
- `session_api`: точный owner lifecycle endpoint; released update получает 410.
|
||||
Освобождение работает и после удаления исходника, не требует materialization.
|
||||
|
||||
## Проверки кода
|
||||
|
||||
- 64 focused backend tests (lifecycle, session API, RRD), Ruff PASS; mypy новых
|
||||
registry/recorded модулей PASS.
|
||||
- Финальный typecheck, 734 frontend tests и production build PASS; тяжёлые
|
||||
проверки строго последовательны. Сохранилось обычное предупреждение Vite о
|
||||
больших chunks; SDK не обновлялся.
|
||||
- Fixtures: stop throws, close before load, late load, timeout, повторный dispose,
|
||||
сохранённый facade/channel/unsubscribe после dispose, копирование event/range,
|
||||
отмена renewal и keepalive release; expired owner, reset, LRU, shutdown,
|
||||
release во время render и fence позднего запроса.
|
||||
- Канонический backend перезапущен один раз, PID 89747, только 8000. После
|
||||
холодного старта `/api/health`, `/` и `/rerun-runtime.html` отвечают 200.
|
||||
|
||||
## Промежуточная browser-проверка: одного iframe недостаточно
|
||||
|
||||
До добавления фасада два штатных открытия того же сохранённого M49 дали:
|
||||
|
||||
| Фаза | Renderer 89791, MB | GPU 72641, MB | Core 89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог до открытия | 124 | 423 | 152 |
|
||||
| Первый просмотр, playback | 993 | 743 | 164 |
|
||||
| Сразу после закрытия | 805 | 401 | 164 |
|
||||
| Через 89 секунд | 810 | 369 | 163 |
|
||||
| Повторное открытие | 1016 | 719 | 166 |
|
||||
| Повторное закрытие | 790 | 410 | 166 |
|
||||
|
||||
GPU освобождён; устойчивый возврат renderer к baseline **не доказан**.
|
||||
Нельзя выдать отсутствие удвоения расхода за полное исправление. Поэтому после
|
||||
этой проверки добавлено размыкание всех ссылок через facade.
|
||||
|
||||
В этой промежуточной версии проверены камера/TGS на начальном cursor, play/pause
|
||||
(39.215 → 49.899с), переключение SOURCE, normal/expanded и Escape из iframe.
|
||||
После закрытия iframe/review = 0, lifecycle POST = 204. Самая ранняя +1мкс
|
||||
граница теперь показала изображение камеры и TGS, но исходный неудобный ракурс
|
||||
и его сброс при blueprint activation не исправлялись этим инкрементом.
|
||||
|
||||
Mac pressure = 1 и swap = 2013.75 MiB без роста в обоих циклах. Docker VM
|
||||
оставалась 2616 MB. GPU-процесс общий; footprint процессов нельзя складывать
|
||||
как точную оценку физической RAM приложения. Значения MB округляет `footprint`.
|
||||
|
||||
## Финальный facade: функциональный PASS, memory acceptance FAIL
|
||||
|
||||
После нормализации pressure до1 выполнен один контрольный цикл финальной сборки
|
||||
`app-C54IKeub.js`, тот же immutable RRD, без вычисления профиля:
|
||||
|
||||
| Фаза | Renderer 90898, MB | GPU 72641, MB | Core 89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог до открытия | 112 | 365 | 162 |
|
||||
| Открыто, пауза | 943 | 680 | 164 |
|
||||
| После короткого playback | 1014 | 715 | 164 |
|
||||
| После закрытия | 819 | 411 | 164 |
|
||||
| Через 62с после предыдущего замера | 829 | 375 | 164 |
|
||||
|
||||
Facade **не устранил остаточный footprint renderer**. Браузерный memory issue
|
||||
не закрыт и итог не выдаётся за возврат RAM к baseline. Явные GPU/server cleanup
|
||||
и прекращение подписок реализованы; какой native/allocator/GC ресурс держит
|
||||
оставшуюся память, этим опытом не доказано. Не называть весь остаток живой
|
||||
утечкой без retained-object/native evidence, но и не называть его безвредным кэшем.
|
||||
|
||||
Функционально проверены normal/expanded, Escape из iframe, play/pause
|
||||
39.215→56.757с, изображения камеры и TGS после playback, iframe/review0 после
|
||||
закрытия, server lifecycle204; browser error logs пусты. На начальном expanded
|
||||
кадре камера оказалась пустой, после play появилась: устойчивость initial frame
|
||||
остаётся визуальным ограничением, несмотря на успешный промежуточный first-open.
|
||||
Проверки bfcache и аварийного старта — fixtures, не реальные fault injection.
|
||||
|
||||
В финальном closed-idle snapshot pressure стал2; swap не вырос (2013.75→2005.75
|
||||
MiB), Docker VM2616 MB. Проверочная вкладка закрыта. Новые тяжёлые циклы
|
||||
остановлены resource gate `mission-core-product-ui`. Следующий шаг — локализация
|
||||
удерживаемой native/WASM памяти либо подтверждённый механизм завершения её
|
||||
владельца; не ещё один такой же open/close и не принудительный GC/reload как
|
||||
продуктовое «лечение». Холодный compute и остальные операции остаются отдельно.
|
||||
|
||||
## Follow-up: карта памяти и primitive-only owner, 2026-09-03
|
||||
|
||||
До изменения boundary повторно проверена прежняя сборка `app-C54IKeub.js`
|
||||
на том же immutable M49, с `vmmap -w` в каждой фазе. Это новая диагностика
|
||||
типов/адресов памяти, а не доказательство исправления по исчезновению canvas.
|
||||
|
||||
| Фаза, UTC | Renderer91040, MB | GPU72641, MB | Core89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог, 11:41:10 | 119 | 381 | 161 |
|
||||
| Открыто, 11:41:24 | 914 | — | — |
|
||||
| Закрыто, 11:41:58 | 834 | 420 | 162 |
|
||||
| Через35с, 11:42:33 | 215 | 379 | 162 |
|
||||
| Повторно открыто | 941 | 717 | 165 |
|
||||
| Повторно закрыто, 11:44:10 | 812 | 415 | 165 |
|
||||
| Через91с, 11:45:41 | 824 | 376 | 163 |
|
||||
|
||||
В первом цикле крупные writable области `1f160…/1f161…` исчезли: вместо них
|
||||
`vmmap` показывает невыделенный 8-GiB адресный резерв с0 resident/dirty/swap.
|
||||
То есть память действительно возвращалась, не просто уходила в swap. Но
|
||||
повторный цикл не воспроизвёл этот результат: оставалось543MB untagged memory,
|
||||
в основном writable/swapped, а не executable code. Это исключает объяснение
|
||||
всего остатка исключительно скомпилированным WASM-кодом, но **не определяет
|
||||
конкретный retained-object root**. Общий memory gate остаётся FAIL.
|
||||
`vmmap` предупреждает, что не может разобрать внутреннюю PartitionAlloc zone;
|
||||
карта VM не заменяет анализ живых объектов. В этой серии сохранён UTC, но
|
||||
per-phase monotonic timestamp не был записан; интервал35/91с — разница UTC.
|
||||
|
||||
Новая реализация boundary v2 оставляет native SDK, каналы, подписки, Error
|
||||
объекты и startup Promise целиком внутри iframe. Родитель получает JSON-строки
|
||||
и создаёт собственные plain objects/Promise; getter/clock остаётся native,
|
||||
дополнительного polling/clock/renderer нет. Это устраняет сам путь передачи
|
||||
foreign SDK objects, не выдавая его за доказанную единственную причину остатка.
|
||||
Межоконные ссылки и prototype chains действительно могут удерживать удалённое
|
||||
окно: [разбор Chrome](https://web.dev/articles/detached-window-memory-leaks).
|
||||
Исходящие native ошибки также превращаются в текст. Каждый auxiliary channel
|
||||
закрывается даже при ошибке соседнего. Dispose немедленно отклоняет pending
|
||||
parent start; позднее завершение SDK start повторно вызывает публичный stop.
|
||||
|
||||
Проверено на v2:15 focused tests (включая architecture), focused strict TypeScript
|
||||
для пяти runtime-модулей PASS. Проверены все используемые аргументы native
|
||||
clock/control API, copied events/ranges, primitive errors, close-before-load,
|
||||
late success/failure и независимая очистка каналов. Это synthetic lifecycle
|
||||
coverage, **не heap/footprint acceptance**.
|
||||
|
||||
На завершении предыдущего прохода production build, полный frontend suite/typecheck и новый browser A/B **не
|
||||
запускались**: после закрытия тестовой вкладки pressure устойчиво2, swap
|
||||
1941.75→1917.75MiB не растёт. Temporary viewer закрыт, других наших временных
|
||||
тяжёлых процессов нет. По `mission-core-product-ui` heavy QA приостановлена;
|
||||
чужие приложения, Little Snitch и Docker VM не менялись. На8000 остаётся
|
||||
предыдущая рабочая сборка, backend89747 healthy. Следующий шаг после pressure1:
|
||||
полный typecheck/tests/build последовательно, затем real normal/expanded/Escape
|
||||
и bounded memory check v2 с повторным открытием, без forced GC/reload.
|
||||
При повторном FAIL нельзя объявлять boundary исправлением всей memory issue.
|
||||
|
||||
Ops instructions read завершился60s timeout; карточка не изменялась.
|
||||
|
||||
## Возврат к основному сценарию: v2 собран, browser gate отдельно открыт
|
||||
|
||||
По прямому решению владельца продолжены необходимые последовательные проверки,
|
||||
без повторного расследования всей памяти Mac. Обнаружен и исправлен ещё один
|
||||
эффект iframe: canvas pointer events не доходили до родительского слежения за
|
||||
native divider, поэтому внешние controls теряли выравнивание при его перетаскивании.
|
||||
Новый relay передаёт только primitive input fields, переводит координаты через
|
||||
iframe bounds, сохраняет Escape и удаляет все5 listeners при dispose. Native
|
||||
canvas/gestures остаются у upstream; качество, слои и clock не меняются.
|
||||
|
||||
Итоговые проверки:16 focused tests,740 frontend tests,64 focused backend tests,
|
||||
полный typecheck и production build PASS. Build15.14с, maximum RSS1994817536 bytes
|
||||
(около1.86GiB), а не требование выделить приложению столько памяти постоянно.
|
||||
Новая сборка `app-DHVEqf5x.js` обслуживается на8000 без нового backend restart.
|
||||
На первом проходе каталога renderer136MB, shared GPU408MB, Core164MB.
|
||||
|
||||
**Browser acceptance v2 не выполнена.** Auto-review отдельно запретил тяжёлое
|
||||
открытие готового M49 при pressure2 с учётом прежнего остаточного footprint.
|
||||
Запрошено явное разрешение на один короткий просмотр; действие не обходилось,
|
||||
проверочная вкладка закрыта. Сохранённый viewer/Worker inference не запускались.
|
||||
Все сборки/тесты завершились без OOM, однако swap в интервале финальной сборки
|
||||
вырос1934.69→2173.69MiB. Нельзя заявить отсутствие memory pressure или приписать
|
||||
этот системный прирост исключительно Core. Никакие чужие сервисы не остановлены.
|
||||
|
||||
Memory issue остаётся открытым критерием завершения операции. Он не блокирует
|
||||
оставшиеся input/catalog/contracts этапа2 и не заменяет основной маршрут:
|
||||
полный расчёт → сохранённый просмотр → матрица профилей/записей → recovery.
|
||||
Проверки и план: [mainline reconciliation](OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
|
||||
|
||||
## Evidence
|
||||
|
||||
Private numeric evidence: `.runtime/observatory-memory-9Xq3IH` (до правки),
|
||||
`.runtime/observatory-memory-fix-V6D9D8` (текущая проверка и test/build logs).
|
||||
Follow-up: `.runtime/observatory-memory-owner-UGy0AS` (VM maps, footprints,
|
||||
focused v2 tests/types; старый browser build отмечен отдельно).
|
||||
Текущий проход: `.runtime/observatory-mainline-FZrkOa`; hashes и версии сборки
|
||||
зафиксированы в mainline reconciliation. Полный browser memory A/B v2 там отсутствует.
|
||||
В Git нет содержимого записи, heap dump или скриншотов исходных данных.
|
||||
Ops direct MCP не ответил на чтение instructions/projects; запись отчёта в
|
||||
MISSIONCOR-72 не выполнена и не заявляется успешной. Локальные документы —
|
||||
фактический handoff до восстановления Ops.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Observatory modular handoff — 2026-09-03
|
||||
|
||||
## 1. Objective and architecture stage
|
||||
|
||||
Owner approved the transition from monolithic full-profile packaging to reusable
|
||||
Docker modules and immutable compositions. This pass audits CURRENT, records
|
||||
TARGET, retires inspected obsolete stopped instances and prepares a self-contained
|
||||
Desktop handoff. It does not implement the new composer or build/install modules.
|
||||
|
||||
Decision: [ADR0051](../../docs/adr/0051-modular-observatory-profiles.md).
|
||||
Sole current route: the new upper section of
|
||||
[ExecPlan](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
Earlier routes and numerical evidence remain historical, not current commands.
|
||||
|
||||
Ops report created through direct Tasker MCP, MISSION CORE card73,
|
||||
`a6e5bea8-5441-44ca-957f-1c117b1decf3`, at `2026-09-03T16:07:07.955933+00:00`.
|
||||
Its12 titled blocks separate completed audit from open modular implementation;
|
||||
the pre-existing saved-replay card72 was not overwritten.
|
||||
|
||||
## 2. Decision question and hypothesis
|
||||
|
||||
Can Observatory expose functional provider groups, reuse model/function images
|
||||
across compositions, keep exact cached LABs and later move the selected composition
|
||||
to a compatible CUDA onboard host? This is the accepted architecture direction,
|
||||
not yet an experimentally qualified performance claim. Mac Mini is explicitly
|
||||
not the onboard target. Avoid per-LAB micro-apps and one image containing every
|
||||
possible model combination.
|
||||
|
||||
The UI uses one canonical Select per group. DDRNet and EoMT are alternatives in
|
||||
new LABs, never two selected segmenters. A module may provide several capabilities
|
||||
without being instantiated several times. Existing dual-segment LAB V1 remains
|
||||
legacy compatibility. Exact completed compositions open their existing LAB;
|
||||
there is no Calculate action or completion badge for that exact configuration.
|
||||
|
||||
## 3. Immutable evidence and bounds
|
||||
|
||||
Session: `observatory-modular-handoff-mHGULT`.
|
||||
Repository: `NODEDC_MISSION_CORE_m5_observatory`, branch
|
||||
`codex/m5-1-observatory`, HEAD `eff60e4`, with pre-existing dirty changes retained.
|
||||
Private evidence directory:
|
||||
`.runtime/observatory-modular-handoff-mHGULT` in that checkout; excluded from Git.
|
||||
No recording contents, credentials, raw logs or model weights enter this report.
|
||||
|
||||
Read-only Worker inventory before: UTC `2026-09-03T15:45:51.756951+00:00`, local
|
||||
monotonic start1116.805708333, duration2.502617583s. After: UTC
|
||||
`2026-09-03T15:57:28.620618+00:00`, local monotonic start1815.378697625,
|
||||
duration0.801071041s. Monotonic values are within the local process/host timeline,
|
||||
not synchronized Worker timestamps.
|
||||
|
||||
| Artifact | SHA256 |
|
||||
| --- | --- |
|
||||
| worker-inventory-before.json | a7f91a21797c9814d441629b59b5eab1307b2976ac84ecde7c23daef8632ba20 |
|
||||
| worker-inventory-after.json | 5463f35a3c2b5134d499c495d93d77f7350438038a7a3010031c6842d417fbef |
|
||||
| stopped-container-archive.json | b1c6d3fbc6ae3fcd9a5a34d1d24616433f7f643e7c8233eab287e88747ed5188 |
|
||||
| cleanup-receipt.json | 58af029462c7fc73472ab1aae29be89f90c1412b2d4e35bfe23e3fb37c6b1030 |
|
||||
| Canonical ordered job identity tuples before/after | 69cae43ed43fac04d6443350e39a5f83f0e64f9c7ed58e6889f7320d2b80cc9f |
|
||||
| Original Desktop document before handoff prepend/rename | 7a0e57ba24eb95c95cd86aae55e24ae933de73746ecb299ce9542f6e8d2228c9 |
|
||||
|
||||
## 4. Method, models, algorithms and identities
|
||||
|
||||
Read source package/runner, worker queue/transport/cache, publication/replay and
|
||||
current Observatory selector; compare installed Docker declarations via the
|
||||
current agent's Docker socket over the existing SSH target `mission-gpu`.
|
||||
Inventory excludes Env/credentials. Inspect stopped writable-layer diffs and
|
||||
mounts before deletion; archive logs privately, not in the normal Git tree.
|
||||
|
||||
Current installed LAB V1 package:
|
||||
`1bc84be07634ff69ac7459a2c39dc8fc9bcee33f72985dfa2c6088bb78976d8e`.
|
||||
Definition:`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
|
||||
Release:`667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b`.
|
||||
|
||||
| Fixed step | Pinned image SHA256 | GPU request | Memory limit |
|
||||
| --- | --- | --- | --- |
|
||||
| prepare | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 4GiB |
|
||||
| EoMT | adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174 | one | 24GiB |
|
||||
| DDRNet | e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051 | one | 16GiB |
|
||||
| assemble | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 8GiB |
|
||||
|
||||
Memory limits in this table are container host-RAM limits, not GPU VRAM quotas.
|
||||
The fixed sequence uses mounted code/model/runtime assets. EoMT and DDRNet have
|
||||
different Python/Torch environments; retain their separation during packaging.
|
||||
This is not yet an operator-editable graph or standalone module distribution.
|
||||
|
||||
M49 CPU TGS runs inside its specialized agent with source preparation and a
|
||||
compiled executor subprocess. Preserve executor image
|
||||
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3` and current assets.
|
||||
Also retain full-graph engineering prototype image `986dbe712699…`
|
||||
(`ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901`). Its presence is
|
||||
not proof of a complete accepted recorded LAB or autonomy.
|
||||
|
||||
## 5. Worker/runtime topology and resource policy
|
||||
|
||||
Current agents, preserved and running before/after:
|
||||
|
||||
- `ndc-observatory-installed-lab-worker-agent`, ID
|
||||
`be85dc40147af3eda18803b913598d733c31f07ff587ededae5a58a0ad76f21e`, image
|
||||
`bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a`.
|
||||
- `ndc-observatory-m49-worker-agent`, ID
|
||||
`d58b3872d2ab399ca3566ab88080da1000988eee22c9c7bab7ccc8f2eac63796`, image
|
||||
`e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650`.
|
||||
|
||||
Latest stopped rollback pair retained:
|
||||
`a01b0fc675a01f52f7312d669d8b0e4147ab175baeca6396ec58f7cea4bd6029` and
|
||||
`c51bd8d95a85e218acc417d8972945656a31cb2dba8dc947842152a225f4d538`.
|
||||
Control declaration directory:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
|
||||
Control agent image identities and compute-step image identities differ.
|
||||
|
||||
After cleanup:11 containers,7 running/4 stopped,80 images,9 named volumes.
|
||||
Other five running services retained pending consumer/dependency retirement:
|
||||
Gaussian gateway, Gaussian pipeline, Gaussian terrain executor, perception worker,
|
||||
Triton. Gateway/pipeline health is unhealthy; perception has recurring restarts.
|
||||
No causal connection to the new cleanup was inferred from those pre-existing states.
|
||||
Frigate and Ollama remain stopped, restart=no, as previously requested.
|
||||
|
||||
One Worker006/RTX4090 retains exclusive composition ownership and sequential heavy
|
||||
GPU execution. No model inference or performance benchmark was launched this pass.
|
||||
The owner-approved EoMT disk floor is250GiB plus estimated working set, instead
|
||||
of350GiB. Source/tests changed earlier and were checked again here; installed
|
||||
EoMT remains350GiB until a new sealed image/release. No GPU/RAM limits or clocks
|
||||
were changed in this pass.
|
||||
|
||||
## 6. Implementation and bounded cleanup
|
||||
|
||||
Two-phase archive then delete; recheck all exact IDs/names/images/stopped states,
|
||||
restart policies and layer diffs. Check both current agents running, queue fully
|
||||
terminal, no active lease or running LAB compute container. Deletions use exact
|
||||
container IDs, `force=false`, `v=false`. No volume, image, bind directory or
|
||||
recording deletion; no global prune. The archive-only first attempt failed on
|
||||
unordered Docker diff results; sorting corrected the comparison before any delete.
|
||||
|
||||
Removed13 stopped instances:
|
||||
|
||||
| Short ID | Name |
|
||||
| --- | --- |
|
||||
| 05222a8e343e | ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e |
|
||||
| 51027773cd1b | ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b |
|
||||
| 7a3e07929c82 | ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82 |
|
||||
| a8e8353d4eb9 | ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9 |
|
||||
| 810212622de9 | ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9 |
|
||||
| b30b41d9e2e2 | ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2 |
|
||||
| 7921ab34712f | ndc-observatory-m49-worker-agent-legacy-v1-20260901 |
|
||||
| 623c651a5a71 | ndc-observatory-m49-worker-agent-892d008-retired |
|
||||
| 1898416f461e | ndc-mission-core-perception-worker-pre-e19-20260731 |
|
||||
| b61e5b237f80 | mission-core-perception-worker-e21-debug3 |
|
||||
| 432cae7d87f6 | mission-core-perception-worker-e21-debug2 |
|
||||
| d928b067f9b9 | mission-core-perception-worker-e21-debug1 |
|
||||
| b33b06d1061b | mission-core-perception-worker-e16-backup-20260723 |
|
||||
|
||||
Private archive contains2078504 log bytes plus redacted instance configuration.
|
||||
It is not a rootfs/volume backup. Removed Docker IDs cannot be restored; services
|
||||
can be recreated from retained images/declarations/data if necessary. Removed
|
||||
writable-layer sizes sum1175552bytes, not tens of GB. No claimed physical disk
|
||||
recovery was measured from sparse backing-store compaction.
|
||||
|
||||
Files changed by this architecture/document pass: ADR0051 added, ADR0050 amended,
|
||||
current ExecPlan upper route replaced by modular route (old text archived), this
|
||||
report added, Desktop status renamed with `_` and self-contained0.46 prepended.
|
||||
Private audit/cleanup scripts are evidence tooling, not a new product runtime.
|
||||
Pre-existing UI/runtime changes were neither reverted nor treated as newly built.
|
||||
|
||||
## 7. Validation and reproduced evidence
|
||||
|
||||
- Exact before/after container set difference is the13 approved stopped IDs;
|
||||
no new containers appeared. Images and volume identity sets unchanged80/9.
|
||||
- Installed release file SHA256 mappings unchanged; both agents running.
|
||||
- Queue13 job tuples `(job_id,state,result_id,claim_generation)` unchanged:
|
||||
11failed/2succeeded,0 live leases.
|
||||
- Core `/api/health` operational=true at `2026-09-03T15:59:16.426016+00:00`;
|
||||
verification local monotonic1923.95940075, duration0.026172833s.
|
||||
PID2421 serves8000; no8765 Mission Core listener. Build `app-w6onjKPq.js`.
|
||||
Recording cache20 entries/653266743bytes; artifact cache9 pinned objects.
|
||||
- `pytest -q tests/test_observatory_portable_lab_v1_component_adapters.py -k
|
||||
'disk or legacy_python39'`:7PASS. Ruff on component and its tests:PASS.
|
||||
No complete frontend suite/build, heavy viewer QA or model benchmark this pass.
|
||||
- The complete former Desktop body is preserved below the historical divider;
|
||||
its SHA is recorded above. Existing architecture and before-full-profile
|
||||
Desktop documents remain untouched.
|
||||
- `git diff --check`:PASS. Desktop historical-body SHA reproduced exactly;
|
||||
both preserved sibling-document SHAs unchanged; old unprefixed current filename
|
||||
absent and new underscore-prefixed file present.
|
||||
|
||||
## 8. Results retained, not newly computed
|
||||
|
||||
M49×RAVNOVES004TREE published job:
|
||||
`observatory-run-b230216709dc4c59bc56c98c7e329bf1`.
|
||||
Result:`m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
|
||||
6830 camera anchors,6811 LiDAR,19 UNOBSERVED, cycle1416.763s, schedule39.215–757.160s
|
||||
of808.779s source. This is CPU TGS, not a full ML perception profile or complete
|
||||
capture coverage. Detailed saved camera/TGS visual and lifecycle acceptance is open.
|
||||
|
||||
Latest LAB V1×004TREE job
|
||||
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` failed on lease loss at EoMT.
|
||||
It did not complete DDRNet/publication. Reconciliation preserved failure history
|
||||
after resource-release verification. Current heartbeat fixes are installed;
|
||||
there is no newly proved successful full LAB V1 run. Disk-floor admission is a
|
||||
separately discovered issue; its causality for the lease failure is not proved.
|
||||
|
||||
## 9. Limitations and rejected approaches
|
||||
|
||||
- New Module/Composition contracts, grouped UI and per-node result reuse remain
|
||||
unimplemented. Source CAS is not node-result cache; current cold input barrier
|
||||
remains. Existing fixed-stack support is useful but not complete modularity.
|
||||
- 749frontend/166backend checks belong to the previous increment, not this design.
|
||||
Historical replay overlays or cached FPS do not establish full-graph inference.
|
||||
- Images, build caches and input/model caches were not pruned. Before deletion,
|
||||
enumerate reachable pinned packages, prototype, models, current consumers and
|
||||
rollback. Image5ad7… is still required by prepare/assemble although one old
|
||||
agent instance using it was retired. Logical image sizes share layers.
|
||||
- Active legacy/Gaussian/Triton retirement needs a separate dependency decision;
|
||||
unhealthy does not itself authorize removing another consumer's data.
|
||||
- No blanket cache deletion, extra agent per model, per-frame Docker startup,
|
||||
GPU concurrency, MacMini onboard target, or hidden motor authority.
|
||||
|
||||
## 10. Decision
|
||||
|
||||
Adopt modular composition as TARGET; preserve the recorded-first product and
|
||||
verified queue/source/publication/viewer foundation. Retain successful M49 and
|
||||
legacy releases during migration. Current audit and bounded cleanup are complete;
|
||||
the new four-stage architecture implementation and broad cleanup are not complete.
|
||||
|
||||
## 11. Next stage and forbidden authority
|
||||
|
||||
Next chat begins with current Desktop0.46, ADR0051, ExecPlan upper section and this
|
||||
report; read `git status` and installed identities before changes. Start executable
|
||||
composition/dependency/identity contracts and retention closure, then reusable
|
||||
module releases/common runtime, grouped UI/end-to-end saved review, finally CUDA
|
||||
onboard qualification. Do not rerun old installers with stale predecessor IDs.
|
||||
|
||||
No Synology/deploy-canon21, device mutation, autonomy, motor controls, Little
|
||||
Snitch changes or Docker Desktop resource-limit changes. Core8000 is durable and
|
||||
remains running. Existing worktree changes, raw records and historical evidence
|
||||
are user-owned and preserved.
|
||||
|
||||
## 12. Acceptance checker
|
||||
|
||||
- [x] Accepted architecture distinguished from installed code.
|
||||
- [x] Single-provider groups; no dual segmentation in new profiles; CUDA not MacMini.
|
||||
- [x] Worker exact inventory,13-target archive/delete, identity/queue/health checks.
|
||||
- [x] Desktop rename/context, ADR and current four-stage route prepared.
|
||||
- [ ] Remaining cache/image/active-legacy retirement after retention closure.
|
||||
- [ ] New executable composition/module contracts and module releases.
|
||||
- [ ] Operator composer and exact node-cache implementation.
|
||||
- [ ] Full saved visual/lifecycle and source×composition acceptance.
|
||||
- [ ] CUDA onboard qualification and separate control/safety acceptance.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Observatory: сохранённые маски, восстановление и реальный LAB V1 canary
|
||||
|
||||
Session: `observatory-full-pass-OjYeg7`, 2026-09-03. Поверх `eff60e4`;
|
||||
предыдущие незакоммиченные изменения сохранены. Этап2 **не закрыт**.
|
||||
|
||||
## Цель и архитектурный этап
|
||||
|
||||
Один крупный проход по сохранённому просмотру и recovery2D, с реальным запуском
|
||||
LAB V1 × RAVNOVES004TREE из2C. Не новый LAB-микроинтерфейс, не remote realtime
|
||||
qualification, не перенос на борт. Legacy остаётся отдельным архивом.
|
||||
|
||||
## Вопрос и гипотеза
|
||||
|
||||
Можно ли получить опубликованный LAB V1, смотреть его маски в общем viewer и
|
||||
пережить краткую потерю связи/подтверждения без повторного вычисления? Контрактные
|
||||
проверки выполнены, но реальный canary до публикации не дошёл. Не переносить
|
||||
локальный PASS тестов на качество или работоспособность всего профиля.
|
||||
|
||||
## Исходные доказательства
|
||||
|
||||
Обычная кнопка «Рассчитать» создала одну job
|
||||
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` в
|
||||
`2026-09-03T13:40:35.163Z`, generation1. Источник:
|
||||
`20260828T130511Z_viewer_live`, RAVNOVES004TREE. Definition
|
||||
`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
|
||||
Source bundle: `2412ee2374590a3b0bca8849a2410188325397b1651b7ae2916c48ead664c417`.
|
||||
Состав: последовательные prepare → EoMT → DDRNet → assemble; не полный граф рига.
|
||||
|
||||
## Метод и идентичность
|
||||
|
||||
Новый `portable_semantic_replay.py` принимает только sealed
|
||||
`missioncore.recorded-eomt-ddrnet-review/v2` и связывает source/bundle/clock,
|
||||
component documents, число кадров, SHA/размеры архивов и taxonomy. EoMT target
|
||||
labels принадлежат точному preprocessing profile
|
||||
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`, не сырому
|
||||
порядку Cityscapes. DDRNet использует64 класса опубликованной taxonomy.
|
||||
|
||||
Маски800×600 читаются по одной: EoMT tar/gzip streaming, DDRNet ZIP. Имена,
|
||||
дубликаты, неполнота, raster/class bounds и синхронизация проверяются. PNG остаются
|
||||
lossless; ни разрешение, ни cadence ради приёмки не изменены. Производитель и
|
||||
архивные LAB adapters не переписаны. Отдельная renderer identity сохраняет старый
|
||||
M49 derivative cache. Источник и результат объединяются общим native Rerun
|
||||
adapter; повторный просмотр идёт с Core, не с Worker, без inference.
|
||||
|
||||
## Worker/runtime и ресурсы
|
||||
|
||||
Один активный профиль на4090. При установке control layer очередь idle,
|
||||
11 failed/2 succeeded,0 live leases; два успешных результата не изменялись.
|
||||
Модели, package/config digests, limits и source-cache mounts сохранены.
|
||||
|
||||
Новая установка control-agent layer завершена `2026-09-03T14:23:22.507723Z`.
|
||||
Plan: `8b16f20e4b7efa14a70344120d063e298e2eda1fd95288904c636806038cab41`.
|
||||
|
||||
| Агент | Новый image SHA-256 | Сохранённый predecessor |
|
||||
|---|---|---|
|
||||
| M49 | `e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650` | `ndc-observatory-m49-worker-agent-pre-heartbeat-c51bd8d95a85` |
|
||||
| Installed LAB | `bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a` | `ndc-observatory-installed-lab-worker-agent-pre-heartbeat-a01b0fc675a0` |
|
||||
|
||||
Durable declarations и rollback:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
|
||||
Predecessors stopped/restart=no. Откат только после проверки отсутствия новых
|
||||
jobs/live owners; никогда не запускать старого и нового владельца одновременно.
|
||||
Установка — два проверенных Python-файла поверх exact parent image, без скачиваний
|
||||
или compute package rebuild. Временные offline helpers удалены, данные сохранены.
|
||||
|
||||
## Реализация recovery
|
||||
|
||||
- Outbox раньше ограничивал первые строки до проверки backoff/max attempts.
|
||||
Несколько старых failed rows могли навсегда скрыть поздний готовый результат.
|
||||
Теперь keyset pages по32, курсор по immutable creation/job identity, limit
|
||||
применяется к реальным попыткам публикации. История не удаляется.
|
||||
- Worker heartbeat теперь повторяет только типизированные transport failures и
|
||||
HTTP408/429/500/502/503/504, с тем же generation/token/sequence. HTTP401/403/409,
|
||||
неверный JSON и изменённая identity не становятся временным успехом.
|
||||
I/O heartbeat ограничен5s; повторы укладываются в монотонный бюджет последней
|
||||
аренды. Потерянный ACK не продлевает локальный срок сам по себе.
|
||||
- При истечении бюджета, позднем ACK или завершении работы во время
|
||||
неопределённого подтверждения результат не объявляется успешным. Новый владелец
|
||||
не запускается вместо старого. Бесконечный сетевой outage пока не превращён в
|
||||
полноценную pause/resume модель — это отдельный незакрытый recovery case.
|
||||
- Добавлены безопасные heartbeat retry/recovered/lost и executor-failure logs:
|
||||
job/generation/sequence и классы ошибок, без exception text/credentials.
|
||||
|
||||
## Проверки
|
||||
|
||||
- **749 frontend tests**, полный typecheck, production build PASS.
|
||||
- Финальный единый backend проход: **166 tests,0 failures/errors/skips**,
|
||||
XML в evidence. Outbox/publisher/SQLite recovery, replay/API/masks, heartbeat,
|
||||
транспорт и installer. Остальные ранние проходы пересекаются с этим набором.
|
||||
- Ruff, mypy7 изменённых backend modules, `git diff --check` PASS.
|
||||
- Lost ACK и краткий transport failure: тот же heartbeat sequence, один start,
|
||||
один результат, без повторного executor. Publication interruption до publish или
|
||||
после publish до ACK: повторная публикация того же package, не новая job.
|
||||
- Build12.68s; maximum RSS2071019520 bytes. Тяжёлые локальные проверки шли
|
||||
последовательно. Docker limits/Little Snitch/посторонние приложения не менялись.
|
||||
- Core healthy на8000, новый `app-w6onjKPq.js`;8765 пуст. Предыдущий dist сохранён.
|
||||
Browser: fresh reload, обычное/развёрнутое окно, Refresh, profile selection и
|
||||
Escape. M49 снизу, LAB V1 в выборе, кнопка только «Рассчитать»; Legacy не вернулся.
|
||||
**Тяжёлый replay не открывался**: ранее отклонённый browser action не обходили,
|
||||
отдельное разрешение запрошено. Это не visual/lifecycle PASS.
|
||||
|
||||
## Реальный результат и выявленные ограничения
|
||||
|
||||
Input transfer завершился примерно за388s; последнее наблюдение — steps1/4,
|
||||
EoMT ещё не завершён, elapsed1318.28s. В `14:02:39.666Z` job перешла в
|
||||
`reconciliation-required / claim-lease-expired`. Новый LAB V1 не опубликован.
|
||||
Последний heartbeat Core принял, после чего renew прекратились; progress некоторое
|
||||
время продолжал поступать. Точный transport exception прежний агент не сохранил.
|
||||
Нельзя объявлять доказанной ни GPU-перегрузку, ни конкретную причину разрыва.
|
||||
|
||||
Прежний runner удалил временный attempt и одноразовый EoMT container; отдельная
|
||||
executor error была замаскирована heartbeat-lost. Подтверждены отсутствие exact
|
||||
job containers/attempt children и idle agent. Через штатный `reconcile_failed`,
|
||||
без ручного SQL update, job переведена в failed; исходная причина и generation
|
||||
сохранены в immutable receipt:
|
||||
`a76131ce20f5def2fe13101d86b93b70911585784dfa839557e82288573cf821`.
|
||||
SQLite backup и proof сохранены до изменения. Повторный расчёт не запускался.
|
||||
|
||||
**Независимо подтверждён disk admission blocker.** В реально установленном EoMT
|
||||
image `adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174`
|
||||
действует `350 * 1024**3` bytes floor плюс
|
||||
`frame_count * 800 * 600 * 7 + input_byte_length`. Source-файл image и репозитория
|
||||
совпал: `938bbb4d98802e470b2baee0fa3ec3baaa3781199415ec0fa116aaaf8f235661`.
|
||||
После cleanup свободно397474037760 bytes. Для6830 кадров нужно минимум
|
||||
398758438400 bytes **ещё без входного видео**. Повтор при этих условиях не пройдёт.
|
||||
Это проверка текущего условия, не восстановленный stderr предыдущего контейнера.
|
||||
Floor в350GiB не означает, что модели реально нужно столько рабочего места.
|
||||
|
||||
## Решение
|
||||
|
||||
Сохранённый semantic adapter и outbox fix установлены на Core, recovery layer —
|
||||
на Worker. Канонический viewer переиспользован по product-ui skill; отдельного
|
||||
микроприложения и новых controls нет. Этап2 не закрываем. Существующие partial
|
||||
full-input подготовка, cache и source identities сохранены;2A не подменяется
|
||||
этой работой. Физическое управление не включено.
|
||||
|
||||
## Непосредственно дальше
|
||||
|
||||
Перед повтором LAB V1: пересмотреть disk policy в **новой sealed версии**,
|
||||
сохранив расчёт рабочего набора и разумный запас; перенести её дешёвую проверку
|
||||
до долгой проверки asset trees/подготовки. Не менять старый image/config под тем
|
||||
же digest и не чистить пользовательские данные ради искусственного350GiB floor.
|
||||
Сохранять bounded failure diagnostics до cleanup. Затем LAB V1 ×004TREE и оба
|
||||
профиля ×RAVNOVES00 последовательно. Параллельно по смыслу плана остаются2A
|
||||
incremental cold input и2B saved visual/open-close; локальные тяжёлые процессы
|
||||
по-прежнему выполняются только последовательно. Standalone3 и будущий борт4 отдельно.
|
||||
|
||||
## Acceptance checker и evidence
|
||||
|
||||
Закрыто: outbox starvation regression, bounded transient heartbeat protocol,
|
||||
локальная установка/rollback identities, shared semantic replay contracts.
|
||||
Не закрыто: реальный успешный LAB V1,2×2 matrix, полная visual/lifecycle
|
||||
приёмка, long outage recovery и самостоятельный полный Docker.
|
||||
|
||||
Private evidence:
|
||||
`.runtime/observatory-full-pass-OjYeg7` — test logs/XML, old dist, screenshot,
|
||||
job/reconciliation documents, SQLite backup, installed EoMT policy и agent
|
||||
plan/receipt с UTC/monotonic. Raw evidence не добавляется в Git.
|
||||
Direct Ops `tasker_get_agent_instructions` завершился60s timeout; карточка не
|
||||
изменялась, отчёт не объявляется опубликованным в Ops.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Observatory: повторное использование входов на Worker
|
||||
|
||||
Session: `observatory-source-reuse-AsldTM`, 2026-09-03.
|
||||
Установлено 12:53:45.460616–12:54:11.038263 UTC; monotonic
|
||||
25614923660636–25640502446818 ns (Worker). Изменения поверх `eff60e4`.
|
||||
|
||||
## Решение и результат
|
||||
|
||||
Продолжен основной этап2A без повторной диагностики общей памяти Mac. Убраны
|
||||
два вида повторной работы: скачивание уже закэшированных source members в новую
|
||||
job/generation и декодирование исходной LiDAR-записи перед обнаружением готового
|
||||
v2 input pack. Изменение установлено в оба существующих агента Worker006.
|
||||
|
||||
Это **кэш исходников для вычисления на Worker**, не перенос пользовательского
|
||||
кэша результатов: сохранённые визуальные результаты по-прежнему выдаёт Core.
|
||||
Холодная передача/подготовка новой записи остаётся целиковой; этап2A целиком
|
||||
не закрыт и recorded-analysis не объявлен реализованным новым режимом.
|
||||
|
||||
## Реализация и сохранённые границы
|
||||
|
||||
- `worker_source_cache.py`: общий content-addressed кэш по SHA-256 и длине.
|
||||
Gateway сначала получает и проверяет актуальный claim-bound manifest; кэш
|
||||
не заменяет claim/generation/source admission. Каждая job сохраняет отдельный
|
||||
fixed layout и свой manifest. Никаких URL/команд/путей от кэша не принимается.
|
||||
- `worker_http_transport.py`: ready members восстанавливаются из локального
|
||||
кэша. Полностью готовая камера не запрашивает архив; частично готовая запрашивает
|
||||
только недостающие members. Cold epoch сохраняет существующий packed transport.
|
||||
В progress учитываются реальные готовые members, не вымышленные inference frames.
|
||||
- Разные агенты имеют разные `/work`; введён явный service config
|
||||
`MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT=/source-cache` и общий named
|
||||
volume `ndc-observatory-source-cas-v1`. Все четыре ownership labels заданы.
|
||||
- На одной filesystem — read-only hardlinks после проверки bytes; между mount
|
||||
points — проверяемая disk copy с буфером до1MiB. Для необязательной cache copy
|
||||
оставляется2GiB свободного диска; нехватка места означает cache miss, не потерю
|
||||
качества/кадров. Прежние записи и результаты не удаляются. Повреждённый cache
|
||||
object не используется и не перезаписывается; свежая загрузка остаётся в job.
|
||||
Cache не удерживает массивы/сессии в RAM. Все temporary links/copies закрываются.
|
||||
- `compute/lidar_preparation.py`: перед неизменным producer ищется exact source /
|
||||
session / current-producer pack. Проверяются raw, metadata и optional clock
|
||||
origin hashes; strict v2 reader проверяет артефакты, arrays, logical content и
|
||||
equivalence. Он закрывается в `finally`. На warm hit исходник не декодируется;
|
||||
потоковое SHA-чтение исходника и полная проверка NPZ остаются. Cold directory
|
||||
не добавляет отдельного предварительного hash-pass перед прежним builder.
|
||||
- `compute/lidar_replay.py` не изменён: SHA
|
||||
`543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8`.
|
||||
Этот producer digest входит в исторические pack identities. Прежние packs
|
||||
сохраняют свои IDs/manifest bytes; новый adapter не подделывает producer version.
|
||||
Коллизия report threshold, который v2 не включал в identity, теперь явно
|
||||
отклоняется при reuse, не возвращает другой отчёт и не перезаписывает данные.
|
||||
- M49 source adapter использует новый preparation path; release source inventory
|
||||
включает новый модуль. Алгоритмы, model/config/profile/package identities,
|
||||
исходные timestamps и правила unknown/obstacle не менялись.
|
||||
|
||||
Кэш наполняется при обычной подготовке. Старые рабочие каталоги не обходились
|
||||
массово и все ранее скачанные записи автоматически не импортированы.
|
||||
|
||||
## Проверки
|
||||
|
||||
111 focused backend tests PASS за3.87с, command maximum RSS115867648 bytes.
|
||||
Ruff PASS; mypy пяти изменённых runtime-модулей PASS. UI в этом инкременте не
|
||||
менялся: повторного frontend build/heavy browser QA не было.
|
||||
|
||||
Проверены warm/cold legacy pack IDs, изменённые raw/metadata/origin/session,
|
||||
повреждённые артефакты, отсутствие metadata, symlink, смена файла во время
|
||||
validation, threshold mismatch и cleanup. Gateway fixtures используют новый
|
||||
процесс-клиент и другой work root с общей CAS: новая generation/другой профиль
|
||||
делают только GET актуального manifest; changed/missing camera member добавляет
|
||||
ровно один member GET, без полного epoch archive. Старый manifest/отсутствующий
|
||||
claim отвергаются. FIFO, directory, unsafe digest, partial copy и corrupt CAS
|
||||
не попадают в вычислительный вход. Admission installer проверен отдельно.
|
||||
|
||||
На реальном Worker выполнен небольшой synthetic proof, **не benchmark профиля**:
|
||||
167936 bytes, SHA `aca7c6f9cce176db34410eeb0cd3e6e5d4ede34f0d0c8da5c97f94e6d3b5ced7`.
|
||||
M49 agent сохранил bytes, installed-LAB agent восстановил их через общий volume
|
||||
в свой другой `/work`, exact сравнение прошло. Третий шаг удалил только этот
|
||||
проверенный synthetic cache object. Temporary work directories удалены каждым
|
||||
шагом. Model jobs=0. UTC/monotonic каждого шага сохранены отдельно.
|
||||
|
||||
## Установка, состояние и восстановление
|
||||
|
||||
Read-only plan SHA `f7d1552161970d074a87aa3e9baa75f080d5f835c0373326c873ae481b5c26a7`.
|
||||
Пять code files установлены offline child layers, исходный producer закреплён
|
||||
отдельным неизменным SHA. До каждого cutover проверены exact container/create
|
||||
hashes и свободная queue; настройки CPU/RAM/GPU/network не менялись. Добавлены
|
||||
только source-cache mount/env и соответствующая code layer.
|
||||
|
||||
| Агент | Новый image SHA-256 | Сохранённый predecessor |
|
||||
| --- | --- | --- |
|
||||
| `ndc-observatory-m49-worker-agent` | `d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607` | `ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b` |
|
||||
| `ndc-observatory-installed-lab-worker-agent` | `b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683` | `ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e` |
|
||||
|
||||
Новые агенты Running, RestartCount0, restart=`unless-stopped`. Предшественники
|
||||
Stopped/restart=`no`. Durable declarations с полным create body и receipt:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-source-reuse-AsldTM\release`.
|
||||
При rollback сначала проверить idle/reconciliation, не запускать predecessor
|
||||
одновременно с replacement. Общий source volume не удалять; старый агент просто
|
||||
не использует его. Прежние package launchers не должны перезаписывать новые
|
||||
declarations. Фактическая перезагрузка Worker не выполнялась.
|
||||
|
||||
Queue после установки:10 failed/2 succeeded,1 v3 grant,0 open live leases —
|
||||
существующий результат/история сохранены, новых jobs не создавалось. Core PID89747
|
||||
healthy на8000,8765 пуст. Временные installer containers удалены штатным `--rm`.
|
||||
Посторонние контейнеры Worker/Mac, Little Snitch и Docker limits не менялись.
|
||||
|
||||
## Остаток основного плана
|
||||
|
||||
Теперь следует убирать **cold whole-input barrier** версионированным execution
|
||||
contract: полный recorded-analysis отдельно от realtime-rehearsal, bounded
|
||||
incremental input и учёт всех предусмотренных результатов без drops ради1×.
|
||||
Переиспользование cache само по себе этого не доказывает. Затем visual2B,
|
||||
матрица LAB V1/другая запись2C и recovery/пагинация2D. Полный standalone3 и борт4
|
||||
остаются отдельными этапами. Нет новых FPS, модельной квалификации или actuation.
|
||||
|
||||
## Evidence manifest
|
||||
|
||||
Private root: `.runtime/observatory-source-reuse-AsldTM`; raw recordings и secrets
|
||||
в Git не добавлены. Полные declarations сохранены private, не в продуктовом UI.
|
||||
|
||||
| Файл | SHA-256 |
|
||||
| --- | --- |
|
||||
| `backend-tests.log` | `39086fa4ec312b3b8d509fda7b211aa37a14673743c9047c574efd3bde9d36df` |
|
||||
| `worker-plan.json` | `0f7cb148dc8d01d7d68157daf9f8f6187c1fac6e72729107cd9b6930d568689c` |
|
||||
| `worker-receipt.json` | `d030763b0d3450d5fbc14990530a80b6588d4e76943ba2d3eb190e3f909e2c3e` |
|
||||
| `worker-cache-retain.json` | `99ec71c328820126664aaf4cef667030c288cf3da2e4e2eacf5dc30100b570f1` |
|
||||
| `worker-cache-restore.json` | `bad2efd604351151a8451d5ae4785b68e6bb9b0d9400cfefb1d2b73ef784de1a` |
|
||||
| `worker-cache-cleanup.json` | `22bd551108acc0711f5ea83c994999fbaf9f3ae5dbd68492aaee4d6552bf04e0` |
|
||||
|
||||
Ops card в этом инкременте не обновлялась; локальный отчёт/ExecPlan/Desktop
|
||||
сводка являются текущим handoff, не утверждением об успешной записи в Ops.
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
|
||||
|
||||
ARG NODEDC_SHARED_SHA256
|
||||
ARG NODEDC_EOMT_SHA256
|
||||
ARG NODEDC_MODULE_SHA256
|
||||
ARG NODEDC_REVISION
|
||||
|
||||
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
|
||||
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
|
||||
COPY run_portable_camera_source_component.py /opt/nodedc/adapter/run_portable_camera_source_component.py
|
||||
|
||||
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
|
||||
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_EOMT_SHA256}" \
|
||||
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_camera_source_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
|
||||
&& chmod 0444 /opt/nodedc/adapter/*.py \
|
||||
&& cd /opt/nodedc/adapter \
|
||||
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
|
||||
run_portable_lab_v1_eomt_component.py run_portable_camera_source_component.py \
|
||||
&& rm -rf /opt/nodedc/adapter/__pycache__
|
||||
|
||||
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
|
||||
com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
com.nodedc.role="ai-module" \
|
||||
com.nodedc.module-id="camera-source" \
|
||||
com.nodedc.managed-by="mission-core-worker"
|
||||
|
||||
ENTRYPOINT ["python3"]
|
||||
CMD ["/opt/nodedc/adapter/run_portable_camera_source_component.py"]
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM ndc/mission-core-installed-lab-v1-ddrnet-step:439127908dba
|
||||
|
||||
ARG NODEDC_SHARED_SHA256
|
||||
ARG NODEDC_MODULE_SHA256
|
||||
ARG NODEDC_REVISION
|
||||
|
||||
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
|
||||
COPY run_portable_lab_v1_ddrnet_component.py /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py
|
||||
|
||||
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
|
||||
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
|
||||
&& chmod 0444 /opt/nodedc/adapter/*.py \
|
||||
&& cd /opt/nodedc/adapter \
|
||||
&& conda run --no-capture-output --name goose python -B -m py_compile \
|
||||
portable_lab_v1_component_adapter.py run_portable_lab_v1_ddrnet_component.py \
|
||||
&& rm -rf /opt/nodedc/adapter/__pycache__
|
||||
|
||||
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
|
||||
com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
com.nodedc.role="ai-module" \
|
||||
com.nodedc.module-id="ddrnet" \
|
||||
com.nodedc.managed-by="mission-core-worker"
|
||||
|
||||
ENTRYPOINT ["conda","run","--no-capture-output","--name","goose","python"]
|
||||
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py"]
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
|
||||
|
||||
ARG NODEDC_SHARED_SHA256
|
||||
ARG NODEDC_MODULE_SHA256
|
||||
ARG NODEDC_REVISION
|
||||
|
||||
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
|
||||
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
|
||||
|
||||
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
|
||||
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
|
||||
&& chmod 0444 /opt/nodedc/adapter/*.py \
|
||||
&& cd /opt/nodedc/adapter \
|
||||
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
|
||||
run_portable_lab_v1_eomt_component.py \
|
||||
&& rm -rf /opt/nodedc/adapter/__pycache__
|
||||
|
||||
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
|
||||
com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
com.nodedc.role="ai-module" \
|
||||
com.nodedc.module-id="eomt" \
|
||||
com.nodedc.managed-by="mission-core-worker"
|
||||
|
||||
ENTRYPOINT ["python3"]
|
||||
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py"]
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
|
||||
|
||||
ARG NODEDC_MODULE_SHA256
|
||||
ARG NODEDC_REVISION
|
||||
|
||||
COPY run_ai_module_object_distance.py /opt/nodedc/adapter/run_ai_module_object_distance.py
|
||||
|
||||
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_object_distance.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
|
||||
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_object_distance.py \
|
||||
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_object_distance.py \
|
||||
&& rm -rf /opt/nodedc/adapter/__pycache__
|
||||
|
||||
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
|
||||
com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
com.nodedc.role="ai-module" \
|
||||
com.nodedc.module-id="object-distance" \
|
||||
com.nodedc.managed-by="mission-core-worker"
|
||||
|
||||
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_object_distance.py"]
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
|
||||
|
||||
ARG NODEDC_MODULE_SHA256
|
||||
ARG NODEDC_REVISION
|
||||
|
||||
COPY run_ai_module_rf_detr.py /opt/nodedc/adapter/run_ai_module_rf_detr.py
|
||||
|
||||
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_rf_detr.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
|
||||
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_rf_detr.py \
|
||||
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_rf_detr.py \
|
||||
&& rm -rf /opt/nodedc/adapter/__pycache__
|
||||
|
||||
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
|
||||
com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
com.nodedc.role="ai-module" \
|
||||
com.nodedc.module-id="rf-detr" \
|
||||
com.nodedc.managed-by="mission-core-worker"
|
||||
|
||||
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_rf_detr.py"]
|
||||
+1
-1
@@ -15,7 +15,7 @@ RUN case "${NODEDC_SOURCE_TREE_SHA256}" in *[!0-9a-f]*|'') exit 64 ;; esac \
|
||||
&& test "${#NODEDC_SOURCE_TREE_SHA256}" -eq 64 \
|
||||
&& find /opt/nodedc/mission-core/src/k1link -type d -exec chmod 0555 {} + \
|
||||
&& find /opt/nodedc/mission-core/src/k1link -type f -exec chmod 0444 {} + \
|
||||
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(steps.main)"
|
||||
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as legacy_steps; import k1link.observatory.modular_installed_package_steps as modular_steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(legacy_steps.main); assert callable(modular_steps.main)"
|
||||
|
||||
LABEL com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
"""Idle-only two-file control-agent update; sealed compute packages are unchanged.
|
||||
|
||||
Offline child images, exact parent/source/create fences, immutable install
|
||||
receipts, and stopped predecessors retained for explicit rollback. No model
|
||||
execution, new volume, new resource limit, or queue mutation occurs here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
|
||||
|
||||
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link/observatory"
|
||||
BEFORE = {
|
||||
"worker_agent.py": "91a65fcde45fa65b0894e6927618f35b4d8aa369653d00163df7fd840e6ea5b2",
|
||||
"worker_http_transport.py": "dfe652d9464d97cba37c4136be8d0fd865f3e93fe75380e471da882b56c517f1",
|
||||
}
|
||||
TARGETS = {
|
||||
"ndc-observatory-m49-worker-agent": (
|
||||
"d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607"
|
||||
),
|
||||
"ndc-observatory-installed-lab-worker-agent": (
|
||||
"b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683"
|
||||
),
|
||||
}
|
||||
LABEL = "com.nodedc.recorded-heartbeat-recovery.plan-sha256"
|
||||
|
||||
|
||||
def probe() -> str:
|
||||
return f"""import hashlib,json,pathlib
|
||||
from k1link.observatory import worker_agent
|
||||
root=pathlib.Path(worker_agent.__file__).resolve().parent
|
||||
assert str(root)=={SOURCE_ROOT!r}
|
||||
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest() for n in {list(BEFORE)!r}}}))
|
||||
"""
|
||||
|
||||
|
||||
def pack(repository: Path, output: Path) -> None:
|
||||
output.mkdir(parents=False, exist_ok=False)
|
||||
files = {}
|
||||
for name in BEFORE:
|
||||
payload = (repository / "src/k1link/observatory" / name).read_bytes()
|
||||
compile(payload, name, "exec")
|
||||
(output / name).write_bytes(payload)
|
||||
files[name] = sha(payload)
|
||||
save(output / "payload.json", {"schema_version": 1, "files": files})
|
||||
|
||||
|
||||
def payload_files(root: Path) -> dict[str, bytes]:
|
||||
manifest = json.loads((root / "payload.json").read_bytes())
|
||||
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
|
||||
raise ValueError("invalid heartbeat payload manifest")
|
||||
if set(manifest["files"]) != set(BEFORE):
|
||||
raise ValueError("heartbeat file set changed")
|
||||
result = {}
|
||||
for name in BEFORE:
|
||||
path = root / name
|
||||
if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size < 256_000:
|
||||
raise ValueError("unsafe heartbeat payload")
|
||||
value = path.read_bytes()
|
||||
if sha(value) != manifest["files"][name]:
|
||||
raise ValueError("heartbeat payload changed")
|
||||
compile(value, name, "exec")
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def create_hash(row: dict) -> str:
|
||||
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
|
||||
|
||||
|
||||
def validate_target(name: str, row: dict) -> None:
|
||||
config, host = row["Config"], row["HostConfig"]
|
||||
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
|
||||
raise ValueError("control-agent identity changed")
|
||||
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
|
||||
raise ValueError("expected durable read-only agent is not running")
|
||||
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
|
||||
raise ValueError("control-agent GPU/network boundary changed")
|
||||
if config["Labels"].get("com.nodedc.authority") != "observation-only":
|
||||
raise ValueError("control-agent authority changed")
|
||||
for item in config["Env"]:
|
||||
key = item.split("=", 1)[0].upper()
|
||||
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET")) and not key.endswith(
|
||||
"_FILE"
|
||||
):
|
||||
raise ValueError("inline credential is forbidden")
|
||||
|
||||
|
||||
def plan(engine: Engine, root: Path) -> dict:
|
||||
files, targets = payload_files(root), []
|
||||
for name, parent in TARGETS.items():
|
||||
row = engine.inspect(name)
|
||||
validate_target(name, row)
|
||||
if engine.execute_json(name, probe()) != BEFORE:
|
||||
raise ValueError("imported source differs from reviewed baseline")
|
||||
require_idle(engine.execute_json(name, READINESS))
|
||||
targets.append(
|
||||
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-heartbeat-recovery-install/v1",
|
||||
"targets": targets,
|
||||
"files": {name: sha(value) for name, value in files.items()},
|
||||
"installer_sha256": sha(Path(__file__).read_bytes()),
|
||||
"helper_sha256": sha(
|
||||
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
|
||||
),
|
||||
"compute_packages_changed": False,
|
||||
}
|
||||
|
||||
|
||||
def fence(engine: Engine, target: dict) -> dict:
|
||||
row = engine.inspect(target["name"])
|
||||
validate_target(target["name"], row)
|
||||
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
|
||||
raise ValueError("control agent changed since plan")
|
||||
require_idle(engine.execute_json(target["name"], READINESS))
|
||||
return row
|
||||
|
||||
|
||||
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
|
||||
created = engine.request(
|
||||
"POST",
|
||||
"/containers/create",
|
||||
{
|
||||
"Image": "sha256:" + target["parent"],
|
||||
"Entrypoint": ["/bin/true"],
|
||||
"Cmd": [],
|
||||
"HostConfig": {
|
||||
"NetworkMode": "none",
|
||||
"CapDrop": ["ALL"],
|
||||
"PidsLimit": 32,
|
||||
"SecurityOpt": ["no-new-privileges"],
|
||||
},
|
||||
},
|
||||
)["Id"]
|
||||
try:
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
|
||||
raise ValueError("offline layer initialization failed")
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||
for name, payload in files.items():
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
|
||||
archive.addfile(item, io.BytesIO(payload))
|
||||
engine.request(
|
||||
"PUT",
|
||||
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
|
||||
buffer.getvalue(),
|
||||
)
|
||||
changes = engine.request("GET", f"/containers/{created}/changes")
|
||||
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
|
||||
parents = {str(parent) for name in allowed for parent in Path(name).parents}
|
||||
if not changes or any(
|
||||
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
|
||||
):
|
||||
raise ValueError("unrelated changes in offline heartbeat layer")
|
||||
if not allowed.issubset({row["Path"] for row in changes}):
|
||||
raise ValueError("heartbeat layer omitted a file")
|
||||
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
|
||||
config = copy.deepcopy(parent["Config"])
|
||||
config.setdefault("Labels", {})[LABEL] = plan_sha
|
||||
image = engine.request(
|
||||
"POST",
|
||||
"/commit?"
|
||||
+ urlencode(
|
||||
{
|
||||
"container": created,
|
||||
"repo": target["name"] + "-heartbeat-recovery",
|
||||
"tag": "v1",
|
||||
}
|
||||
),
|
||||
config,
|
||||
)["Id"]
|
||||
after = engine.request("GET", f"/images/{image}/json")
|
||||
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
|
||||
raise ValueError("parent image layers changed")
|
||||
return image
|
||||
finally:
|
||||
engine.request("DELETE", f"/containers/{created}")
|
||||
|
||||
|
||||
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
|
||||
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
|
||||
proposal = plan(engine, root)
|
||||
if sha(canonical(proposal)) != expected:
|
||||
raise ValueError("heartbeat install plan changed")
|
||||
evidence.mkdir(parents=False, exist_ok=False)
|
||||
save(evidence / "plan.json", proposal)
|
||||
files, results = payload_files(root), []
|
||||
for target in proposal["targets"]:
|
||||
name = target["name"]
|
||||
fence(engine, target)
|
||||
image = build(engine, target, files, expected)
|
||||
before = fence(engine, target)
|
||||
body = copy.deepcopy(before["Config"])
|
||||
body["Image"] = image
|
||||
body["Labels"][LABEL] = expected
|
||||
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
|
||||
backup = name + "-pre-heartbeat-" + before["Id"][:12]
|
||||
save(
|
||||
evidence / (name + "-declaration.json"),
|
||||
{
|
||||
"name": name,
|
||||
"create_body": body,
|
||||
"rollback_name": backup,
|
||||
"rollback_container_id": before["Id"],
|
||||
"parent": target["parent"],
|
||||
},
|
||||
)
|
||||
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
|
||||
engine.request(
|
||||
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
|
||||
)
|
||||
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
|
||||
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
|
||||
"Id"
|
||||
]
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
time.sleep(3)
|
||||
after = engine.inspect(name)
|
||||
if not after["State"]["Running"] or after["RestartCount"] != 0:
|
||||
raise ValueError("replacement requires reconciliation; predecessor retained")
|
||||
if engine.execute_json(name, probe()) != proposal["files"]:
|
||||
raise ValueError("replacement imported another payload")
|
||||
results.append(
|
||||
{
|
||||
"name": name,
|
||||
"id": created,
|
||||
"image": image,
|
||||
"rollback": backup,
|
||||
"readiness": engine.execute_json(name, READINESS),
|
||||
}
|
||||
)
|
||||
save(evidence / (name + "-acceptance.json"), results[-1])
|
||||
receipt = {
|
||||
"plan_sha256": expected,
|
||||
"agents": results,
|
||||
"compute_packages_changed": False,
|
||||
"started_at_utc": started,
|
||||
"finished_at_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": mono,
|
||||
"finished_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
save(evidence / "receipt.json", receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path)
|
||||
parser.add_argument("--pack", type=Path)
|
||||
parser.add_argument("--payload", type=Path)
|
||||
parser.add_argument("--apply-plan-sha256")
|
||||
parser.add_argument("--evidence", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.pack:
|
||||
pack(args.repository, args.pack)
|
||||
return
|
||||
engine = Engine()
|
||||
if args.apply_plan_sha256:
|
||||
document = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
|
||||
else:
|
||||
document = plan(engine, args.payload)
|
||||
document = {"plan": document, "plan_sha256": sha(canonical(document))}
|
||||
print(json.dumps(document, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Exact offline control-agent update: shared source cache and LiDAR reuse.
|
||||
|
||||
Plan is read-only, apply requires its hash and an idle queue. Models, package
|
||||
definitions, legacy LiDAR producer and existing work/results stay unchanged.
|
||||
Stopped predecessors and full create declarations are retained for rollback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
|
||||
|
||||
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link"
|
||||
PRODUCER = "compute/lidar_replay.py"
|
||||
PRODUCER_SHA = "543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8"
|
||||
BEFORE = {
|
||||
"compute/lidar_preparation.py": None,
|
||||
"observatory/m49_portable_source.py": (
|
||||
"aff9baf5a11732c3f5d4bf53a5a3306df8ac020efbb0dc235202f7193b43c043"
|
||||
),
|
||||
"observatory/worker_http_transport.py": (
|
||||
"363daa574139ee062f0d4141c6ce8ca9c3f25883e1fee6660dd771e15ec71088"
|
||||
),
|
||||
"observatory/worker_service.py": (
|
||||
"a742d1de10e9c76e531f5be78935d6c34c8195d4ecc8c24cf25b987ec162ba1a"
|
||||
),
|
||||
"observatory/worker_source_cache.py": None,
|
||||
}
|
||||
TARGETS = {
|
||||
"ndc-observatory-m49-worker-agent": (
|
||||
"927c3c4f5b00ae6c084d1f5a8bc77f7b262cfc5e83cf4be1c48f615c12c06e80"
|
||||
),
|
||||
"ndc-observatory-installed-lab-worker-agent": (
|
||||
"052af3ccd10e11b162c163943f5427af2dc95b09b93481dd85e954e494ba1107"
|
||||
),
|
||||
}
|
||||
VOLUME = "ndc-observatory-source-cas-v1"
|
||||
CACHE_PATH = "/source-cache"
|
||||
CACHE_ENV = "MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT"
|
||||
LABELS = {
|
||||
"com.nodedc.product": "mission-core",
|
||||
"com.nodedc.stack": "observatory",
|
||||
"com.nodedc.role": "source-cache",
|
||||
"com.nodedc.managed-by": "recorded-source-reuse-v1",
|
||||
}
|
||||
|
||||
|
||||
def probe() -> str:
|
||||
names = [PRODUCER, *BEFORE]
|
||||
return f"""import hashlib,json,pathlib
|
||||
from k1link.observatory import worker_http_transport
|
||||
root=pathlib.Path(worker_http_transport.__file__).resolve().parents[1]
|
||||
assert str(root)=={SOURCE_ROOT!r}
|
||||
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest()
|
||||
if (root/n).is_file() else None for n in {names!r}}}))
|
||||
"""
|
||||
|
||||
|
||||
def pack(repository: Path, output: Path) -> None:
|
||||
if sha((repository / "src/k1link" / PRODUCER).read_bytes()) != PRODUCER_SHA:
|
||||
raise ValueError("legacy LiDAR producer changed")
|
||||
output.mkdir(parents=False, exist_ok=False)
|
||||
files = {}
|
||||
for name in BEFORE:
|
||||
payload = (repository / "src/k1link" / name).read_bytes()
|
||||
compile(payload, name, "exec")
|
||||
target = output / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(payload)
|
||||
files[name] = sha(payload)
|
||||
save(output / "payload.json", {"schema_version": 1, "files": files})
|
||||
|
||||
|
||||
def payload_files(root: Path) -> dict[str, bytes]:
|
||||
manifest = json.loads((root / "payload.json").read_bytes())
|
||||
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
|
||||
raise ValueError("invalid source-reuse payload manifest")
|
||||
if set(manifest["files"]) != set(BEFORE):
|
||||
raise ValueError("source-reuse file set changed")
|
||||
result = {}
|
||||
for name in BEFORE:
|
||||
path = root / name
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 256_000:
|
||||
raise ValueError("unsafe source-reuse payload")
|
||||
value = path.read_bytes()
|
||||
if sha(value) != manifest["files"][name]:
|
||||
raise ValueError("source-reuse payload changed")
|
||||
compile(value, name, "exec")
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def create_hash(row: dict) -> str:
|
||||
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
|
||||
|
||||
|
||||
def validate_target(name: str, row: dict) -> None:
|
||||
config, host = row["Config"], row["HostConfig"]
|
||||
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
|
||||
raise ValueError("control-agent identity changed")
|
||||
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
|
||||
raise ValueError("control agent must be running/read-only")
|
||||
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
|
||||
raise ValueError("control-agent network/GPU boundary changed")
|
||||
if config["Labels"].get("com.nodedc.authority") != "observation-only":
|
||||
raise ValueError("control-agent authority changed")
|
||||
if any(mount["Destination"] == CACHE_PATH for mount in row["Mounts"]):
|
||||
raise ValueError("source-cache mount is already occupied")
|
||||
for entry in config["Env"]:
|
||||
key = entry.split("=", 1)[0]
|
||||
if key == CACHE_ENV:
|
||||
raise ValueError("source-cache configuration already exists")
|
||||
if any(
|
||||
word in key.upper() for word in ("TOKEN", "PASSWORD", "SECRET")
|
||||
) and not key.endswith("_FILE"):
|
||||
raise ValueError("inline secret is forbidden in saved declarations")
|
||||
|
||||
|
||||
def volume_state(engine: Engine) -> dict:
|
||||
response = engine.request(
|
||||
"GET", "/volumes?" + urlencode({"filters": json.dumps({"name": [VOLUME]})})
|
||||
)
|
||||
matches = [row for row in response.get("Volumes", []) or [] if row["Name"] == VOLUME]
|
||||
if not matches:
|
||||
return {"exists": False}
|
||||
row = matches[0]
|
||||
if row["Driver"] != "local" or row.get("Labels") != LABELS:
|
||||
raise ValueError("existing source-cache volume has another owner")
|
||||
return {"exists": True, "name": VOLUME, "driver": "local", "labels": LABELS}
|
||||
|
||||
|
||||
def plan(engine: Engine, root: Path) -> dict:
|
||||
files = payload_files(root)
|
||||
targets = []
|
||||
for name, parent in TARGETS.items():
|
||||
row = engine.inspect(name)
|
||||
validate_target(name, row)
|
||||
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **BEFORE}:
|
||||
raise ValueError("imported source differs from reviewed baseline")
|
||||
require_idle(engine.execute_json(name, READINESS))
|
||||
targets.append(
|
||||
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-source-reuse-install-plan/v1",
|
||||
"targets": targets,
|
||||
"files": {name: sha(value) for name, value in files.items()},
|
||||
"producer_sha256": PRODUCER_SHA,
|
||||
"shared_cache": {"name": VOLUME, "target": CACHE_PATH, "before": volume_state(engine)},
|
||||
"installer_sha256": sha(Path(__file__).read_bytes()),
|
||||
"engine_helper_sha256": sha(
|
||||
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
|
||||
),
|
||||
"compute_packages_changed": False,
|
||||
}
|
||||
|
||||
|
||||
def fence(engine: Engine, target: dict) -> dict:
|
||||
row = engine.inspect(target["name"])
|
||||
validate_target(target["name"], row)
|
||||
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
|
||||
raise ValueError("control agent changed since plan")
|
||||
require_idle(engine.execute_json(target["name"], READINESS))
|
||||
return row
|
||||
|
||||
|
||||
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
|
||||
created = engine.request(
|
||||
"POST",
|
||||
"/containers/create",
|
||||
{
|
||||
"Image": "sha256:" + target["parent"],
|
||||
"Entrypoint": ["/bin/true"],
|
||||
"Cmd": [],
|
||||
"HostConfig": {
|
||||
"NetworkMode": "none",
|
||||
"CapDrop": ["ALL"],
|
||||
"PidsLimit": 32,
|
||||
"SecurityOpt": ["no-new-privileges"],
|
||||
},
|
||||
},
|
||||
)["Id"]
|
||||
try:
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
|
||||
raise ValueError("offline layer initialization failed")
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||
for name, payload in files.items():
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
|
||||
archive.addfile(item, io.BytesIO(payload))
|
||||
engine.request(
|
||||
"PUT",
|
||||
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
|
||||
buffer.getvalue(),
|
||||
)
|
||||
changes = engine.request("GET", f"/containers/{created}/changes")
|
||||
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
|
||||
parents = {str(parent) for name in allowed for parent in Path(name).parents}
|
||||
if not changes or any(
|
||||
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
|
||||
):
|
||||
raise ValueError("unrelated changes in offline source-reuse layer")
|
||||
if not allowed.issubset({row["Path"] for row in changes}):
|
||||
raise ValueError("source-reuse layer omitted a file")
|
||||
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
|
||||
config = copy.deepcopy(parent["Config"])
|
||||
config.setdefault("Labels", {})["com.nodedc.source-reuse.plan-sha256"] = plan_sha
|
||||
image = engine.request(
|
||||
"POST",
|
||||
"/commit?"
|
||||
+ urlencode(
|
||||
{
|
||||
"container": created,
|
||||
"repo": target["name"] + "-source-reuse",
|
||||
"tag": "v1",
|
||||
}
|
||||
),
|
||||
config,
|
||||
)["Id"]
|
||||
after = engine.request("GET", f"/images/{image}/json")
|
||||
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
|
||||
raise ValueError("parent image layers changed")
|
||||
return image
|
||||
finally:
|
||||
engine.request("DELETE", f"/containers/{created}")
|
||||
|
||||
|
||||
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
|
||||
started_at, started_mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
|
||||
proposal = plan(engine, root)
|
||||
if sha(canonical(proposal)) != expected:
|
||||
raise ValueError("source-reuse install plan changed")
|
||||
evidence.mkdir(parents=False, exist_ok=False)
|
||||
save(evidence / "plan.json", proposal)
|
||||
files = payload_files(root)
|
||||
engine.request("POST", "/volumes/create", {"Name": VOLUME, "Driver": "local", "Labels": LABELS})
|
||||
volume_state(engine)
|
||||
results = []
|
||||
for target in proposal["targets"]:
|
||||
name = target["name"]
|
||||
fence(engine, target)
|
||||
image = build(engine, target, files, expected)
|
||||
before = fence(engine, target)
|
||||
body = copy.deepcopy(before["Config"])
|
||||
body["Image"] = image
|
||||
body["Env"].append(CACHE_ENV + "=" + CACHE_PATH)
|
||||
body["Labels"]["com.nodedc.source-reuse.plan-sha256"] = expected
|
||||
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
|
||||
body["HostConfig"].setdefault("Mounts", []).append(
|
||||
{
|
||||
"Type": "volume",
|
||||
"Source": VOLUME,
|
||||
"Target": CACHE_PATH,
|
||||
"ReadOnly": False,
|
||||
}
|
||||
)
|
||||
backup = name + "-pre-source-reuse-" + before["Id"][:12]
|
||||
save(
|
||||
evidence / (name + "-declaration.json"),
|
||||
{
|
||||
"name": name,
|
||||
"create_body": body,
|
||||
"rollback_name": backup,
|
||||
"rollback_container_id": before["Id"],
|
||||
"parent": target["parent"],
|
||||
},
|
||||
)
|
||||
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
|
||||
engine.request(
|
||||
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
|
||||
)
|
||||
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
|
||||
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
|
||||
"Id"
|
||||
]
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
# Never auto-delete a replacement: it may already own operator work.
|
||||
time.sleep(3)
|
||||
after = engine.inspect(name)
|
||||
if not after["State"]["Running"] or after["RestartCount"] != 0:
|
||||
raise ValueError("replacement requires reconciliation; predecessor retained")
|
||||
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **proposal["files"]}:
|
||||
raise ValueError("replacement imported another payload")
|
||||
results.append(
|
||||
{
|
||||
"name": name,
|
||||
"id": created,
|
||||
"image": image,
|
||||
"rollback": backup,
|
||||
"readiness": engine.execute_json(name, READINESS),
|
||||
}
|
||||
)
|
||||
save(evidence / (name + "-acceptance.json"), results[-1])
|
||||
receipt = {
|
||||
"plan_sha256": expected,
|
||||
"agents": results,
|
||||
"shared_cache": VOLUME,
|
||||
"compute_packages_changed": False,
|
||||
"started_at_utc": started_at,
|
||||
"finished_at_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": started_mono,
|
||||
"finished_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
save(evidence / "receipt.json", receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path)
|
||||
parser.add_argument("--pack", type=Path)
|
||||
parser.add_argument("--payload", type=Path)
|
||||
parser.add_argument("--apply-plan-sha256")
|
||||
parser.add_argument("--evidence", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.pack:
|
||||
pack(args.repository, args.pack)
|
||||
return
|
||||
engine = Engine()
|
||||
if args.apply_plan_sha256:
|
||||
result = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
|
||||
else:
|
||||
proposal = plan(engine, args.payload)
|
||||
result = {"plan": proposal, "plan_sha256": sha(canonical(proposal))}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+62
-7
@@ -71,7 +71,7 @@ _SAFE_COMPONENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
|
||||
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
||||
|
||||
Component = Literal["eomt", "ddrnet"]
|
||||
Component = Literal["camera-source", "eomt", "ddrnet"]
|
||||
AssetKind = Literal["file", "tree"]
|
||||
AssetVerification = Literal["sha256", "identity-sha256"]
|
||||
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
|
||||
@@ -168,7 +168,7 @@ class RuntimeLayout:
|
||||
component: Component,
|
||||
expectations: Sequence[AssetExpectation],
|
||||
) -> RuntimeLayout:
|
||||
if component not in ("eomt", "ddrnet"):
|
||||
if component not in ("camera-source", "eomt", "ddrnet"):
|
||||
raise ComponentAdapterError("installed package component is invalid")
|
||||
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
|
||||
return cls(
|
||||
@@ -176,7 +176,7 @@ class RuntimeLayout:
|
||||
camera_job_root=prepared / "camera-job",
|
||||
output_root=Path(PACKAGE_OUTPUT_ROOT),
|
||||
effective_ddrnet_config=prepared / "effective-ddrnet-config.json",
|
||||
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "eomt",
|
||||
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "camera-source",
|
||||
asset_paths={item.asset_id: Path(item.path) for item in expectations},
|
||||
)
|
||||
|
||||
@@ -289,7 +289,7 @@ def load_component_request(
|
||||
source = _source_binding(document["source"])
|
||||
paths = _paths(document["paths"], component)
|
||||
effective_value = document["effective_ddrnet_config_sha256"]
|
||||
if component == "eomt":
|
||||
if component != "ddrnet":
|
||||
if effective_value is not None:
|
||||
raise ComponentAdapterError("EoMT request contains a DDRNet config")
|
||||
effective_sha256: str | None = None
|
||||
@@ -440,6 +440,61 @@ def validate_tree_asset(
|
||||
return _real_directory(layout.asset_paths[asset_id], f"asset {asset_id}")
|
||||
|
||||
|
||||
def validate_prepared_camera_root(
|
||||
root: Path,
|
||||
request: ComponentRequest,
|
||||
) -> dict[str, Path]:
|
||||
"""Verify the model-neutral camera-source output before any AI consumes it."""
|
||||
resolved = _real_directory(root, "prepared camera root")
|
||||
expected_children = {
|
||||
"camera-source.json",
|
||||
"decode-repair.json",
|
||||
"source-frames",
|
||||
"source-frames.json",
|
||||
"timeline.jsonl",
|
||||
}
|
||||
if {path.name for path in resolved.iterdir()} != expected_children:
|
||||
raise ComponentAdapterError("prepared camera artifact set changed")
|
||||
receipt = load_canonical_json(
|
||||
resolved / "camera-source.json",
|
||||
label="prepared camera receipt",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=resolved,
|
||||
)
|
||||
source = request.source
|
||||
if receipt.get("schema_version") != "missioncore.observatory-prepared-camera/v1" or receipt.get(
|
||||
"source"
|
||||
) != {
|
||||
"camera_job_id": source.camera_job_id,
|
||||
"input_sha256": source.camera_input_sha256,
|
||||
"frame_count": source.frame_count,
|
||||
}:
|
||||
raise ComponentAdapterError("prepared camera identity changed")
|
||||
rows = receipt.get("artifacts")
|
||||
expected = {
|
||||
"decode-repair": "decode-repair.json",
|
||||
"source-frames": "source-frames.json",
|
||||
"timeline": "timeline.jsonl",
|
||||
}
|
||||
if not isinstance(rows, list) or len(rows) != len(expected):
|
||||
raise ComponentAdapterError("prepared camera receipt changed")
|
||||
result: dict[str, Path] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or set(row) != {"role", "path", "byte_length", "sha256"}:
|
||||
raise ComponentAdapterError("prepared camera artifact changed")
|
||||
role = row.get("role")
|
||||
name = expected.get(role) if isinstance(role, str) else None
|
||||
if name != row.get("path"):
|
||||
raise ComponentAdapterError("prepared camera artifact role changed")
|
||||
path = _real_file(resolved / name, "prepared camera artifact")
|
||||
if path.parent != resolved or (
|
||||
row.get("byte_length") != path.stat().st_size or row.get("sha256") != sha256_file(path)
|
||||
):
|
||||
raise ComponentAdapterError("prepared camera artifact identity changed")
|
||||
result[role] = path
|
||||
return result
|
||||
|
||||
|
||||
def validate_tree_receipt(
|
||||
request: ComponentRequest,
|
||||
root: Path,
|
||||
@@ -833,12 +888,12 @@ def _paths(value: object, component: Component) -> dict[str, str | None]:
|
||||
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
|
||||
),
|
||||
"eomt_result_root": (
|
||||
f"{PACKAGE_STEP_INPUT_ROOT}/eomt" if component == "ddrnet" else None
|
||||
f"{PACKAGE_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
|
||||
),
|
||||
"decoded_frames_root": (
|
||||
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
|
||||
if component == "eomt"
|
||||
else f"{PACKAGE_STEP_INPUT_ROOT}/eomt/source-frames"
|
||||
if component == "camera-source"
|
||||
else f"{PACKAGE_STEP_INPUT_ROOT}/camera-source/source-frames"
|
||||
),
|
||||
}
|
||||
_exact_keys(document, set(legacy), "component paths")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Small synthetic shared-cache proof; never claims work or loads a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.observatory.worker_service import ObservatoryWorkerServiceConfiguration
|
||||
from k1link.observatory.worker_source_cache import WorkerSourceCache
|
||||
|
||||
PAYLOAD = b"missioncore-source-reuse-proof-AsldTM-v1\n" * 4096
|
||||
SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mode", choices=("retain", "restore", "cleanup"))
|
||||
args = parser.parse_args()
|
||||
configuration = ObservatoryWorkerServiceConfiguration.from_environment()
|
||||
assert configuration.source_cache_root == Path("/source-cache")
|
||||
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
|
||||
cache = WorkerSourceCache(configuration.source_cache_root)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".source-reuse-proof-", dir=configuration.work_root
|
||||
) as root:
|
||||
source = Path(root) / "synthetic.bin"
|
||||
if args.mode == "retain":
|
||||
source.write_bytes(PAYLOAD)
|
||||
assert cache.retain(source, sha256=SHA256, byte_length=len(PAYLOAD))
|
||||
else:
|
||||
assert cache.restore(source, sha256=SHA256, byte_length=len(PAYLOAD))
|
||||
assert source.read_bytes() == PAYLOAD
|
||||
if args.mode == "cleanup":
|
||||
cached = cache.root / SHA256
|
||||
assert cached.read_bytes() == PAYLOAD
|
||||
cached.unlink() # Only this probe's verified synthetic cache object.
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.worker-source-cache-proof/v1",
|
||||
"mode": args.mode,
|
||||
"source_sha256": SHA256,
|
||||
"byte_length": len(PAYLOAD),
|
||||
"exact": True,
|
||||
"model_jobs": 0,
|
||||
"temporary_work_removed": True,
|
||||
"synthetic_cache_removed": args.mode == "cleanup",
|
||||
"started_at_utc": started,
|
||||
"finished_at_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": mono,
|
||||
"finished_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Associate RF-DETR boxes with current K1 LiDAR and publish metric ranges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.compute.lidar_local_surface_shadow import (
|
||||
K1LocalSurfaceShadowEstimator,
|
||||
K1LocalSurfaceShadowInput,
|
||||
)
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
ObjectProposal2D,
|
||||
SourceEnvelope,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.geometry import (
|
||||
GEOMETRY_PROVIDER_ID,
|
||||
GeometryFrame,
|
||||
GeometryProfile,
|
||||
Ravnoves00GeometryAssociationProvider,
|
||||
)
|
||||
from k1link.perception.geometry_math import GeometryAssociationProfile, Kb4ProjectionProfile
|
||||
from k1link.perception.providers import SourcePacket
|
||||
|
||||
SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
|
||||
ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
|
||||
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
|
||||
AUTHORITY = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
|
||||
_PACKAGE_DETECTIONS = Path("/missioncore/input/steps/rf-detr/detections.jsonl")
|
||||
_PACKAGE_LIDAR_PACK = Path("/missioncore/input/steps/prepare/lidar-pack")
|
||||
_PACKAGE_BINDING_INDEX = Path("/missioncore/input/steps/prepare/m49-source/sequence-index.ndjson")
|
||||
_PACKAGE_CALIBRATION = Path("/opt/nodedc/assets/k1-camera-lidar-calibration")
|
||||
_PACKAGE_OUTPUT = Path("/missioncore/output")
|
||||
_MAX_CALIBRATION_PACK_BYTES = 128 * 1024 * 1024
|
||||
_LIDAR_PACK_ID = re.compile(r"^lidar-replay-pack-([a-f0-9]{64})$")
|
||||
ASSOCIATION = GeometryAssociationProfile(
|
||||
bbox_inset_fraction=0.03,
|
||||
depth_cluster_minimum_gap_m=0.45,
|
||||
depth_cluster_gap_fraction=0.08,
|
||||
spatial_cluster_radius_m=0.6,
|
||||
semantic_minimum_occupied_points=2,
|
||||
semantic_minimum_occupied_voxels=1,
|
||||
semantic_voxel_size_m=0.35,
|
||||
conflict_minimum_classified_points=6,
|
||||
conflict_surface_fraction=0.8,
|
||||
geometry_local_radius_m=10.0,
|
||||
geometry_voxel_size_m=0.45,
|
||||
geometry_minimum_cluster_points=4,
|
||||
geometry_minimum_cluster_voxels=1,
|
||||
maximum_geometry_clusters_per_frame=64,
|
||||
)
|
||||
|
||||
|
||||
class ObjectDistanceModuleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _CurrentStore:
|
||||
def __init__(self, profile: GeometryProfile) -> None:
|
||||
self.profile = profile
|
||||
self.current: GeometryFrame | None = None
|
||||
|
||||
def frame(self, _packet: SourcePacket) -> GeometryFrame | None:
|
||||
return self.current
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PointFrame:
|
||||
received_monotonic_ns: int
|
||||
xyz_map: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PoseFrame:
|
||||
received_monotonic_ns: int
|
||||
position_map: tuple[float, float, float]
|
||||
orientation_map_from_lidar: tuple[float, float, float, float]
|
||||
|
||||
|
||||
class _LidarPack:
|
||||
"""Narrow reader for the already sealed replay-pack arrays."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
candidate = root.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
self.root = candidate.resolve(strict=True)
|
||||
manifest_path = self.root / "manifest.json"
|
||||
if manifest_path.is_symlink() or not manifest_path.is_file():
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
pack_id = manifest.get("pack_id") if isinstance(manifest, dict) else None
|
||||
match = _LIDAR_PACK_ID.fullmatch(pack_id) if isinstance(pack_id, str) else None
|
||||
if (
|
||||
self.root.is_symlink()
|
||||
or not self.root.is_dir()
|
||||
or not isinstance(manifest, dict)
|
||||
or manifest.get("schema_version") != "missioncore.lidar-replay-pack/v2"
|
||||
or match is None
|
||||
or manifest.get("identity_sha256") != match.group(1)
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack artifacts changed")
|
||||
row = next(
|
||||
(
|
||||
item
|
||||
for item in artifacts
|
||||
if isinstance(item, dict) and item.get("kind") == "lidar-arrays"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None or row.get("path") != "lidar-replay.npz":
|
||||
raise ObjectDistanceModuleError("LiDAR replay arrays are unavailable")
|
||||
arrays_path = self.root / "lidar-replay.npz"
|
||||
if (
|
||||
arrays_path.is_symlink()
|
||||
or not arrays_path.is_file()
|
||||
or arrays_path.resolve(strict=True).parent != self.root
|
||||
or arrays_path.stat().st_size != row.get("byte_length")
|
||||
or _sha(arrays_path) != row.get("sha256")
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR replay arrays identity changed")
|
||||
self.arrays_path = arrays_path.resolve(strict=True)
|
||||
archive = np.load(arrays_path, allow_pickle=False)
|
||||
try:
|
||||
self.arrays = {name: np.asarray(archive[name]) for name in archive.files}
|
||||
finally:
|
||||
archive.close()
|
||||
required = {
|
||||
"point_offsets",
|
||||
"point_xyz_map",
|
||||
"point_received_monotonic_ns",
|
||||
"pose_positions_map",
|
||||
"pose_quaternions_map_from_lidar",
|
||||
"pose_received_monotonic_ns",
|
||||
}
|
||||
if not required.issubset(self.arrays):
|
||||
raise ObjectDistanceModuleError("LiDAR replay array set changed")
|
||||
self.pack_id = pack_id
|
||||
self.point_frame_count = int(self.arrays["point_received_monotonic_ns"].shape[0])
|
||||
self.pose_frame_count = int(self.arrays["pose_received_monotonic_ns"].shape[0])
|
||||
self.point_count = int(self.arrays["point_xyz_map"].shape[0])
|
||||
|
||||
def point_frame(self, index: int) -> _PointFrame:
|
||||
if not 0 <= index < self.point_frame_count:
|
||||
raise ObjectDistanceModuleError("LiDAR frame index is outside the replay pack")
|
||||
begin, end = (int(self.arrays["point_offsets"][index + offset]) for offset in (0, 1))
|
||||
points = np.asarray(self.arrays["point_xyz_map"][begin:end], dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1:] != (3,) or not np.isfinite(points).all():
|
||||
raise ObjectDistanceModuleError("LiDAR frame points changed")
|
||||
return _PointFrame(int(self.arrays["point_received_monotonic_ns"][index]), points)
|
||||
|
||||
def pose_frame(self, index: int) -> _PoseFrame:
|
||||
if not 0 <= index < self.pose_frame_count:
|
||||
raise ObjectDistanceModuleError("pose frame index is outside the replay pack")
|
||||
position = tuple(float(value) for value in self.arrays["pose_positions_map"][index])
|
||||
orientation = tuple(
|
||||
float(value) for value in self.arrays["pose_quaternions_map_from_lidar"][index]
|
||||
)
|
||||
return _PoseFrame(
|
||||
int(self.arrays["pose_received_monotonic_ns"][index]),
|
||||
cast(tuple[float, float, float], position),
|
||||
cast(tuple[float, float, float, float], orientation),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.arrays.clear()
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _projection(path: Path) -> Kb4ProjectionProfile:
|
||||
candidate = path.expanduser().resolve(strict=True)
|
||||
# The admitted E10 source pack contains the three small calibration arrays
|
||||
# together with the full point cloud. Bound the sealed archive itself while
|
||||
# retaining strict per-member limits for the arrays read below.
|
||||
if (
|
||||
candidate.is_symlink()
|
||||
or not candidate.is_file()
|
||||
or candidate.stat().st_size > _MAX_CALIBRATION_PACK_BYTES
|
||||
):
|
||||
raise ObjectDistanceModuleError("camera/LiDAR calibration is unavailable")
|
||||
with zipfile.ZipFile(candidate) as archive:
|
||||
arrays = {}
|
||||
for name in ("intrinsic_fx_fy_cx_cy", "distortion_kb4", "t_camera_from_lidar"):
|
||||
info = archive.getinfo(name + ".npy")
|
||||
if info.file_size > 4096:
|
||||
raise ObjectDistanceModuleError("camera/LiDAR calibration exceeds its bound")
|
||||
with archive.open(info) as stream:
|
||||
arrays[name] = np.lib.format.read_array(stream, allow_pickle=False)
|
||||
return Kb4ProjectionProfile(
|
||||
800,
|
||||
600,
|
||||
cast(
|
||||
tuple[float, float, float, float],
|
||||
tuple(float(value) for value in arrays["intrinsic_fx_fy_cx_cy"]),
|
||||
),
|
||||
cast(
|
||||
tuple[float, float, float, float],
|
||||
tuple(float(value) for value in arrays["distortion_kb4"]),
|
||||
),
|
||||
np.asarray(arrays["t_camera_from_lidar"], dtype=np.float64),
|
||||
)
|
||||
|
||||
|
||||
def _detections(path: Path, count: int) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 8 * 1024 * 1024 or len(rows) >= count:
|
||||
raise ObjectDistanceModuleError("RF-DETR result exceeds its bound")
|
||||
row = json.loads(raw)
|
||||
if not isinstance(row, dict) or row.get("schema_version") != RF_ROW_SCHEMA:
|
||||
raise ObjectDistanceModuleError("RF-DETR result contract changed")
|
||||
rows.append(row)
|
||||
if len(rows) != count:
|
||||
raise ObjectDistanceModuleError("RF-DETR and LiDAR timelines differ")
|
||||
return rows
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, label: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
return number
|
||||
|
||||
|
||||
def _profile(session_id: str, pack: _LidarPack) -> GeometryProfile:
|
||||
return GeometryProfile(
|
||||
profile_id="observatory-object-distance-v1",
|
||||
provider_id=GEOMETRY_PROVIDER_ID,
|
||||
source_id="recorded-k1",
|
||||
session_id=session_id,
|
||||
source_pack_id=pack.pack_id,
|
||||
source_pack_sha256=_sha(pack.arrays_path),
|
||||
frame_count=pack.point_frame_count,
|
||||
point_count=pack.point_count,
|
||||
local_surface_model_id="k1-local-surface-shadow-v1",
|
||||
local_surface_sha256="0" * 64,
|
||||
valid_frame_count=pack.point_frame_count,
|
||||
width=800,
|
||||
height=600,
|
||||
coordinate_frame="map",
|
||||
association=ASSOCIATION,
|
||||
profile_sha256=hashlib.sha256(
|
||||
_canonical(
|
||||
{
|
||||
"module": "object-distance",
|
||||
"association": ASSOCIATION.__dict__
|
||||
if hasattr(ASSOCIATION, "__dict__")
|
||||
else str(ASSOCIATION),
|
||||
}
|
||||
)
|
||||
).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _packet(
|
||||
*,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
frame_id: str,
|
||||
frame_index: int,
|
||||
session_seconds: float,
|
||||
spatial_available: bool,
|
||||
) -> SourcePacket:
|
||||
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-module-input")
|
||||
missing = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "no-synchronous-lidar")
|
||||
spatial = available if spatial_available else missing
|
||||
nanoseconds = round(session_seconds * 1_000_000_000)
|
||||
return SourcePacket(
|
||||
SourceEnvelope(
|
||||
source_id=source_id,
|
||||
session_id=session_id,
|
||||
frame_id=frame_id,
|
||||
sequence=frame_index,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=nanoseconds,
|
||||
monotonic_ns=nanoseconds,
|
||||
source_ns=nanoseconds,
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="camera-lidar-past-only-binding",
|
||||
calibration_id="camera-1-kb4-05f3ad9b",
|
||||
representation_id="object-distance-current-cloud-v1",
|
||||
image=available,
|
||||
registered_point_increment=spatial,
|
||||
pose=spatial,
|
||||
),
|
||||
b"rf-detr-proposals",
|
||||
b"current-k1-cloud" if spatial_available else None,
|
||||
b"current-k1-pose" if spatial_available else None,
|
||||
)
|
||||
|
||||
|
||||
def _binding_rows(path: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
previous = -1.0
|
||||
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 64 * 1024 or len(rows) >= 250_000:
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline exceeds its bound")
|
||||
row = json.loads(raw)
|
||||
if (
|
||||
not isinstance(row, dict)
|
||||
or row.get("schema_version") != "missioncore.m49-tgs-portable-source-index-row/v1"
|
||||
or row.get("timeline_frame_index") != len(rows)
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR binding row identity changed")
|
||||
seconds = _number(row.get("session_seconds"), "LiDAR binding time")
|
||||
if seconds <= previous or not isinstance(row.get("sample_available"), bool):
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline changed")
|
||||
rows.append(row)
|
||||
previous = seconds
|
||||
if not rows:
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline is empty")
|
||||
return rows
|
||||
|
||||
|
||||
def _aligned_camera_seconds(
|
||||
binding: dict[str, object],
|
||||
rf_row: dict[str, object],
|
||||
*,
|
||||
frame_index: int,
|
||||
previous_camera_seconds: float,
|
||||
) -> float:
|
||||
# Validate both clocks, but join the sealed products by their shared frame
|
||||
# index. Camera-source repairs MP4 discontinuities; the LiDAR binding keeps
|
||||
# the original segment clock, so equality between their seconds is invalid.
|
||||
_number(binding.get("session_seconds"), "LiDAR binding time")
|
||||
if rf_row.get("frame_index") != frame_index:
|
||||
raise ObjectDistanceModuleError("RF-DETR and LiDAR frame identities differ")
|
||||
seconds = _number(rf_row.get("session_seconds"), "camera session time")
|
||||
if seconds <= previous_camera_seconds:
|
||||
raise ObjectDistanceModuleError("RF-DETR camera timeline changed")
|
||||
return seconds
|
||||
|
||||
|
||||
def execute(
|
||||
*,
|
||||
detections: Path,
|
||||
lidar_pack: Path,
|
||||
binding_index: Path,
|
||||
calibration: Path,
|
||||
output: Path,
|
||||
session_id: str,
|
||||
) -> dict[str, object]:
|
||||
index = _binding_rows(binding_index)
|
||||
rf_rows = _detections(detections, len(index))
|
||||
projection = _projection(calibration)
|
||||
output = output.expanduser().absolute()
|
||||
if output.exists():
|
||||
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
|
||||
raise ObjectDistanceModuleError("output root is unsafe")
|
||||
else:
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
if output.is_symlink() or not output.is_dir():
|
||||
raise ObjectDistanceModuleError("output root is unsafe")
|
||||
pack = _LidarPack(lidar_pack)
|
||||
started = time.monotonic()
|
||||
ranged = 0
|
||||
proposal_count = 0
|
||||
unavailable = 0
|
||||
result_path = output / "object-distances.jsonl"
|
||||
try:
|
||||
store = _CurrentStore(_profile(session_id, pack))
|
||||
provider = Ravnoves00GeometryAssociationProvider(store=store) # type: ignore[arg-type]
|
||||
surface = K1LocalSurfaceShadowEstimator()
|
||||
previous_camera_seconds = -1.0
|
||||
with result_path.open("xb") as stream:
|
||||
for frame_index, (binding, rf_row) in enumerate(zip(index, rf_rows, strict=True)):
|
||||
seconds = _aligned_camera_seconds(
|
||||
binding,
|
||||
rf_row,
|
||||
frame_index=frame_index,
|
||||
previous_camera_seconds=previous_camera_seconds,
|
||||
)
|
||||
previous_camera_seconds = seconds
|
||||
raw_proposals = rf_row.get("proposals")
|
||||
if not isinstance(raw_proposals, list):
|
||||
raise ObjectDistanceModuleError("RF-DETR proposals are unavailable")
|
||||
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
|
||||
proposal_count += len(proposals)
|
||||
store.current = None
|
||||
available = binding["sample_available"] is True
|
||||
if available:
|
||||
point = pack.point_frame(
|
||||
_integer(binding["selected_lidar_frame_index"], "LiDAR frame index")
|
||||
)
|
||||
pose = pack.pose_frame(
|
||||
_integer(binding["selected_pose_frame_index"], "pose frame index")
|
||||
)
|
||||
age_ms = abs(point.received_monotonic_ns - pose.received_monotonic_ns) / 1e6
|
||||
surface_frame = surface.process(
|
||||
K1LocalSurfaceShadowInput(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=_integer(
|
||||
binding["source_frame_index"], "source frame index"
|
||||
),
|
||||
session_seconds=seconds,
|
||||
pose_binding_age_ms=age_ms,
|
||||
points_map=point.xyz_map,
|
||||
position_map=np.asarray(pose.position_map, dtype=np.float64),
|
||||
published_monotonic_ns=point.received_monotonic_ns,
|
||||
)
|
||||
)
|
||||
store.current = GeometryFrame(
|
||||
frame_index,
|
||||
point.xyz_map,
|
||||
surface_frame.point_class,
|
||||
np.asarray(pose.position_map, dtype=np.float64),
|
||||
np.asarray(pose.orientation_map_from_lidar, dtype=np.float64),
|
||||
projection,
|
||||
surface_frame.valid,
|
||||
)
|
||||
elif proposals:
|
||||
unavailable += len(proposals)
|
||||
source_id = proposals[0].source_id if proposals else "recorded-k1"
|
||||
frame_id = proposals[0].frame_id if proposals else f"frame-{frame_index + 1:06d}"
|
||||
observations = tuple(
|
||||
item
|
||||
for item in provider.associate(
|
||||
_packet(
|
||||
session_id=session_id,
|
||||
source_id=source_id,
|
||||
frame_id=frame_id,
|
||||
frame_index=frame_index,
|
||||
session_seconds=seconds,
|
||||
spatial_available=available,
|
||||
),
|
||||
proposals,
|
||||
)
|
||||
if item.proposal_ids
|
||||
)
|
||||
ranged += sum(item.metric_geometry is not None for item in observations)
|
||||
stream.write(
|
||||
_canonical(
|
||||
{
|
||||
"schema_version": ROW_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": seconds,
|
||||
"observations": [item.to_dict() for item in observations],
|
||||
}
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
result = {
|
||||
"schema_version": SCHEMA,
|
||||
"module_id": "object-distance",
|
||||
"source_session_id": session_id,
|
||||
"frame_count": len(index),
|
||||
"proposal_count": proposal_count,
|
||||
"ranged_proposal_count": ranged,
|
||||
"unavailable_proposal_count": unavailable,
|
||||
"object_distances_sha256": _sha(result_path),
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"range_estimator": "median-camera-z-of-owned-current-points/v1",
|
||||
"authority": AUTHORITY,
|
||||
}
|
||||
(output / "result.json").write_bytes(_canonical(result))
|
||||
return result
|
||||
finally:
|
||||
pack.close()
|
||||
|
||||
|
||||
def _package_session(path: Path) -> str:
|
||||
document = json.loads(path.read_text())
|
||||
source = document.get("source") if isinstance(document, dict) else None
|
||||
session_id = source.get("session_id") if isinstance(source, dict) else None
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
|
||||
or not isinstance(session_id, str)
|
||||
):
|
||||
raise ObjectDistanceModuleError("prepared source identity changed")
|
||||
return session_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--package-step", "object-distance"]:
|
||||
execute(
|
||||
detections=_PACKAGE_DETECTIONS,
|
||||
lidar_pack=_PACKAGE_LIDAR_PACK,
|
||||
binding_index=_PACKAGE_BINDING_INDEX,
|
||||
calibration=_PACKAGE_CALIBRATION,
|
||||
output=_PACKAGE_OUTPUT,
|
||||
session_id=_package_session(_PACKAGE_SOURCE),
|
||||
)
|
||||
return 0
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--detections", type=Path, required=True)
|
||||
parser.add_argument("--lidar-pack", type=Path, required=True)
|
||||
parser.add_argument("--binding-index", type=Path, required=True)
|
||||
parser.add_argument("--calibration", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session-id", required=True)
|
||||
execute(**vars(parser.parse_args(arguments)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recorded-camera RF-DETR module with a sealed, path-local output contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
SourceEnvelope,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.detector import NativeRfDetrShadowDetectorProvider
|
||||
from k1link.perception.providers import SourcePacket
|
||||
from k1link.perception.rf_detr_native_object_detector import (
|
||||
RF_DETR_NATIVE_ENGINE_SHA256,
|
||||
TritonNativeRfDetrHttpInferenceBackend,
|
||||
)
|
||||
from k1link.perception.yolox_object_detector import load_valid_fov_mask
|
||||
|
||||
SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
|
||||
ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
|
||||
AUTHORITY = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
VALID_FOV_SHA256 = "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
|
||||
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
|
||||
_PACKAGE_FRAMES = Path("/missioncore/input/steps/camera-source/source-frames")
|
||||
_PACKAGE_TIMELINE = Path("/missioncore/input/steps/camera-source/timeline.jsonl")
|
||||
_PACKAGE_VALID_FOV = Path("/opt/nodedc/assets/valid-fov-mask")
|
||||
_PACKAGE_ENGINE = Path("/models/rf_detr_large_native_kb4/1/model.plan")
|
||||
_PACKAGE_OUTPUT = Path("/missioncore/output")
|
||||
|
||||
|
||||
class RfDetrModuleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _file(path: Path, expected: str, label: str) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise RfDetrModuleError(f"{label} identity changed")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
if not candidate.is_file() or _sha(candidate) != expected:
|
||||
raise RfDetrModuleError(f"{label} identity changed")
|
||||
return candidate
|
||||
|
||||
|
||||
def _empty(path: Path) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.exists():
|
||||
if candidate.is_symlink() or not candidate.is_dir() or any(candidate.iterdir()):
|
||||
raise RfDetrModuleError("output root is unsafe")
|
||||
else:
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise RfDetrModuleError("output root is unsafe")
|
||||
return candidate
|
||||
|
||||
|
||||
def _rows(path: Path, *, maximum: int) -> list[dict[str, object]]:
|
||||
result: list[dict[str, object]] = []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 64 * 1024 or len(result) >= maximum:
|
||||
raise RfDetrModuleError("camera timeline exceeds the module bound")
|
||||
row = json.loads(raw)
|
||||
if not isinstance(row, dict):
|
||||
raise RfDetrModuleError("camera timeline row is invalid")
|
||||
result.append(row)
|
||||
if not result:
|
||||
raise RfDetrModuleError("camera timeline is empty")
|
||||
return result
|
||||
|
||||
|
||||
def _wait_triton(process: subprocess.Popen[bytes]) -> None:
|
||||
import http.client
|
||||
|
||||
deadline = time.monotonic() + 45
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RfDetrModuleError("RF-DETR inference runtime stopped during startup")
|
||||
try:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1)
|
||||
connection.request("GET", "/v2/models/rf_detr_large_native_kb4/ready")
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
connection.close()
|
||||
if response.status == 200:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RfDetrModuleError("RF-DETR inference runtime did not become ready")
|
||||
|
||||
|
||||
def _packet(
|
||||
session_id: str, source_id: str, frame_index: int, session_seconds: float, image: np.ndarray
|
||||
) -> SourcePacket:
|
||||
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-camera-frame")
|
||||
unavailable = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "module-input-not-requested")
|
||||
nanoseconds = round(session_seconds * 1_000_000_000)
|
||||
envelope = SourceEnvelope(
|
||||
source_id=source_id,
|
||||
session_id=session_id,
|
||||
frame_id=f"frame-{frame_index + 1:06d}",
|
||||
sequence=frame_index,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=nanoseconds,
|
||||
monotonic_ns=nanoseconds,
|
||||
source_ns=nanoseconds,
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="recorded-camera-timeline",
|
||||
calibration_id="camera-1-kb4-05f3ad9b",
|
||||
representation_id="rf-detr-native-kb4-v1",
|
||||
image=available,
|
||||
registered_point_increment=unavailable,
|
||||
pose=unavailable,
|
||||
)
|
||||
return SourcePacket(envelope, image, None, None)
|
||||
|
||||
|
||||
def execute(
|
||||
*,
|
||||
frames: Path,
|
||||
timeline: Path,
|
||||
valid_fov: Path,
|
||||
engine: Path,
|
||||
output: Path,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
) -> dict[str, object]:
|
||||
frames = frames.expanduser().resolve(strict=True)
|
||||
if frames.is_symlink() or not frames.is_dir():
|
||||
raise RfDetrModuleError("prepared camera frames are unavailable")
|
||||
timeline = timeline.expanduser().resolve(strict=True)
|
||||
valid_fov = _file(valid_fov, VALID_FOV_SHA256, "valid-FOV mask")
|
||||
_file(engine, RF_DETR_NATIVE_ENGINE_SHA256, "RF-DETR TensorRT engine")
|
||||
rows = _rows(timeline, maximum=100_000)
|
||||
names = tuple(f"frame-{index + 1:06d}.png" for index in range(len(rows)))
|
||||
if tuple(sorted(path.name for path in frames.iterdir())) != names:
|
||||
raise RfDetrModuleError("prepared camera frame set changed")
|
||||
output = _empty(output)
|
||||
log = (output / "triton.log").open("wb")
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"tritonserver",
|
||||
"--model-repository=/models",
|
||||
"--model-control-mode=explicit",
|
||||
"--load-model=rf_detr_large_native_kb4",
|
||||
"--allow-grpc=false",
|
||||
"--allow-metrics=false",
|
||||
"--http-address=127.0.0.1",
|
||||
"--pinned-memory-pool-byte-size=16777216",
|
||||
"--cuda-memory-pool-byte-size=0:16777216",
|
||||
],
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
backend: TritonNativeRfDetrHttpInferenceBackend | None = None
|
||||
started = time.monotonic()
|
||||
counts = 0
|
||||
detections_path = output / "detections.jsonl"
|
||||
try:
|
||||
_wait_triton(process)
|
||||
backend = TritonNativeRfDetrHttpInferenceBackend("http://127.0.0.1:8000")
|
||||
detector = NativeRfDetrShadowDetectorProvider(
|
||||
mask=load_valid_fov_mask(valid_fov),
|
||||
backend=backend,
|
||||
)
|
||||
detector.warm_up()
|
||||
with detections_path.open("xb") as stream:
|
||||
previous = -1.0
|
||||
for frame_index, (name, row) in enumerate(zip(names, rows, strict=True)):
|
||||
seconds = row.get("session_seconds")
|
||||
if (
|
||||
row.get("frame_index") != frame_index
|
||||
or isinstance(seconds, bool)
|
||||
or not isinstance(seconds, (int, float))
|
||||
or float(seconds) <= previous
|
||||
):
|
||||
raise RfDetrModuleError("camera timeline identity changed")
|
||||
with Image.open(frames / name) as source:
|
||||
rgb = np.asarray(source.convert("RGB"), dtype=np.uint8)
|
||||
if rgb.shape != (600, 800, 3):
|
||||
raise RfDetrModuleError("camera raster changed")
|
||||
proposals = detector.detect(
|
||||
_packet(
|
||||
session_id,
|
||||
source_id,
|
||||
frame_index,
|
||||
float(seconds),
|
||||
np.ascontiguousarray(rgb[:, :, ::-1]),
|
||||
)
|
||||
)
|
||||
counts += len(proposals)
|
||||
stream.write(
|
||||
_canonical(
|
||||
{
|
||||
"schema_version": ROW_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": float(seconds),
|
||||
"proposals": [proposal.to_dict() for proposal in proposals],
|
||||
}
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
previous = float(seconds)
|
||||
snapshot = detector.snapshot()
|
||||
result = {
|
||||
"schema_version": SCHEMA,
|
||||
"module_id": "rf-detr",
|
||||
"source": {"session_id": session_id, "source_id": source_id},
|
||||
"frame_count": len(rows),
|
||||
"proposal_count": counts,
|
||||
"zero_proposal_frame_count": snapshot.zero_proposal_frames,
|
||||
"detections_sha256": _sha(detections_path),
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"authority": AUTHORITY,
|
||||
}
|
||||
(output / "result.json").write_bytes(_canonical(result))
|
||||
return result
|
||||
finally:
|
||||
if backend is not None:
|
||||
backend.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
log.close()
|
||||
|
||||
|
||||
def _package_source(path: Path) -> tuple[str, str]:
|
||||
document = json.loads(path.read_text())
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
|
||||
or not isinstance(document.get("source"), dict)
|
||||
or not isinstance(document.get("camera_compute_job"), dict)
|
||||
):
|
||||
raise RfDetrModuleError("prepared camera source contract changed")
|
||||
source = document["source"]
|
||||
camera = document["camera_compute_job"]
|
||||
session_id = source.get("session_id")
|
||||
source_id = camera.get("source_id")
|
||||
if not isinstance(session_id, str) or not isinstance(source_id, str):
|
||||
raise RfDetrModuleError("prepared camera source identity changed")
|
||||
return session_id, source_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--package-step", "rf-detr"]:
|
||||
session_id, source_id = _package_source(_PACKAGE_SOURCE)
|
||||
execute(
|
||||
frames=_PACKAGE_FRAMES,
|
||||
timeline=_PACKAGE_TIMELINE,
|
||||
valid_fov=_PACKAGE_VALID_FOV,
|
||||
engine=_PACKAGE_ENGINE,
|
||||
output=_PACKAGE_OUTPUT,
|
||||
session_id=session_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
return 0
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--frames", type=Path, required=True)
|
||||
parser.add_argument("--timeline", type=Path, required=True)
|
||||
parser.add_argument("--valid-fov", type=Path, required=True)
|
||||
parser.add_argument("--engine", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session-id", required=True)
|
||||
parser.add_argument("--source-id", required=True)
|
||||
options = parser.parse_args(arguments)
|
||||
execute(**vars(options))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode one sealed K1 camera epoch without loading any AI model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from portable_lab_v1_component_adapter import (
|
||||
AssetExpectation,
|
||||
ComponentAdapterError,
|
||||
RuntimeLayout,
|
||||
load_component_request,
|
||||
resolve_runtime_layout,
|
||||
run_command,
|
||||
sha256_file,
|
||||
validate_camera_compute_job,
|
||||
validate_tree_asset,
|
||||
validate_tree_receipt,
|
||||
)
|
||||
from run_portable_lab_v1_eomt_component import (
|
||||
FFMPEG_TREE_BYTE_LENGTH,
|
||||
FFMPEG_TREE_IDENTITY_SHA256,
|
||||
FFMPEG_TREE_SOURCE_IMAGE_SHA256,
|
||||
_decode_camera_epoch,
|
||||
_source_frame_manifest_document,
|
||||
_write_canonical_json,
|
||||
)
|
||||
|
||||
FFMPEG_ASSET = AssetExpectation(
|
||||
"eomt-ffmpeg-runtime",
|
||||
"/opt/nodedc/assets/ffmpeg-runtime",
|
||||
"tree",
|
||||
"identity-sha256",
|
||||
FFMPEG_TREE_IDENTITY_SHA256,
|
||||
FFMPEG_TREE_BYTE_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def execute(*, request_path: Path, layout: RuntimeLayout) -> None:
|
||||
request = load_component_request(
|
||||
request_path, component="camera-source", expectations=(FFMPEG_ASSET,)
|
||||
)
|
||||
output = layout.output_root
|
||||
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
|
||||
raise ComponentAdapterError("camera-source output must be an empty real directory")
|
||||
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
|
||||
ffmpeg_root = validate_tree_asset(request, layout, FFMPEG_ASSET.asset_id)
|
||||
validate_tree_receipt(
|
||||
request,
|
||||
ffmpeg_root,
|
||||
FFMPEG_ASSET.asset_id,
|
||||
expected_metadata={
|
||||
"source_image_sha256": FFMPEG_TREE_SOURCE_IMAGE_SHA256,
|
||||
"source_path": "/usr/lib/ffmpeg/7.0",
|
||||
},
|
||||
additional_metadata_keys=frozenset({"binaries"}),
|
||||
verify_payload=True,
|
||||
)
|
||||
workspace = output / ".camera-source-work"
|
||||
workspace.mkdir(mode=0o700)
|
||||
try:
|
||||
frames, timeline, repair = _decode_camera_epoch(
|
||||
request=request,
|
||||
input_document=input_document,
|
||||
camera_job_root=layout.camera_job_root,
|
||||
output_root=output,
|
||||
work_root=workspace,
|
||||
ffmpeg_root=ffmpeg_root,
|
||||
command_runner=run_command,
|
||||
)
|
||||
_write_canonical_json(
|
||||
output / "source-frames.json", _source_frame_manifest_document(frames, request)
|
||||
)
|
||||
_write_canonical_json(output / "decode-repair.json", repair)
|
||||
os.replace(timeline, output / "timeline.jsonl")
|
||||
_write_canonical_json(
|
||||
output / "camera-source.json",
|
||||
{
|
||||
"schema_version": "missioncore.observatory-prepared-camera/v1",
|
||||
"source": {
|
||||
"camera_job_id": request.source.camera_job_id,
|
||||
"input_sha256": request.source.camera_input_sha256,
|
||||
"frame_count": request.source.frame_count,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"role": role,
|
||||
"path": name,
|
||||
"byte_length": (output / name).stat().st_size,
|
||||
"sha256": sha256_file(output / name),
|
||||
}
|
||||
for role, name in (
|
||||
("decode-repair", "decode-repair.json"),
|
||||
("source-frames", "source-frames.json"),
|
||||
("timeline", "timeline.jsonl"),
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
layout = resolve_runtime_layout(
|
||||
tuple(sys.argv[1:]), component="camera-source", expectations=(FFMPEG_ASSET,)
|
||||
)
|
||||
execute(request_path=layout.request, layout=layout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+23
-6
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the sealed LAB V1 DDRNet component over EoMT-decoded K1 frames."""
|
||||
"""Run DDRNet over independently prepared, immutable K1 camera frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,6 +38,7 @@ from portable_lab_v1_component_adapter import (
|
||||
validate_file_asset,
|
||||
validate_fixed_result,
|
||||
validate_grayscale_png_payload,
|
||||
validate_prepared_camera_root,
|
||||
)
|
||||
|
||||
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
|
||||
@@ -136,7 +137,7 @@ def execute_ddrnet_component(
|
||||
layout.effective_ddrnet_config,
|
||||
request,
|
||||
)
|
||||
eomt_root, source_frames, decode_repair = _validate_eomt_input(
|
||||
prepared_root, source_frames, decode_repair = _validate_prepared_input(
|
||||
layout.eomt_result_root,
|
||||
request,
|
||||
)
|
||||
@@ -148,7 +149,7 @@ def execute_ddrnet_component(
|
||||
# tmpfs makes a full 6,830-frame K1 run fail even though the immutable
|
||||
# input is valid. Verify every manifest digest through an O_NOFOLLOW
|
||||
# descriptor, then let the sealed runner read that same read-only tree.
|
||||
frames_root = _verify_source_frames(eomt_root, source_frames)
|
||||
frames_root = _verify_source_frames(prepared_root, source_frames)
|
||||
mapping_copy = workspace / "goose_label_mapping.csv"
|
||||
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
|
||||
os.chmod(mapping_copy, 0o400)
|
||||
@@ -182,7 +183,7 @@ def execute_ddrnet_component(
|
||||
"0",
|
||||
)
|
||||
command_runner(argv, _ddrnet_environment())
|
||||
shutil.copyfile(eomt_root / "decode-repair.json", staging / "decode-repair.json")
|
||||
shutil.copyfile(prepared_root / "decode-repair.json", staging / "decode-repair.json")
|
||||
if (
|
||||
load_json(
|
||||
staging / "decode-repair.json",
|
||||
@@ -269,13 +270,29 @@ def _validate_effective_config(
|
||||
return config
|
||||
|
||||
|
||||
def _validate_eomt_input(
|
||||
def _validate_prepared_input(
|
||||
root: Path,
|
||||
request: ComponentRequest,
|
||||
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
|
||||
if root.is_symlink():
|
||||
raise ComponentAdapterError("EoMT result root is a symbolic link")
|
||||
raise ComponentAdapterError("prepared camera root is a symbolic link")
|
||||
resolved = root.resolve(strict=True)
|
||||
if not (resolved / "result.json").exists():
|
||||
validate_prepared_camera_root(resolved, request)
|
||||
manifest_path = _confined_regular_file(
|
||||
resolved / "source-frames.json", resolved, "prepared source frame manifest"
|
||||
)
|
||||
source_frames = _source_frame_manifest(manifest_path, resolved, request)
|
||||
repair = load_json(
|
||||
resolved / "decode-repair.json",
|
||||
label="camera decode repair",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=resolved,
|
||||
)
|
||||
_validate_decode_repair(repair, request.source.frame_count)
|
||||
_confined_regular_file(resolved / "timeline.jsonl", resolved, "prepared camera timeline")
|
||||
return resolved, source_frames, repair
|
||||
# Migration compatibility for already sealed dual-model releases only.
|
||||
result = validate_fixed_result(
|
||||
resolved,
|
||||
schema_version=EOMT_RESULT_SCHEMA,
|
||||
|
||||
+40
-17
@@ -37,6 +37,7 @@ from portable_lab_v1_component_adapter import (
|
||||
validate_fixed_result,
|
||||
validate_grayscale_png_payload,
|
||||
validate_identity_manifest,
|
||||
validate_prepared_camera_root,
|
||||
validate_tree_asset,
|
||||
validate_tree_receipt,
|
||||
)
|
||||
@@ -50,11 +51,10 @@ MODEL_REVISION: Final = "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"
|
||||
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
|
||||
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
|
||||
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
|
||||
# Keep a large post-run floor while admitting the full 6,830-frame K1 record on
|
||||
# Worker 006. The independent ``reserve`` below already accounts for the
|
||||
# complete worst-case working set, so adding the historical 360 GiB floor made
|
||||
# the real job miss admission by about 1.4 GB despite 408 GB being free.
|
||||
DISK_FLOOR_BYTES: Final = 350 * 1024**3
|
||||
# Owner-approved post-run disk floor (2026-09-03); not a RAM/VRAM reservation.
|
||||
# The independent ``reserve`` below remains additional to this free-space floor.
|
||||
# Deploy only through a new sealed image/release, never patch an installed digest.
|
||||
DISK_FLOOR_BYTES: Final = 250 * 1024**3
|
||||
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
|
||||
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
|
||||
)
|
||||
@@ -163,26 +163,49 @@ def execute_eomt_component(
|
||||
component="eomt",
|
||||
expectations=EOMT_ASSETS,
|
||||
)
|
||||
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
|
||||
roots = _validate_release_assets(request, layout)
|
||||
output = _empty_output_root(layout.output_root)
|
||||
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
|
||||
free_before = available_bytes(output)
|
||||
if disk_floor_bytes < 0 or free_before < disk_floor_bytes + reserve:
|
||||
if (
|
||||
isinstance(disk_floor_bytes, bool)
|
||||
or not isinstance(disk_floor_bytes, int)
|
||||
or disk_floor_bytes < 0
|
||||
or free_before < disk_floor_bytes + reserve
|
||||
):
|
||||
raise ComponentAdapterError("EoMT output does not satisfy its disk reserve")
|
||||
# Fail before hashing the complete recording and several GiB of model assets.
|
||||
# The typed request supplies a bounded estimate; input/asset validation is
|
||||
# still mandatory before decoding or model execution.
|
||||
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
|
||||
roots = _validate_release_assets(request, layout)
|
||||
workspace = _prepare_workspace(output / ".eomt-work" if work_root is None else work_root)
|
||||
try:
|
||||
total_started = time.perf_counter()
|
||||
extract_started = time.perf_counter()
|
||||
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
|
||||
request=request,
|
||||
input_document=input_document,
|
||||
camera_job_root=layout.camera_job_root,
|
||||
output_root=output,
|
||||
work_root=workspace,
|
||||
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
|
||||
command_runner=command_runner,
|
||||
)
|
||||
if (layout.eomt_result_root / "camera-source.json").is_file():
|
||||
prepared = validate_prepared_camera_root(layout.eomt_result_root, request)
|
||||
frames_root = layout.eomt_result_root / "source-frames"
|
||||
timeline_path = prepared["timeline"]
|
||||
decode_repair = load_json(
|
||||
prepared["decode-repair"],
|
||||
label="prepared camera decode repair",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=layout.eomt_result_root,
|
||||
)
|
||||
_validate_source_frame_manifest(prepared["source-frames"], frames_root, request)
|
||||
else:
|
||||
# Historical fixed-layout tests and already sealed releases retain
|
||||
# their old in-component decode path. New modular packages always
|
||||
# mount camera-source and never make one model prepare another.
|
||||
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
|
||||
request=request,
|
||||
input_document=input_document,
|
||||
camera_job_root=layout.camera_job_root,
|
||||
output_root=output,
|
||||
work_root=workspace,
|
||||
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
|
||||
command_runner=command_runner,
|
||||
)
|
||||
extract_seconds = _elapsed(extract_started)
|
||||
free_post_extract = available_bytes(output)
|
||||
if free_post_extract < disk_floor_bytes:
|
||||
|
||||
Reference in New Issue
Block a user