# 物理モデル健全性の一括監査 (Physical Model Sanity Audit)

本ドキュメントは，[TEST_12](TEST_12_シェーディング法線アーティファクトの発見と修正.md) と同じ
**「エラーも警告も出ないまま結果だけが静かに歪む」故障モード**が他に残っていないかを一括で
洗い出し，見つかった問題の修正と再発防止テストの常設化を行った記録である（2026-08-30）．

## 動機 (Motivation)

TEST_12 のバグ（PLY 自動スムーズ法線 × 評価板の巨大三角形）は，CoV の 6 割超を占める
系統誤差を生みながら Step 8-13 まで気づかれなかった．損失は計算コストではなく，
**間違った前提の上に積み上げた考察と設計判断**そのものだった．同種の故障は
[TEST_08](TEST_08_面光源スケール軸バグの発見と修正.md)（面光源スケール軸バグ）でも起きており，
そのときは [TEST_07](TEST_07_LEDグループ別動作確認.md) が誤った出力を「物理的に正しい」と
一度は正当化してしまっている．

このパターンは 2 度繰り返されている．3 度目を防ぐため，レンダリング規約・幾何・単位系・
エネルギー収支を横断的に検査し，**検出力を実証したテストだけ**を通常実行に常設した．

## 監査結果のまとめ (Summary)

| # | 監査項目 | 結果 | 対応 |
| --- | --- | --- | --- |
| 1 | シェーディング法線 == 幾何法線 | **健全**（実測逸脱 0.044 度） | 再発防止テストを追加（負のコントロール付き） |
| 2 | 片面 BSDF による無警告の黒化 | **潜在的脆弱性あり** | `twosided` ラップで恒久修正 |
| 3 | メッシュ法線の向き | **健全**（裏面ヒット 0 件 / 全 15 STL の巻き順一貫） | 監査関数と回帰テストを追加 |
| 4 | 絶対放射量（単位系・反射率・光源パワー・センサ） | **健全**（解析解と 0.2% 一致） | 積分球テストを常設 |
| 5 | 多重反射の打ち切り（`max_depth`） | **問題あり**（基準設計シーンで **9.7% のエネルギー欠損**） | 警告を実装．設定変更は要判断 |
| 6 | 面光源のサイズ→軸マッピング（TEST_08 の再発） | **健全**だが**テストが存在しなかった** | 実効的な回帰テストを追加 |
| 7 | レンダ経路のテスト（`slow` 層） | **長期間全滅していた** | LLVM 初期化順序のバグを修正 |
| 8 | 3D 可視化のテスト（`slow` 層） | **全滅**（Smart App Control が VTK の DLL をブロック） | `vtk==9.4.2` に固定して解消．import 監視テストを常設 |

## 検査 1・3 — 法線の健全性 (Normals)

読み込み済み Mitsuba シーンの空洞内部からレイを飛ばし，`si.sh_frame.n`（シェーディング法線）と
`si.n`（幾何法線）を実測比較する監査関数 `scene/audit.py: audit_scene_normals` を追加した．

基準設計シーン（`configs/scene_baseline.yaml`，実 STL 8 個）での実測:

- ヒット 26,853 本に対し **シェーディング法線と幾何法線の最大なす角 0.044 度**
  （＝浮動小数点誤差のみ．`face_normals: True` が Mitsuba レベルで実効）
- **裏面ヒット 0 件**（空洞内壁の法線は全て内向き）
- 全 15 STL について巻き順一貫・退化三角形ゼロ・体積正を確認

既存の再発防止テストは scene dict に `face_normals` キーが存在することしか見ていなかった．
本監査は実際にレンダラへ読み込ませて挙動を測るので，キーの意味が変わった場合も検出できる．

**負のコントロール**: `face_normals` を外すと逸脱が 30 度以上に跳ね上がることをテストで確認している．
検出力を実証できない検査は再発防止にならないため，この対照実験は必ず維持する．

## 検査 2 — 片面 BSDF による無警告の黒化 (One-sided BSDF)

Mitsuba の `diffuse` / `roughconductor` は**片面 BSDF** であり，法線が裏返った面は
入射光を一切反射せず**エラーも警告もなく真っ黒**になる．SmTIAS は LED 直接光が評価板に
届かない間接照明のみの構成（[SPEC_03](../04_SPEC/SPEC_03_基準設計の物理構成.md)）なので，
内壁が 1 面でも黒化すれば輝度分布が静かに壊れる．

現行の STL は全て法線が正しいため実害は出ていなかったが，STL を差し替えた瞬間に
発現しうる「気づけない」構造だった．積分球の解析解で定量化した結果:

| 法線 | BSDF | 観測放射輝度 | 解析解比 |
| --- | --- | --- | --- |
| 内向き（正常） | `diffuse` | 0.407280 | 0.9996 |
| 内向き（正常） | `twosided` | 0.407280 | **0.9996（完全に同一＝無影響）** |
| 外向き（裏返り） | `diffuse` | **0.000000** | **0.0000** |
| 外向き（裏返り） | `twosided` | 0.407475 | 1.0001 |

**修正**: `scene/materials.py` に `build_scene_bsdf_dict` を追加し，不透明反射材
（`diffuse` / `roughconductor` / 不透明のみで構成される `blendbsdf`）を `twosided` で包む．
透過を伴う誘電体は両面挙動が本質なのでラップしない．

実シーン（`scene_baseline`，spp=256 / max_depth=16）での修正前後比較は
**mean +0.015% / CoV -0.009%**（MC ノイズの範囲内）で，**過去の結果に影響しない**ことを確認した．

## 検査 4 — 絶対放射量の検証 (Absolute Radiometry)

内面反射率 rho の閉じた球殻（積分球）は厳密な閉形式解を持つ．全放射束 Phi の光源を
内部に置くと壁面の全照度は `E = Phi / (A (1 - rho))`（`A = 4 pi R^2`）となり，
直接光が届かない領域の壁面放射輝度は `L = rho^2 Phi / (pi A (1 - rho))` になる．

これを 1 本のテストで突き合わせると，**単位系（mm→m）・反射率・発光面の放射束・
センサ校正・多重反射の収束**をまとめて検証できる．いずれかが壊れていれば一致しない．

実測（rho=0.8・R=100mm・発光面 4x4mm・max_depth 収束後）:

| max_depth | 観測 L | 解析解比 |
| --- | --- | --- |
| 8 | 0.300061 | 0.7365 |
| 16 | 0.388804 | 0.9543 |
| 32 | 0.406155 | 0.9969 |
| 64 | 0.406630 | **0.9980** |
| 128 | 0.406630 | 0.9980 |

収束値は解析解の **0.998**（残差 0.2% は icosphere が内接多面体であることによる面積欠損）．
**パイプラインの絶対放射量は正しい**と結論できる．

## 検査 5 — 多重反射の打ち切りバイアス (Truncation Bias)

上表が示すとおり `max_depth` の打ち切りは無視できない．**基準設計シーンは
LED 直接光が評価板に届かない間接照明のみの構成**であり，輝度は白筐体内壁
（rho=0.85）の多重反射の累積だけで決まるため，影響はより大きい．

`configs/scene_baseline.yaml`（spp=256）での実測:

| max_depth | mean | CoV | 最深値比（mean） |
| --- | --- | --- | --- |
| 8 | 12.634 | 0.26035 | 0.6176 |
| 16（現行設定） | 18.476 | 0.25270 | **0.9032** |
| 32 | 20.334 | 0.25181 | 0.9940 |
| 64 | 20.457 | 0.25181 | 1.0000 |

**現行の `max_depth=16` は収束値の 90.3% しかエネルギーを拾えていない**（約 10% の過小評価）．

ただし **CoV への影響は +0.35% と軽微**である．これまでの検討の主題は形状指標
（CoV・勾配・C/P・NCC）であり，絶対輝度は実測へのアンカー（mean anchor / OETF 露出ソルバ）で
吸収されるため，**過去の形状に関する結論は影響を受けない**．

一方で以下には影響する．

- LED radiance の校正値は打ち切り分を含んだ値になっている（自己整合的ではあるが物理的な絶対値ではない）
- **反射回数の分布が異なる筐体形状どうしの絶対輝度比較**（現行筐体 vs 球ロフト等）には系統差が乗る

**対応**: `scene/audit.py: check_truncation_bias` を実装し，`build_scene` 時に
警告ログを出す．基準設計シーン（rho=0.85）では以下が出力される．

```text
render.max_depth=16 は最大反射率 0.850 の多重反射を拾いきれていません
（相対エネルギー欠損の見積もり 8.7%）．絶対輝度が系統的に過小評価されます．
max_depth >= 30 を推奨します．
```

欠損の見積もり式 `rho^(max_depth - 1)` は実測とよく一致する（積分球 rho=0.8: 予測 3.5% / 実測 4.6%，
基準設計 rho=0.85: 予測 8.7% / 実測 9.7%）．

> ⚠ **`configs/*.yaml` の `max_depth` は未変更**．値を上げると絶対輝度の基準が動き
> LED radiance の再校正が必要になるため，設定変更は人間の判断に委ねる．
> 計算コストは max_depth=16 で 80 秒 / 64 で 96 秒（spp=256・512x512）と +20% 程度．

## 検査 6 — 面光源のサイズ→軸マッピング (Area-Light Axis Mapping)

TEST_08 のバグ（発光面の高さを縦 Y ではなく奥行き Z に入れていた）に対する
**実効的な回帰テストが存在しなかった**．既存テストは `to_world` キーの存在と，
`position_scale` を変えたときに文字列表現が変わることしか検証しておらず，
軸を入れ替えても検出できない．

単位図形の頂点を実際に `to_world` で変換し，世界座標での寸法を検証するテストを追加した
（現状は健全: size=(2.8, 3.5) が X=2.8mm / Y=3.5mm / Z=0 に正しく対応）．

## 検査 7 — レンダ経路のテストが全滅していた (Broken Render Tests)

`slow` マークのテストを実行したところ **47 件中 47 件が失敗**していた．
うち 8 件はレンダリング経路で，原因は **LLVM 初期化の順序バグ**．

`render/runner.py` は `mi.set_variant` の前に `ensure_drjit_libllvm_path()` を呼んで
`DRJIT_LIBLLVM_PATH` を自動探索する設計だったが，`scene/lights.py` と `scene/sensors.py` が
**モジュール読み込み時に mitsuba を import** するため，`runner` を import した時点で
既に Dr.Jit の LLVM 初期化が走っており，環境変数の設定が手遅れになっていた．

開発機では環境変数が User scope に永続設定されているため症状が隠れていたが，
**環境変数が無い環境ではレンダリングが一切できない**（[ENV_02](../02_ENV/ENV_02_環境構築手順.md) が
案内する自動探索が機能していなかった）．

**修正**: `scene/lights.py` / `scene/sensors.py` の mitsuba import 直前と，
パッケージの `__init__.py` で `ensure_drjit_libllvm_path()` を呼ぶ．これで 8 件が復活した．

> **教訓**: 物理的健全性の検査を `slow` に隔離すると，通常実行から外れて壊れても気づかない．
> 本監査で追加した積分球テストは 1 本 0.2 秒（32x32 / spp=128）で合計 1.5 秒程度なので，
> `slow` を付けず通常実行に含めている．

## 検査 8 — 3D 可視化テストの全滅 (Broken Visualization Tests)

残る `slow` 失敗 39 件は全て `tests/visualize/` と `tests/cli/test_visualize_cli.py` で，
`sm-visualize` も実行できない状態だった．

### 原因 — Windows Smart App Control による DLL のブロック

**依存関係の破損ではなく，OS のアプリケーション制御ポリシーが VTK の DLL を弾いていた．**

```text
DLL load failed while importing vtkRenderingVolume:
アプリケーション制御ポリシーによってこのファイルがブロックされました。
```

開発機の Smart App Control が**オン（強制モード）**になっていた．

| 確認項目 | 値 |
| --- | --- |
| `HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy\VerifiedAndReputablePolicyState` | 1（オン・強制） |
| `Win32_DeviceGuard.UsermodeCodeIntegrityPolicyEnforcementStatus` | 2（強制中） |

Smart App Control は署名の有無ではなく**ファイル単位のクラウドレピュテーション**で判定する．
そのため vtk 9.6.1 では **209 個の DLL のうち 14 個だけ**がブロックされ，残りは通るという
状態になっていた．ブロックされたのは以下（`vtkRenderingAnnotation` はスカラーバー・座標軸，
`vtkInteractionWidgets` はウィジェットを担い，どちらも PyVista の描画に必須）．

```text
vtkRenderingVolume        vtkRenderingVolumeOpenGL2   vtkRenderingVolumeAMR
vtkRenderingAnnotation    vtkInteractionWidgets       vtkInteractionImage
vtkImagingColor           vtkRenderingVR              vtkRenderingOpenXR
vtkRenderingOpenXRRemoting  vtkTestingRendering       vtkViewsInfovis
vtkWebCore                vtkWebGLExporter
```

依存の破損でないことの根拠:

- VTK の 153 モジュール分の `.pyd` はすべて正しく存在していた
- 通った `vtkRenderingCore` も弾かれた `vtkRenderingVolume` も**どちらも未署名**
  （`Get-AuthenticodeSignature`）．署名の有無は判定基準ではない
- `uv.lock` の vtk 9.6.1 は Step 7（2026-05-17）から変わっておらず，
  Dependabot による更新も入っていない

### 症状のメッセージが 2 通りあったことについて

最初に観測した症状は
`ImportError: Failed to load vtkCommonMath: No module named vtkmodules.vtkCommonCore`
で，上記のアプリケーション制御エラーとは別のメッセージだった．両者の関係は完全には
確定していない．事実として確認できたのは以下である．

- `vtkCommonCore` はブロックされた 14 個に含まれず，単独では import できた
- 同一環境で `import pyvista` が一度失敗し，のちに成功した（判定に揺れがある）
- 一貫して再現したのはアプリケーション制御によるブロックの方

`vtkmodules` は独自のローダ（`vtkmodules/__init__.py` の `exec_module`）で依存モジュールを
読み込むため，**DLL がブロックされた事実を握りつぶして「モジュールが無い」という
誤解を招くメッセージに化けさせた二次症状**と推定しているが，機構までは確認していない．
最初の切り分けで「VTK のインストール破損」と誤診したのはこのメッセージが原因であり，
**ネイティブ拡張の ImportError は表示されたメッセージを額面どおり受け取らない**という
教訓として記録しておく．

### 対処 — vtk を 9.4.2 に固定

より長く配布されレピュテーションが確立しているバージョンへ下げる方針を採った
（pyvista 0.48.0 の要求は `vtk>=9.2.2, <9.7.0, !=9.4.1`）．

`vtk==9.4.2` では **147 モジュール全てが読み込め，ブロック 0 件**．`sm-visualize` の
3D 表示（筐体・LED マーカー 18 個・センサマーカー・座標軸）も復旧し，
`slow` の可視化テスト 54 件が全て通るようになった．

`pyproject.toml` に固定理由をコメントで残している（`/deps-update` や Dependabot が
9.6 系へ戻して静かに再破損するのを防ぐため）．

> Smart App Control の無効化は**一度オフにすると Windows を再インストールしない限り
> 再びオンにできない**ため採用しなかった．可視化ツール 1 本のために恒久的に
> マルウェア保護を手放すのは釣り合わない．

### 再発を即座に検知する仕組み

破損は 2026-08-17 から 8-30 まで約 2 週間気づかれなかった．**可視化テストが全て
`slow` マークで通常実行から外れていたことが原因**であり，検査 7（LLVM 初期化バグで
レンダテストが長期間全滅していた件）とまったく同じ構図である．

そこで `tests/visualize/test_vtk_import_guard.py` を **`slow` を付けずに**追加した．
pyvista とブロック実績のあるモジュールの import，および固定バージョンの確認のみを行い，
レンダリングはしないので 1 秒未満で完了する．

## 採用を見送った検査 (Rejected Checks)

**軸対称シーンの対称性テスト**は検出力を実証できなかったため採用しなかった．

立方体空洞＋軸上点光源＋軸上 orthographic カメラで，`face_normals` の有無による
象限比を比較したところ **1.0020 vs 1.0028** とほぼ差が出なかった（一方で mean は
394.4 → 300.2 と 24% 変化した）．TEST_11 で異方性が顕在化したのは評価板メッシュの
非対称な三角形分割が効いていたためで，合成シーンでは再現できない．

**検出できない検査をテストとして常設すると「検査済み」という誤った安心を与える**ため，
対称性チェックは手動の診断手段として残し，自動テストには含めない．

## 追加した再発防止テスト (Added Regression Tests)

すべて通常実行（`uv run pytest`）に含まれる．合計約 2 秒．

| テストファイル | 内容 | 負のコントロール |
| --- | --- | --- |
| `tests/scene/test_audit.py` | 打ち切りバイアスの見積もり・警告生成 | — |
| `tests/scene/test_audit_normals.py` | シェーディング法線 == 幾何法線 / 裏面ヒット 0 | あり（`face_normals` を外す・メッシュを裏返す） |
| `tests/scene/test_materials_twosided.py` | 不透明反射材の `twosided` ラップと誘電体の除外 | あり |
| `tests/scene/test_lights_axis_mapping.py` | 面光源サイズの世界座標軸マッピング（TEST_08） | — |
| `tests/render/test_physics_validation.py` | 積分球の解析解一致・線形性・法線裏返り耐性・打ち切り | あり |

`tests/scene/test_audit_normals.py` には基準設計シーン（実 STL 8 個）に対する
`slow` マークのテストも含む．

## 過去の結論への影響 (Impact on Past Conclusions)

- **形状指標に関する結論は影響を受けない**．`twosided` 化は無影響（MC ノイズ以内）で，
  `max_depth` の打ち切りが CoV に与える影響も +0.35% にとどまる．
- **絶対輝度の解釈には注意が必要**．現行の LED radiance 校正値は打ち切り分（約 10%）を
  含んでいる．筐体形状の異なるシーン間で絶対 mean を比較する際は，
  `max_depth` を揃えるだけでなく，反射回数分布の差による系統差が残ることに留意する．

## 次にやること (Next Steps)

1. `max_depth` を 32〜64 に引き上げるかの判断（引き上げる場合は LED radiance の再校正が必要）
2. 残る差分＝「形」（実機の斜め構造・相補性の振幅）の切り分けへ復帰
