diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0d6c70e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+# Optional local Edge profile used for GPU smoke tests.
+.test-edge/
+tests/.browser-smoke.generated.html
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..6f3a291
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "liveServer.settings.port": 5501
+}
\ No newline at end of file
diff --git a/ECOLOGY-MODEL.md b/ECOLOGY-MODEL.md
index 6425089..56f4a45 100644
--- a/ECOLOGY-MODEL.md
+++ b/ECOLOGY-MODEL.md
@@ -1,4 +1,4 @@
-# ECOSPHERE v41 — 生態モデル仕様
+# ECOSPHERE v45 — 生態モデル仕様
## 1. 単位と timestep
@@ -6,12 +6,12 @@
- `dt = 1/24 day`
- distance: m
- animal wet mass: kg
-- animal energy reserve: kJ
+- animal reserve energy: kJ
- producer / zooplankton: kg biomass m^-2
## 2. 表現
-- terrestrial/aquatic producer: `ResourceField` grid
+- terrestrial/aquatic producer: grid biomass field
- zooplankton: cohort biomass grid
- macro consumers: individual agents
- food web: fixed sparse consumer-resource graph
@@ -28,52 +28,42 @@
`mu = muMax(T) * min(fN,fP) * fLight`
-freshwater / marine で Bissinger式を温度上限として使い、光・N・P で制限する。dissolved oxygen はこの式へ直接掛けない。
+freshwater / marine で Bissinger 式を温度上限として使い、光・N・P で制限する。dissolved oxygen はこの式へ直接掛けない。
陸上 producer は fallback として `rMax=0.03 day^-1`, `K=0.5 kg m^-2` と moisture / light / temperature limitation を使う。
## 4. 摂食
-固定 interaction edge は resource type ごとに
+### 個体資源
-- preference weight
-- assimilation efficiency
-- attack baseline
-- handling-time baseline
-- functional response type
-
-を持つ。
-
-植物由来 assimilation = 0.45、動物由来 = 0.85。
-
-Holling response:
+動物個体どうしの捕食では interaction edge の attack / handling / preference を使い、Rall et al. 2012 の mass-temperature scaling を接続する。
`Fij = aij * Pij * Nj^q / (1 + sum(aik * Pik * hik * Nk^q))`
-既定 edge は Type II (`q=1`)。関数は Type III (`q=2`) にも対応するが、v41 は runtime API で edge を差し替えない。
-
-Rall et al. 2012 の all-data slopes を attack / handling の mass-temperature scaling に使う。
-
-- attack: consumer `+0.47`, resource `+0.15`, activation energy `+0.44 eV`
-- handling: consumer `-0.48`, resource `+0.34`, activation energy `-0.27 eV`
-- local mass-ratio residualsと handling-temperature residualも基準点へ正規化して適用
-
捕食イベントは `p = 1-exp(-rate*dt)` でサンプリングする。
+### 連続 biomass 資源
+
+producer と zooplankton は「1個体の獲物体重」を持たない。したがって field density を Rall の predator:prey body-mass ratio へ代入しない。
+
+v45 は biomass density に対する Holling 型飽和を使い、consumer mass に応じた最大摂取量を上限とする。既定 half-saturation / max-intake 係数は simulator calibration であり `PARAMETER-PROVENANCE.json` に記録する。
+
+植物由来 assimilation = 0.45、動物由来 = 0.85。
+
## 5. 代謝・死亡・繁殖
-basal metabolism は `B0 * M^0.75` を基本にする。ectotherm は Arrhenius 型温度補正、endotherm は別 normalization。
+basal metabolism は `B0 * M^0.75`。ectotherm は Arrhenius 型温度補正、endotherm は別 normalization。
-死亡:
+死亡要因:
- predation
- starvation
- lifespan
- background hazard
- thermal stress
-個体は `structuralMassKg` と reserve energy (kJ) を分離して保持する。juvenile の structural growth は余剰 reserve energy から支払い、adult body mass を上限とする。年齢だけで体重を自動増加させない。
+個体は `structuralMassKg` と reserve energy を分離し、juvenile structural growth は余剰 reserve energy から支払う。
-繁殖は mature / breeding season / interval / energy threshold を満たした個体が offspring を生成し、親は死亡しない。
+繁殖は maturity / seasonal window / interval / energy threshold を満たすと発生し、親は生存する。generic 初期種は同一 role 内で繁殖季節が完全同期しないよう、species ごとに annual phase offset を持つ。
## 6. 移動
@@ -89,20 +79,40 @@ Hirt et al. 2017 Supplementary Table 4 の
| running | 25.5 | 0.26 | 22.0 | -0.60 |
| swimming | 11.2 | 0.36 | 19.5 | -0.56 |
-maximum / routine / foraging / escape speed、daily movement budget、home range、dispersal を分離する。後者の behavioural multipliers は simulator calibration。
+maximum / routine / foraging / escape speed、daily movement budget、free roaming waypoint を分離する。
-## 7. climate / profile
+スポーン地点への復帰制約は持たない。徘徊時は現在位置から自由移動 waypoint を生成し、到達後に次の waypoint へ更新する。岸線では signed-distance 勾配から生息可能側の法線を求め、禁止側への速度成分を除いて接線方向へ連続投影する。producer / zooplankton の仮想目標は到達時に破棄し、local feeding と navigation を分離する。
+
+固定目標への接近では `distance - arrivalRadius` を1 timestepの上限距離とし overshoot を防ぐ。producer / zooplankton 到達後は最大90 m/dayの局所 grazing movement とする。
+
+### social steering
+
+v45 は **位置ベースのみ**。
+
+- separation: 近接しすぎた個体から離れる
+- cohesion: 同種近隣の重心へ弱く寄る
+
+neighbor velocity alignment、回転指数、角運動量検出、逆トルクは使わない。これにより旋回速度を別個体へ伝播させる feedback loop を構造的に除去する。
+
+## 7. playback / 描画
+
+生態 timestep とUI再生速度を分離する。
+
+- ×1 target = 1 simulated day / real second
+- 実効 day/s はUIへ表示
+- 高倍率では描画 snapshot cadence を下げ、計算を優先
+- 高速統計では個体/field描画転送を停止
+
+## 8. climate / profile
`terrestrial / freshwater / marine` profile を分離する。通常UIでは aquatic profile を freshwater / marine で切り替える。
-`ClimateProvider` は seasonal fallback を持つ。観測 series を扱える内部機構は残すが、v41 では外部 Worker API を公開しない。
-
-## 8. 水域
+## 9. 水域
初期 procedural water の radius に `sqrt(2)` を一度掛ける。union 面積の厳密測定・最適化はしない。
-ユーザー編集水域は `setWater()` に渡された形状をそのまま採用する。追加ツール既定半径は 170 m。
+ユーザー編集水域は `setWater()` に渡された形状をそのまま採用する。追加ツール既定半径は170 m。
-## 9. provenance
+## 10. provenance / validation
-文献由来値と較正値の区分は `PARAMETER-PROVENANCE.json`、実測検査は `VALIDATION.md` を参照。
+文献由来値と simulator calibration は `PARAMETER-PROVENANCE.json` で区別する。実行検査は `VALIDATION.md` を参照。
diff --git a/IMPLEMENTATION-NOTES.md b/IMPLEMENTATION-NOTES.md
index d8b1a32..ea196ca 100644
--- a/IMPLEMENTATION-NOTES.md
+++ b/IMPLEMENTATION-NOTES.md
@@ -1,65 +1,98 @@
-# 実装記録 — v41
+# 実装記録 — v45 free movement
+
+## v45 モジュール整合性修正
+
+- `engine.js` が要求する `fieldResourceIntakeKgPerDay` は `ecology/feeding.js` に実在する。
+- v44 は entry module だけに版クエリが付き、依存モジュールが無版指定だったため、ブラウザキャッシュで旧 `feeding.js` と新 `engine.js` が混在し得た。
+- v45 は production の相対 `.js` import と Worker URL をすべて `?v=45` に統一した。
+- 回帰テストは 58 行のスモークテストへ縮小し、モジュール契約・自由移動・水域編集・短期出生・削除済み機能の再混入だけを確認する。長期較正や文献式の確認は `VALIDATION.md` に分離する。
## 目的
-v38 の描画・空間探索・水域/岩編集・Web Worker を維持しつつ、生態学コアを実単位ベースへ置換する。ユーザー指定により水深と人為的攪乱は実装しない。初期水域は厳密面積探索を使わず概ね2倍とし、ユーザー編集水域には倍率を掛けない。
+v43 で残っていた以下の移動不具合を、スポーン地点への拘束や症状検出型補正を使わずに修正する。
-v41 では追加要求により、実験用 API と詳細診断出力を削除した。
+- 個体がほとんどスポーン地点から離れない
+- 微動だにしない個体が多い
+- 岸線や資源目標付近で往復振動する
+- home-range を完全に削除する
-## v41 で削除したもの
+## 1. home-range の完全削除
-- Worker: `set-interactions`
-- Worker: `set-climate`
-- Worker: `run-batch`
-- batch progress/result/error 出力
-- `benchmark.mjs`
-- runtime interaction graph 置換機能
-- `consumptionFlux`
-- `assimilationFlux`
-- `respirationLoss`
-- `trophicFlux`
-- `populationByGroup`
-- `biomassByGroup`
-- `meanBodyMass`
-- `occupiedArea`
-- `resourceTurnover`
-- これらのためだけに存在した production/consumption counters
+削除した状態/処理:
-`worker.js` の state message は Canvas/UI の描画に必要な内部通信なので残す。`stats()` は `time / temp / population / plant / species / generation` のみ返す。
+- home point (`homeX`, `homeY`)
+- home radius
+- profile別 home-range multiplier
+- スポーン地点へ戻す steering
+- home-range を用いた daily movement 計算
-## 生態ロジック
+動物は現在位置から自由移動 waypoint を生成する。複数 waypoint を経由するため、開始地点を中心とする空間拘束はない。
+## 2. 初期配置
+
+非両棲個体は、指定された生成中心が不適切でも単に岸線ぎりぎりへ押し出さない。
+
+`make()` は個体半径を考慮した岸線 clearance を満たす有効 habitat を探索して初期位置を決める。これにより、開始直後から habitat boundary clipping を繰り返す条件を除去した。
+
+## 3. 岸線での連続 steering
+
+左右の候補角を交互に試す方式は使用しない。
+
+岸線近傍では signed-distance field の勾配から legal habitat 側の法線を求め、希望進行方向のうち禁止 habitat へ向かう成分だけを除く。残った接線成分で岸に沿って進む。
+
+この方式は「振動を検出して止める」のではなく、禁止方向へ向かう速度成分を幾何学的に除去する。
+
+## 4. persistent free-roaming waypoint
+
+短いランダム微動ではなく、daily movement capacity から距離を抽選した waypoint を保持する。
+
+- waypoint は現在位置から生成
+- habitat を横切る直線経路は候補から除外
+- 到達すると次 waypoint を生成
+- 移動不能になった waypoint は破棄して再計画
+- world edge 到達時も再計画
+
+開始地点を記憶していないため、徘徊範囲は日ごとに空間上を移動できる。
+
+## 5. producer / zooplankton 目標の往復除去
+
+continuous field の資源点は実在する個体ではない。到達後も同じ仮想点を追跡すると、roaming と資源点追跡が切り替わり往復する。
+
+v45 は feeding envelope に入った時点で仮想 target を破棄し、field density から摂食しながら roaming を続ける。
+
+## 6. stamina
+
+旧方式では sprint 消費と回復の単位スケールが釣り合わず、数日規模の休息が発生し得た。
+
+v45:
+
+- routine movement: sprint stamina を消費しない
+- chase / escape: stamina capacity に対する割合で消費
+- rest: capacity に対する割合で回復
+- 通常活動中も少量回復
+
+これにより大きな stamina pool を持つ個体だけ回復に極端に長時間かかることを避ける。
+
+## 7. 群れ制御
+
+social steering は位置のみを入力とする。
+
+- separation
+- 弱い centroid cohesion
+
+近隣個体の速度 alignment は使わない。回転指数、角運動量、逆トルク等を production runtime で検出・補正する処理もない。
+
+## 8. 維持した仕様
+
+- day / m / kg / kJ
- `DT = 1/24 day`
-- producer: grid biomass field
-- zooplankton: grid cohort biomass
-- consumers: individual agents
-- interaction graph: `ecology/feeding.js` の固定 sparse graph
-- feeding: Holling denominator + Rall allometric/temperature scaling
-- predation event: continuous rate → `1-exp(-rate*dt)`
-- metabolism: `M^0.75` + ectotherm Arrhenius response
-- reproduction: parent survives birth
-- mortality: background / starvation / thermal / predation / lifespan
-- movement: Hirt maximum-speed fit + behaviour-specific speed + daily budget + home range + dispersal
-- growth: structural mass と reserve energy を分離し、juvenile growth は余剰 energy から支払う
+- 水深なし
+- 人為的攪乱なし
+- experiment/batch API なし
+- 詳細診断出力なし
+- user-edited water geometry はそのまま
+- 初期 procedural water のみ `radius × sqrt(2)`
+- 厳密2倍化アルゴリズムなし
+- 通常表示 `×1` target = 1 simulated day / real second
-## v41 監査中の修正
-
-追加監査で、陸上 producer の light field が既に shade を含むのに growth 側でも shade を掛けていた二重適用を発見し、growth 側では light を一度だけ使うよう修正した。
-
-v40 では aquatic producer growth に dissolved oxygen multiplier を掛けていたが、元仕様の式は
-
-`mu = muMax(T) * min(fN, fP) * fLight`
-
-であり DO は phytoplankton growth の直接因子として指定されていなかった。v41 では DO multiplier を producer growth から削除した。
-
-また、年齢だけで `biomass()` を決めていた処理を廃止し、`structuralMassKg` を個体状態として保持するよう変更した。成長は reserve floor を超えた energy だけを使用し、成長コストは provenance に較正値として明記した。
-
-## 水域
-
-初期 procedural water の半径だけ `sqrt(2)` 倍する。重なりのため union 面積は厳密2倍にはならない。面積を計測して二分探索するコードはない。
-
-ユーザー編集では `setWater()` が渡された円群を clone してそのまま採用する。既定追加半径は 170 m のまま。
-
-## 文献値と較正値
-
-Hirt / Rall / Bissinger 等から直接採用した係数と、simulator calibration を `PARAMETER-PROVENANCE.json` で区別する。routine movement、home range、life history、resource K、zooplankton cohort 係数などは普遍的な実測定数とは主張しない。
+検査結果は `VALIDATION.md` に記録する。
diff --git a/PARAMETER-PROVENANCE.json b/PARAMETER-PROVENANCE.json
index 3932d9d..653a995 100644
--- a/PARAMETER-PROVENANCE.json
+++ b/PARAMETER-PROVENANCE.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
- "modelVersion": "41.0.0",
+ "modelVersion": "43.0.0",
"parameters": {
"timeStepDays": {
"value": 0.041666666666666664,
@@ -143,7 +143,7 @@
},
"movement.behaviourFractions": {
"sourceType": "model-calibration",
- "note": "routine/foraging/escape fractions, home-range multipliers and dispersal scale are simulator behaviour parameters; they are not claimed as Hirt coefficients"
+ "note": "routine/foraging/escape fractions and free-roaming waypoint scale are simulator behaviour parameters; they are not claimed as Hirt coefficients"
},
"zooplankton.cohort": {
"sourceType": "model-calibration",
@@ -187,6 +187,53 @@
"unit": "m",
"sourceType": "compatibility",
"note": "same default as v38"
+ },
+ "movement.grazingSpeed": {
+ "routineFraction": 0.55,
+ "minimumMPerDay": 45,
+ "sourceType": "model-calibration",
+ "note": "while feeding from continuous producer/zooplankton fields, navigation continues at a reduced fraction of routine travel speed; no fixed 90 m/day cap"
+ },
+ "movement.spawnShorelineClearance": {
+ "value": 24,
+ "unit": "m minimum before body-radius adjustment",
+ "sourceType": "simulation-structure",
+ "note": "prevents initial non-amphibious agents from being snapped directly onto the habitat boundary"
+ },
+ "movement.staminaRecovery": {
+ "equation": "capacity_fraction_per_day = clamp((0.46 + 0.32*gene) * (M/3)^-0.035, 0.62, 1.45)",
+ "sourceType": "model-calibration",
+ "note": "capacity-relative recovery; routine travel does not consume sprint stamina"
+ },
+ "movement.sprintStaminaDrain": {
+ "equation": "capacity_fraction_per_day = 0.72 + 0.48*clamp(speed/routineSpeed - 1, 0, 2)",
+ "sourceType": "model-calibration",
+ "note": "applied only to escape/chase sprint states"
+ },
+ "ui.normalPlaybackRate": {
+ "value": 1,
+ "unit": "simulated day per real second at x1 target",
+ "sourceType": "interface-setting",
+ "note": "playback target only; ecological dt remains 1/24 day and actual throughput is reported separately"
+ },
+ "feeding.fieldResources": {
+ "massExponent": 0.78,
+ "producerMaxIntakeCoefficient": 0.16,
+ "producerHalfSaturationKgPerM2": 0.08,
+ "zooplanktonMaxIntakeCoefficient": 0.12,
+ "zooplanktonHalfSaturationKgPerM2": 0.012,
+ "sourceType": "model-calibration",
+ "note": "continuous producer/zooplankton biomass fields use density-based Holling saturation; field density is not treated as an individual prey body mass and is not passed through Rall predator:prey mass-ratio scaling"
+ },
+ "demography.breedingPhase": {
+ "equation": "phaseDays = (speciesId * 137.508) mod 365",
+ "unit": "day of annual cycle",
+ "sourceType": "simulation-structure",
+ "note": "staggers generic species breeding windows so all species in one trophic role do not reproduce in lockstep; window lengths and intervals remain role fallbacks"
+ },
+ "movement.socialSteering": {
+ "sourceType": "model-calibration",
+ "note": "position-only separation plus weak centroid attraction; neighbor velocities and any rotation/spin detector are intentionally excluded to prevent self-sustaining milling feedback"
}
}
}
diff --git a/README-JA.txt b/README-JA.txt
index a0b0c7e..98c5433 100644
--- a/README-JA.txt
+++ b/README-JA.txt
@@ -1,31 +1,34 @@
-食物連鎖シミュレータ v41
+食物連鎖シミュレータ v45
起動:
python3 -m http.server 8000
http://localhost:8000/
-Web Worker と JavaScript module を使うため、index.html の直接起動ではなく HTTP 経由を推奨します。
+Web Worker と JavaScript module を使うため HTTP 経由で起動してください。
主な仕様:
-- day / m / kg / kJ の実単位ベース
-- producer は grid field、zooplankton は cohort、動物は individual agent
-- 単一 diet を廃止し、固定 consumer-resource interaction graph を使用
-- Holling II を標準とし、handling time と Rall mass/temperature scaling を実計算へ接続
+- day / m / kg / kJ の実単位
+- producer = grid field、zooplankton = cohort、動物 = individual agent
+- fixed consumer-resource graph
+- 動物個体捕食 = Holling + Rall mass/temperature scaling
+- producer/zooplankton = biomass density に対する飽和摂食(field density を prey body mass として扱わない)
- 植物由来同化 0.45、動物由来同化 0.85
- 親が生存する出生イベント
-- Hirt型 maximum speed、routine / foraging / escape speed、daily movement budget、home range、dispersal
-- freshwater / marine profile と seasonal climate fallback
-- 自動移入・自動種分化なし
+- speciesごとに繁殖季節の位相をずらし、全種同時休止を防止
+- Hirt maximum speed + routine / foraging / escape + daily movement budget + free roaming
+- スポーン地点への復帰制約なし。岸線では signed-distance 勾配で接線方向へ連続回避
+- social steering は position-only separation + cohesion。velocity alignment / 回転検出 / 逆トルクなし
+- ×1 target = 1 day/秒。実効速度は画面に表示
+- freshwater / marine profile
- 水深なし、人為的攪乱なし
-- 初期水域のみ radius × sqrt(2) で概ね旧版の2倍。厳密面積最適化なし
+- 自動移入・自動種分化なし
+- 初期水域のみ radius × sqrt(2)、厳密面積最適化なし
- ユーザー編集水域は入力形状をそのまま適用
削除済み:
-- 実験用 Worker API(set-interactions / set-climate / run-batch)
-- batch 実行ユーティリティ
-- consumer×resource flux、assimilation、respiration、occupied area 等の詳細診断出力
-
-UI内部で必要な最小状態(時刻、気温、個体数、植物量、種一覧、世代)は描画のため残しています。
+- 実験用 Worker API
+- batch 実行
+- 詳細 trophic flux / assimilation / respiration 等の外部出力
検証:
node test/ecology.test.mjs
@@ -35,8 +38,3 @@ UI内部で必要な最小状態(時刻、気温、個体数、植物量、種
- IMPLEMENTATION-NOTES.md
- PARAMETER-PROVENANCE.json
- VALIDATION.md
-
-最終監査で追加修正:
-- 陸上 producer の shade 二重適用を除去
-- 動物の構造体重を energy-limited growth 化(年齢だけの自動成長を廃止)
-- 初期配置専用処理を seedInitialPopulation() に限定し、実行中の導入 API は持たない
diff --git a/README.md b/README.md
index e69de29..98d9915 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,28 @@
+# Mandelbrot Viewer — runtime-cost removal build
+
+Production source of truth is `index.html`.
+
+This build starts from the integrated five-improvement viewer and applies the A/B-supported runtime-cost removals. The numerical architecture remains direct f32 / perturbation + series / sparse recovery / finite continuation / CPU fallback.
+
+## Validation
+
+```bash
+node tests/regression.mjs
+node experiments/ab/variant_check.mjs index.html
+node experiments/ab/supplement.mjs
+node experiments/integrated_bench.mjs docs/removal-pass/integrated-bench.json
+```
+
+The optional real-browser WebGPU test is `tests/browser_smoke.mjs`; it requires Chromium with a working WebGPU backend.
+
+## Current records
+
+- `docs/removal-pass/IMPLEMENTATION.md` — why each runtime cost was removed or retained
+- `docs/removal-pass/results.json` — structural checks, hashes, and A/B evidence
+- `docs/removal-pass/integrated-bench.json` — post-removal CPU/surrogate benchmark
+
+Historical investigation records remain under `docs/performance-cost-audit/` and `docs/integrated-improvements/`.
+
+## Reported deep-view artifact fix (2026-09-29)
+
+A reported deep URL exposed an unsafe 4th-order series jump and premature publication of FAST numerical failures. Production series acceleration is disabled pending a propagated error-bound redesign, and FAST provisional publication now occurs only after numerical failure repair. See `docs/reported-view-bugfix/IMPLEMENTATION.md`.
diff --git a/VALIDATION.md b/VALIDATION.md
index 837337b..5dcb132 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -1,88 +1,109 @@
-# v41 最終監査記録
+# v45 検証記録
検証日: 2026-09-29
## 結論
-API/詳細診断出力の削除後に再監査した。監査中に、(1) 陸上 producer の shade 二重適用、(2) animal body mass が energy budget と独立して年齢だけで増える不整合を発見し、修正後に全テストを再実行した。
+v45 は、報告された「スポーン地点付近から離れない」「停止個体が多い」「境界付近で往復振動する」を、home-range の撤去と移動経路の原因修正で対応した。
-このファイルに PASS と書くのは実際に再実行した項目だけとする。
+production code にはスポーン地点への復帰点・復帰半径・home-range 補正は存在しない。回転検出、角運動量検出、逆トルクのような症状検出型の補正も使用していない。
+
+## 原因と修正
+
+1. **岸際スポーン**
+ - 旧配置では陸棲/水棲個体が最寄りの合法地点へ押し出され、岸線から数 m の位置に置かれる場合があった。
+ - v45 は非両棲個体の初期配置に岸線 clearance を要求する。
+
+2. **境界での移動量切り捨て**
+ - 禁止 habitat 側を向いた個体は移動量がほぼゼロになり、同じ方向を保持して停止/微振動し得た。
+ - v45 は signed-distance の勾配から岸線法線を求め、禁止側へ向かう成分だけを除いて接線方向へ連続的に投影する。
+
+3. **短い徘徊とスポーン復帰**
+ - home-range を完全に削除した。
+ - 徘徊は現在位置から persistent free-roaming waypoint を生成し、到達後に次 waypoint を選ぶ。
+
+4. **continuous resource target の往復**
+ - producer / zooplankton の仮想目標点へ到達後は target を破棄し、その場で摂食可能なまま roaming へ戻る。
+ - 仮想目標点と roaming waypoint の交互追跡を行わない。
+
+5. **stamina による長時間停止**
+ - routine travel は sprint stamina を消費しない。
+ - 逃走・追跡だけを capacity-relative に消費し、休息時は capacity-relative に回復する。
## 自動テスト
-`node test/ecology.test.mjs` — PASS
+`npm test` — **PASS**
-対象:
-- `dt = 1/24 day`
-- Bissinger 10/20/30°C golden values
-- aquatic producer = `muMax * min(N,P) * light`
-- terrestrial light limitation の一重適用
-- assimilation 0.45 / 0.85
-- continuous hazard の timestep invariance
-- handling time の Holling denominator 接続
-- World predation path の local multi-resource saturation
-- Rall mass / temperature scaling
-- Hirt running / flying / swimming coefficients
-- producer field / zooplankton cohort
-- parent-surviving reproduction
-- energy-limited structural growth
-- daily movement budget
-- spontaneous immigration/speciation absence
-- deterministic repeatability
-- 初期水域の概ね2倍化
-- user-edited water geometry preservation
-- water depth / human disturbance absence
-- exact water-area optimizer absence
-- experiment API / detailed diagnostic output absence
+回帰テストは v45 で意図的に簡略化した。確認対象は次の5点のみ。
-## 初期水域面積
+1. `engine.js?v=45` と `feeding.js?v=45` の named export/import が成立する
+2. ユーザー編集水域が変形されない
+3. 3日間の自由移動でスポーン拘束・広範な停止・ping-pong反転が再発しない
+4. 14日以内に出生と純増日が発生し、主要状態が有限値を保つ
+5. home-range、回転対症療法、削除済み experiment API が production code に再混入しない
-production code は半径へ `sqrt(2)` を一度掛けるだけで、面積測定・二分探索は行わない。テスト時のみ v38 と wet-cell union を比較した。
+数式係数・長期較正・複数seedテレメトリは回帰テストから外し、この文書の検証記録として保持する。
-| seed | v41 / v38 wet-cell area |
+### v45 module graph 検査
+
+- production の相対 `.js` 参照はすべて `?v=45` に統一
+- `fieldResourceIntakeKgPerDay` export: 存在確認
+- `engine.js` から同名 import: 存在確認
+- 全 `.js/.mjs`: `node --check` PASS
+- Node ESM で `engine.js?v=45` 読込: PASS
+
+## 7日移動テレメトリ — 複数seed
+
+| seed | 生存初期個体 | 距離 q10 (m) | 距離中央値 (m) | 停止率 q90 | 即時反転率 q90 |
+|---:|---:|---:|---:|---:|---:|
+| 481516 | 360 | 203.9 | 771.0 | 0.226 | 0.135 |
+| 1 | 327 | 132.7 | 585.7 | 0.238 | 0.101 |
+| 123456789 | 348 | 127.5 | 404.1 | 0.250 | 0.137 |
+| 4294967295 | 343 | 164.4 | 520.4 | 0.214 | 0.119 |
+
+ここで距離は「最終位置と、その個体自身の実際の初期位置」の直線距離。固定された home point への復帰処理はない。
+
+## 365日 run — seed 481516
+
+- initial population: **404**
+- final population: **309**
+- minimum population: **244**
+- maximum population: **404**
+- population-increase days: **68**
+- surviving species: **6**
+- elapsed: **21.98 s**
+- throughput: **16.60 simulated day/s**
+- NaN / Infinity: **none**
+- negative producer / zooplankton biomass: **none**
+
+個体数は初期値より低下するが単調減少ではなく、純増日が実際に存在する。このテストは「常に増加させる」ためのものではなく、出生が死亡を上回る局面をモデルが取り得ることの回帰検査である。
+
+## 初期水域
+
+厳密な面積最適化は使用しない。初期 procedural water の半径を `sqrt(2)` 倍するだけで、v38 wet-cell union に対する比は以下。
+
+| seed | 面積比 |
|---:|---:|
| 481516 | 1.9972 |
| 1 | 1.9730 |
| 123456789 | 1.9617 |
| 4294967295 | 1.8248 |
-ユーザー編集水域は入力 geometry と完全一致する回帰テストを PASS。
-
-## 365日 headless
-
-seed 481516、修正後コード:
-- elapsed: 7.56 s
-- final population: 134
-- max agents observed: 416
-- surviving species: 6
-- producer biomass: 2,364,186.82 kg
-- zooplankton biomass: 0 kg
-- NaN/Infinity: none
-- negative resource biomass: none
-- 18,000-agent limit: not reached
-
-## 730日 headless
-
-seed 481516、修正後コード:
-- elapsed: 17.40 s
-- final population: 264
-- surviving species: 5
-- producer biomass: 2,493,088.04 kg
-- zooplankton biomass: 0 kg
-- NaN/Infinity: none
-- negative resource biomass: none
-- 18,000-agent limit: not reached
+ユーザーが編集した水域 geometry はそのまま保持するテストを PASS。
## 工学検査
- 全 `.js/.mjs`: `node --check` PASS
-- local relative import reference: PASS
-- JSON parse: PASS
-- removed experiment API strings: production executable code になし
-- removed detailed diagnostic fields: production executable code になし
+- local relative import resolution: PASS
+- `PARAMETER-PROVENANCE.json`: JSON parse PASS
+- no water-depth runtime: PASS
+- no human-disturbance runtime: PASS
+- no experiment/batch API: PASS
+- no detailed diagnostic output: PASS
+- no exact water-area optimizer: PASS
+- no home-range runtime: PASS
+- no rotation/counter-torque workaround in production: PASS
-## 未解決の較正事項
+## 未認証範囲
-標準パラメータでは zooplankton cohort が長期的に 0 へ到達する。これは NaN/負値等の実装破綻ではないが、淡水/海洋の長期較正としては未解決。cohort ingestion/mortality と consumer pressure の較正が必要。
-
-また、life-history、resource K、routine movement/home range、energy reserve/growth cost 等には model-calibration 値が残る。文献から直接採用した係数と較正値は `PARAMETER-PROVENANCE.json` で区別する。
+この検証ではブラウザCanvasを用いた完全E2E操作を認証対象にしていない。Worker/World計算、モジュール構文・依存、移動テレメトリ、長期headless runを検証対象とする。
diff --git a/app.js b/app.js
index eda9681..b0bec1e 100644
--- a/app.js
+++ b/app.js
@@ -1,32 +1,32 @@
-import {habitat} from './water.js';
-import {drawScene} from './scene.js?v=41';
-import {lineageOrder,drawLineage} from './lineage.js';
-import {paintTerrain} from './terrain.js?v=41';
-import {createTreeSprites} from './tree-sprites.js';
-import {formatElapsed as elapsed} from './time.js';
-import {roleLabel} from './ecology/feeding.js';
-import {pinchCamera} from './gestures.js';
-import {W,H,GW,GH,CELL,traits} from './engine.js?v=41';
-const $=id=>document.getElementById(id),worker=new Worker('./worker.js?v=41',{type:'module'}),canvas=$('world'),ctx=canvas.getContext('2d'),chart=$('chart'),cc=chart.getContext('2d'),terrain=document.createElement('canvas');terrain.width=GW;terrain.height=GH;const tc=terrain.getContext('2d');let state=null,tool='observe',waterMode='add',water=[],speed=1,lastSpeed=1,selected=null,layer='plants',zoom=1,panX=0,panY=0,drag=null,view={},lastPaint=0,tab='overview',hover=null;
+import {habitat} from './water.js?v=45';
+import {drawScene} from './scene.js?v=45';
+import {lineageOrder,drawLineage} from './lineage.js?v=45';
+import {paintTerrain} from './terrain.js?v=45';
+import {createTreeSprites} from './tree-sprites.js?v=45';
+import {formatElapsed as elapsed} from './time.js?v=45';
+import {roleLabel} from './ecology/feeding.js?v=45';
+import {pinchCamera} from './gestures.js?v=45';
+import {W,H,GW,GH,CELL,traits} from './engine.js?v=45';
+const $=id=>document.getElementById(id),worker=new Worker('./worker.js?v=45',{type:'module'}),canvas=$('world'),ctx=canvas.getContext('2d'),chart=$('chart'),cc=chart.getContext('2d'),terrain=document.createElement('canvas');terrain.width=GW;terrain.height=GH;const tc=terrain.getContext('2d');let state=null,tool='observe',waterMode='add',water=[],speed=1,lastSpeed=1,selected=null,layer='plants',zoom=1,panX=0,panY=0,drag=null,view={},lastPaint=0,tab='overview',hover=null,paintNeeded=true;
const treeSprites=createTreeSprites(()=>document.createElement('canvas'));
const names={bodySize:'成体体重 (kg)',moveSpeed:'日常移動速度 (m/day)',staminaCapacity:'スタミナ係数',staminaRecovery:'回復係数',visionRange:'視野範囲 (m)',perceptionAbility:'知覚能力',preferredTemperature:'適温 (°C)',temperatureTolerance:'温度許容幅 (°C)',waterAffinity:'水域適応',offspringSize:'出生時体重比',maturityAge:'成熟齢 (day)',lifespan:'最大寿命 (day)',sociability:'群居性',fear:'警戒性',aggression:'攻撃性'};
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function toast(t){$('toast').textContent=t;$('toast').style.opacity=1;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').style.opacity=0,2500)}
function setTab(t){tab=t;document.querySelectorAll('[data-tab]').forEach(b=>{const a=b.dataset.tab===t;b.classList.toggle('active',a);b.setAttribute('aria-selected',a)});document.querySelectorAll('.tab-panel').forEach(p=>p.hidden=p.id!==t);if(state)renderDetails()}
document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>setTab(b.dataset.tab));
-function options(){const el=$('toolOptions');if(tool==='observe')el.replaceChildren();if(tool==='water'||tool==='rocks'){el.innerHTML=`${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}`;el.querySelectorAll('button').forEach(b=>{b.classList.toggle('active',b.dataset.water===waterMode);b.onclick=()=>{waterMode=b.dataset.water;options()}})}}
-document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>{tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));options();if(state?.statMode&&tool!=='observe')toast('フィールド編集は通常の表示速度で行ってください')});
+function options(){const el=$('toolOptions');if(tool==='observe')el.replaceChildren();if(tool==='water'||tool==='rocks'){el.innerHTML=`${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}`;el.querySelectorAll('button').forEach(b=>{b.classList.toggle('active',b.dataset.water===waterMode);b.onclick=()=>{waterMode=b.dataset.water;paintNeeded=true;options()}})}}
+document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>{tool=b.dataset.tool;paintNeeded=true;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));options();if(state?.statMode&&tool!=='observe')toast('フィールド編集は通常の表示速度で行ってください')});
function setSpeed(v){speed=v;if(v)lastSpeed=v;worker.postMessage({type:'speed',speed:v,stat:v===1000});$('pause').textContent=v?'Ⅱ':'▶';$('pause').setAttribute('aria-label',v?'一時停止':'再開');$('runStatus').textContent=v?'シミュレーション実行中':'一時停止中';document.querySelectorAll('[data-speed]').forEach(b=>b.classList.toggle('active',+b.dataset.speed===v))}
-$('pause').onclick=()=>setSpeed(speed?0:lastSpeed);document.querySelectorAll('[data-speed]').forEach(b=>b.onclick=()=>setSpeed(+b.dataset.speed));$('layer').onchange=e=>{layer=e.target.value;updateTerrain();if(state)render()};$('aquaticProfile').onchange=e=>worker.postMessage({type:'load-profile',profile:e.target.value});$('helpBtn').onclick=()=>$('help').showModal();$('closeHelp').onclick=()=>$('help').close();$('help').onclick=e=>{if(e.target===$('help')&&e.offsetX<0)$('help').close()};
-function resize(){const r=canvas.getBoundingClientRect(),d=Math.min(devicePixelRatio||1,1.5);canvas.width=r.width*d;canvas.height=r.height*d;view={width:r.width,height:r.height,d,base:Math.min(r.width/W,r.height/H)};const c=chart.getBoundingClientRect();chart.width=c.width*d;chart.height=c.height*d;if(state)drawChart()}
+$('pause').onclick=()=>setSpeed(speed?0:lastSpeed);document.querySelectorAll('[data-speed]').forEach(b=>b.onclick=()=>setSpeed(+b.dataset.speed));$('layer').onchange=e=>{layer=e.target.value;updateTerrain();paintNeeded=true;if(state)render()};$('aquaticProfile').onchange=e=>worker.postMessage({type:'load-profile',profile:e.target.value});$('helpBtn').onclick=()=>$('help').showModal();$('closeHelp').onclick=()=>$('help').close();$('help').onclick=e=>{if(e.target===$('help')&&e.offsetX<0)$('help').close()};
+function resize(){const r=canvas.getBoundingClientRect(),d=Math.min(devicePixelRatio||1,1.5);canvas.width=r.width*d;canvas.height=r.height*d;view={width:r.width,height:r.height,d,base:Math.min(r.width/W,r.height/H)};const c=chart.getBoundingClientRect();chart.width=c.width*d;chart.height=c.height*d;if(state)drawChart();paintNeeded=true}
new ResizeObserver(resize).observe($('field'));window.addEventListener('resize',resize);function transform(){const s=view.base*zoom;return {s,x:(view.width-W*s)/2+panX,y:(view.height-H*s)/2+panY}}function point(e){const r=canvas.getBoundingClientRect(),t=transform();return {x:(e.clientX-r.left-t.x)/t.s,y:(e.clientY-r.top-t.y)/t.s,px:e.clientX-r.left,py:e.clientY-r.top}}
-function zoomBy(f){zoom=Math.max(1,Math.min(32,zoom*f));if(zoom===1)panX=panY=0} $('toggleUI').onclick=()=>{const hidden=$('field').classList.toggle('ui-hidden'),button=$('toggleUI');button.textContent=hidden?'UI 表示':'UI 非表示';button.setAttribute('aria-pressed',String(hidden));button.setAttribute('aria-label',hidden?'操作UIを表示':'操作UIを非表示')};canvas.addEventListener('wheel',e=>{e.preventDefault();const p=point(e),old=transform();zoomBy(e.deltaY<0?1.12:1/1.12);const t=transform();panX+=p.px-(p.x*t.s+t.x);panY+=p.py-(p.y*t.s+t.y);if(zoom===1)panX=panY=0},{passive:false});
+function zoomBy(f){zoom=Math.max(1,Math.min(32,zoom*f));if(zoom===1)panX=panY=0;paintNeeded=true} $('toggleUI').onclick=()=>{const hidden=$('field').classList.toggle('ui-hidden'),button=$('toggleUI');button.textContent=hidden?'UI 表示':'UI 非表示';button.setAttribute('aria-pressed',String(hidden));button.setAttribute('aria-label',hidden?'操作UIを表示':'操作UIを非表示')};canvas.addEventListener('wheel',e=>{e.preventDefault();const p=point(e),old=transform();zoomBy(e.deltaY<0?1.12:1/1.12);const t=transform();panX+=p.px-(p.x*t.s+t.x);panY+=p.py-(p.y*t.s+t.y);if(zoom===1)panX=panY=0},{passive:false});
const shapeTool=()=>tool==='water'||tool==='rocks';
const contacts=new Map();let pinch=null;
canvas.onpointerdown=e=>{if(!state||state.statMode)return;canvas.setPointerCapture(e.pointerId);contacts.set(e.pointerId,{x:e.clientX,y:e.clientY});if(contacts.size===2){const [a,b]=[...contacts.values()],mid=point({clientX:(a.x+b.x)/2,clientY:(a.y+b.y)/2});pinch={distance:Math.max(1,Math.hypot(a.x-b.x,a.y-b.y)),zoom,anchorX:mid.x,anchorY:mid.y};drag=null;return}if(contacts.size>2)return;const p=point(e);drag={...p,start:p,moved:false};if(shapeTool()){water=state[tool].map(w=>({...w}));const hits=water.map((w,i)=>({i,d:Math.hypot(w.x-p.x,w.y-p.y),w})).filter(v=>v.dMath.abs(a.d-a.w.r)-Math.abs(b.d-b.w.r));if(waterMode==='delete'){if(hits.length){water.splice(hits[0].i,1);sendShapes()}drag=null}else if(waterMode==='add'){if(water.length>=80){toast('各地形は最大80円です');drag=null;return}water.push({x:Math.max(0,Math.min(W,p.x)),y:Math.max(0,Math.min(H,p.y)),r:tool==='water'?170:90,seed:tool==='water'?(p.x*.017+p.y*.011)%6.28:undefined});drag.water=water.length-1;drag.resize=true}else if(hits.length){drag.water=hits[0].i;drag.resize=Math.abs(hits[0].d-hits[0].w.r)<25;drag.origin={...hits[0].w}}}};
-canvas.onpointermove=e=>{if(contacts.has(e.pointerId))contacts.set(e.pointerId,{x:e.clientX,y:e.clientY});if(pinch&&contacts.size>=2){const [a,b]=[...contacts.values()],camera=pinchCamera(pinch,a,b,canvas.getBoundingClientRect(),view,W,H);({zoom,panX,panY}=camera);return}const p=point(e);hover=p;if(!drag)return;if(Math.hypot(p.px-drag.start.px,p.py-drag.start.py)>4)drag.moved=true;if(tool==='observe'){panX+=p.px-drag.px;panY+=p.py-drag.py}else if(shapeTool()&&drag.water!==undefined){const w=water[drag.water];if(drag.resize)w.r=Math.max(tool==='water'?20:80,Math.min(tool==='water'?800:420,Math.hypot(p.x-w.x,p.y-w.y)));else{w.x=Math.max(0,Math.min(W,drag.origin.x+p.x-drag.start.x));w.y=Math.max(0,Math.min(H,drag.origin.y+p.y-drag.start.y))}}drag.px=p.px;drag.py=p.py};
+canvas.onpointermove=e=>{if(contacts.has(e.pointerId))contacts.set(e.pointerId,{x:e.clientX,y:e.clientY});if(pinch&&contacts.size>=2){const [a,b]=[...contacts.values()],camera=pinchCamera(pinch,a,b,canvas.getBoundingClientRect(),view,W,H);({zoom,panX,panY}=camera);paintNeeded=true;return}const p=point(e);hover=p;paintNeeded=true;if(!drag)return;if(Math.hypot(p.px-drag.start.px,p.py-drag.start.py)>4)drag.moved=true;if(tool==='observe'){panX+=p.px-drag.px;panY+=p.py-drag.py}else if(shapeTool()&&drag.water!==undefined){const w=water[drag.water];if(drag.resize)w.r=Math.max(tool==='water'?20:80,Math.min(tool==='water'?800:420,Math.hypot(p.x-w.x,p.y-w.y)));else{w.x=Math.max(0,Math.min(W,drag.origin.x+p.x-drag.start.x));w.y=Math.max(0,Math.min(H,drag.origin.y+p.y-drag.start.y))}}drag.px=p.px;drag.py=p.py;paintNeeded=true};
function sendShapes(){worker.postMessage({type:tool,[tool]:water});}
-canvas.onpointerup=e=>{contacts.delete(e.pointerId);if(pinch){if(contacts.size<2)pinch=null;drag=null;return}if(!drag)return;const p=point(e);if(tool==='observe'&&!drag.moved){let best=null,dist=25/transform().s;for(let i=0;i{contacts.delete(e.pointerId);if(contacts.size<2)pinch=null;drag=null};
+canvas.onpointerup=e=>{contacts.delete(e.pointerId);if(pinch){if(contacts.size<2)pinch=null;drag=null;paintNeeded=true;return}if(!drag)return;const p=point(e);if(tool==='observe'&&!drag.moved){let best=null,dist=25/transform().s;for(let i=0;i{contacts.delete(e.pointerId);if(contacts.size<2)pinch=null;drag=null;paintNeeded=true};
function updateTerrain(){if(state)paintTerrain(tc,state,layer)}
function render(redrawChart=true){if(!state)return;const s=state.stats;$('date').textContent=`経過 ${elapsed(s.time)}`;$('temperature').textContent=s.temp.toFixed(1)+' °C';$('population').innerHTML=s.population.toLocaleString()+'個体';$('speciesCount').innerHTML=s.species.length+'種';$('plantMass').innerHTML=(s.plant/1000).toFixed(1)+'t';$('generation').innerHTML=s.generation+'世代';$('actual').textContent=state.actual.toFixed(2)+' 日/秒';$('statOverlay').hidden=!state.statMode;$('mapLegend').innerHTML=layer==='trophic'?'● 一次消費者 ● 二次消費者 ● 高次消費者 ':layer==='habitat'?'● 陸棲 ● 両棲 ● 水棲 ':'岩倒木死骸';$('events').innerHTML=state.events.slice(0,10).map(x=>`
${esc(x.text)}
`).join('');renderDetails();if(redrawChart)drawChart();if(state.limited&&speed){setSpeed(0);toast('個体数が計算上限に達したため停止しました')}}
function renderDetails(){if(tab==='individual'){const a=state.selected;if(a){const sp=state.stats.species.find(s=>s.id===a.sid),phys=sp.physiology==='endotherm'?'内温性':'変温性';$('individual').innerHTML=`