Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- 事件 / API / storage schema:看 [api_spec.md](../modules/storage/api_spec.md)
- 能力/REQ/Profile、具名 owner、inherited scope/proposed delta 與代表鏈狀態:看 [capability_requirements_535.md](capability_requirements_535.md)(#535;批准與待決狀態只在該檔維護)
- S-SHIP/S-EXPORT 的展開圖(現況與目標)、completion/identity/export 接口契約卡與 ID 索引:看 [ship_export_contracts_536.md](ship_export_contracts_536.md)(#536;設計狀態只在該檔維護)
- 失敗/fallback/降級的現況基線(分 profile 的 capability/failure 矩陣、降級轉換表現況與已採納目標、owner 裁決與特徵測試):看 [failure_degradation_537.md](failure_degradation_537.md)(#537 C0;只固定現況,不修復)
- 失敗/fallback/降級(分 profile 矩陣、owner 裁決、已落地的 F07/C2/R-1、D2 host lifecycle 與 D2-GPU-0/1;WorkbenchPool 仍未 online qualified):看 [failure_degradation_537.md](failure_degradation_537.md);NVML 失敗的 4A 核准方案見 [§19](failure_degradation_537.md#19-c0-05nvml-失敗的重現與核准方案),實作與證據見 §20。
- 安裝後 package 的 Completion/Gate A、entrypoint re-pin 與驗證證據:看 [package re-pin](../reference/native_runtime_package_repin_536.md)(#536;不擴大支持範圍)
- 模型 bundle 與 native runtime 的分離(現況/目標架構圖、信任根、責任分工、A/B/C 比較):看 [ADR 028](../decisions/028-model-bundle-runtime-separation.md);bundle manifest 契約、fail-closed 規則、G01–G07 處置與控制矩陣:看 [model_bundle_contract_549.md](model_bundle_contract_549.md)(#549 S1;設計狀態只在該檔維護)

Expand Down
111 changes: 107 additions & 4 deletions docs/architecture/failure_degradation_537.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/modules/resource/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
## 🟢 目前現況
* **ResourceManager 階梯降級邏輯**:
* 使用 `pynvml` 連接 GPU 驅動,監控顯存佔用百分比。
* NVML 初始化/讀取失敗或原始 sample 無效時,拋具名 `ResourceMonitorUnavailable`,不以全零冒充健康;AsyncDispatcher 在啟動前檢查,執行中失敗進入 FAILED,stats 顯示 UNKNOWN 與監控錯誤。契約見 [degradation §7](architecture_degradation.md#7-nvml-失敗契約537-c0-05d-537-4a)。
* 降級閾值設定為:NORMAL (<85%) ➡️ REDUCED (85%~92%,壓縮緩衝池) ➡️ FAST_PATH (92%~96%,跳過 ReID/RAG) ➡️ EMERGENCY (>96%,縮小解析度至 320p、丟棄非異常影格)。
* **遲滯保護 (Hysteresis)**:下降門檻比上升門檻低 $5\%$,即升級到 EMERGENCY (96%) 後,必須降到 $91\%$ 以下才恢復為 FAST_PATH;FAST_PATH (92%) 必須降到 $87\%$ 以下恢復成 REDUCED,以此防止顯存處於臨界值時系統抖動。
* **跨進程廣播設計 (`saccade_vram_level`)**:
Expand Down
28 changes: 26 additions & 2 deletions docs/modules/resource/architecture_degradation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ L6 是 Saccade 的「決策大腦」,負責高層級的 VRAM 資源監控與
- 一次決策不會同時升級又恢復,也不會一次降兩級。從 EMERGENCY 回到 NORMAL 至少要 3 次決策。
- 決策頻率:dispatcher 每個 batch 決策一次,閒置時不決策;沒有最短停留時間(F0)。
- 初值:新的 `ResourceManager` 從 NORMAL 開始,第一次決策就升到 usage 對應的級別,不繼承前一個 writer 的狀態(R0)。
- 不在本契約內:NVML 不可用(C0-05,REQ-535-07-01);NaN、負值或超過 100 的 usage(目前 NaN 維持原級、負值降一級、>100 升到 EMERGENCY,只是現況,不是支持契約)。
- NVML 不可用與無效原始 sample 依 §7 明確失敗,不做這次 hysteresis 決策。測試替身直接注入 NaN、負值或超過 100 的 `VRAMStats.usage_percent` 仍不在決策支持契約內(NaN 維持原級、負值降一級、>100 升到 EMERGENCY,只是 O0 現況)。

契約測試在 `tests/unit/test_resource_degradation_hysteresis_537.py`;修復前的轉換表與修復紀錄見 [#537 C0 §3、§8](../../architecture/failure_degradation_537.md#8-c2-遲滯修復c0-04-收斂)。

Expand Down Expand Up @@ -91,6 +91,30 @@ Dispatcher 端的 `VRAMLevelWriter` 把每次決策發佈為一份完整快照

- 「嘗試持久化」不等於不遺失資料。`add_memory` 失敗時例外仍被吞掉,整批照樣 ACK;PEL 也沒有回收(REQ-535-06-01,#538)。
- 不保證 unknown 狀態下不使用 GPU。Chroma 的預設 embedding 沒有指定 provider,可能用到 GPU;已經在執行的 RAG task,以及啟動時的 RAG 初始化,都不受這個 gate 控制。
- NVML 初始化失敗時,writer 仍會發佈 FRESH NORMAL(C0-05),本契約不涵蓋這種情況(REQ-535-07-01)。
- NVML 失敗的處理另見 §7(C0-05/D-537-4A);失敗時不產生新決策,現存快照仍依本節的 T 老化,不保證跨程序即時知道 monitor failure。

**部署規則**:v1 記錄與修復前的 1-byte segment 不能混用。新 reader 會把舊 segment 判為 INVALID,但舊 reader 不會檢查 freshness、會保留舊 mapping,而且它結束時,它的 `resource_tracker` 會刪除公開名稱。升級或 rollback 時:(1) 停掉 perception(writer)與 orchestrator(reader)兩端;(2) 確認兩端程序與它們的 `multiprocessing.resource_tracker` 子程序都已結束(例如以服務使用者執行 `pgrep -f multiprocessing.resource_tracker` 沒有結果);(3) 再啟動新版本。如果漏掉第 (2) 步,殘留 tracker 刪除的只是名稱:reader 會讀到 MISSING(不會讀到錯誤的 level),writer 下一次發佈就會恢復(整合測試 `test_legacy_attacher_tracker_removal_heals_on_next_decision`)。

## 7. NVML 失敗契約(#537 C0-05/D-537-4A)

Owner 在 2026-10-20 核准 4A,裁決與原始重現見 [#537 §19](../../architecture/failure_degradation_537.md#19-c0-05nvml-失敗的重現與核准方案),實作證據見該文件 §20。此契約限 `ResourceManager` → `AsyncDispatcher` 的 online 路徑;不批准整個 online-service profile,也不把 WorkbenchPool、health CLI 或獨立 telemetry 視為已接上此監控。

**取得有效測量才決策**:constructor 保留 NVML init 的原始錯誤,允許 import/construct,讓 orchestrator-only 入口不因監控能力缺失而在 import 時失敗。`get_stats()` 只在有效測量時回 `VRAMStats`;init/handle/memory info 取得失敗時,拋 `ResourceMonitorUnavailable(RuntimeError)` 並保留原始 cause。原始 sample 必須 total 為有限正數、used 有限且 0 ≤ used ≤ total、free 有限且非負;不要求 free+used==total。驗證失敗時,同一具名錯誤以 `ValueError` 保留驗證原因。真正 used=0 且 total>0 仍是合法測量,依 §5 恢復。

**啟動與執行中失敗**:

| 邊界 | 行為與可觀測結果 |
|:--|:--|
| `AsyncDispatcher.start()` | native preflight 後、建立 writer/worker 前先 `get_stats()`。失敗拋具名 monitor error,狀態維持 CREATED/STOPPED,不建立 writer/task、不接受 frame;成功測量不推進 hysteresis,也不發佈決策 |
| 每個 batch | 決策成功才更新 level、發佈帶原決策開始時間的快照。monitor error 沿既有 D2-AD 通道進入 FAILED;拒絕新 frame,結清失敗批與 queue;`stop()` 清理後拋 `DispatcherFailed`,cause 鏈保留 monitor error 與原始 probe/驗證原因 |
| dispatcher stats | `resource_monitor_status` 初始為 `unprobed`,成功測量為 `available`,監控失敗為 `unavailable`;`resource_monitor_error` 初始/成功為空字串,失敗為具名錯誤。`current_level` 在初始、start 嘗試後尚無有效 batch 決策或 monitor 失敗時是 `"UNKNOWN"`;只有有效 batch 決策才顯示 level 名稱。startup monitor error 不覆寫 D2 worker failure 的歷史 |
| 其他 worker 錯誤 | detector/tracker/callback 失敗仍依 D2-AD;不把這些錯誤標成 NVML unavailable |
| caller 回報 | main 的既有例外通道保留 startup/runtime cause;8stream 的 dispatcher stats 印出 UNKNOWN、monitor status 與具名 error。main 的 process-global manager 與 8stream 的 per-run cleanup 所有權保持各自的既有契約 |

**跨程序與恢復限制**:

- 失敗 probe 不發佈 FRESH;舊快照在 T 內仍可 FRESH,超過 T 才 STALE。preflight 拒絕不碰現存快照,其中可能有舊版 fallback 寫出的 level。
- 正常 stop 嘗試退役自己的快照;replacement 成功才立即 STALE。退役失敗仍靠 T 老化,不能把 stop 返回當作跨程序已即時同步。
- 沒有背景 init retry。init 失敗後要換新 manager;post-init 的暫時故障可在修復 NVML 後明確 stop/start,重新 preflight 成功才接受 frame,沿用該 manager 的 hysteresis。新 manager 則遵守 R0 的 NORMAL 初值。
- 初始化錯誤只在建構時診斷一次;讀取失敗保留在例外/stats,不按 frame 重複印 log。FAILED 也不會繼續消費 frame 來反覆故障。
- 本次不改快照 v1、T、事件持久化/ACK/RAG policy、tracker 數值或 public facade。D2-GPU 完成前不宣稱 online-service qualification、不部署。
16 changes: 9 additions & 7 deletions docs/ownership/tests_inventory.generated.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- Generated by scripts/tools/build_tests_index.py; do not edit this file by hand. -->
# tests/ inventory (generated)

Total tracked test files: **267**. Source of truth = each `test_*.py`'s own docstring + `# scope:` / `# function:` / `# lifecycle:` header; regenerate with `scripts/tools/build_tests_index.py`.
Total tracked test files: **269**. Source of truth = each `test_*.py`'s own docstring + `# scope:` / `# function:` / `# lifecycle:` header; regenerate with `scripts/tools/build_tests_index.py`.

> **Snapshot, may lag.** CI regenerates this file on every run and publishes it as the `derived-indexes` artifact; the checked-in copy is refreshed whenever someone reruns the generator, and no gate requires that (#493 PR-2).

Expand All @@ -14,17 +14,17 @@ Guarantee coverage per module (behavior/contract/regression/acceptance/diagnosti
| `api` | 0 | 1 | 0 | 0 | 0 | 1 |
| `media` | 5 | 0 | 0 | 0 | 0 | 5 |
| `pipeline` | 1 | 1 | 0 | 0 | 0 | 2 |
| `resource` | 1 | 5 | 0 | 0 | 2 | 8 |
| `resource` | 1 | 7 | 0 | 0 | 2 | 10 |
| `cognition` | 2 | 0 | 0 | 0 | 0 | 2 |
| `storage` | 3 | 0 | 0 | 0 | 0 | 3 |
| `perception` | 11 | 6 | 1 | 0 | 3 | 21 |
| `perception` | 11 | 7 | 1 | 0 | 3 | 22 |
| `detection` | 15 | 8 | 0 | 0 | 0 | 23 |
| `reid` | 3 | 0 | 0 | 0 | 0 | 3 |
| `eval` | 49 | 46 | 1 | 0 | 0 | 96 |
| `tracking` | 8 | 23 | 0 | 0 | 1 | 32 |
| `cross-module` | 0 | 6 | 0 | 0 | 0 | 6 |
| `system` | 0 | 98 | 4 | 1 | 1 | 104 |
| **Total** | 98 | 194 | 6 | 1 | 7 | **306** |
| **Total** | 98 | 197 | 6 | 1 | 7 | **309** |

## Scope × lifecycle

Expand All @@ -35,17 +35,17 @@ Governance state per module. A multi-scope test counts once per scope.
| `api` | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
| `media` | 5 | 0 | 0 | 0 | 0 | 0 | 5 |
| `pipeline` | 2 | 0 | 0 | 0 | 0 | 0 | 2 |
| `resource` | 8 | 0 | 0 | 0 | 0 | 0 | 8 |
| `resource` | 10 | 0 | 0 | 0 | 0 | 0 | 10 |
| `cognition` | 2 | 0 | 0 | 0 | 0 | 0 | 2 |
| `storage` | 3 | 0 | 0 | 0 | 0 | 0 | 3 |
| `perception` | 21 | 0 | 0 | 0 | 0 | 0 | 21 |
| `perception` | 22 | 0 | 0 | 0 | 0 | 0 | 22 |
| `detection` | 23 | 0 | 0 | 0 | 0 | 0 | 23 |
| `reid` | 3 | 0 | 0 | 0 | 0 | 0 | 3 |
| `eval` | 87 | 0 | 9 | 0 | 0 | 0 | 96 |
| `tracking` | 32 | 0 | 0 | 0 | 0 | 0 | 32 |
| `cross-module` | 6 | 0 | 0 | 0 | 0 | 0 | 6 |
| `system` | 104 | 0 | 0 | 0 | 0 | 0 | 104 |
| **Total** | 297 | 0 | 9 | 0 | 0 | 0 | **306** |
| **Total** | 300 | 0 | 9 | 0 | 0 | 0 | **309** |

## Disposition queue

Expand Down Expand Up @@ -225,6 +225,8 @@ Governance state per module. A multi-scope test counts once per scope.
| `test_resolved_shipping_config.py` | system | contract | active | The headline resolved shipping config equals what the Python oracle uses. |
| `test_resource_degradation_hysteresis_537.py` | resource | contract | active | #537 C2: degradation-level hysteresis contract (D-537-2). |
| `test_resource_degradation_observed_537.py` | resource | diagnostic | active | #537 C0: pin the observed resource degradation / VRAM-level SHM behavior. |
| `test_resource_monitor_contract_537.py` | resource | contract | active | #537 D-537-4A: NVML measurements have explicit acquisition validity. |
| `test_resource_monitor_dispatcher_contract_537.py` | resource,perception | contract | active | #537 D-537-4A: monitor preflight, failure reporting and explicit recovery. |
| `test_saccade_track_completion_cli.py` | system | contract | active | ``saccade_track`` run completion at the real entrypoint, without a GPU (#536). |
| `test_saccade_track_model_bundle_cli.py` | system | contract | active | ``saccade_track --model-bundle`` at the built entrypoints (#549 S2-1). |
| `test_saccade_track_preflight_cli.py` | system | contract | active | ``saccade_track`` Gate A at the built entrypoint: refusals make no CUDA call (#536). |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/runtime_identity.generated.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions scripts/ops/run_8stream_perception.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ def report() -> None:
"📈 Dispatcher Stats: "
f"state={stats['state']} "
f"level={stats['current_level']} "
f"monitor={stats['resource_monitor_status']} "
f"monitor_error={stats['resource_monitor_error']!r} "
f"active={stats['active_streams']} "
f"queue={stats['queue_depth']} "
f"batch_mean={stats['mean_batch_size']:.2f} "
Expand Down
37 changes: 34 additions & 3 deletions src/saccade/perception/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from saccade.resource.resource_manager import (
DegradationLevel,
ResourceManager,
ResourceMonitorUnavailable,
VRAMLevelWriter,
vram_level_clock_ns,
)
Expand Down Expand Up @@ -742,7 +743,12 @@ def __init__(
self._running = False
self._vram_writer: Optional[VRAMLevelWriter] = None
self._worker_task: Optional[asyncio.Task[None]] = None
self._current_level = DegradationLevel.NORMAL
# #537 D-537-4A: a retained hysteresis value is not a valid observation
# for a new run. ResourceManager owns that state; this is only the last
# successful decision in the current run, hidden after monitor failure.
self._current_level: Optional[DegradationLevel] = None
self._resource_monitor_status = "unprobed"
self._resource_monitor_error = ""
# #538 D2-AD: lifecycle state, the worker's original error, the batch
# taken off the queue but not yet task_done(), and frame accounting.
self._state = DispatcherState.CREATED
Expand Down Expand Up @@ -798,15 +804,30 @@ def _failed(self) -> DispatcherFailed:
err.__cause__ = self._failure
return err

def _monitor_unavailable(self, error: ResourceMonitorUnavailable) -> None:
self._resource_monitor_status = "unavailable"
self._resource_monitor_error = repr(error)
self._current_level = None

async def start(self) -> None:
if self._state is DispatcherState.RUNNING:
raise RuntimeError("AsyncDispatcher is already running")
if self._state is DispatcherState.FAILED:
# A failed run must be stopped (and cleaned up) before a restart.
raise self._failed()
self._current_level = None
reason = native_unavailable_reason()
if reason is not None:
raise OnlineTrackerUnavailable(reason)
try:
# Probe before acquiring a writer or starting a worker. This does
# not decide a level or advance the manager's hysteresis state.
self.resource_manager.get_stats()
except ResourceMonitorUnavailable as error:
self._monitor_unavailable(error)
raise
self._resource_monitor_status = "available"
self._resource_monitor_error = ""
self._failure = None
self._state = DispatcherState.RUNNING
self._running = True
Expand Down Expand Up @@ -926,7 +947,11 @@ def get_stats(self) -> dict[str, float | int | str]:
"failed_batch_frames": self._failed_batch_frames,
"discarded_on_failure": self._discarded_on_failure,
"discarded_on_stop": self._discarded_on_stop,
"current_level": self._current_level.name,
"current_level": self._current_level.name
if self._current_level is not None
else "UNKNOWN",
"resource_monitor_status": self._resource_monitor_status,
"resource_monitor_error": self._resource_monitor_error,
# The level only caps the batch size; the tracker itself is never
# degraded (#537 R-1).
"tracker_degradation": "none",
Expand Down Expand Up @@ -994,7 +1019,13 @@ async def _worker_loop(self) -> None:
self._inflight = batch_items
try:
decided_at_ns = vram_level_clock_ns()
level = self.resource_manager.decide_degradation_level()
try:
level = self.resource_manager.decide_degradation_level()
except ResourceMonitorUnavailable as error:
self._monitor_unavailable(error)
raise
self._resource_monitor_status = "available"
self._resource_monitor_error = ""
self._current_level = level
if self._vram_writer is not None:
self._vram_writer.write(level, decided_at_ns)
Expand Down
Loading
Loading