feat(observatory): ship modular AI inference labs

This commit is contained in:
DCCONSTRUCTIONS
2026-09-04 17:59:05 +03:00
parent eff60e490a
commit cada687173
145 changed files with 17651 additions and 1667 deletions
@@ -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"]
@@ -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"]
@@ -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" \
@@ -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()
@@ -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())
@@ -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())
@@ -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,
@@ -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: